mirror of
https://github.com/immich-app/immich.git
synced 2025-12-07 05:11:00 -08:00
Compare commits
1 Commits
workflow-u
...
ben/tree-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e525aa04ab |
2
.github/package.json
vendored
2
.github/package.json
vendored
@@ -4,6 +4,6 @@
|
||||
"format:fix": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.7.4"
|
||||
"prettier": "^3.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
|
||||
@@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev
|
||||
|
||||
#### Trigger Dump
|
||||
|
||||
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues).
|
||||
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
|
||||
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
|
||||
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
|
||||
This dumps will count towards the last `X` dumps that will be kept based on your settings.
|
||||
|
||||
@@ -21,9 +21,6 @@ server {
|
||||
# allow large file uploads
|
||||
client_max_body_size 50000M;
|
||||
|
||||
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Set headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -48,6 +48,7 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
|
||||
**Notes:**
|
||||
|
||||
- The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors
|
||||
- In case of rootless docker setup, you need to use root within the container, otherwise you will encounter read/write permission related errors, see comments in `docker/docker-compose.dev.yml`.
|
||||
|
||||
#### Connect web to a remote backend
|
||||
|
||||
|
||||
@@ -1222,4 +1222,4 @@ Feel free to make a feature request if there's a model you want to use that we d
|
||||
[huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07
|
||||
[huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7
|
||||
[smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search
|
||||
[job-status-page]: https://my.immich.app/admin/queues
|
||||
[job-status-page]: https://my.immich.app/admin/jobs-status
|
||||
|
||||
@@ -53,7 +53,7 @@ Version mismatches between both hosts may cause bugs and instability, so remembe
|
||||
|
||||
Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used.
|
||||
|
||||
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/queues) page for the jobs to be retried.
|
||||
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/jobs-status) page for the jobs to be retried.
|
||||
|
||||
## Load balancing
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@docusaurus/module-type-aliases": "~3.9.0",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.7.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.2.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"oidc-provider": "^9.0.0",
|
||||
"pg": "^8.11.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
|
||||
78
i18n/en.json
78
i18n/en.json
@@ -5,10 +5,8 @@
|
||||
"acknowledge": "Acknowledge",
|
||||
"action": "Action",
|
||||
"action_common_update": "Update",
|
||||
"action_description": "A set of action to perform on the filtered assets",
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"active_count": "Active: {count}",
|
||||
"activity": "Activity",
|
||||
"activity_changed": "Activity is {enabled, select, true {enabled} other {disabled}}",
|
||||
"add": "Add",
|
||||
@@ -16,13 +14,9 @@
|
||||
"add_a_location": "Add a location",
|
||||
"add_a_name": "Add a name",
|
||||
"add_a_title": "Add a title",
|
||||
"add_action": "Add action",
|
||||
"add_action_description": "Click to add an action to perform",
|
||||
"add_birthday": "Add a birthday",
|
||||
"add_endpoint": "Add endpoint",
|
||||
"add_exclusion_pattern": "Add exclusion pattern",
|
||||
"add_filter": "Add filter",
|
||||
"add_filter_description": "Click to add a filter condition",
|
||||
"add_location": "Add location",
|
||||
"add_more_users": "Add more users",
|
||||
"add_partner": "Add partner",
|
||||
@@ -41,7 +35,6 @@
|
||||
"add_to_shared_album": "Add to shared album",
|
||||
"add_upload_to_stack": "Add upload to stack",
|
||||
"add_url": "Add URL",
|
||||
"add_workflow_step": "Add workflow step",
|
||||
"added_to_archive": "Added to archive",
|
||||
"added_to_favorites": "Added to favorites",
|
||||
"added_to_favorites_count": "Added {count, number} to favorites",
|
||||
@@ -84,6 +77,7 @@
|
||||
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
|
||||
"export_config_as_json_description": "Download the current system config as a JSON file",
|
||||
"external_libraries_page_description": "Admin external library page",
|
||||
"external_library_management": "External Library Management",
|
||||
"face_detection": "Face detection",
|
||||
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
|
||||
"facial_recognition_job_description": "Group detected faces into people. This step runs after Face Detection is complete. \"Reset\" (re-)clusters all faces. \"Missing\" queues faces that don't have a person assigned.",
|
||||
@@ -117,9 +111,10 @@
|
||||
"job_not_concurrency_safe": "This job is not concurrency-safe.",
|
||||
"job_settings": "Job Settings",
|
||||
"job_settings_description": "Manage job concurrency",
|
||||
"job_status": "Job Status",
|
||||
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
|
||||
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
||||
"jobs_over_time": "Jobs over time",
|
||||
"jobs_page_description": "Admin jobs page",
|
||||
"library_created": "Created library: {library}",
|
||||
"library_deleted": "Library deleted",
|
||||
"library_details": "Library details",
|
||||
@@ -282,14 +277,10 @@
|
||||
"password_settings_description": "Manage password login settings",
|
||||
"paths_validated_successfully": "All paths validated successfully",
|
||||
"person_cleanup_job": "Person cleanup",
|
||||
"queue_details": "Queue Details",
|
||||
"queues": "Job Queues",
|
||||
"queues_page_description": "Admin job queues page",
|
||||
"quota_size_gib": "Quota Size (GiB)",
|
||||
"refreshing_all_libraries": "Refreshing all libraries",
|
||||
"registration": "Admin Registration",
|
||||
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
|
||||
"remove_failed_jobs": "Remove failed jobs",
|
||||
"require_password_change_on_login": "Require user to change password on first login",
|
||||
"reset_settings_to_default": "Reset settings to default",
|
||||
"reset_settings_to_recent_saved": "Reset settings to the recent saved settings",
|
||||
@@ -473,7 +464,6 @@
|
||||
"album_remove_user": "Remove user?",
|
||||
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
|
||||
"album_search_not_found": "No albums found matching your search",
|
||||
"album_selected": "Album selected",
|
||||
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
|
||||
"album_summary": "Album summary",
|
||||
"album_updated": "Album updated",
|
||||
@@ -495,7 +485,6 @@
|
||||
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
|
||||
"albums_feature_description": "Collections of assets that can be shared with other users.",
|
||||
"albums_on_device_count": "Albums on device ({count})",
|
||||
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
|
||||
"all": "All",
|
||||
"all_albums": "All albums",
|
||||
"all_people": "All people",
|
||||
@@ -532,12 +521,10 @@
|
||||
"archived_count": "{count, plural, other {Archived #}}",
|
||||
"are_these_the_same_person": "Are these the same person?",
|
||||
"are_you_sure_to_do_this": "Are you sure you want to do this?",
|
||||
"array_field_not_fully_supported": "Array fields require manual JSON editing",
|
||||
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
|
||||
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
|
||||
"asset_added_to_album": "Added to album",
|
||||
"asset_adding_to_album": "Adding to album…",
|
||||
"asset_created": "Asset created",
|
||||
"asset_description_updated": "Asset description has been updated",
|
||||
"asset_filename_is_offline": "Asset {filename} is offline",
|
||||
"asset_has_unassigned_faces": "Asset has unassigned faces",
|
||||
@@ -720,8 +707,6 @@
|
||||
"change_password_form_password_mismatch": "Passwords do not match",
|
||||
"change_password_form_reenter_new_password": "Re-enter New Password",
|
||||
"change_pin_code": "Change PIN code",
|
||||
"change_trigger": "Change trigger",
|
||||
"change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
|
||||
"change_your_password": "Change your password",
|
||||
"changed_visibility_successfully": "Changed visibility successfully",
|
||||
"charging": "Charging",
|
||||
@@ -797,7 +782,6 @@
|
||||
"create_album": "Create album",
|
||||
"create_album_page_untitled": "Untitled",
|
||||
"create_api_key": "Create API key",
|
||||
"create_first_workflow": "Create first workflow",
|
||||
"create_library": "Create Library",
|
||||
"create_link": "Create link",
|
||||
"create_link_to_share": "Create link to share",
|
||||
@@ -812,7 +796,6 @@
|
||||
"create_tag": "Create tag",
|
||||
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
|
||||
"create_user": "Create user",
|
||||
"create_workflow": "Create workflow",
|
||||
"created": "Created",
|
||||
"created_at": "Created",
|
||||
"creating_linked_albums": "Creating linked albums...",
|
||||
@@ -879,7 +862,6 @@
|
||||
"deselect_all": "Deselect All",
|
||||
"details": "Details",
|
||||
"direction": "Direction",
|
||||
"disable": "Disable",
|
||||
"disabled": "Disabled",
|
||||
"disallow_edits": "Disallow edits",
|
||||
"discord": "Discord",
|
||||
@@ -942,13 +924,11 @@
|
||||
"edit_tag": "Edit tag",
|
||||
"edit_title": "Edit Title",
|
||||
"edit_user": "Edit user",
|
||||
"edit_workflow": "Edit workflow",
|
||||
"editor": "Editor",
|
||||
"editor_close_without_save_prompt": "The changes will not be saved",
|
||||
"editor_close_without_save_title": "Close editor?",
|
||||
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
|
||||
"editor_crop_tool_h2_rotation": "Rotation",
|
||||
"editor_mode": "Editor mode",
|
||||
"email": "Email",
|
||||
"email_notifications": "Email notifications",
|
||||
"empty_folder": "This folder is empty",
|
||||
@@ -1029,7 +1009,6 @@
|
||||
"unable_to_complete_oauth_login": "Unable to complete OAuth login",
|
||||
"unable_to_connect": "Unable to connect",
|
||||
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
|
||||
"unable_to_create": "Unable to create workflow",
|
||||
"unable_to_create_admin_account": "Unable to create admin account",
|
||||
"unable_to_create_api_key": "Unable to create a new API Key",
|
||||
"unable_to_create_library": "Unable to create library",
|
||||
@@ -1040,7 +1019,6 @@
|
||||
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
|
||||
"unable_to_delete_shared_link": "Unable to delete shared link",
|
||||
"unable_to_delete_user": "Unable to delete user",
|
||||
"unable_to_delete_workflow": "Unable to delete workflow",
|
||||
"unable_to_download_files": "Unable to download files",
|
||||
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
|
||||
"unable_to_empty_trash": "Unable to empty trash",
|
||||
@@ -1091,7 +1069,6 @@
|
||||
"unable_to_update_settings": "Unable to update settings",
|
||||
"unable_to_update_timeline_display_status": "Unable to update timeline display status",
|
||||
"unable_to_update_user": "Unable to update user",
|
||||
"unable_to_update_workflow": "Unable to update workflow",
|
||||
"unable_to_upload_file": "Unable to upload file"
|
||||
},
|
||||
"exclusion_pattern": "Exclusion pattern",
|
||||
@@ -1125,7 +1102,6 @@
|
||||
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
|
||||
"face_unassigned": "Unassigned",
|
||||
"failed": "Failed",
|
||||
"failed_count": "Failed: {count}",
|
||||
"failed_to_authenticate": "Failed to authenticate",
|
||||
"failed_to_load_assets": "Failed to load assets",
|
||||
"failed_to_load_folder": "Failed to load folder",
|
||||
@@ -1144,10 +1120,8 @@
|
||||
"filename": "Filename",
|
||||
"filetype": "Filetype",
|
||||
"filter": "Filter",
|
||||
"filter_description": "Conditions to filter the target assets",
|
||||
"filter_people": "Filter people",
|
||||
"filter_places": "Filter places",
|
||||
"filters": "Filters",
|
||||
"find_them_fast": "Find them fast by name with search",
|
||||
"first": "First",
|
||||
"fix_incorrect_match": "Fix incorrect match",
|
||||
@@ -1163,7 +1137,6 @@
|
||||
"general": "General",
|
||||
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
|
||||
"get_help": "Get Help",
|
||||
"get_people_error": "Error getting people",
|
||||
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
|
||||
"getting_started": "Getting Started",
|
||||
"go_back": "Go back",
|
||||
@@ -1195,7 +1168,6 @@
|
||||
"hide_named_person": "Hide person {name}",
|
||||
"hide_password": "Hide password",
|
||||
"hide_person": "Hide person",
|
||||
"hide_schema": "Hide schema",
|
||||
"hide_text_recognition": "Hide text recognition",
|
||||
"hide_unnamed_people": "Hide unnamed people",
|
||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||
@@ -1268,8 +1240,6 @@
|
||||
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
|
||||
"items_count": "{count, plural, one {# item} other {# items}}",
|
||||
"jobs": "Jobs",
|
||||
"json_editor": "JSON editor",
|
||||
"json_error": "JSON error",
|
||||
"keep": "Keep",
|
||||
"keep_all": "Keep All",
|
||||
"keep_this_delete_others": "Keep this, delete others",
|
||||
@@ -1438,13 +1408,11 @@
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"more": "More",
|
||||
"move": "Move",
|
||||
"move_down": "Move down",
|
||||
"move_off_locked_folder": "Move out of locked folder",
|
||||
"move_to": "Move to",
|
||||
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||
"move_to_locked_folder": "Move to locked folder",
|
||||
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
||||
"move_up": "Move up",
|
||||
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
|
||||
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
|
||||
"moved_to_trash": "Moved to trash",
|
||||
@@ -1454,7 +1422,6 @@
|
||||
"my_albums": "My albums",
|
||||
"name": "Name",
|
||||
"name_or_nickname": "Name or nickname",
|
||||
"name_required": "Name is required",
|
||||
"navigate": "Navigate",
|
||||
"navigate_to_time": "Navigate to Time",
|
||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||
@@ -1479,7 +1446,6 @@
|
||||
"next": "Next",
|
||||
"next_memory": "Next memory",
|
||||
"no": "No",
|
||||
"no_actions_added": "No actions added yet",
|
||||
"no_albums_message": "Create an album to organize your photos and videos",
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
"no_albums_yet": "It looks like you do not have any albums yet.",
|
||||
@@ -1489,13 +1455,11 @@
|
||||
"no_cast_devices_found": "No cast devices found",
|
||||
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
||||
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
|
||||
"no_configuration_needed": "No configuration needed",
|
||||
"no_devices": "No authorized devices",
|
||||
"no_duplicates_found": "No duplicates were found.",
|
||||
"no_exif_info_available": "No exif info available",
|
||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||
"no_filters_added": "No filters added yet",
|
||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||
"no_local_assets_found": "No local assets found with this checksum",
|
||||
"no_location_set": "No location set",
|
||||
@@ -1591,7 +1555,6 @@
|
||||
"people": "People",
|
||||
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
|
||||
"people_feature_description": "Browsing photos and videos grouped by people",
|
||||
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
|
||||
"people_sidebar_description": "Display a link to People in the sidebar",
|
||||
"permanent_deletion_warning": "Permanent deletion warning",
|
||||
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
|
||||
@@ -1616,8 +1579,6 @@
|
||||
"person_age_years": "{years, plural, other {# years}} old",
|
||||
"person_birthdate": "Born on {date}",
|
||||
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
||||
"person_recognized": "Person recognized",
|
||||
"person_selected": "Person selected",
|
||||
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
|
||||
"photos": "Photos",
|
||||
"photos_and_videos": "Photos & Videos",
|
||||
@@ -1867,22 +1828,17 @@
|
||||
"second": "Second",
|
||||
"see_all_people": "See all people",
|
||||
"select": "Select",
|
||||
"select_album": "Select album",
|
||||
"select_album_cover": "Select album cover",
|
||||
"select_albums": "Select albums",
|
||||
"select_all": "Select all",
|
||||
"select_all_duplicates": "Select all duplicates",
|
||||
"select_all_in": "Select all in {group}",
|
||||
"select_avatar_color": "Select avatar color",
|
||||
"select_count": "{count, plural, one {Select #} other {Select #}}",
|
||||
"select_face": "Select face",
|
||||
"select_featured_photo": "Select featured photo",
|
||||
"select_from_computer": "Select from computer",
|
||||
"select_keep_all": "Select keep all",
|
||||
"select_library_owner": "Select library owner",
|
||||
"select_new_face": "Select new face",
|
||||
"select_people": "Select people",
|
||||
"select_person": "Select person",
|
||||
"select_person_to_tag": "Select a person to tag",
|
||||
"select_photos": "Select photos",
|
||||
"select_trash_all": "Select trash all",
|
||||
@@ -2018,7 +1974,6 @@
|
||||
"show_password": "Show password",
|
||||
"show_person_options": "Show person options",
|
||||
"show_progress_bar": "Show Progress Bar",
|
||||
"show_schema": "Show schema",
|
||||
"show_search_options": "Show search options",
|
||||
"show_shared_links": "Show shared links",
|
||||
"show_slideshow_transition": "Show slideshow transition",
|
||||
@@ -2146,13 +2101,6 @@
|
||||
"trash_page_select_assets_btn": "Select assets",
|
||||
"trash_page_title": "Trash ({count})",
|
||||
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
|
||||
"trigger": "Trigger",
|
||||
"trigger_asset_uploaded": "Asset Uploaded",
|
||||
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
|
||||
"trigger_description": "An event that kick off the workflow",
|
||||
"trigger_person_recognized": "Person Recognized",
|
||||
"trigger_person_recognized_description": "Triggered when a person is detected",
|
||||
"trigger_type": "Trigger type",
|
||||
"troubleshoot": "Troubleshoot",
|
||||
"type": "Type",
|
||||
"unable_to_change_pin_code": "Unable to change PIN code",
|
||||
@@ -2183,9 +2131,7 @@
|
||||
"unstack": "Un-stack",
|
||||
"unstack_action_prompt": "{count} unstacked",
|
||||
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
|
||||
"unsupported_field_type": "Unsupported field type",
|
||||
"untagged": "Untagged",
|
||||
"untitled_workflow": "Untitled workflow",
|
||||
"up_next": "Up next",
|
||||
"update_location_action_prompt": "Update the location of {count} selected assets with:",
|
||||
"updated_at": "Updated",
|
||||
@@ -2231,7 +2177,6 @@
|
||||
"utilities": "Utilities",
|
||||
"validate": "Validate",
|
||||
"validate_endpoint_error": "Please enter a valid URL",
|
||||
"validation_error": "Validation error",
|
||||
"variables": "Variables",
|
||||
"version": "Version",
|
||||
"version_announcement_closing": "Your friend, Alex",
|
||||
@@ -2263,28 +2208,13 @@
|
||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||
"viewer_unstack": "Un-Stack",
|
||||
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
|
||||
"visual": "Visual",
|
||||
"visual_builder": "Visual builder",
|
||||
"waiting": "Waiting",
|
||||
"waiting_count": "Waiting: {count}",
|
||||
"warning": "Warning",
|
||||
"week": "Week",
|
||||
"welcome": "Welcome",
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
|
||||
"workflow_deleted": "Workflow deleted",
|
||||
"workflow_description": "Workflow description",
|
||||
"workflow_info": "Workflow info",
|
||||
"workflow_json": "Workflow JSON",
|
||||
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
|
||||
"workflow_name": "Workflow name",
|
||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
||||
"workflow_summary": "Workflow summary",
|
||||
"workflow_update_success": "Workflow updated successfully",
|
||||
"workflow_updated": "Workflow updated",
|
||||
"workflows": "Workflows",
|
||||
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
|
||||
"workflow": "Workflow",
|
||||
"wrong_pin_code": "Wrong PIN code",
|
||||
"year": "Year",
|
||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||
|
||||
@@ -89,10 +89,7 @@ data class PlatformAsset (
|
||||
val height: Long? = null,
|
||||
val durationInSeconds: Long,
|
||||
val orientation: Long,
|
||||
val isFavorite: Boolean,
|
||||
val adjustmentTime: Long? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null
|
||||
val isFavorite: Boolean
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
@@ -107,10 +104,7 @@ data class PlatformAsset (
|
||||
val durationInSeconds = pigeonVar_list[7] as Long
|
||||
val orientation = pigeonVar_list[8] as Long
|
||||
val isFavorite = pigeonVar_list[9] as Boolean
|
||||
val adjustmentTime = pigeonVar_list[10] as Long?
|
||||
val latitude = pigeonVar_list[11] as Double?
|
||||
val longitude = pigeonVar_list[12] as Double?
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
@@ -125,9 +119,6 @@ data class PlatformAsset (
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v14.json
generated
1
mobile/drift_schemas/main/drift_schema_v14.json
generated
File diff suppressed because one or more lines are too long
@@ -140,9 +140,6 @@ struct PlatformAsset: Hashable {
|
||||
var durationInSeconds: Int64
|
||||
var orientation: Int64
|
||||
var isFavorite: Bool
|
||||
var adjustmentTime: Int64? = nil
|
||||
var latitude: Double? = nil
|
||||
var longitude: Double? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
@@ -157,9 +154,6 @@ struct PlatformAsset: Hashable {
|
||||
let durationInSeconds = pigeonVar_list[7] as! Int64
|
||||
let orientation = pigeonVar_list[8] as! Int64
|
||||
let isFavorite = pigeonVar_list[9] as! Bool
|
||||
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
|
||||
let latitude: Double? = nilOrValue(pigeonVar_list[11])
|
||||
let longitude: Double? = nilOrValue(pigeonVar_list[12])
|
||||
|
||||
return PlatformAsset(
|
||||
id: id,
|
||||
@@ -171,10 +165,7 @@ struct PlatformAsset: Hashable {
|
||||
height: height,
|
||||
durationInSeconds: durationInSeconds,
|
||||
orientation: orientation,
|
||||
isFavorite: isFavorite,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
isFavorite: isFavorite
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
@@ -189,9 +180,6 @@ struct PlatformAsset: Hashable {
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
]
|
||||
}
|
||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||
|
||||
@@ -12,10 +12,7 @@ extension PHAsset {
|
||||
height: Int64(pixelHeight),
|
||||
durationInSeconds: Int64(duration),
|
||||
orientation: 0,
|
||||
isFavorite: isFavorite,
|
||||
adjustmentTime: adjustmentTimestamp,
|
||||
latitude: location?.coordinate.latitude,
|
||||
longitude: location?.coordinate.longitude
|
||||
isFavorite: isFavorite
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,13 +23,6 @@ extension PHAsset {
|
||||
var filename: String? {
|
||||
return value(forKey: "filename") as? String
|
||||
}
|
||||
|
||||
var adjustmentTimestamp: Int64? {
|
||||
if let date = value(forKey: "adjustmentTimestamp") as? Date {
|
||||
return Int64(date.timeIntervalSince1970)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
|
||||
var originalFilename: String? {
|
||||
|
||||
@@ -5,10 +5,6 @@ class LocalAsset extends BaseAsset {
|
||||
final String? remoteAssetId;
|
||||
final int orientation;
|
||||
|
||||
final DateTime? adjustmentTime;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
const LocalAsset({
|
||||
required this.id,
|
||||
String? remoteId,
|
||||
@@ -23,9 +19,6 @@ class LocalAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
@@ -40,8 +33,6 @@ class LocalAsset extends BaseAsset {
|
||||
@override
|
||||
String get heroTag => '${id}_${remoteId ?? checksum}';
|
||||
|
||||
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''LocalAsset {
|
||||
@@ -56,9 +47,6 @@ class LocalAsset extends BaseAsset {
|
||||
remoteId: ${remoteId ?? "<NA>"}
|
||||
isFavorite: $isFavorite,
|
||||
orientation: $orientation,
|
||||
adjustmentTime: $adjustmentTime,
|
||||
latitude: ${latitude ?? "<NA>"},
|
||||
longitude: ${longitude ?? "<NA>"},
|
||||
}''';
|
||||
}
|
||||
|
||||
@@ -67,23 +55,11 @@ class LocalAsset extends BaseAsset {
|
||||
bool operator ==(Object other) {
|
||||
if (other is! LocalAsset) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return super == other &&
|
||||
id == other.id &&
|
||||
orientation == other.orientation &&
|
||||
adjustmentTime == other.adjustmentTime &&
|
||||
latitude == other.latitude &&
|
||||
longitude == other.longitude;
|
||||
return super == other && id == other.id && orientation == other.orientation;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
super.hashCode ^
|
||||
id.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
orientation.hashCode ^
|
||||
adjustmentTime.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode;
|
||||
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
|
||||
|
||||
LocalAsset copyWith({
|
||||
String? id,
|
||||
@@ -98,9 +74,6 @@ class LocalAsset extends BaseAsset {
|
||||
int? durationInSeconds,
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -115,9 +88,6 @@ class LocalAsset extends BaseAsset {
|
||||
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class ExifInfo {
|
||||
|
||||
String get fNumber => f == null ? "" : f!.toStringAsFixed(1);
|
||||
|
||||
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3);
|
||||
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(1);
|
||||
|
||||
const ExifInfo({
|
||||
this.assetId,
|
||||
|
||||
@@ -286,23 +286,11 @@ class LocalSyncService {
|
||||
}
|
||||
|
||||
bool _assetsEqual(LocalAsset a, LocalAsset b) {
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
|
||||
a.createdAt.isAtSameMomentAs(b.createdAt) &&
|
||||
a.width == b.width &&
|
||||
a.height == b.height &&
|
||||
a.durationInSeconds == b.durationInSeconds;
|
||||
}
|
||||
|
||||
final firstAdjustment = a.adjustmentTime?.millisecondsSinceEpoch ?? 0;
|
||||
final secondAdjustment = b.adjustmentTime?.millisecondsSinceEpoch ?? 0;
|
||||
return firstAdjustment == secondAdjustment &&
|
||||
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
|
||||
a.createdAt.isAtSameMomentAs(b.createdAt) &&
|
||||
a.width == b.width &&
|
||||
a.height == b.height &&
|
||||
a.durationInSeconds == b.durationInSeconds &&
|
||||
a.latitude == b.latitude &&
|
||||
a.longitude == b.longitude;
|
||||
a.durationInSeconds == b.durationInSeconds;
|
||||
}
|
||||
|
||||
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
|
||||
@@ -388,8 +376,5 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
durationInSeconds: durationInSeconds,
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,6 +166,5 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
|
||||
mm: focalLength?.toDouble(),
|
||||
lens: lens,
|
||||
isFlipped: ExifDtoConverter.isOrientationFlipped(orientation),
|
||||
exposureSeconds: ExifDtoConverter.exposureTimeToSeconds(exposureTime),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,6 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
IntColumn get orientation => integer().withDefault(const Constant(0))();
|
||||
|
||||
DateTimeColumn get adjustmentTime => dateTime().nullable()();
|
||||
|
||||
RealColumn get latitude => real().nullable()();
|
||||
|
||||
RealColumn get longitude => real().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -40,8 +34,5 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
width: width,
|
||||
remoteId: remoteId,
|
||||
orientation: orientation,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<String?> checksum,
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
i0.Value<DateTime?> adjustmentTime,
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -38,9 +35,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<String?> checksum,
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
i0.Value<DateTime?> adjustmentTime,
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -107,21 +101,6 @@ class $$LocalAssetEntityTableFilterComposer
|
||||
column: $table.orientation,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<DateTime> get adjustmentTime => $composableBuilder(
|
||||
column: $table.adjustmentTime,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<double> get latitude => $composableBuilder(
|
||||
column: $table.latitude,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<double> get longitude => $composableBuilder(
|
||||
column: $table.longitude,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableOrderingComposer
|
||||
@@ -187,21 +166,6 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||
column: $table.orientation,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<DateTime> get adjustmentTime => $composableBuilder(
|
||||
column: $table.adjustmentTime,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<double> get latitude => $composableBuilder(
|
||||
column: $table.latitude,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<double> get longitude => $composableBuilder(
|
||||
column: $table.longitude,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableAnnotationComposer
|
||||
@@ -251,17 +215,6 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||
column: $table.orientation,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<DateTime> get adjustmentTime => $composableBuilder(
|
||||
column: $table.adjustmentTime,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<double> get latitude =>
|
||||
$composableBuilder(column: $table.latitude, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<double> get longitude =>
|
||||
$composableBuilder(column: $table.longitude, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableTableManager
|
||||
@@ -315,9 +268,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<String?> checksum = const i0.Value.absent(),
|
||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||
i0.Value<int> orientation = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -330,9 +280,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
checksum: checksum,
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -347,9 +294,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<String?> checksum = const i0.Value.absent(),
|
||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||
i0.Value<int> orientation = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -362,9 +306,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
checksum: checksum,
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -532,39 +473,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const i4.Constant(0),
|
||||
);
|
||||
static const i0.VerificationMeta _adjustmentTimeMeta =
|
||||
const i0.VerificationMeta('adjustmentTime');
|
||||
@override
|
||||
late final i0.GeneratedColumn<DateTime> adjustmentTime =
|
||||
i0.GeneratedColumn<DateTime>(
|
||||
'adjustment_time',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _latitudeMeta = const i0.VerificationMeta(
|
||||
'latitude',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<double> latitude = i0.GeneratedColumn<double>(
|
||||
'latitude',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.double,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _longitudeMeta = const i0.VerificationMeta(
|
||||
'longitude',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<double> longitude = i0.GeneratedColumn<double>(
|
||||
'longitude',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.double,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
@@ -578,9 +486,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
checksum,
|
||||
isFavorite,
|
||||
orientation,
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -661,27 +566,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('adjustment_time')) {
|
||||
context.handle(
|
||||
_adjustmentTimeMeta,
|
||||
adjustmentTime.isAcceptableOrUnknown(
|
||||
data['adjustment_time']!,
|
||||
_adjustmentTimeMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('latitude')) {
|
||||
context.handle(
|
||||
_latitudeMeta,
|
||||
latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('longitude')) {
|
||||
context.handle(
|
||||
_longitudeMeta,
|
||||
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -740,18 +624,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}orientation'],
|
||||
)!,
|
||||
adjustmentTime: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}adjustment_time'],
|
||||
),
|
||||
latitude: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.double,
|
||||
data['${effectivePrefix}latitude'],
|
||||
),
|
||||
longitude: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.double,
|
||||
data['${effectivePrefix}longitude'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -781,9 +653,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
final String? checksum;
|
||||
final bool isFavorite;
|
||||
final int orientation;
|
||||
final DateTime? adjustmentTime;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
const LocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -796,9 +665,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.checksum,
|
||||
required this.isFavorite,
|
||||
required this.orientation,
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -826,15 +692,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
}
|
||||
map['is_favorite'] = i0.Variable<bool>(isFavorite);
|
||||
map['orientation'] = i0.Variable<int>(orientation);
|
||||
if (!nullToAbsent || adjustmentTime != null) {
|
||||
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime);
|
||||
}
|
||||
if (!nullToAbsent || latitude != null) {
|
||||
map['latitude'] = i0.Variable<double>(latitude);
|
||||
}
|
||||
if (!nullToAbsent || longitude != null) {
|
||||
map['longitude'] = i0.Variable<double>(longitude);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -857,9 +714,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
checksum: serializer.fromJson<String?>(json['checksum']),
|
||||
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
|
||||
orientation: serializer.fromJson<int>(json['orientation']),
|
||||
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
|
||||
latitude: serializer.fromJson<double?>(json['latitude']),
|
||||
longitude: serializer.fromJson<double?>(json['longitude']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -879,9 +733,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
'checksum': serializer.toJson<String?>(checksum),
|
||||
'isFavorite': serializer.toJson<bool>(isFavorite),
|
||||
'orientation': serializer.toJson<int>(orientation),
|
||||
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
|
||||
'latitude': serializer.toJson<double?>(latitude),
|
||||
'longitude': serializer.toJson<double?>(longitude),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -897,9 +748,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i0.Value<String?> checksum = const i0.Value.absent(),
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -914,11 +762,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
checksum: checksum.present ? checksum.value : this.checksum,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
adjustmentTime: adjustmentTime.present
|
||||
? adjustmentTime.value
|
||||
: this.adjustmentTime,
|
||||
latitude: latitude.present ? latitude.value : this.latitude,
|
||||
longitude: longitude.present ? longitude.value : this.longitude,
|
||||
);
|
||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||
return LocalAssetEntityData(
|
||||
@@ -939,11 +782,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
orientation: data.orientation.present
|
||||
? data.orientation.value
|
||||
: this.orientation,
|
||||
adjustmentTime: data.adjustmentTime.present
|
||||
? data.adjustmentTime.value
|
||||
: this.adjustmentTime,
|
||||
latitude: data.latitude.present ? data.latitude.value : this.latitude,
|
||||
longitude: data.longitude.present ? data.longitude.value : this.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -960,10 +798,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('id: $id, ')
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude')
|
||||
..write('orientation: $orientation')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -981,9 +816,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
checksum,
|
||||
isFavorite,
|
||||
orientation,
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -999,10 +831,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.id == this.id &&
|
||||
other.checksum == this.checksum &&
|
||||
other.isFavorite == this.isFavorite &&
|
||||
other.orientation == this.orientation &&
|
||||
other.adjustmentTime == this.adjustmentTime &&
|
||||
other.latitude == this.latitude &&
|
||||
other.longitude == this.longitude);
|
||||
other.orientation == this.orientation);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -1018,9 +847,6 @@ class LocalAssetEntityCompanion
|
||||
final i0.Value<String?> checksum;
|
||||
final i0.Value<bool> isFavorite;
|
||||
final i0.Value<int> orientation;
|
||||
final i0.Value<DateTime?> adjustmentTime;
|
||||
final i0.Value<double?> latitude;
|
||||
final i0.Value<double?> longitude;
|
||||
const LocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -1033,9 +859,6 @@ class LocalAssetEntityCompanion
|
||||
this.checksum = const i0.Value.absent(),
|
||||
this.isFavorite = const i0.Value.absent(),
|
||||
this.orientation = const i0.Value.absent(),
|
||||
this.adjustmentTime = const i0.Value.absent(),
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
});
|
||||
LocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1049,9 +872,6 @@ class LocalAssetEntityCompanion
|
||||
this.checksum = const i0.Value.absent(),
|
||||
this.isFavorite = const i0.Value.absent(),
|
||||
this.orientation = const i0.Value.absent(),
|
||||
this.adjustmentTime = const i0.Value.absent(),
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id);
|
||||
@@ -1067,9 +887,6 @@ class LocalAssetEntityCompanion
|
||||
i0.Expression<String>? checksum,
|
||||
i0.Expression<bool>? isFavorite,
|
||||
i0.Expression<int>? orientation,
|
||||
i0.Expression<DateTime>? adjustmentTime,
|
||||
i0.Expression<double>? latitude,
|
||||
i0.Expression<double>? longitude,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1083,9 +900,6 @@ class LocalAssetEntityCompanion
|
||||
if (checksum != null) 'checksum': checksum,
|
||||
if (isFavorite != null) 'is_favorite': isFavorite,
|
||||
if (orientation != null) 'orientation': orientation,
|
||||
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1101,9 +915,6 @@ class LocalAssetEntityCompanion
|
||||
i0.Value<String?>? checksum,
|
||||
i0.Value<bool>? isFavorite,
|
||||
i0.Value<int>? orientation,
|
||||
i0.Value<DateTime?>? adjustmentTime,
|
||||
i0.Value<double?>? latitude,
|
||||
i0.Value<double?>? longitude,
|
||||
}) {
|
||||
return i1.LocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1117,9 +928,6 @@ class LocalAssetEntityCompanion
|
||||
checksum: checksum ?? this.checksum,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1161,15 +969,6 @@ class LocalAssetEntityCompanion
|
||||
if (orientation.present) {
|
||||
map['orientation'] = i0.Variable<int>(orientation.value);
|
||||
}
|
||||
if (adjustmentTime.present) {
|
||||
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime.value);
|
||||
}
|
||||
if (latitude.present) {
|
||||
map['latitude'] = i0.Variable<double>(latitude.value);
|
||||
}
|
||||
if (longitude.present) {
|
||||
map['longitude'] = i0.Variable<double>(longitude.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1186,10 +985,7 @@ class LocalAssetEntityCompanion
|
||||
..write('id: $id, ')
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude')
|
||||
..write('orientation: $orientation')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
|
||||
@@ -20,7 +21,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
|
||||
@@ -95,7 +95,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 14;
|
||||
int get schemaVersion => 13;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -185,11 +185,6 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
|
||||
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
|
||||
},
|
||||
from13To14: (m, v14) async {
|
||||
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
|
||||
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
|
||||
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -5485,462 +5485,6 @@ i1.GeneratedColumn<String> _column_95(String aliasedName) =>
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
);
|
||||
|
||||
final class Schema14 extends i0.VersionedSchema {
|
||||
Schema14({required super.database}) : super(version: 14);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAssetChecksum,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
idxLatLng,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
late final Shape20 userEntity = Shape20(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_91,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape17 remoteAssetEntity = Shape17(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_86,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape3 stackEntity = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape24 localAssetEntity = Shape24(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape9 remoteAlbumEntity = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_56,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_57,
|
||||
_column_58,
|
||||
_column_59,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape19 localAlbumEntity = Shape19(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_5,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_90,
|
||||
_column_33,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_34, _column_35, _column_33],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
late final Shape21 authUserEntity = Shape21(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_2,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_92,
|
||||
_column_93,
|
||||
_column_7,
|
||||
_column_94,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_25, _column_26, _column_27],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 partnerEntity = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_28, _column_29, _column_30],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 remoteExifEntity = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
_column_38,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_41,
|
||||
_column_11,
|
||||
_column_10,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_48,
|
||||
_column_49,
|
||||
_column_50,
|
||||
_column_51,
|
||||
_column_52,
|
||||
_column_53,
|
||||
_column_54,
|
||||
_column_55,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_36, _column_60],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_60, _column_25, _column_61],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 memoryEntity = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_18,
|
||||
_column_15,
|
||||
_column_8,
|
||||
_column_62,
|
||||
_column_63,
|
||||
_column_64,
|
||||
_column_65,
|
||||
_column_66,
|
||||
_column_67,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_36, _column_68],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape14 personEntity = Shape14(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_1,
|
||||
_column_69,
|
||||
_column_71,
|
||||
_column_72,
|
||||
_column_73,
|
||||
_column_74,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape15 assetFaceEntity = Shape15(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_76,
|
||||
_column_77,
|
||||
_column_78,
|
||||
_column_79,
|
||||
_column_80,
|
||||
_column_81,
|
||||
_column_82,
|
||||
_column_83,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_87, _column_88, _column_89],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape23 trashedLocalAssetEntity = Shape23(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_95,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape24 extends i0.VersionedTable {
|
||||
Shape24({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationInSeconds =>
|
||||
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<DateTime> _column_96(String aliasedName) =>
|
||||
i1.GeneratedColumn<DateTime>(
|
||||
'adjustment_time',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.dateTime,
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -5954,7 +5498,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -6018,11 +5561,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from12To13(migrator, schema);
|
||||
return 13;
|
||||
case 13:
|
||||
final schema = Schema14(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from13To14(migrator, schema);
|
||||
return 14;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -6042,7 +5580,6 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -6057,6 +5594,5 @@ i1.OnUpgrade stepByStep({
|
||||
from10To11: from10To11,
|
||||
from11To12: from11To12,
|
||||
from12To13: from12To13,
|
||||
from13To14: from13To14,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@@ -246,56 +244,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
Future<void> Function(Iterable<LocalAsset>) get _upsertAssets =>
|
||||
CurrentPlatform.isIOS ? _upsertAssetsDarwin : _upsertAssetsAndroid;
|
||||
|
||||
Future<void> _upsertAssetsDarwin(Iterable<LocalAsset> localAssets) async {
|
||||
if (localAssets.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
// Reset checksum if asset changed
|
||||
await _db.batch((batch) async {
|
||||
for (final asset in localAssets) {
|
||||
final companion = LocalAssetEntityCompanion(
|
||||
checksum: const Value(null),
|
||||
adjustmentTime: Value(asset.adjustmentTime),
|
||||
);
|
||||
batch.update(
|
||||
_db.localAssetEntity,
|
||||
companion,
|
||||
where: (row) => row.id.equals(asset.id) & row.adjustmentTime.isNotExp(Variable(asset.adjustmentTime)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return _db.batch((batch) async {
|
||||
for (final asset in localAssets) {
|
||||
final companion = LocalAssetEntityCompanion.insert(
|
||||
name: asset.name,
|
||||
type: asset.type,
|
||||
createdAt: Value(asset.createdAt),
|
||||
updatedAt: Value(asset.updatedAt),
|
||||
width: Value(asset.width),
|
||||
height: Value(asset.height),
|
||||
durationInSeconds: Value(asset.durationInSeconds),
|
||||
id: asset.id,
|
||||
orientation: Value(asset.orientation),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
latitude: Value(asset.latitude),
|
||||
longitude: Value(asset.longitude),
|
||||
adjustmentTime: Value(asset.adjustmentTime),
|
||||
);
|
||||
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||
_db.localAssetEntity,
|
||||
companion.copyWith(checksum: const Value(null)),
|
||||
onConflict: DoUpdate((old) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _upsertAssetsAndroid(Iterable<LocalAsset> localAssets) async {
|
||||
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) {
|
||||
if (localAssets.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
@@ -311,7 +260,6 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
height: Value(asset.height),
|
||||
durationInSeconds: Value(asset.durationInSeconds),
|
||||
id: asset.id,
|
||||
checksum: const Value(null),
|
||||
orientation: Value(asset.orientation),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ abstract final class ExifDtoConverter {
|
||||
f: dto.fNumber?.toDouble(),
|
||||
mm: dto.focalLength?.toDouble(),
|
||||
iso: dto.iso?.toInt(),
|
||||
exposureSeconds: exposureTimeToSeconds(dto.exposureTime),
|
||||
exposureSeconds: _exposureTimeToSeconds(dto.exposureTime),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,15 +36,15 @@ abstract final class ExifDtoConverter {
|
||||
return isRotated90CW || isRotated270CW;
|
||||
}
|
||||
|
||||
static double? exposureTimeToSeconds(String? second) {
|
||||
if (second == null) {
|
||||
static double? _exposureTimeToSeconds(String? s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
double? value = double.tryParse(second);
|
||||
double? value = double.tryParse(s);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
final parts = second.split("/");
|
||||
final parts = s.split("/");
|
||||
if (parts.length == 2) {
|
||||
final numerator = double.tryParse(parts.firstOrNull ?? "-");
|
||||
final denominator = double.tryParse(parts.lastOrNull ?? "-");
|
||||
|
||||
@@ -49,7 +49,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
|
||||
|
||||
var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude);
|
||||
selectedLatLng.value = currentLatLng;
|
||||
await controller.value?.animateCamera(CameraUpdate.newLatLngZoom(currentLatLng, 12));
|
||||
await controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng));
|
||||
}
|
||||
|
||||
return MapThemeOverride(
|
||||
@@ -66,10 +66,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
|
||||
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(40), bottomRight: Radius.circular(40)),
|
||||
),
|
||||
child: MapLibreMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: initialLatLng,
|
||||
zoom: (initialLatLng.latitude == 0 && initialLatLng.longitude == 0) ? 1 : 12,
|
||||
),
|
||||
initialCameraPosition: CameraPosition(target: initialLatLng, zoom: 12),
|
||||
styleString: style,
|
||||
onMapCreated: (mapController) => controller.value = mapController,
|
||||
onStyleLoadedCallback: onStyleLoaded,
|
||||
|
||||
28
mobile/lib/platform/native_sync_api.g.dart
generated
28
mobile/lib/platform/native_sync_api.g.dart
generated
@@ -41,9 +41,6 @@ class PlatformAsset {
|
||||
required this.durationInSeconds,
|
||||
required this.orientation,
|
||||
required this.isFavorite,
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
String id;
|
||||
@@ -66,28 +63,8 @@ class PlatformAsset {
|
||||
|
||||
bool isFavorite;
|
||||
|
||||
int? adjustmentTime;
|
||||
|
||||
double? latitude;
|
||||
|
||||
double? longitude;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
width,
|
||||
height,
|
||||
durationInSeconds,
|
||||
orientation,
|
||||
isFavorite,
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
];
|
||||
return <Object?>[id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
@@ -107,9 +84,6 @@ class PlatformAsset {
|
||||
durationInSeconds: result[7]! as int,
|
||||
orientation: result[8]! as int,
|
||||
isFavorite: result[9]! as bool,
|
||||
adjustmentTime: result[10] as int?,
|
||||
latitude: result[11] as double?,
|
||||
longitude: result[12] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -130,15 +129,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
|
||||
properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString()));
|
||||
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
|
||||
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
|
||||
if (CurrentPlatform.isIOS) {
|
||||
properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString()));
|
||||
}
|
||||
properties.add(
|
||||
_PropertyItem(
|
||||
label: 'GPS Coordinates',
|
||||
value: asset.hasCoordinates ? '${asset.latitude}, ${asset.longitude}' : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addRemoteAssetProperties(RemoteAsset asset) async {
|
||||
|
||||
@@ -251,8 +251,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getFileInfo(asset, exifInfo),
|
||||
subtitleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -268,8 +268,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
color: context.textTheme.labelLarge?.color,
|
||||
),
|
||||
subtitle: _getFileInfo(asset, exifInfo),
|
||||
subtitleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -280,7 +280,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
// Asset Date and Time
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
||||
),
|
||||
@@ -289,7 +289,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
const SheetLocationDetails(),
|
||||
// Details header
|
||||
SheetTile(
|
||||
title: 'details'.t(context: context).toUpperCase(),
|
||||
title: 'exif_bottom_sheet_details'.t(context: context),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -298,33 +298,29 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
// File info
|
||||
buildFileInfoTile(),
|
||||
// Camera info
|
||||
if (cameraTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
if (cameraTitle != null)
|
||||
SheetTile(
|
||||
title: cameraTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getCameraInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Lens info
|
||||
if (lensTitle != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
if (lensTitle != null)
|
||||
SheetTile(
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
subtitle: _getLensInfoSubtitle(exifInfo),
|
||||
subtitleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Appears in (Albums)
|
||||
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
||||
_buildAppearsInList(ref, context),
|
||||
// padding at the bottom to avoid cut-off
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
|
||||
@@ -78,7 +78,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SheetTile(
|
||||
title: 'location'.t(context: context).toUpperCase(),
|
||||
title: 'exif_bottom_sheet_location'.t(context: context),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -102,7 +102,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
Text(
|
||||
coordinates,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(150),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -46,7 +46,7 @@ class SheetTile extends ConsumerWidget {
|
||||
} else {
|
||||
titleWidget = Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(left: 15, right: 15),
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
child: Text(title, style: titleStyle),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
@@ -151,7 +150,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
try {
|
||||
bool syncSuccess = false;
|
||||
await Future.wait([
|
||||
_safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"),
|
||||
_safeRun(backgroundManager.syncLocal(), "syncLocal"),
|
||||
_safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
|
||||
]);
|
||||
if (syncSuccess) {
|
||||
|
||||
@@ -81,7 +81,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
}
|
||||
|
||||
if (version < 19 && Store.isBetaTimelineEnabled) {
|
||||
if (!await _populateLocalAssetTime(drift)) {
|
||||
if (!await _populateUpdatedAtTime(drift)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _populateLocalAssetTime(Drift db) async {
|
||||
Future<bool> _populateUpdatedAtTime(Drift db) async {
|
||||
try {
|
||||
final nativeApi = NativeSyncApi();
|
||||
final albums = await nativeApi.getAlbums();
|
||||
@@ -240,9 +240,6 @@ Future<bool> _populateLocalAssetTime(Drift db) async {
|
||||
batch.update(
|
||||
db.localAssetEntity,
|
||||
LocalAssetEntityCompanion(
|
||||
longitude: Value(asset.longitude),
|
||||
latitude: Value(asset.latitude),
|
||||
adjustmentTime: Value(tryFromSecondsSinceEpoch(asset.adjustmentTime, isUtc: true)),
|
||||
updatedAt: Value(tryFromSecondsSinceEpoch(asset.updatedAt, isUtc: true) ?? DateTime.timestamp()),
|
||||
),
|
||||
where: (t) => t.id.equals(asset.id),
|
||||
@@ -253,7 +250,7 @@ Future<bool> _populateLocalAssetTime(Drift db) async {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while populating asset time: $error");
|
||||
dPrint(() => "[MIGRATION] Error while populating updatedAt time: $error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
@@ -199,7 +199,6 @@ Class | Method | HTTP request | Description
|
||||
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
|
||||
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
||||
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
||||
*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
|
||||
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
|
||||
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
||||
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
|
||||
@@ -466,10 +465,9 @@ Class | Method | HTTP request | Description
|
||||
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
|
||||
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
|
||||
- [PluginContextType](doc//PluginContextType.md)
|
||||
- [PluginContext](doc//PluginContext.md)
|
||||
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
|
||||
- [PluginResponseDto](doc//PluginResponseDto.md)
|
||||
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
|
||||
- [PluginTriggerType](doc//PluginTriggerType.md)
|
||||
- [PurchaseResponse](doc//PurchaseResponse.md)
|
||||
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
||||
|
||||
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@@ -217,10 +217,9 @@ part 'model/pin_code_reset_dto.dart';
|
||||
part 'model/pin_code_setup_dto.dart';
|
||||
part 'model/places_response_dto.dart';
|
||||
part 'model/plugin_action_response_dto.dart';
|
||||
part 'model/plugin_context_type.dart';
|
||||
part 'model/plugin_context.dart';
|
||||
part 'model/plugin_filter_response_dto.dart';
|
||||
part 'model/plugin_response_dto.dart';
|
||||
part 'model/plugin_trigger_response_dto.dart';
|
||||
part 'model/plugin_trigger_type.dart';
|
||||
part 'model/purchase_response.dart';
|
||||
part 'model/purchase_update.dart';
|
||||
|
||||
51
mobile/openapi/lib/api/plugins_api.dart
generated
51
mobile/openapi/lib/api/plugins_api.dart
generated
@@ -73,57 +73,6 @@ class PluginsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all plugin triggers
|
||||
///
|
||||
/// Retrieve a list of all available plugin triggers.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getPluginTriggersWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/plugins/triggers';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// List all plugin triggers
|
||||
///
|
||||
/// Retrieve a list of all available plugin triggers.
|
||||
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
|
||||
final response = await getPluginTriggersWithHttpInfo();
|
||||
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<PluginTriggerResponseDto>') as List)
|
||||
.cast<PluginTriggerResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all plugins
|
||||
///
|
||||
/// Retrieve a list of plugins available to the authenticated user.
|
||||
|
||||
6
mobile/openapi/lib/api_client.dart
generated
6
mobile/openapi/lib/api_client.dart
generated
@@ -482,14 +482,12 @@ class ApiClient {
|
||||
return PlacesResponseDto.fromJson(value);
|
||||
case 'PluginActionResponseDto':
|
||||
return PluginActionResponseDto.fromJson(value);
|
||||
case 'PluginContextType':
|
||||
return PluginContextTypeTypeTransformer().decode(value);
|
||||
case 'PluginContext':
|
||||
return PluginContextTypeTransformer().decode(value);
|
||||
case 'PluginFilterResponseDto':
|
||||
return PluginFilterResponseDto.fromJson(value);
|
||||
case 'PluginResponseDto':
|
||||
return PluginResponseDto.fromJson(value);
|
||||
case 'PluginTriggerResponseDto':
|
||||
return PluginTriggerResponseDto.fromJson(value);
|
||||
case 'PluginTriggerType':
|
||||
return PluginTriggerTypeTypeTransformer().decode(value);
|
||||
case 'PurchaseResponse':
|
||||
|
||||
4
mobile/openapi/lib/api_helper.dart
generated
4
mobile/openapi/lib/api_helper.dart
generated
@@ -127,8 +127,8 @@ String parameterToString(dynamic value) {
|
||||
if (value is Permission) {
|
||||
return PermissionTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is PluginContextType) {
|
||||
return PluginContextTypeTypeTransformer().encode(value).toString();
|
||||
if (value is PluginContext) {
|
||||
return PluginContextTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is PluginTriggerType) {
|
||||
return PluginTriggerTypeTypeTransformer().encode(value).toString();
|
||||
|
||||
@@ -32,7 +32,7 @@ class PluginActionResponseDto {
|
||||
|
||||
Object? schema;
|
||||
|
||||
List<PluginContextType> supportedContexts;
|
||||
List<PluginContext> supportedContexts;
|
||||
|
||||
String title;
|
||||
|
||||
@@ -90,7 +90,7 @@ class PluginActionResponseDto {
|
||||
methodName: mapValueOfType<String>(json, r'methodName')!,
|
||||
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
||||
schema: mapValueOfType<Object>(json, r'schema'),
|
||||
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
|
||||
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class PluginContextType {
|
||||
class PluginContext {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const PluginContextType._(this.value);
|
||||
const PluginContext._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
@@ -23,24 +23,24 @@ class PluginContextType {
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const asset = PluginContextType._(r'asset');
|
||||
static const album = PluginContextType._(r'album');
|
||||
static const person = PluginContextType._(r'person');
|
||||
static const asset = PluginContext._(r'asset');
|
||||
static const album = PluginContext._(r'album');
|
||||
static const person = PluginContext._(r'person');
|
||||
|
||||
/// List of all possible values in this [enum][PluginContextType].
|
||||
static const values = <PluginContextType>[
|
||||
/// List of all possible values in this [enum][PluginContext].
|
||||
static const values = <PluginContext>[
|
||||
asset,
|
||||
album,
|
||||
person,
|
||||
];
|
||||
|
||||
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value);
|
||||
static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value);
|
||||
|
||||
static List<PluginContextType> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginContextType>[];
|
||||
static List<PluginContext> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginContext>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginContextType.fromJson(row);
|
||||
final value = PluginContext.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
@@ -50,16 +50,16 @@ class PluginContextType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [PluginContextType] to String,
|
||||
/// and [decode] dynamic data back to [PluginContextType].
|
||||
class PluginContextTypeTypeTransformer {
|
||||
factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._();
|
||||
/// Transformation class that can [encode] an instance of [PluginContext] to String,
|
||||
/// and [decode] dynamic data back to [PluginContext].
|
||||
class PluginContextTypeTransformer {
|
||||
factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._();
|
||||
|
||||
const PluginContextTypeTypeTransformer._();
|
||||
const PluginContextTypeTransformer._();
|
||||
|
||||
String encode(PluginContextType data) => data.value;
|
||||
String encode(PluginContext data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a PluginContextType.
|
||||
/// Decodes a [dynamic value][data] to a PluginContext.
|
||||
///
|
||||
/// 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]
|
||||
@@ -67,12 +67,12 @@ class PluginContextTypeTypeTransformer {
|
||||
///
|
||||
/// 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.
|
||||
PluginContextType? decode(dynamic data, {bool allowNull = true}) {
|
||||
PluginContext? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'asset': return PluginContextType.asset;
|
||||
case r'album': return PluginContextType.album;
|
||||
case r'person': return PluginContextType.person;
|
||||
case r'asset': return PluginContext.asset;
|
||||
case r'album': return PluginContext.album;
|
||||
case r'person': return PluginContext.person;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
@@ -82,7 +82,7 @@ class PluginContextTypeTypeTransformer {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [PluginContextTypeTypeTransformer] instance.
|
||||
static PluginContextTypeTypeTransformer? _instance;
|
||||
/// Singleton [PluginContextTypeTransformer] instance.
|
||||
static PluginContextTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class PluginFilterResponseDto {
|
||||
|
||||
Object? schema;
|
||||
|
||||
List<PluginContextType> supportedContexts;
|
||||
List<PluginContext> supportedContexts;
|
||||
|
||||
String title;
|
||||
|
||||
@@ -90,7 +90,7 @@ class PluginFilterResponseDto {
|
||||
methodName: mapValueOfType<String>(json, r'methodName')!,
|
||||
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
||||
schema: mapValueOfType<Object>(json, r'schema'),
|
||||
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
|
||||
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 PluginTriggerResponseDto {
|
||||
/// Returns a new [PluginTriggerResponseDto] instance.
|
||||
PluginTriggerResponseDto({
|
||||
required this.contextType,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
PluginContextType contextType;
|
||||
|
||||
PluginTriggerType type;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
|
||||
other.contextType == contextType &&
|
||||
other.type == type;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(contextType.hashCode) +
|
||||
(type.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'contextType'] = this.contextType;
|
||||
json[r'type'] = this.type;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PluginTriggerResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PluginTriggerResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginTriggerResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginTriggerResponseDto(
|
||||
contextType: PluginContextType.fromJson(json[r'contextType'])!,
|
||||
type: PluginTriggerType.fromJson(json[r'type'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PluginTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginTriggerResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginTriggerResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PluginTriggerResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginTriggerResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PluginTriggerResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginTriggerResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'contextType',
|
||||
'type',
|
||||
};
|
||||
}
|
||||
|
||||
78
mobile/openapi/lib/model/workflow_response_dto.dart
generated
78
mobile/openapi/lib/model/workflow_response_dto.dart
generated
@@ -40,7 +40,7 @@ class WorkflowResponseDto {
|
||||
|
||||
String ownerId;
|
||||
|
||||
PluginTriggerType triggerType;
|
||||
WorkflowResponseDtoTriggerTypeEnum triggerType;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
|
||||
@@ -105,7 +105,7 @@ class WorkflowResponseDto {
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
|
||||
triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -165,3 +165,77 @@ class WorkflowResponseDto {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
class WorkflowResponseDtoTriggerTypeEnum {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const WorkflowResponseDtoTriggerTypeEnum._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate');
|
||||
static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized');
|
||||
|
||||
/// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum].
|
||||
static const values = <WorkflowResponseDtoTriggerTypeEnum>[
|
||||
assetCreate,
|
||||
personRecognized,
|
||||
];
|
||||
|
||||
static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().decode(value);
|
||||
|
||||
static List<WorkflowResponseDtoTriggerTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowResponseDtoTriggerTypeEnum>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowResponseDtoTriggerTypeEnum.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String,
|
||||
/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum].
|
||||
class WorkflowResponseDtoTriggerTypeEnumTypeTransformer {
|
||||
factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
|
||||
|
||||
const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
|
||||
|
||||
String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum.
|
||||
///
|
||||
/// 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.
|
||||
WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate;
|
||||
case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance.
|
||||
static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
23
mobile/openapi/lib/model/workflow_update_dto.dart
generated
23
mobile/openapi/lib/model/workflow_update_dto.dart
generated
@@ -18,7 +18,6 @@ class WorkflowUpdateDto {
|
||||
this.enabled,
|
||||
this.filters = const [],
|
||||
this.name,
|
||||
this.triggerType,
|
||||
});
|
||||
|
||||
List<WorkflowActionItemDto> actions;
|
||||
@@ -49,22 +48,13 @@ class WorkflowUpdateDto {
|
||||
///
|
||||
String? name;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
PluginTriggerType? triggerType;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
|
||||
_deepEquality.equals(other.actions, actions) &&
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
_deepEquality.equals(other.filters, filters) &&
|
||||
other.name == name &&
|
||||
other.triggerType == triggerType;
|
||||
other.name == name;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
@@ -73,11 +63,10 @@ class WorkflowUpdateDto {
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(filters.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(triggerType == null ? 0 : triggerType!.hashCode);
|
||||
(name == null ? 0 : name!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
|
||||
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -98,11 +87,6 @@ class WorkflowUpdateDto {
|
||||
} else {
|
||||
// json[r'name'] = null;
|
||||
}
|
||||
if (this.triggerType != null) {
|
||||
json[r'triggerType'] = this.triggerType;
|
||||
} else {
|
||||
// json[r'triggerType'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -120,7 +104,6 @@ class WorkflowUpdateDto {
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -27,10 +27,6 @@ class PlatformAsset {
|
||||
final int orientation;
|
||||
final bool isFavorite;
|
||||
|
||||
final int? adjustmentTime;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
const PlatformAsset({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -42,9 +38,6 @@ class PlatformAsset {
|
||||
this.durationInSeconds = 0,
|
||||
this.orientation = 0,
|
||||
this.isFavorite = false,
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
5
mobile/test/drift/main/generated/schema.dart
generated
5
mobile/test/drift/main/generated/schema.dart
generated
@@ -16,7 +16,6 @@ import 'schema_v10.dart' as v10;
|
||||
import 'schema_v11.dart' as v11;
|
||||
import 'schema_v12.dart' as v12;
|
||||
import 'schema_v13.dart' as v13;
|
||||
import 'schema_v14.dart' as v14;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -48,12 +47,10 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v12.DatabaseAtV12(db);
|
||||
case 13:
|
||||
return v13.DatabaseAtV13(db);
|
||||
case 14:
|
||||
return v14.DatabaseAtV14(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
}
|
||||
|
||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
|
||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
||||
}
|
||||
|
||||
7878
mobile/test/drift/main/generated/schema_v14.dart
generated
7878
mobile/test/drift/main/generated/schema_v14.dart
generated
File diff suppressed because it is too large
Load Diff
@@ -8020,55 +8020,6 @@
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/plugins/triggers": {
|
||||
"get": {
|
||||
"description": "Retrieve a list of all available plugin triggers.",
|
||||
"operationId": "getPluginTriggers",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginTriggerResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "List all plugin triggers",
|
||||
"tags": [
|
||||
"Plugins"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.3.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.3.0",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "plugin.read",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/plugins/{id}": {
|
||||
"get": {
|
||||
"description": "Retrieve information about a specific plugin by its ID.",
|
||||
@@ -18331,7 +18282,7 @@
|
||||
},
|
||||
"supportedContexts": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginContextType"
|
||||
"$ref": "#/components/schemas/PluginContext"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
@@ -18350,7 +18301,7 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginContextType": {
|
||||
"PluginContext": {
|
||||
"enum": [
|
||||
"asset",
|
||||
"album",
|
||||
@@ -18378,7 +18329,7 @@
|
||||
},
|
||||
"supportedContexts": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginContextType"
|
||||
"$ref": "#/components/schemas/PluginContext"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
@@ -18450,29 +18401,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginTriggerResponseDto": {
|
||||
"properties": {
|
||||
"contextType": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PluginContextType"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PluginTriggerType"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"contextType",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginTriggerType": {
|
||||
"enum": [
|
||||
"AssetCreate",
|
||||
@@ -23388,11 +23316,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"triggerType": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PluginTriggerType"
|
||||
}
|
||||
]
|
||||
"enum": [
|
||||
"AssetCreate",
|
||||
"PersonRecognized"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -23430,13 +23358,6 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"triggerType": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PluginTriggerType"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -942,7 +942,7 @@ export type PluginActionResponseDto = {
|
||||
methodName: string;
|
||||
pluginId: string;
|
||||
schema: object | null;
|
||||
supportedContexts: PluginContextType[];
|
||||
supportedContexts: PluginContext[];
|
||||
title: string;
|
||||
};
|
||||
export type PluginFilterResponseDto = {
|
||||
@@ -951,7 +951,7 @@ export type PluginFilterResponseDto = {
|
||||
methodName: string;
|
||||
pluginId: string;
|
||||
schema: object | null;
|
||||
supportedContexts: PluginContextType[];
|
||||
supportedContexts: PluginContext[];
|
||||
title: string;
|
||||
};
|
||||
export type PluginResponseDto = {
|
||||
@@ -966,10 +966,6 @@ export type PluginResponseDto = {
|
||||
updatedAt: string;
|
||||
version: string;
|
||||
};
|
||||
export type PluginTriggerResponseDto = {
|
||||
contextType: PluginContextType;
|
||||
"type": PluginTriggerType;
|
||||
};
|
||||
export type QueueResponseDto = {
|
||||
isPaused: boolean;
|
||||
name: QueueName;
|
||||
@@ -1754,7 +1750,7 @@ export type WorkflowResponseDto = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
ownerId: string;
|
||||
triggerType: PluginTriggerType;
|
||||
triggerType: TriggerType;
|
||||
};
|
||||
export type WorkflowActionItemDto = {
|
||||
actionConfig?: object;
|
||||
@@ -1778,7 +1774,6 @@ export type WorkflowUpdateDto = {
|
||||
enabled?: boolean;
|
||||
filters?: WorkflowFilterItemDto[];
|
||||
name?: string;
|
||||
triggerType?: PluginTriggerType;
|
||||
};
|
||||
/**
|
||||
* List all activities
|
||||
@@ -3661,17 +3656,6 @@ export function getPlugins(opts?: Oazapfts.RequestOpts) {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* List all plugin triggers
|
||||
*/
|
||||
export function getPluginTriggers(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: PluginTriggerResponseDto[];
|
||||
}>("/plugins/triggers", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Retrieve a plugin
|
||||
*/
|
||||
@@ -5434,15 +5418,11 @@ export enum PartnerDirection {
|
||||
SharedBy = "shared-by",
|
||||
SharedWith = "shared-with"
|
||||
}
|
||||
export enum PluginContextType {
|
||||
export enum PluginContext {
|
||||
Asset = "asset",
|
||||
Album = "album",
|
||||
Person = "person"
|
||||
}
|
||||
export enum PluginTriggerType {
|
||||
AssetCreate = "AssetCreate",
|
||||
PersonRecognized = "PersonRecognized"
|
||||
}
|
||||
export enum QueueJobStatus {
|
||||
Active = "active",
|
||||
Failed = "failed",
|
||||
@@ -5659,3 +5639,11 @@ export enum OAuthTokenEndpointAuthMethod {
|
||||
ClientSecretPost = "client_secret_post",
|
||||
ClientSecretBasic = "client_secret_basic"
|
||||
}
|
||||
export enum TriggerType {
|
||||
AssetCreate = "AssetCreate",
|
||||
PersonRecognized = "PersonRecognized"
|
||||
}
|
||||
export enum PluginTriggerType {
|
||||
AssetCreate = "AssetCreate",
|
||||
PersonRecognized = "PersonRecognized"
|
||||
}
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"name": "immich-core",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.0",
|
||||
"title": "Immich Core",
|
||||
"description": "Core workflow capabilities for Immich",
|
||||
"author": "Immich Team",
|
||||
|
||||
"wasm": {
|
||||
"path": "dist/plugin.wasm"
|
||||
},
|
||||
|
||||
"filters": [
|
||||
{
|
||||
"methodName": "filterFileName",
|
||||
"title": "Filter by filename",
|
||||
"description": "Filter assets by filename pattern using text matching or regular expressions",
|
||||
"supportedContexts": [
|
||||
"asset"
|
||||
],
|
||||
"supportedContexts": ["asset"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"title": "Filename pattern",
|
||||
"description": "Text or regex pattern to match against filename"
|
||||
},
|
||||
"matchType": {
|
||||
"type": "string",
|
||||
"title": "Match type",
|
||||
"enum": [
|
||||
"contains",
|
||||
"regex",
|
||||
"exact"
|
||||
],
|
||||
"enum": ["contains", "regex", "exact"],
|
||||
"default": "contains",
|
||||
"description": "Type of pattern matching to perform"
|
||||
},
|
||||
@@ -40,57 +34,43 @@
|
||||
"description": "Whether matching should be case-sensitive"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
]
|
||||
"required": ["pattern"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"methodName": "filterFileType",
|
||||
"title": "Filter by file type",
|
||||
"description": "Filter assets by file type",
|
||||
"supportedContexts": [
|
||||
"asset"
|
||||
],
|
||||
"supportedContexts": ["asset"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fileTypes": {
|
||||
"type": "array",
|
||||
"title": "File types",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video"
|
||||
]
|
||||
"enum": ["IMAGE", "VIDEO"]
|
||||
},
|
||||
"description": "Allowed file types"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"fileTypes"
|
||||
]
|
||||
"required": ["fileTypes"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"methodName": "filterPerson",
|
||||
"title": "Filter by person",
|
||||
"description": "Filter by detected person",
|
||||
"supportedContexts": [
|
||||
"person"
|
||||
],
|
||||
"supportedContexts": ["person"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"personIds": {
|
||||
"type": "array",
|
||||
"title": "Person IDs",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "List of person to match",
|
||||
"subType": "people-picker"
|
||||
"description": "List of person to match"
|
||||
},
|
||||
"matchAny": {
|
||||
"type": "boolean",
|
||||
@@ -98,29 +78,24 @@
|
||||
"description": "Match any name (true) or require all names (false)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"personIds"
|
||||
]
|
||||
"required": ["personIds"]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"methodName": "actionArchive",
|
||||
"title": "Archive",
|
||||
"description": "Move the asset to archive",
|
||||
"supportedContexts": [
|
||||
"asset"
|
||||
],
|
||||
"supportedContexts": ["asset"],
|
||||
"schema": {}
|
||||
},
|
||||
{
|
||||
"methodName": "actionFavorite",
|
||||
"title": "Favorite",
|
||||
"description": "Mark the asset as favorite or unfavorite",
|
||||
"supportedContexts": [
|
||||
"asset"
|
||||
],
|
||||
"supportedContexts": ["asset"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -136,23 +111,16 @@
|
||||
"methodName": "actionAddToAlbum",
|
||||
"title": "Add to Album",
|
||||
"description": "Add the item to a specified album",
|
||||
"supportedContexts": [
|
||||
"asset",
|
||||
"person"
|
||||
],
|
||||
"supportedContexts": ["asset", "person"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"albumId": {
|
||||
"type": "string",
|
||||
"title": "Album ID",
|
||||
"description": "Target album ID",
|
||||
"subType": "album-picker"
|
||||
"description": "Target album ID"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"albumId"
|
||||
]
|
||||
"required": ["albumId"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
724
pnpm-lock.yaml
generated
724
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -153,7 +153,7 @@
|
||||
"mock-fs": "^5.2.0",
|
||||
"node-gyp": "^12.0.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.0.2",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sql-formatter": "^15.0.0",
|
||||
"supertest": "^7.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { PluginResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Authenticated } from 'src/middleware/auth.guard';
|
||||
import { PluginService } from 'src/services/plugin.service';
|
||||
@@ -12,17 +12,6 @@ import { UUIDParamDto } from 'src/validation';
|
||||
export class PluginController {
|
||||
constructor(private service: PluginService) {}
|
||||
|
||||
@Get('triggers')
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'List all plugin triggers',
|
||||
description: 'Retrieve a list of all available plugin triggers.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
getPluginTriggers(): PluginTriggerResponseDto[] {
|
||||
return this.service.getTriggers();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { PluginAction, PluginFilter } from 'src/database';
|
||||
import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum';
|
||||
import { PluginContext } from 'src/enum';
|
||||
import type { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
export class PluginTriggerResponseDto {
|
||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
|
||||
type!: PluginTriggerType;
|
||||
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
|
||||
contextType!: PluginContextType;
|
||||
}
|
||||
|
||||
export class PluginResponseDto {
|
||||
id!: string;
|
||||
name!: string;
|
||||
@@ -31,8 +24,8 @@ export class PluginFilterResponseDto {
|
||||
title!: string;
|
||||
description!: string;
|
||||
|
||||
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
|
||||
supportedContexts!: PluginContextType[];
|
||||
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
|
||||
supportedContexts!: PluginContext[];
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
@@ -43,8 +36,8 @@ export class PluginActionResponseDto {
|
||||
title!: string;
|
||||
description!: string;
|
||||
|
||||
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
|
||||
supportedContexts!: PluginContextType[];
|
||||
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
|
||||
supportedContexts!: PluginContext[];
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,6 @@ export class WorkflowCreateDto {
|
||||
}
|
||||
|
||||
export class WorkflowUpdateDto {
|
||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', optional: true })
|
||||
triggerType?: PluginTriggerType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
@@ -77,7 +74,6 @@ export class WorkflowUpdateDto {
|
||||
export class WorkflowResponseDto {
|
||||
id!: string;
|
||||
ownerId!: string;
|
||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
|
||||
triggerType!: PluginTriggerType;
|
||||
name!: string | null;
|
||||
description!: string;
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { PluginContext, PluginTriggerType } from 'src/enum';
|
||||
import { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
|
||||
export type PluginTrigger = {
|
||||
name: string;
|
||||
type: PluginTriggerType;
|
||||
contextType: PluginContext;
|
||||
description: string;
|
||||
context: PluginContext;
|
||||
schema: JSONSchema | null;
|
||||
};
|
||||
|
||||
export const pluginTriggers: PluginTrigger[] = [
|
||||
{
|
||||
name: 'Asset Uploaded',
|
||||
type: PluginTriggerType.AssetCreate,
|
||||
contextType: PluginContext.Asset,
|
||||
description: 'Triggered when a new asset is uploaded',
|
||||
context: PluginContext.Asset,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
assetType: {
|
||||
type: 'string',
|
||||
description: 'Type of the asset',
|
||||
default: 'ALL',
|
||||
enum: ['Image', 'Video', 'All'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Person Recognized',
|
||||
type: PluginTriggerType.PersonRecognized,
|
||||
contextType: PluginContext.Person,
|
||||
description: 'Triggered when a person is detected in an asset',
|
||||
context: PluginContext.Person,
|
||||
schema: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -369,7 +369,6 @@ select
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."encodedVideoPath",
|
||||
"asset"."originalPath",
|
||||
"asset"."isOffline",
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
(
|
||||
select
|
||||
|
||||
@@ -7,8 +7,6 @@ from
|
||||
"workflow"
|
||||
where
|
||||
"id" = $1
|
||||
order by
|
||||
"createdAt" desc
|
||||
|
||||
-- WorkflowRepository.getWorkflowsByOwner
|
||||
select
|
||||
@@ -18,7 +16,7 @@ from
|
||||
where
|
||||
"ownerId" = $1
|
||||
order by
|
||||
"createdAt" desc
|
||||
"name"
|
||||
|
||||
-- WorkflowRepository.getWorkflowsByTrigger
|
||||
select
|
||||
|
||||
@@ -232,7 +232,6 @@ export class AssetJobRepository {
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.encodedVideoPath',
|
||||
'asset.originalPath',
|
||||
'asset.isOffline',
|
||||
])
|
||||
.$call(withExif)
|
||||
.select(withFacesAndPeople)
|
||||
|
||||
@@ -12,22 +12,12 @@ export class WorkflowRepository {
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getWorkflow(id: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.executeTakeFirst();
|
||||
return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getWorkflowsByOwner(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.selectAll()
|
||||
.where('ownerId', '=', ownerId)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute();
|
||||
return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })
|
||||
|
||||
@@ -585,6 +585,8 @@ describe(AssetService.name, () => {
|
||||
'/uploads/user-id/webp/path.ext',
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
'/uploads/user-id/fullsize/path.webp',
|
||||
assetWithFace.encodedVideoPath, // this value is null
|
||||
undefined, // no sidecar path
|
||||
assetWithFace.originalPath,
|
||||
],
|
||||
},
|
||||
@@ -646,6 +648,8 @@ describe(AssetService.name, () => {
|
||||
'/uploads/user-id/webp/path.ext',
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
'/uploads/user-id/fullsize/path.webp',
|
||||
undefined,
|
||||
undefined,
|
||||
'fake_path/asset_1.jpeg',
|
||||
],
|
||||
},
|
||||
@@ -672,6 +676,8 @@ describe(AssetService.name, () => {
|
||||
'/uploads/user-id/webp/path.ext',
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
'/uploads/user-id/fullsize/path.webp',
|
||||
undefined,
|
||||
undefined,
|
||||
'fake_path/asset_1.jpeg',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -363,11 +363,11 @@ export class AssetService extends BaseService {
|
||||
const { fullsizeFile, previewFile, thumbnailFile, sidecarFile } = getAssetFiles(asset.files ?? []);
|
||||
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
|
||||
|
||||
if (deleteOnDisk && !asset.isOffline) {
|
||||
if (deleteOnDisk) {
|
||||
files.push(sidecarFile?.path, asset.originalPath);
|
||||
}
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: files.filter(Boolean) } });
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files } });
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,8 @@ import { join } from 'node:path';
|
||||
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
|
||||
import { pluginTriggers } from 'src/plugins';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
|
||||
@@ -51,10 +50,6 @@ export class PluginService extends BaseService {
|
||||
await this.loadPlugins();
|
||||
}
|
||||
|
||||
getTriggers(): PluginTriggerResponseDto[] {
|
||||
return pluginTriggers;
|
||||
}
|
||||
|
||||
//
|
||||
// CRUD operations for plugins
|
||||
//
|
||||
|
||||
@@ -16,10 +16,10 @@ import { BaseService } from 'src/services/base.service';
|
||||
@Injectable()
|
||||
export class WorkflowService extends BaseService {
|
||||
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
|
||||
const context = this.getContextForTrigger(dto.triggerType);
|
||||
const trigger = this.getTriggerOrFail(dto.triggerType);
|
||||
|
||||
const filterInserts = await this.validateAndMapFilters(dto.filters, context);
|
||||
const actionInserts = await this.validateAndMapActions(dto.actions, context);
|
||||
const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context);
|
||||
const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context);
|
||||
|
||||
const workflow = await this.workflowRepository.createWorkflow(
|
||||
{
|
||||
@@ -56,11 +56,11 @@ export class WorkflowService extends BaseService {
|
||||
}
|
||||
|
||||
const workflow = await this.findOrFail(id);
|
||||
const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType);
|
||||
const trigger = this.getTriggerOrFail(workflow.triggerType);
|
||||
|
||||
const { filters, actions, ...workflowUpdate } = dto;
|
||||
const filterInserts = filters && (await this.validateAndMapFilters(filters, context));
|
||||
const actionInserts = actions && (await this.validateAndMapActions(actions, context));
|
||||
const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context));
|
||||
const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context));
|
||||
|
||||
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
|
||||
id,
|
||||
@@ -124,12 +124,12 @@ export class WorkflowService extends BaseService {
|
||||
}));
|
||||
}
|
||||
|
||||
private getContextForTrigger(type: PluginTriggerType) {
|
||||
const trigger = pluginTriggers.find((t) => t.type === type);
|
||||
private getTriggerOrFail(triggerType: PluginTriggerType) {
|
||||
const trigger = pluginTriggers.find((t) => t.type === triggerType);
|
||||
if (!trigger) {
|
||||
throw new BadRequestException(`Invalid trigger type: ${type}`);
|
||||
throw new BadRequestException(`Invalid trigger type: ${triggerType}`);
|
||||
}
|
||||
return trigger.contextType;
|
||||
return trigger;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { asOptions } from 'src/sql-tools/helpers';
|
||||
import { register } from 'src/sql-tools/register';
|
||||
import { ColumnStorage, ColumnType, DatabaseEnum } from 'src/sql-tools/types';
|
||||
|
||||
export type ColumnValue = null | boolean | string | number | Array<unknown> | object | Date | (() => string);
|
||||
export type ColumnValue = null | boolean | string | number | object | Date | (() => string);
|
||||
|
||||
export type ColumnBaseOptions = {
|
||||
name?: string;
|
||||
|
||||
@@ -39,10 +39,6 @@ export const fromColumnValue = (columnValue?: ColumnValue) => {
|
||||
return `'${value.toISOString()}'`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return "'{}'";
|
||||
}
|
||||
|
||||
return `'${String(value)}'`;
|
||||
};
|
||||
|
||||
|
||||
@@ -394,20 +394,6 @@ describe(schemaDiff.name, () => {
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('should support arrays, ignoring types', () => {
|
||||
const diff = schemaDiff(
|
||||
fromColumn({ name: 'column1', type: 'character varying', isArray: true, default: "'{}'" }),
|
||||
fromColumn({
|
||||
name: 'column1',
|
||||
type: 'character varying',
|
||||
isArray: true,
|
||||
default: "'{}'::character varying[]",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diff.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,16 +2,13 @@ import { Kysely } from 'kysely';
|
||||
import { AssetFileType, JobName, SharedLinkType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { StackRepository } from 'src/repositories/stack.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
@@ -23,16 +20,8 @@ let defaultDatabase: Kysely<DB>;
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(AssetService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [
|
||||
AssetRepository,
|
||||
AssetJobRepository,
|
||||
AlbumRepository,
|
||||
AccessRepository,
|
||||
SharedLinkAssetRepository,
|
||||
StackRepository,
|
||||
UserRepository,
|
||||
],
|
||||
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
|
||||
real: [AssetRepository, AlbumRepository, AccessRepository, SharedLinkAssetRepository, StackRepository],
|
||||
mock: [LoggingRepository, JobRepository, StorageRepository],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -221,51 +210,4 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete asset', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const thumbnailPath = '/path/to/thumbnail.jpg';
|
||||
const previewPath = '/path/to/preview.jpg';
|
||||
const sidecarPath = '/path/to/sidecar.xmp';
|
||||
await Promise.all([
|
||||
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Thumbnail, path: thumbnailPath }),
|
||||
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: previewPath }),
|
||||
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath }),
|
||||
]);
|
||||
|
||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||
|
||||
expect(ctx.getMock(JobRepository).queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [thumbnailPath, previewPath, sidecarPath, asset.originalPath] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not delete offline assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, isOffline: true });
|
||||
const thumbnailPath = '/path/to/thumbnail.jpg';
|
||||
const previewPath = '/path/to/preview.jpg';
|
||||
await Promise.all([
|
||||
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Thumbnail, path: thumbnailPath }),
|
||||
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: previewPath }),
|
||||
ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: `/path/to/sidecar.xmp` }),
|
||||
]);
|
||||
|
||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||
|
||||
expect(ctx.getMock(JobRepository).queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [thumbnailPath, previewPath] },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -611,100 +611,6 @@ describe(WorkflowService.name, () => {
|
||||
sut.update(auth, created.id, { actions: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should update trigger type', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.PersonRecognized,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await sut.update(auth, created.id, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
});
|
||||
|
||||
const fetched = await sut.get(auth, created.id);
|
||||
expect(fetched.triggerType).toBe(PluginTriggerType.AssetCreate);
|
||||
});
|
||||
|
||||
it('should add filters', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await sut.update(auth, created.id, {
|
||||
filters: [
|
||||
{ pluginFilterId: testFilterId, filterConfig: { first: true } },
|
||||
{ pluginFilterId: testFilterId, filterConfig: { second: true } },
|
||||
],
|
||||
});
|
||||
|
||||
const fetched = await sut.get(auth, created.id);
|
||||
expect(fetched.filters).toHaveLength(2);
|
||||
expect(fetched.filters[0].filterConfig).toEqual({ first: true });
|
||||
expect(fetched.filters[1].filterConfig).toEqual({ second: true });
|
||||
});
|
||||
|
||||
it('should replace existing filters', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [{ pluginFilterId: testFilterId, filterConfig: { original: true } }],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await sut.update(auth, created.id, {
|
||||
filters: [{ pluginFilterId: testFilterId, filterConfig: { replaced: true } }],
|
||||
});
|
||||
|
||||
const fetched = await sut.get(auth, created.id);
|
||||
expect(fetched.filters).toHaveLength(1);
|
||||
expect(fetched.filters[0].filterConfig).toEqual({ replaced: true });
|
||||
});
|
||||
|
||||
it('should remove existing filters', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [{ pluginFilterId: testFilterId, filterConfig: { toRemove: true } }],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await sut.update(auth, created.id, {
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const fetched = await sut.get(auth, created.id);
|
||||
expect(fetched.filters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Column, DatabaseSchema, Table } from 'src/sql-tools';
|
||||
|
||||
@Table()
|
||||
export class Table1 {
|
||||
@Column({ type: 'character varying', array: true, default: [] })
|
||||
column1!: string[];
|
||||
}
|
||||
|
||||
export const description = 'should register a table with a column with a default value (array)';
|
||||
export const schema: DatabaseSchema = {
|
||||
databaseName: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
overrides: [],
|
||||
tables: [
|
||||
{
|
||||
name: 'table1',
|
||||
columns: [
|
||||
{
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
type: 'character varying',
|
||||
nullable: false,
|
||||
isArray: true,
|
||||
primary: false,
|
||||
synchronize: true,
|
||||
default: "'{}'",
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
triggers: [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
@@ -28,7 +28,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@immich/ui": "^0.50.0",
|
||||
"@immich/ui": "^0.49.2",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.14.0",
|
||||
@@ -58,12 +58,10 @@
|
||||
"socket.io-client": "~4.8.0",
|
||||
"svelte-gestures": "^5.2.2",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"svelte-jsoneditor": "^3.10.0",
|
||||
"svelte-maplibre": "^1.2.5",
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"tabbable": "^6.2.0",
|
||||
"thumbhash": "^0.1.1",
|
||||
"uplot": "^1.6.32"
|
||||
"thumbhash": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
@@ -94,7 +92,7 @@
|
||||
"factory.ts": "^1.4.1",
|
||||
"globals": "^16.0.0",
|
||||
"happy-dom": "^20.0.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
export interface DragAndDropOptions {
|
||||
index: number;
|
||||
onDragStart?: (index: number) => void;
|
||||
onDragEnter?: (index: number) => void;
|
||||
onDrop?: (e: DragEvent, index: number) => void;
|
||||
onDragEnd?: () => void;
|
||||
isDragging?: boolean;
|
||||
isDragOver?: boolean;
|
||||
}
|
||||
|
||||
export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
|
||||
let { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
|
||||
|
||||
const isFormElement = (element: HTMLElement) => {
|
||||
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT';
|
||||
};
|
||||
|
||||
const handleDragStart = (e: DragEvent) => {
|
||||
// Prevent drag if it originated from an input, textarea, or select element
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onDragStart?.(index);
|
||||
};
|
||||
|
||||
const handleDragEnter = () => {
|
||||
onDragEnter?.(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
onDrop?.(e, index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
onDragEnd?.();
|
||||
};
|
||||
|
||||
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
node.setAttribute('draggable', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
node.setAttribute('draggable', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
node.setAttribute('draggable', 'true');
|
||||
node.setAttribute('role', 'button');
|
||||
node.setAttribute('tabindex', '0');
|
||||
|
||||
node.addEventListener('dragstart', handleDragStart);
|
||||
node.addEventListener('dragenter', handleDragEnter);
|
||||
node.addEventListener('dragover', handleDragOver);
|
||||
node.addEventListener('drop', handleDrop);
|
||||
node.addEventListener('dragend', handleDragEnd);
|
||||
node.addEventListener('focusin', handleFocusIn);
|
||||
node.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
// Update classes based on drag state
|
||||
const updateClasses = (dragging: boolean, dragOver: boolean) => {
|
||||
// Remove all drag-related classes first
|
||||
node.classList.remove('opacity-50', 'border-gray-400', 'dark:border-gray-500', 'border-solid');
|
||||
|
||||
// Add back only the active ones
|
||||
if (dragging) {
|
||||
node.classList.add('opacity-50');
|
||||
}
|
||||
|
||||
if (dragOver) {
|
||||
node.classList.add('border-gray-400', 'dark:border-gray-500', 'border-solid');
|
||||
node.classList.remove('border-transparent');
|
||||
} else {
|
||||
node.classList.add('border-transparent');
|
||||
}
|
||||
};
|
||||
|
||||
updateClasses(isDragging || false, isDragOver || false);
|
||||
|
||||
return {
|
||||
update(newOptions: DragAndDropOptions) {
|
||||
index = newOptions.index;
|
||||
onDragStart = newOptions.onDragStart;
|
||||
onDragEnter = newOptions.onDragEnter;
|
||||
onDrop = newOptions.onDrop;
|
||||
onDragEnd = newOptions.onDragEnd;
|
||||
|
||||
const newIsDragging = newOptions.isDragging || false;
|
||||
const newIsDragOver = newOptions.isDragOver || false;
|
||||
|
||||
if (newIsDragging !== isDragging || newIsDragOver !== isDragOver) {
|
||||
isDragging = newIsDragging;
|
||||
isDragOver = newIsDragOver;
|
||||
updateClasses(isDragging, isDragOver);
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('dragstart', handleDragStart);
|
||||
node.removeEventListener('dragenter', handleDragEnter);
|
||||
node.removeEventListener('dragover', handleDragOver);
|
||||
node.removeEventListener('drop', handleDrop);
|
||||
node.removeEventListener('dragend', handleDragEnd);
|
||||
node.removeEventListener('focusin', handleFocusIn);
|
||||
node.removeEventListener('focusout', handleFocusOut);
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.2 KiB |
@@ -1,105 +0,0 @@
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
|
||||
export interface DragAndDropOptions {
|
||||
index: number;
|
||||
onDragStart?: (index: number) => void;
|
||||
onDragEnter?: (index: number) => void;
|
||||
onDrop?: (e: DragEvent, index: number) => void;
|
||||
onDragEnd?: () => void;
|
||||
isDragging?: boolean;
|
||||
isDragOver?: boolean;
|
||||
}
|
||||
|
||||
export function dragAndDrop(options: DragAndDropOptions): Attachment {
|
||||
return (node: Element) => {
|
||||
const element = node as HTMLElement;
|
||||
const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
|
||||
|
||||
const isFormElement = (el: HTMLElement) => {
|
||||
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
|
||||
};
|
||||
|
||||
const handleDragStart = (e: DragEvent) => {
|
||||
// Prevent drag if it originated from an input, textarea, or select element
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onDragStart?.(index);
|
||||
};
|
||||
|
||||
const handleDragEnter = () => {
|
||||
onDragEnter?.(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
onDrop?.(e, index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
onDragEnd?.();
|
||||
};
|
||||
|
||||
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
element.setAttribute('draggable', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (isFormElement(target)) {
|
||||
element.setAttribute('draggable', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
// Update classes based on drag state
|
||||
const updateClasses = (dragging: boolean, dragOver: boolean) => {
|
||||
// Remove all drag-related classes first
|
||||
element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
|
||||
|
||||
// Add back only the active ones
|
||||
if (dragging) {
|
||||
element.classList.add('opacity-50');
|
||||
}
|
||||
|
||||
if (dragOver) {
|
||||
element.classList.add('border-light-500', 'border-solid');
|
||||
element.classList.remove('border-transparent');
|
||||
} else {
|
||||
element.classList.add('border-transparent');
|
||||
}
|
||||
};
|
||||
|
||||
element.setAttribute('draggable', 'true');
|
||||
element.setAttribute('role', 'button');
|
||||
element.setAttribute('tabindex', '0');
|
||||
|
||||
element.addEventListener('dragstart', handleDragStart);
|
||||
element.addEventListener('dragenter', handleDragEnter);
|
||||
element.addEventListener('dragover', handleDragOver);
|
||||
element.addEventListener('drop', handleDrop);
|
||||
element.addEventListener('dragend', handleDragEnd);
|
||||
element.addEventListener('focusin', handleFocusIn);
|
||||
element.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
updateClasses(isDragging || false, isDragOver || false);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('dragstart', handleDragStart);
|
||||
element.removeEventListener('dragenter', handleDragEnter);
|
||||
element.removeEventListener('dragover', handleDragOver);
|
||||
element.removeEventListener('drop', handleDrop);
|
||||
element.removeEventListener('dragend', handleDragEnd);
|
||||
element.removeEventListener('focusin', handleFocusIn);
|
||||
element.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { Button } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
action: HeaderButtonActionItem;
|
||||
};
|
||||
|
||||
const { action }: Props = $props();
|
||||
const { title, icon, color = 'secondary', onAction } = $derived(action);
|
||||
</script>
|
||||
|
||||
{#if action.$if?.() ?? true}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
{color}
|
||||
leadingIcon={icon}
|
||||
onclick={() => onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
{/if}
|
||||
17
web/src/lib/components/HeaderButton.svelte
Normal file
17
web/src/lib/components/HeaderButton.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { type ActionItem, Button, Text } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
action: ActionItem;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const { action, title: titleAttr }: Props = $props();
|
||||
const { title, icon, color = 'secondary', onAction } = $derived(action);
|
||||
</script>
|
||||
|
||||
{#if action.$if?.() ?? true}
|
||||
<Button variant="ghost" size="small" {color} leadingIcon={icon} onclick={() => onAction(action)} title={titleAttr}>
|
||||
<Text class="hidden md:block">{title}</Text>
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -1,160 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import type { QueueSnapshot } from '$lib/types';
|
||||
import type { QueueResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, Theme, theme } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import uPlot, { type AlignedData, type Axis } from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
|
||||
type Props = {
|
||||
queue: QueueResponseDto;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { queue, class: className = '' }: Props = $props();
|
||||
|
||||
type Data = number | null;
|
||||
type NormalizedData = [
|
||||
Data[], // timestamps
|
||||
Data[], // failed counts
|
||||
Data[], // active counts
|
||||
Data[], // waiting counts
|
||||
];
|
||||
|
||||
const normalizeData = (snapshots: QueueSnapshot[]) => {
|
||||
const items: NormalizedData = [[], [], [], []];
|
||||
|
||||
for (const { timestamp, snapshot } of snapshots) {
|
||||
items[0].push(timestamp);
|
||||
|
||||
const statistics = (snapshot || []).find(({ name }) => name === queue.name)?.statistics;
|
||||
|
||||
if (statistics) {
|
||||
items[1].push(statistics.failed);
|
||||
items[2].push(statistics.active);
|
||||
items[3].push(statistics.waiting + statistics.paused);
|
||||
} else {
|
||||
items[0].push(timestamp);
|
||||
items[1].push(null);
|
||||
items[2].push(null);
|
||||
items[3].push(null);
|
||||
}
|
||||
}
|
||||
|
||||
items[0].push(Date.now() + 5000);
|
||||
items[1].push(items[1].at(-1) ?? 0);
|
||||
items[2].push(items[2].at(-1) ?? 0);
|
||||
items[3].push(items[3].at(-1) ?? 0);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const data = $derived(normalizeData(queueManager.snapshots));
|
||||
|
||||
let chartElement: HTMLDivElement | undefined = $state();
|
||||
let isDark = $derived(theme.value === Theme.Dark);
|
||||
let plot: uPlot;
|
||||
|
||||
const axisOptions: Axis = {
|
||||
stroke: () => (isDark ? '#ccc' : 'black'),
|
||||
ticks: {
|
||||
show: true,
|
||||
stroke: () => (isDark ? '#444' : '#ddd'),
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
stroke: () => (isDark ? '#444' : '#ddd'),
|
||||
},
|
||||
};
|
||||
|
||||
const seriesOptions: uPlot.Series = {
|
||||
spanGaps: false,
|
||||
points: {
|
||||
show: false,
|
||||
},
|
||||
width: 2,
|
||||
};
|
||||
|
||||
const options: uPlot.Options = {
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
cursor: {
|
||||
show: false,
|
||||
lock: true,
|
||||
drag: {
|
||||
setScale: false,
|
||||
},
|
||||
},
|
||||
width: 200,
|
||||
height: 200,
|
||||
ms: 1,
|
||||
pxAlign: true,
|
||||
scales: {
|
||||
y: {
|
||||
distr: 1,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: '#d94a4a',
|
||||
...seriesOptions,
|
||||
},
|
||||
{
|
||||
stroke: '#4250af',
|
||||
...seriesOptions,
|
||||
},
|
||||
{
|
||||
stroke: '#1075db',
|
||||
...seriesOptions,
|
||||
},
|
||||
],
|
||||
|
||||
axes: [
|
||||
{
|
||||
...axisOptions,
|
||||
values: (plot, values) => {
|
||||
return values.map((value) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return DateTime.fromMillis(value).toFormat('hh:mm:ss');
|
||||
});
|
||||
},
|
||||
},
|
||||
axisOptions,
|
||||
],
|
||||
};
|
||||
|
||||
const onThemeChange = () => plot?.redraw(false);
|
||||
|
||||
$effect(() => theme.value && onThemeChange());
|
||||
|
||||
onMount(() => {
|
||||
plot = new uPlot(options, data as AlignedData, chartElement);
|
||||
});
|
||||
|
||||
const update = () => {
|
||||
if (plot && chartElement && data[0].length > 0) {
|
||||
const now = Date.now();
|
||||
const scale = { min: now - chartElement!.clientWidth * 100, max: now };
|
||||
|
||||
plot.setData(data as AlignedData, false);
|
||||
plot.setScale('x', scale);
|
||||
plot.setSize({ width: chartElement.clientWidth, height: chartElement.clientHeight });
|
||||
}
|
||||
|
||||
requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
requestAnimationFrame(update);
|
||||
</script>
|
||||
|
||||
<div class="w-full {className}" bind:this={chartElement}>
|
||||
{#if data[0].length === 0}
|
||||
<LoadingSpinner size="giant" />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,132 +0,0 @@
|
||||
<script lang="ts">
|
||||
import QueueCard from '$lib/components/QueueCard.svelte';
|
||||
import QueueStorageMigrationDescription from '$lib/components/QueueStorageMigrationDescription.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { queueManager } from '$lib/managers/queue-manager.svelte';
|
||||
import { asQueueItem } from '$lib/services/queue.service';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
QueueCommand,
|
||||
type QueueCommandDto,
|
||||
QueueName,
|
||||
type QueueResponseDto,
|
||||
runQueueCommandLegacy,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager } from '@immich/ui';
|
||||
import type { Component } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
queues: QueueResponseDto[];
|
||||
};
|
||||
|
||||
let { queues }: Props = $props();
|
||||
const featureFlags = featureFlagsManager.value;
|
||||
|
||||
type QueueDetails = {
|
||||
description?: Component;
|
||||
allText?: string;
|
||||
refreshText?: string;
|
||||
missingText: string;
|
||||
disabled?: boolean;
|
||||
handleCommand?: (jobId: QueueName, jobCommand: QueueCommandDto) => Promise<void>;
|
||||
};
|
||||
|
||||
const queueDetails: Partial<Record<QueueName, QueueDetails>> = {
|
||||
[QueueName.ThumbnailGeneration]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.MetadataExtraction]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.Library]: {
|
||||
missingText: $t('rescan'),
|
||||
},
|
||||
[QueueName.Sidecar]: {
|
||||
allText: $t('sync'),
|
||||
missingText: $t('discover'),
|
||||
disabled: !featureFlags.sidecar,
|
||||
},
|
||||
[QueueName.SmartSearch]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.smartSearch,
|
||||
},
|
||||
[QueueName.DuplicateDetection]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.duplicateDetection,
|
||||
},
|
||||
[QueueName.FaceDetection]: {
|
||||
allText: $t('reset'),
|
||||
refreshText: $t('refresh'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.facialRecognition,
|
||||
},
|
||||
[QueueName.FacialRecognition]: {
|
||||
allText: $t('reset'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.facialRecognition,
|
||||
},
|
||||
[QueueName.Ocr]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.ocr,
|
||||
},
|
||||
[QueueName.VideoConversion]: {
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.StorageTemplateMigration]: {
|
||||
missingText: $t('start'),
|
||||
description: QueueStorageMigrationDescription,
|
||||
},
|
||||
[QueueName.Migration]: {
|
||||
missingText: $t('start'),
|
||||
},
|
||||
};
|
||||
|
||||
let queueList = Object.entries(queueDetails) as [QueueName, QueueDetails][];
|
||||
|
||||
const handleCommand = async (name: QueueName, dto: QueueCommandDto) => {
|
||||
const item = asQueueItem($t, { name });
|
||||
|
||||
switch (name) {
|
||||
case QueueName.FaceDetection:
|
||||
case QueueName.FacialRecognition: {
|
||||
if (dto.force) {
|
||||
const confirmed = await modalManager.showDialog({ prompt: $t('admin.confirm_reprocess_all_faces') });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await runQueueCommandLegacy({ name, queueCommandDto: dto });
|
||||
await queueManager.refresh();
|
||||
|
||||
switch (dto.command) {
|
||||
case QueueCommand.Empty: {
|
||||
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.failed_job_command', { values: { command: dto.command, job: item.title } }));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-7 mt-10">
|
||||
{#each queueList as [queueName, props] (queueName)}
|
||||
{@const queue = queues.find(({ name }) => name === queueName)}
|
||||
{#if queue}
|
||||
<QueueCard {queue} onCommand={(command) => handleCommand(queueName, command)} {...props} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -254,7 +254,7 @@
|
||||
values={{ job: $t('admin.storage_template_migration_job') }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<a href={resolve(AppRoute.ADMIN_QUEUES)} class="text-primary">
|
||||
<a href={resolve(AppRoute.ADMIN_JOBS)} class="text-primary">
|
||||
{message}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<ControlAppBar showBackButton={false}>
|
||||
{#snippet leading()}
|
||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||
<Logo variant="inline" class="min-w-min" />
|
||||
<Logo variant="inline" />
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<script lang="ts">
|
||||
import QueueCardBadge from '$lib/components/QueueCardBadge.svelte';
|
||||
import QueueCardButton from '$lib/components/QueueCardButton.svelte';
|
||||
import Badge from '$lib/elements/Badge.svelte';
|
||||
import { asQueueItem, getQueueDetailUrl } from '$lib/services/queue.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { QueueCommand, type QueueCommandDto, type QueueResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, Link } from '@immich/ui';
|
||||
import { QueueCommand, type QueueCommandDto, type QueueStatisticsDto, type QueueStatusLegacyDto } from '@immich/sdk';
|
||||
import { Icon, IconButton } from '@immich/ui';
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiAllInclusive,
|
||||
mdiChartLine,
|
||||
mdiClose,
|
||||
mdiFastForward,
|
||||
mdiImageRefreshOutline,
|
||||
@@ -19,23 +15,39 @@
|
||||
} from '@mdi/js';
|
||||
import { type Component } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import JobTileButton from './JobTileButton.svelte';
|
||||
import JobTileStatus from './JobTileStatus.svelte';
|
||||
|
||||
interface Props {
|
||||
queue: QueueResponseDto;
|
||||
description?: Component;
|
||||
title: string;
|
||||
subtitle: string | undefined;
|
||||
description: Component | undefined;
|
||||
statistics: QueueStatisticsDto;
|
||||
queueStatus: QueueStatusLegacyDto;
|
||||
icon: string;
|
||||
disabled?: boolean;
|
||||
allText?: string;
|
||||
refreshText?: string;
|
||||
allText: string | undefined;
|
||||
refreshText: string | undefined;
|
||||
missingText: string;
|
||||
onCommand: (command: QueueCommandDto) => void;
|
||||
}
|
||||
|
||||
let { queue, description, disabled = false, allText, refreshText, missingText, onCommand }: Props = $props();
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
statistics,
|
||||
queueStatus,
|
||||
icon,
|
||||
disabled = false,
|
||||
allText,
|
||||
refreshText,
|
||||
missingText,
|
||||
onCommand,
|
||||
}: Props = $props();
|
||||
|
||||
const { icon, title, subtitle } = $derived(asQueueItem($t, queue));
|
||||
const { statistics } = $derived(queue);
|
||||
let waitingCount = $derived(statistics.waiting + statistics.paused + statistics.delayed);
|
||||
let isIdle = $derived(statistics.active + statistics.waiting === 0 && !queue.isPaused);
|
||||
let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused);
|
||||
let multipleButtons = $derived(allText || refreshText);
|
||||
|
||||
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pe-4 ps-6';
|
||||
@@ -43,25 +55,17 @@
|
||||
|
||||
<div class="flex flex-col overflow-hidden rounded-2xl bg-gray-100 dark:bg-immich-dark-gray sm:flex-row sm:rounded-9">
|
||||
<div class="flex w-full flex-col">
|
||||
{#if queue.isPaused}
|
||||
<QueueCardBadge color="warning">{$t('paused')}</QueueCardBadge>
|
||||
{:else if statistics.active > 0}
|
||||
<QueueCardBadge color="success">{$t('active')}</QueueCardBadge>
|
||||
{#if queueStatus.isPaused}
|
||||
<JobTileStatus color="warning">{$t('paused')}</JobTileStatus>
|
||||
{:else if queueStatus.isActive}
|
||||
<JobTileStatus color="success">{$t('active')}</JobTileStatus>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
|
||||
<div class="flex items-center gap-2 text-xl font-semibold text-primary">
|
||||
<Link class="flex items-center gap-2 hover:underline" href={getQueueDetailUrl(queue)} underline={false}>
|
||||
<div class="flex items-center gap-4 text-xl font-semibold text-primary">
|
||||
<span class="flex items-center gap-2">
|
||||
<Icon {icon} size="1.25em" class="hidden shrink-0 sm:block" />
|
||||
<span class="uppercase">{title}</span>
|
||||
</Link>
|
||||
<IconButton
|
||||
color="primary"
|
||||
icon={mdiChartLine}
|
||||
aria-label={$t('view_details')}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
href={getQueueDetailUrl(queue)}
|
||||
/>
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
{#if statistics.failed > 0}
|
||||
<Badge>
|
||||
@@ -124,62 +128,62 @@
|
||||
</div>
|
||||
<div class="flex w-full flex-row overflow-hidden sm:w-32 sm:flex-col">
|
||||
{#if disabled}
|
||||
<QueueCardButton
|
||||
<JobTileButton
|
||||
disabled={true}
|
||||
color="light-gray"
|
||||
onClick={() => onCommand({ command: QueueCommand.Start, force: false })}
|
||||
>
|
||||
<Icon icon={mdiAlertCircle} size="36" />
|
||||
<span class="uppercase">{$t('disabled')}</span>
|
||||
</QueueCardButton>
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
|
||||
{#if !disabled && !isIdle}
|
||||
{#if waitingCount > 0}
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
|
||||
<JobTileButton color="gray" onClick={() => onCommand({ command: QueueCommand.Empty, force: false })}>
|
||||
<Icon icon={mdiClose} size="24" />
|
||||
<span class="uppercase">{$t('clear')}</span>
|
||||
</QueueCardButton>
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
{#if queue.isPaused}
|
||||
{#if queueStatus.isPaused}
|
||||
{@const size = waitingCount > 0 ? '24' : '48'}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}>
|
||||
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Resume, force: false })}>
|
||||
<!-- size property is not reactive, so have to use width and height -->
|
||||
<Icon icon={mdiFastForward} {size} />
|
||||
<span class="uppercase">{$t('resume')}</span>
|
||||
</QueueCardButton>
|
||||
</JobTileButton>
|
||||
{:else}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
|
||||
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Pause, force: false })}>
|
||||
<Icon icon={mdiPause} size="24" />
|
||||
<span class="uppercase">{$t('pause')}</span>
|
||||
</QueueCardButton>
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !disabled && multipleButtons && isIdle}
|
||||
{#if allText}
|
||||
<QueueCardButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
|
||||
<JobTileButton color="dark-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: true })}>
|
||||
<Icon icon={mdiAllInclusive} size="24" />
|
||||
<span class="uppercase">{allText}</span>
|
||||
</QueueCardButton>
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
{#if refreshText}
|
||||
<QueueCardButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
|
||||
<JobTileButton color="gray" onClick={() => onCommand({ command: QueueCommand.Start, force: undefined })}>
|
||||
<Icon icon={mdiImageRefreshOutline} size="24" />
|
||||
<span class="uppercase">{refreshText}</span>
|
||||
</QueueCardButton>
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiSelectionSearch} size="24" />
|
||||
<span class="uppercase">{missingText}</span>
|
||||
</QueueCardButton>
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
|
||||
{#if !disabled && !multipleButtons && isIdle}
|
||||
<QueueCardButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<JobTileButton color="light-gray" onClick={() => onCommand({ command: QueueCommand.Start, force: false })}>
|
||||
<Icon icon={mdiPlay} size="48" />
|
||||
<span class="uppercase">{missingText}</span>
|
||||
</QueueCardButton>
|
||||
</JobTileButton>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
197
web/src/lib/components/jobs/JobsPanel.svelte
Normal file
197
web/src/lib/components/jobs/JobsPanel.svelte
Normal file
@@ -0,0 +1,197 @@
|
||||
<script lang="ts">
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { getQueueName } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
QueueCommand,
|
||||
type QueueCommandDto,
|
||||
QueueName,
|
||||
type QueuesResponseLegacyDto,
|
||||
runQueueCommandLegacy,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
mdiContentDuplicate,
|
||||
mdiFaceRecognition,
|
||||
mdiFileJpgBox,
|
||||
mdiFileXmlBox,
|
||||
mdiFolderMove,
|
||||
mdiImageSearch,
|
||||
mdiLibraryShelves,
|
||||
mdiOcr,
|
||||
mdiTable,
|
||||
mdiTagFaces,
|
||||
mdiVideo,
|
||||
} from '@mdi/js';
|
||||
import type { Component } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import JobTile from './JobTile.svelte';
|
||||
import StorageMigrationDescription from './StorageMigrationDescription.svelte';
|
||||
|
||||
interface Props {
|
||||
jobs: QueuesResponseLegacyDto;
|
||||
}
|
||||
|
||||
let { jobs = $bindable() }: Props = $props();
|
||||
const featureFlags = featureFlagsManager.value;
|
||||
|
||||
type JobDetails = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: Component;
|
||||
allText?: string;
|
||||
refreshText?: string;
|
||||
missingText: string;
|
||||
disabled?: boolean;
|
||||
icon: string;
|
||||
handleCommand?: (jobId: QueueName, jobCommand: QueueCommandDto) => Promise<void>;
|
||||
};
|
||||
|
||||
const handleConfirmCommand = async (jobId: QueueName, dto: QueueCommandDto) => {
|
||||
if (dto.force) {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
prompt: $t('admin.confirm_reprocess_all_faces'),
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
await handleCommand(jobId, { command: QueueCommand.Start, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleCommand(jobId, dto);
|
||||
};
|
||||
|
||||
let jobDetails: Partial<Record<QueueName, JobDetails>> = {
|
||||
[QueueName.ThumbnailGeneration]: {
|
||||
icon: mdiFileJpgBox,
|
||||
title: $getQueueName(QueueName.ThumbnailGeneration),
|
||||
subtitle: $t('admin.thumbnail_generation_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.MetadataExtraction]: {
|
||||
icon: mdiTable,
|
||||
title: $getQueueName(QueueName.MetadataExtraction),
|
||||
subtitle: $t('admin.metadata_extraction_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.Library]: {
|
||||
icon: mdiLibraryShelves,
|
||||
title: $getQueueName(QueueName.Library),
|
||||
subtitle: $t('admin.library_tasks_description'),
|
||||
missingText: $t('rescan'),
|
||||
},
|
||||
[QueueName.Sidecar]: {
|
||||
title: $getQueueName(QueueName.Sidecar),
|
||||
icon: mdiFileXmlBox,
|
||||
subtitle: $t('admin.sidecar_job_description'),
|
||||
allText: $t('sync'),
|
||||
missingText: $t('discover'),
|
||||
disabled: !featureFlags.sidecar,
|
||||
},
|
||||
[QueueName.SmartSearch]: {
|
||||
icon: mdiImageSearch,
|
||||
title: $getQueueName(QueueName.SmartSearch),
|
||||
subtitle: $t('admin.smart_search_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.smartSearch,
|
||||
},
|
||||
[QueueName.DuplicateDetection]: {
|
||||
icon: mdiContentDuplicate,
|
||||
title: $getQueueName(QueueName.DuplicateDetection),
|
||||
subtitle: $t('admin.duplicate_detection_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.duplicateDetection,
|
||||
},
|
||||
[QueueName.FaceDetection]: {
|
||||
icon: mdiFaceRecognition,
|
||||
title: $getQueueName(QueueName.FaceDetection),
|
||||
subtitle: $t('admin.face_detection_description'),
|
||||
allText: $t('reset'),
|
||||
refreshText: $t('refresh'),
|
||||
missingText: $t('missing'),
|
||||
handleCommand: handleConfirmCommand,
|
||||
disabled: !featureFlags.facialRecognition,
|
||||
},
|
||||
[QueueName.FacialRecognition]: {
|
||||
icon: mdiTagFaces,
|
||||
title: $getQueueName(QueueName.FacialRecognition),
|
||||
subtitle: $t('admin.facial_recognition_job_description'),
|
||||
allText: $t('reset'),
|
||||
missingText: $t('missing'),
|
||||
handleCommand: handleConfirmCommand,
|
||||
disabled: !featureFlags.facialRecognition,
|
||||
},
|
||||
[QueueName.Ocr]: {
|
||||
icon: mdiOcr,
|
||||
title: $getQueueName(QueueName.Ocr),
|
||||
subtitle: $t('admin.ocr_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
disabled: !featureFlags.ocr,
|
||||
},
|
||||
[QueueName.VideoConversion]: {
|
||||
icon: mdiVideo,
|
||||
title: $getQueueName(QueueName.VideoConversion),
|
||||
subtitle: $t('admin.video_conversion_job_description'),
|
||||
allText: $t('all'),
|
||||
missingText: $t('missing'),
|
||||
},
|
||||
[QueueName.StorageTemplateMigration]: {
|
||||
icon: mdiFolderMove,
|
||||
title: $getQueueName(QueueName.StorageTemplateMigration),
|
||||
missingText: $t('start'),
|
||||
description: StorageMigrationDescription,
|
||||
},
|
||||
[QueueName.Migration]: {
|
||||
icon: mdiFolderMove,
|
||||
title: $getQueueName(QueueName.Migration),
|
||||
subtitle: $t('admin.migration_job_description'),
|
||||
missingText: $t('start'),
|
||||
},
|
||||
};
|
||||
|
||||
let jobList = Object.entries(jobDetails) as [QueueName, JobDetails][];
|
||||
|
||||
async function handleCommand(name: QueueName, dto: QueueCommandDto) {
|
||||
const title = jobDetails[name]?.title;
|
||||
|
||||
try {
|
||||
jobs[name] = await runQueueCommandLegacy({ name, queueCommandDto: dto });
|
||||
|
||||
switch (dto.command) {
|
||||
case QueueCommand.Empty: {
|
||||
toastManager.success($t('admin.cleared_jobs', { values: { job: title } }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.failed_job_command', { values: { command: dto.command, job: title } }));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-7">
|
||||
{#each jobList as [jobName, { title, subtitle, description, disabled, allText, refreshText, missingText, icon, handleCommand: handleCommandOverride }] (jobName)}
|
||||
{@const { jobCounts: statistics, queueStatus } = jobs[jobName]}
|
||||
<JobTile
|
||||
{icon}
|
||||
{title}
|
||||
{disabled}
|
||||
{subtitle}
|
||||
{description}
|
||||
{allText}
|
||||
{refreshText}
|
||||
{missingText}
|
||||
{statistics}
|
||||
{queueStatus}
|
||||
onCommand={(command) => (handleCommandOverride || handleCommand)(jobName, command)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,33 +1,19 @@
|
||||
<script lang="ts">
|
||||
import PageContent from '$lib/components/layouts/PageContent.svelte';
|
||||
import TitleLayout from '$lib/components/layouts/TitleLayout.svelte';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import {
|
||||
AppShell,
|
||||
AppShellHeader,
|
||||
AppShellSidebar,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ContextMenuButton,
|
||||
HStack,
|
||||
MenuItemType,
|
||||
Scrollable,
|
||||
isMenuItemType,
|
||||
type BreadcrumbItem,
|
||||
} from '@immich/ui';
|
||||
import { mdiSlashForward } from '@mdi/js';
|
||||
import { AppShell, AppShellHeader, AppShellSidebar, Scrollable, type BreadcrumbItem } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
actions?: Array<HeaderButtonActionItem | MenuItemType>;
|
||||
buttons?: Snippet;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { breadcrumbs, actions = [], children }: Props = $props();
|
||||
let { breadcrumbs, buttons, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<AppShell>
|
||||
@@ -38,37 +24,11 @@
|
||||
<AdminSidebar />
|
||||
</AppShellSidebar>
|
||||
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
|
||||
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
|
||||
|
||||
{#if actions.length > 0}
|
||||
<div class="hidden md:block">
|
||||
<HStack gap={0}>
|
||||
{#each actions as action, i (i)}
|
||||
{#if !isMenuItemType(action) && (action.$if?.() ?? true)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
color={action.color ?? 'secondary'}
|
||||
leadingIcon={action.icon}
|
||||
onclick={() => action.onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
|
||||
{/if}
|
||||
</div>
|
||||
<TitleLayout {breadcrumbs} {buttons}>
|
||||
<Scrollable class="grow">
|
||||
<PageContent>
|
||||
{@render children?.()}
|
||||
</PageContent>
|
||||
</Scrollable>
|
||||
</div>
|
||||
</TitleLayout>
|
||||
</AppShell>
|
||||
|
||||
21
web/src/lib/components/layouts/TitleLayout.svelte
Normal file
21
web/src/lib/components/layouts/TitleLayout.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Breadcrumbs, type BreadcrumbItem } from '@immich/ui';
|
||||
import { mdiSlashForward } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
buttons?: Snippet;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { breadcrumbs, buttons, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex h-16 w-full place-items-center justify-between border-b p-2">
|
||||
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
|
||||
{@render buttons?.()}
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -1,7 +1,3 @@
|
||||
<script lang="ts" module>
|
||||
export const headerId = 'user-page-header';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
@@ -68,7 +64,7 @@
|
||||
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if title}
|
||||
<div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>
|
||||
<div class="font-medium outline-none pe-8" tabindex="-1">{title}</div>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||
import { Button, IconButton, modalManager } from '@immich/ui';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SearchHistoryBox from './search-history-box.svelte';
|
||||
|
||||
@@ -31,8 +31,6 @@
|
||||
let isSearchSuggestions = $state(false);
|
||||
let selectedId: string | undefined = $state();
|
||||
let close: (() => Promise<void>) | undefined;
|
||||
let showSearchTypeDropdown = $state(false);
|
||||
let currentSearchType = $state('smart');
|
||||
|
||||
const listboxId = generateId();
|
||||
const searchTypeId = generateId();
|
||||
@@ -72,7 +70,6 @@
|
||||
|
||||
const onFocusIn = () => {
|
||||
searchStore.isSearchEnabled = true;
|
||||
getSearchType();
|
||||
};
|
||||
|
||||
const onFocusOut = () => {
|
||||
@@ -101,9 +98,6 @@
|
||||
const searchResult = await result.onClose;
|
||||
close = undefined;
|
||||
|
||||
// Refresh search type after modal closes
|
||||
getSearchType();
|
||||
|
||||
if (!searchResult) {
|
||||
return;
|
||||
}
|
||||
@@ -145,7 +139,6 @@
|
||||
|
||||
const onEscape = () => {
|
||||
closeDropdown();
|
||||
closeSearchTypeDropdown();
|
||||
};
|
||||
|
||||
const onArrow = async (direction: 1 | -1) => {
|
||||
@@ -175,20 +168,6 @@
|
||||
searchHistoryBox?.clearSelection();
|
||||
};
|
||||
|
||||
const toggleSearchTypeDropdown = () => {
|
||||
showSearchTypeDropdown = !showSearchTypeDropdown;
|
||||
};
|
||||
|
||||
const closeSearchTypeDropdown = () => {
|
||||
showSearchTypeDropdown = false;
|
||||
};
|
||||
|
||||
const selectSearchType = (type: string) => {
|
||||
localStorage.setItem('searchQueryType', type);
|
||||
currentSearchType = type;
|
||||
showSearchTypeDropdown = false;
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
@@ -201,18 +180,17 @@
|
||||
case 'metadata':
|
||||
case 'description':
|
||||
case 'ocr': {
|
||||
currentSearchType = searchType;
|
||||
return searchType;
|
||||
}
|
||||
default: {
|
||||
currentSearchType = 'smart';
|
||||
return 'smart';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchTypeText(): string {
|
||||
switch (currentSearchType) {
|
||||
const searchType = getSearchType();
|
||||
switch (searchType) {
|
||||
case 'smart': {
|
||||
return $t('context');
|
||||
}
|
||||
@@ -225,22 +203,8 @@
|
||||
case 'ocr': {
|
||||
return $t('ocr');
|
||||
}
|
||||
default: {
|
||||
return $t('context');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
getSearchType();
|
||||
});
|
||||
|
||||
const searchTypes = [
|
||||
{ value: 'smart', label: () => $t('context') },
|
||||
{ value: 'metadata', label: () => $t('filename') },
|
||||
{ value: 'description', label: () => $t('description') },
|
||||
{ value: 'ocr', label: () => $t('ocr') },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -329,34 +293,11 @@
|
||||
class:max-md:hidden={value}
|
||||
class:end-28={value.length > 0}
|
||||
>
|
||||
<div class="relative">
|
||||
<Button
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onclick={toggleSearchTypeDropdown}
|
||||
aria-expanded={showSearchTypeDropdown}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{getSearchTypeText()}
|
||||
</Button>
|
||||
|
||||
{#if showSearchTypeDropdown}
|
||||
<div
|
||||
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
|
||||
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
|
||||
>
|
||||
{#each searchTypes as searchType (searchType.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
|
||||
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
|
||||
onclick={() => selectSearchType(searchType.value)}
|
||||
>
|
||||
{searchType.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs"
|
||||
>
|
||||
{getSearchTypeText()}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
active: string;
|
||||
icons: { default: string; active: string };
|
||||
getLink: (path: string) => string;
|
||||
isNested?: boolean;
|
||||
}
|
||||
|
||||
let { tree, active, icons, getLink }: Props = $props();
|
||||
let { tree, active, icons, getLink, isNested = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul class="list-none ms-2">
|
||||
<ul role={isNested ? 'group' : 'tree'} class="list-none ms-2">
|
||||
{#each tree.children as node (node.color ? node.path + node.color : node.path)}
|
||||
<li>
|
||||
<Tree {node} {icons} {active} {getLink} />
|
||||
</li>
|
||||
<Tree {node} {icons} {active} {getLink} />
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
||||
import { TreeNode } from '$lib/utils/tree-utils';
|
||||
import { Icon } from '@immich/ui';
|
||||
@@ -21,30 +22,108 @@
|
||||
event.preventDefault();
|
||||
isOpen = !isOpen;
|
||||
};
|
||||
|
||||
const handleSelect = (event: MouseEvent | KeyboardEvent, path: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigateTo(path);
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent, node: TreeNode) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
handleSelect(event, node.path);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const hasChildren = node.children.length > 0;
|
||||
if (isOpen && hasChildren) {
|
||||
const target = event.target as HTMLElement;
|
||||
const child = target.querySelector<HTMLLIElement>('ul[role="group"] > li[role="treeitem"]');
|
||||
child?.focus();
|
||||
} else if (!isOpen && hasChildren) {
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const hasChildren = node.children.length > 0;
|
||||
if (isOpen && hasChildren) {
|
||||
isOpen = false;
|
||||
} else if (node.parents.length > 0) {
|
||||
const target = event.target as HTMLElement;
|
||||
const parent = target.parentElement?.closest<HTMLLIElement>('li[role="treeitem"]');
|
||||
parent?.focus();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
console.log('focus previous node');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
console.log('focus next node');
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
const link = getLink(path);
|
||||
void goto(link, { keepFocus: true });
|
||||
};
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={getLink(node.path)}
|
||||
title={node.value}
|
||||
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
|
||||
data-sveltekit-keepfocus
|
||||
<!-- href={getLink(node.path)} -->
|
||||
<li
|
||||
role="treeitem"
|
||||
aria-selected={false}
|
||||
tabindex="0"
|
||||
class="outline-none"
|
||||
onkeydown={(event) => handleKeydown(event, node)}
|
||||
onclick={(event) => handleSelect(event, node.path)}
|
||||
>
|
||||
{#if node.size > 0}
|
||||
<button type="button" {onclick}>
|
||||
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
|
||||
</button>
|
||||
{/if}
|
||||
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
|
||||
<Icon
|
||||
icon={isActive ? icons.active : icons.default}
|
||||
class={isActive ? 'text-primary' : 'text-gray-400'}
|
||||
color={node.color}
|
||||
size="20"
|
||||
/>
|
||||
<div
|
||||
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
|
||||
>
|
||||
{#if node.size > 0}
|
||||
<button tabindex={-1} aria-hidden="true" type="button" {onclick}>
|
||||
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
|
||||
</button>
|
||||
{/if}
|
||||
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
|
||||
<Icon
|
||||
icon={isActive ? icons.active : icons.default}
|
||||
class={isActive ? 'text-primary' : 'text-gray-400'}
|
||||
color={node.color}
|
||||
size="20"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
|
||||
</div>
|
||||
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
|
||||
</a>
|
||||
|
||||
{#if isOpen}
|
||||
<TreeItems tree={node} {icons} {active} {getLink} />
|
||||
{/if}
|
||||
{#if isOpen}
|
||||
<TreeItems tree={node} {icons} {active} {getLink} isNested />
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<style>
|
||||
li[role='treeitem']:focus-visible > div {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
mdiCrosshairsGps,
|
||||
mdiImageSizeSelectLarge,
|
||||
mdiLinkEdit,
|
||||
mdiStateMachine,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -17,7 +16,6 @@
|
||||
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
|
||||
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
|
||||
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
|
||||
{ href: AppRoute.WORKFLOWS, icon: mdiStateMachine, label: $t('workflows') },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
|
||||
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
|
||||
import WorkflowPickerField from './WorkflowPickerField.svelte';
|
||||
|
||||
type Props = {
|
||||
schema: object | null;
|
||||
config: Record<string, unknown>;
|
||||
configKey?: string;
|
||||
};
|
||||
|
||||
let { schema = null, config = $bindable({}), configKey }: Props = $props();
|
||||
|
||||
const components = $derived(getComponentFromSchema(schema));
|
||||
|
||||
// Get the actual config object to work with
|
||||
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
|
||||
|
||||
// Update function that handles nested config
|
||||
const updateConfig = (key: string, value: unknown) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
|
||||
};
|
||||
|
||||
const updateConfigBatch = (updates: Record<string, unknown>) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
|
||||
};
|
||||
|
||||
// Derive which keys need initialization (missing from actualConfig)
|
||||
const uninitializedKeys = $derived.by(() => {
|
||||
if (!components) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(components)
|
||||
.filter(([key]) => actualConfig[key] === undefined)
|
||||
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
|
||||
});
|
||||
|
||||
// Derive the batch updates needed
|
||||
const pendingUpdates = $derived.by(() => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const { key, defaultValue } of uninitializedKeys) {
|
||||
updates[key] = defaultValue;
|
||||
}
|
||||
return updates;
|
||||
});
|
||||
|
||||
// Initialize config namespace if needed
|
||||
$effect(() => {
|
||||
if (configKey && !config[configKey]) {
|
||||
config = { ...config, [configKey]: {} };
|
||||
}
|
||||
});
|
||||
|
||||
// Apply pending config updates
|
||||
$effect(() => {
|
||||
if (Object.keys(pendingUpdates).length > 0) {
|
||||
updateConfigBatch(pendingUpdates);
|
||||
}
|
||||
});
|
||||
|
||||
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
|
||||
</script>
|
||||
|
||||
{#if components}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each Object.entries(components) as [key, component] (key)}
|
||||
{@const label = component.title || component.label || key}
|
||||
|
||||
<div class="flex flex-col gap-1 border bg-light p-4 rounded-xl">
|
||||
<!-- Select component -->
|
||||
{#if component.type === 'select'}
|
||||
{#if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
{@const currentValue = actualConfig[key]}
|
||||
{@const selectedItem = options.find((opt) => opt.value === String(currentValue)) ?? options[0]}
|
||||
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} value={selectedItem} />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- MultiSelect component -->
|
||||
{:else if component.type === 'multiselect'}
|
||||
{#if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
{@const currentValues = (actualConfig[key] as string[]) ?? []}
|
||||
{@const selectedItems = options.filter((opt) => currentValues.includes(opt.value))}
|
||||
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<MultiSelect
|
||||
data={options}
|
||||
onChange={(opt) =>
|
||||
updateConfig(
|
||||
key,
|
||||
opt.map((o) => o.value),
|
||||
)}
|
||||
values={selectedItems}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- Switch component -->
|
||||
{:else if component.type === 'switch'}
|
||||
{@const checked = Boolean(actualConfig[key])}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
|
||||
</Field>
|
||||
|
||||
<!-- Text input -->
|
||||
{:else if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Input
|
||||
id={key}
|
||||
value={actualConfig[key] as string}
|
||||
oninput={(e) => updateConfig(key, e.currentTarget.value)}
|
||||
required={component.required}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Text size="small" color="muted">No configuration required</Text>
|
||||
{/if}
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
animated?: boolean;
|
||||
};
|
||||
|
||||
let { animated = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center py-2">
|
||||
<div class="relative h-12 w-0.5">
|
||||
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
|
||||
{#if animated}
|
||||
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
|
||||
{/if}
|
||||
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
|
||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
|
||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes flow {
|
||||
0% {
|
||||
transform: translateY(-25%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(25%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-pulse {
|
||||
animation: flow 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,69 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import type { WorkflowPayload } from '$lib/services/workflow.service';
|
||||
import { Button, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, VStack } from '@immich/ui';
|
||||
import { mdiCodeJson } from '@mdi/js';
|
||||
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
||||
|
||||
type Props = {
|
||||
jsonContent: WorkflowPayload;
|
||||
onApply: () => void;
|
||||
onContentChange: (content: WorkflowPayload) => void;
|
||||
};
|
||||
|
||||
let { jsonContent, onApply, onContentChange }: Props = $props();
|
||||
|
||||
let content: Content = $derived({ json: jsonContent });
|
||||
let canApply = $state(false);
|
||||
let editorClass = $derived(themeManager.isDark ? 'jse-theme-dark' : '');
|
||||
|
||||
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
|
||||
if (status.contentErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
canApply = true;
|
||||
|
||||
if ('text' in updated && updated.text !== undefined) {
|
||||
try {
|
||||
const parsed = JSON.parse(updated.text);
|
||||
onContentChange(parsed);
|
||||
} catch (error_) {
|
||||
console.error('Invalid JSON in text mode:', error_);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply();
|
||||
canApply = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<VStack gap={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle>Workflow JSON</CardTitle>
|
||||
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack gap={2}>
|
||||
<div class="w-full h-[600px] rounded-lg overflow-hidden border {editorClass}">
|
||||
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
||||
</div>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<style>
|
||||
@import 'svelte-jsoneditor/themes/jse-theme-dark.css';
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<script lang="ts">
|
||||
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
||||
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
|
||||
import type { ComponentConfig } from '$lib/utils/workflow';
|
||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, modalManager } from '@immich/ui';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
component: ComponentConfig;
|
||||
configKey: string;
|
||||
value: string | string[] | undefined;
|
||||
onchange: (value: string | string[]) => void;
|
||||
};
|
||||
|
||||
let { component, configKey, value = $bindable(), onchange }: Props = $props();
|
||||
|
||||
const label = $derived(component.title || component.label || configKey);
|
||||
const subType = $derived(component.subType as 'album-picker' | 'people-picker');
|
||||
const isAlbum = $derived(subType === 'album-picker');
|
||||
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
|
||||
|
||||
let pickerMetadata = $state<PickerMetadata | undefined>();
|
||||
|
||||
$effect(() => {
|
||||
if (!value) {
|
||||
pickerMetadata = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pickerMetadata) {
|
||||
void loadMetadata();
|
||||
}
|
||||
});
|
||||
|
||||
const loadMetadata = async () => {
|
||||
pickerMetadata = await fetchPickerMetadata(value, subType);
|
||||
};
|
||||
|
||||
const handlePicker = async () => {
|
||||
if (isAlbum) {
|
||||
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
|
||||
if (albums && albums.length > 0) {
|
||||
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? albums : albums[0];
|
||||
}
|
||||
} else {
|
||||
const currentIds = (Array.isArray(value) ? value : []) as string[];
|
||||
const excludedIds = multiple ? currentIds : [];
|
||||
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
|
||||
if (people && people.length > 0) {
|
||||
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? people : people[0];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelection = () => {
|
||||
onchange(multiple ? [] : '');
|
||||
pickerMetadata = undefined;
|
||||
};
|
||||
|
||||
const removeItemFromSelection = (itemId: string) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = value.filter((id) => id !== itemId);
|
||||
onchange(newValue);
|
||||
|
||||
if (Array.isArray(pickerMetadata)) {
|
||||
pickerMetadata = pickerMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[];
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isAlbum) {
|
||||
return multiple ? $t('select_albums') : $t('select_album');
|
||||
}
|
||||
return multiple ? $t('select_people') : $t('select_person');
|
||||
};
|
||||
</script>
|
||||
|
||||
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
|
||||
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
|
||||
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pickerMetadata as item (item.id)}
|
||||
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Card, CardBody, IconButton, Text } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
item: AlbumResponseDto | PersonResponseDto;
|
||||
isAlbum: boolean;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
let { item, isAlbum, onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card color="secondary">
|
||||
<CardBody class="flex items-center gap-3">
|
||||
<div class="shrink-0">
|
||||
{#if isAlbum && 'albumThumbnailAssetId' in item}
|
||||
{#if item.albumThumbnailAssetId}
|
||||
<img
|
||||
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
|
||||
alt={item.albumName}
|
||||
class="h-12 w-12 rounded-lg object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-12 w-12 rounded-lg"></div>
|
||||
{/if}
|
||||
{:else if !isAlbum && 'name' in item}
|
||||
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Text class="font-semibold truncate">
|
||||
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
|
||||
</Text>
|
||||
{#if isAlbum && 'assetCount' in item}
|
||||
<Text size="small" color="muted">
|
||||
{$t('items_count', { values: { count: item.assetCount } })}
|
||||
</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
type="button"
|
||||
onclick={onRemove}
|
||||
class="shrink-0"
|
||||
shape="round"
|
||||
aria-label={$t('remove')}
|
||||
icon={mdiClose}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -1,184 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
PluginTriggerType,
|
||||
type PluginActionResponseDto,
|
||||
type PluginFilterResponseDto,
|
||||
type PluginTriggerResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, Text } from '@immich/ui';
|
||||
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: PluginTriggerResponseDto;
|
||||
filters: PluginFilterResponseDto[];
|
||||
actions: PluginActionResponseDto[];
|
||||
};
|
||||
|
||||
let { trigger, filters, actions }: Props = $props();
|
||||
|
||||
const getTriggerName = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded');
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return $t('trigger_person_recognized');
|
||||
}
|
||||
default: {
|
||||
return triggerType;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let isOpen = $state(false);
|
||||
let position = $state({ x: 0, y: 0 });
|
||||
let isDragging = $state(false);
|
||||
let dragOffset = $state({ x: 0, y: 0 });
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (!containerEl) {
|
||||
return;
|
||||
}
|
||||
isDragging = true;
|
||||
const rect = containerEl.getBoundingClientRect();
|
||||
dragOffset = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
position = {
|
||||
x: e.clientX - dragOffset.x,
|
||||
y: e.clientY - dragOffset.y,
|
||||
};
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
// Initialize position to bottom-right on mount
|
||||
if (globalThis.window && position.x === 0 && position.y === 0) {
|
||||
position = {
|
||||
x: globalThis.innerWidth - 280,
|
||||
y: globalThis.innerHeight - 400,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={containerEl}
|
||||
class="hidden sm:block fixed w-64 hover:cursor-grab select-none"
|
||||
style="left: {position.x}px; top: {position.y}px;"
|
||||
class:cursor-grabbing={isDragging}
|
||||
onmousedown={handleMouseDown}
|
||||
>
|
||||
<div
|
||||
class="rounded-xl border-transparent border-2 hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-light-300 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4 cursor-grab select-none">
|
||||
<Text size="small" class="font-semibold">{$t('workflow_summary')}</Text>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
title="Close summary"
|
||||
aria-label="Close summary"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
isOpen = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Trigger -->
|
||||
<div class="rounded-lg bg-light-100 border p-3">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span>
|
||||
</div>
|
||||
<p class="text-sm truncate pl-5">{getTriggerName(trigger.type)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Connector -->
|
||||
<div class="flex justify-center">
|
||||
<div class="w-0.5 h-3 bg-light-400"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
{#if filters.length > 0}
|
||||
<div class="rounded-lg bg-light-100 border p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Icon icon={mdiFilterOutline} size="18" class="text-warning" />
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
|
||||
</div>
|
||||
<div class="space-y-1 pl-5">
|
||||
{#each filters as filter, index (index)}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<p class="text-sm truncate">{filter.title}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connector -->
|
||||
<div class="flex justify-center">
|
||||
<div class="w-0.5 h-3 bg-light-400"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
{#if actions.length > 0}
|
||||
<div class="rounded-lg bg-light-100 border p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
|
||||
</div>
|
||||
<div class="space-y-1 pl-5">
|
||||
{#each actions as action, index (index)}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<p class="text-sm truncate">{action.title}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="hidden sm:flex fixed right-6 bottom-6 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
|
||||
title={$t('workflow_summary')}
|
||||
onclick={() => (isOpen = true)}
|
||||
>
|
||||
<Icon icon={mdiViewDashboardOutline} size="24" />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
|
||||
import { Icon, Text } from '@immich/ui';
|
||||
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: PluginTriggerResponseDto;
|
||||
selected: boolean;
|
||||
onclick: () => void;
|
||||
};
|
||||
|
||||
let { trigger, selected, onclick }: Props = $props();
|
||||
|
||||
const getTriggerIcon = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return mdiFileUploadOutline;
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return mdiFaceRecognition;
|
||||
}
|
||||
default: {
|
||||
return mdiLightningBolt;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTriggerName = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded');
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return $t('trigger_person_recognized');
|
||||
}
|
||||
default: {
|
||||
return triggerType;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTriggerDescription = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded_description');
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return $t('trigger_person_recognized_description');
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{onclick}
|
||||
class="group rounded-xl p-4 w-full text-left cursor-pointer border-2 {selected
|
||||
? 'border-primary text-primary'
|
||||
: 'border-light-100 hover:border-light-200 text-light-400 hover:text-light-700'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="rounded-xl p-2 {selected
|
||||
? 'bg-primary text-light'
|
||||
: 'text-light-100 bg-light-300 group-hover:bg-light-500'}"
|
||||
>
|
||||
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Text class="font-semibold mb-1">{getTriggerName(trigger.type)}</Text>
|
||||
{#if getTriggerDescription(trigger.type)}
|
||||
<Text size="small">{getTriggerDescription(trigger.type)}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -23,7 +23,7 @@ export enum AppRoute {
|
||||
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
|
||||
ADMIN_SETTINGS = '/admin/system-settings',
|
||||
ADMIN_STATS = '/admin/server-status',
|
||||
ADMIN_QUEUES = '/admin/queues',
|
||||
ADMIN_JOBS = '/admin/jobs-status',
|
||||
ADMIN_REPAIR = '/admin/repair',
|
||||
|
||||
ALBUMS = '/albums',
|
||||
@@ -55,7 +55,6 @@ export enum AppRoute {
|
||||
DUPLICATES = '/utilities/duplicates',
|
||||
LARGE_FILES = '/utilities/large-files',
|
||||
GEOLOCATION = '/utilities/geolocation',
|
||||
WORKFLOWS = '/utilities/workflows',
|
||||
|
||||
FOLDERS = '/folders',
|
||||
TAGS = '/tags',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user