mirror of
https://github.com/immich-app/immich.git
synced 2026-05-28 02:22:34 -04:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b15a90618 | |||
| a5ce3fc927 | |||
| 3b23f71a3f | |||
| dec33cadd9 | |||
| 80c15a5e27 | |||
| 936c28a40b | |||
| 1a837a28ac | |||
| 8d5d12b108 | |||
| dd7a94135f | |||
| 1acc511b5c | |||
| 452e88267a | |||
| b941108cbd | |||
| e46f2843f7 | |||
| cf991e7b1b | |||
| 748a13104a | |||
| 2dd6b47714 |
@@ -8,6 +8,8 @@ log "Preparing Immich Web Frontend"
|
||||
log ""
|
||||
run_cmd pnpm --filter @immich/sdk install
|
||||
run_cmd pnpm --filter @immich/sdk build
|
||||
run_cmd pnpm --filter @immich/plugin-sdk install
|
||||
run_cmd pnpm --filter @immich/plugin-sdk build
|
||||
run_cmd pnpm --filter immich-web install
|
||||
|
||||
log "Starting Immich Web Frontend"
|
||||
|
||||
@@ -230,6 +230,10 @@ jobs:
|
||||
- name: Generate platform APIs
|
||||
run: mise //mobile:codegen:pigeon
|
||||
|
||||
- name: Resolve iOS Swift Packages
|
||||
working-directory: ./mobile
|
||||
run: flutter build ios --config-only --no-codesign
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
|
||||
with:
|
||||
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
volumes:
|
||||
- ..:/usr/src/app
|
||||
# - ../../ui:/usr/src/ui
|
||||
- pnpm_cache:/buildcache/pnpm_cache
|
||||
- build_cache:/buildcache
|
||||
- server_node_modules:/usr/src/app/server/node_modules
|
||||
- web_node_modules:/usr/src/app/web/node_modules
|
||||
- github_node_modules:/usr/src/app/.github/node_modules
|
||||
@@ -45,11 +45,11 @@ services:
|
||||
target: dev
|
||||
command:
|
||||
- |
|
||||
pnpm install
|
||||
mise install
|
||||
touch /tmp/init-complete
|
||||
exec tail -f /dev/null
|
||||
volumes:
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- build_cache:/buildcache
|
||||
restart: 'no'
|
||||
healthcheck:
|
||||
test: ['CMD', 'test', '-f', '/tmp/init-complete']
|
||||
@@ -73,7 +73,6 @@ services:
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}/photos:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../packages/plugin-core:/build/plugins/immich-plugin-core
|
||||
env_file:
|
||||
- .env
|
||||
@@ -122,8 +121,6 @@ services:
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 24678:24678
|
||||
volumes:
|
||||
- pnpm_store_web:/buildcache/pnpm-store
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
immich-init:
|
||||
@@ -203,9 +200,7 @@ volumes:
|
||||
model_cache:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
pnpm_cache:
|
||||
pnpm_store_server:
|
||||
pnpm_store_web:
|
||||
build_cache:
|
||||
server_node_modules:
|
||||
web_node_modules:
|
||||
github_node_modules:
|
||||
|
||||
@@ -26,6 +26,8 @@ For organizations seeking to resell Immich, we have established the following gu
|
||||
|
||||
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
|
||||
|
||||
---
|
||||
|
||||
## User
|
||||
|
||||
### How can I reset the admin password?
|
||||
@@ -36,6 +38,10 @@ The admin password can be reset by running the [reset-admin-password](/administr
|
||||
|
||||
You can see the list of all users by running [list-users](/administration/server-commands.md) Command on the Immich-server.
|
||||
|
||||
### How can I change my profile picture?
|
||||
|
||||
View a single photo, press the three dots in the top-right to show context menu, and select "Set as profile picture". In the pop-up, use your mouse scroll wheel to zoom in the picture until it completely fills the circle. Click and drag the picture to align it to your liking. Press "Save" to save your changes.
|
||||
|
||||
---
|
||||
|
||||
## Mobile App
|
||||
|
||||
@@ -5,7 +5,7 @@ After making any changes in the `server/src/schema`, a database migration need t
|
||||
1. Run the command
|
||||
|
||||
```bash
|
||||
pnpm run migrations:generate <migration-name>
|
||||
mise //server:migrations generate <migration-name>
|
||||
```
|
||||
|
||||
2. Check if the migration file makes sense.
|
||||
@@ -18,7 +18,7 @@ The server will automatically detect `*.ts` file changes and restart. Part of th
|
||||
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
|
||||
|
||||
```bash
|
||||
pnpm run migrations:revert
|
||||
mise //server:migrations revert
|
||||
```
|
||||
|
||||
This command rolls back the latest migration and brings the database schema back to its previous state.
|
||||
|
||||
@@ -252,44 +252,33 @@ To connect the mobile app to your Dev Container:
|
||||
|
||||
The Dev Container supports multiple ways to run tests:
|
||||
|
||||
#### Using Mise Commands (Recommended)
|
||||
|
||||
```bash
|
||||
# Run tests for specific components
|
||||
mise run checklist # in `server/`, `web/`, `packages/cli`
|
||||
# Server
|
||||
mise //server:test # unit tests
|
||||
mise //server:test-medium # medium / integration tests
|
||||
|
||||
# Web
|
||||
mise //web:test # unit tests
|
||||
|
||||
# E2E
|
||||
mise //e2e:test # API tests
|
||||
mise //e2e:test-web # web UI tests (Playwright)
|
||||
|
||||
# Run all checks for a component
|
||||
mise //server:checklist
|
||||
mise //web:checklist
|
||||
```
|
||||
|
||||
#### Using PNPM Directly
|
||||
|
||||
```bash
|
||||
# Server tests
|
||||
cd /workspaces/immich/server
|
||||
pnpm test # Run all tests
|
||||
pnpm run test:medium # Medium tests (integration tests)
|
||||
pnpm run test:watch # Watch mode
|
||||
pnpm run test:cov # Coverage report
|
||||
|
||||
# Web tests
|
||||
cd /workspaces/immich/web
|
||||
pnpm test # Run all tests
|
||||
pnpm run test:watch # Watch mode
|
||||
|
||||
# E2E tests
|
||||
cd /workspaces/immich/e2e
|
||||
pnpm run test # Run API tests
|
||||
pnpm run test:web # Run web UI tests
|
||||
```
|
||||
|
||||
### Additional Make Commands
|
||||
### Additional Commands
|
||||
|
||||
```bash
|
||||
# API generation
|
||||
make open-api # Generate OpenAPI specs
|
||||
make open-api-typescript # Generate TypeScript SDK
|
||||
make open-api-dart # Generate Dart SDK
|
||||
mise //:open-api # Generate OpenAPI specs
|
||||
mise //:open-api-typescript # Generate TypeScript SDK
|
||||
mise //:open-api-dart # Generate Dart SDK
|
||||
|
||||
# Database
|
||||
mise sql # Sync database schema
|
||||
mise //server:sql # Sync database schema
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
@@ -8,34 +8,42 @@ When contributing code through a pull request, please check the following:
|
||||
|
||||
## Web Checks
|
||||
|
||||
- [ ] `pnpm run lint` (linting via ESLint)
|
||||
- [ ] `pnpm run format` (formatting via Prettier)
|
||||
- [ ] `pnpm run check:svelte` (Type checking via SvelteKit)
|
||||
- [ ] `pnpm run check:typescript` (check typescript)
|
||||
- [ ] `pnpm test` (unit tests)
|
||||
- [ ] `mise //web:lint` (linting via ESLint)
|
||||
- [ ] `mise //web:format` (formatting via Prettier)
|
||||
- [ ] `mise //web:check-svelte` (type checking via SvelteKit)
|
||||
- [ ] `mise //web:check-typescript` (type checking via `tsc`)
|
||||
- [ ] `mise //web:test` (unit tests)
|
||||
|
||||
:::tip AIO
|
||||
Run all web checks with `pnpm run check:all`
|
||||
Run all web checks with `mise //web:checklist`
|
||||
:::
|
||||
|
||||
:::tip Auto Fix
|
||||
Use `mise //web:lint-fix` and `mise //web:format-fix` to automatically correct some issues.
|
||||
:::
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] `pnpm run format` (formatting via Prettier)
|
||||
- [ ] `mise //docs:format` (formatting via Prettier)
|
||||
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
|
||||
|
||||
:::tip Auto Fix
|
||||
Use `mise //docs:format-fix` to automatically fix formatting.
|
||||
:::
|
||||
|
||||
## Server Checks
|
||||
|
||||
- [ ] `pnpm run lint` (linting via ESLint)
|
||||
- [ ] `pnpm run format` (formatting via Prettier)
|
||||
- [ ] `pnpm run check` (Type checking via `tsc`)
|
||||
- [ ] `pnpm test` (unit tests)
|
||||
- [ ] `mise //server:lint` (linting via ESLint)
|
||||
- [ ] `mise //server:format` (formatting via Prettier)
|
||||
- [ ] `mise //server:check` (type checking via `tsc`)
|
||||
- [ ] `mise //server:test` (unit tests)
|
||||
|
||||
:::tip AIO
|
||||
Run all server checks with `pnpm run check:all`
|
||||
Run all server checks with `mise //server:checklist`
|
||||
:::
|
||||
|
||||
:::tip Auto Fix
|
||||
You can use `pnpm run __:fix` to potentially correct some issues automatically for `pnpm run format` and `lint`.
|
||||
Use `mise //server:lint-fix` and `mise //server:format-fix` to automatically correct some issues.
|
||||
:::
|
||||
|
||||
## Mobile Checklist
|
||||
@@ -53,6 +61,17 @@ Run all these commands at once with `mise //mobile:checklist`
|
||||
You can use `mise //mobile:lint-fix` to potentially correct some issues automatically for `mise //mobile:lint`.
|
||||
:::
|
||||
|
||||
## Machine Learning Checklist
|
||||
|
||||
- [ ] `mise //machine-learning:lint` (linting via ruff)
|
||||
- [ ] `mise //machine-learning:format` (formatting via ruff)
|
||||
- [ ] `mise //machine-learning:check` (type checking via mypy)
|
||||
- [ ] `mise //machine-learning:test` (unit tests via pytest)
|
||||
|
||||
:::tip AIO
|
||||
Run all machine learning checks with `mise //machine-learning:checklist`
|
||||
:::
|
||||
|
||||
## OpenAPI
|
||||
|
||||
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details.
|
||||
|
||||
@@ -32,6 +32,10 @@ This environment includes the services below. Additional details are available i
|
||||
|
||||
All the services are packaged to run as with single Docker Compose command.
|
||||
|
||||
:::tip mise
|
||||
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
|
||||
:::
|
||||
|
||||
### Server and web apps
|
||||
|
||||
1. Clone the project repo.
|
||||
@@ -56,22 +60,23 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
|
||||
|
||||
#### Connect web to a remote backend
|
||||
|
||||
If you only want to do web development connected to an existing, remote backend, follow these steps:
|
||||
|
||||
1. Build the Immich SDK - `pnpm --filter @immich/sdk install && pnpm --filter @immich/sdk build`
|
||||
2. Enter the web directory - `cd web/`
|
||||
3. Install web dependencies - `pnpm i`
|
||||
4. Start the web development server
|
||||
If you only want to do web development connected to an existing, remote backend, run from the repo root:
|
||||
|
||||
```bash
|
||||
IMMICH_SERVER_URL=https://demo.immich.app/ pnpm run dev
|
||||
IMMICH_SERVER_URL=https://demo.immich.app/ mise //web:start
|
||||
```
|
||||
|
||||
This will install all dependencies (including the SDK) and start the dev server in one step. To connect to the hosted demo server specifically, use the shorthand:
|
||||
|
||||
```bash
|
||||
mise //web:start-demo
|
||||
```
|
||||
|
||||
If you're using PowerShell on Windows you may need to set the env var separately like so:
|
||||
|
||||
```powershell
|
||||
$env:IMMICH_SERVER_URL = "https://demo.immich.app/"
|
||||
pnpm run dev
|
||||
mise //web:start
|
||||
```
|
||||
|
||||
#### `@immich/ui`
|
||||
@@ -90,20 +95,16 @@ To see local changes to `@immich/ui` in Immich, do the following:
|
||||
|
||||
#### Setup
|
||||
|
||||
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
|
||||
2. Change to the immich (root) directory and trust the mise config with `mise trust`.
|
||||
3. Install tools with mise: `mise install`.
|
||||
4. Change to the `mobile/` directory.
|
||||
5. Run `flutter pub get` to install the dependencies.
|
||||
6. Run `make translation` to generate the translation file.
|
||||
7. Run `flutter run` to start the app.
|
||||
1. Run `mise //mobile:install` to install Flutter dependencies.
|
||||
2. Run `mise //mobile:translation` to generate the translation file.
|
||||
3. Change to the `mobile/` directory and run `flutter run` to start the app.
|
||||
|
||||
#### Translation
|
||||
|
||||
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then, from the `mobile/` directory, run
|
||||
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then run:
|
||||
|
||||
```bash
|
||||
make translation
|
||||
mise //mobile:translation
|
||||
```
|
||||
|
||||
The mobile app asks you what backend to connect to. You can utilize the demo backend (https://demo.immich.app/) if you don't need to change server code or upload photos. Alternatively, you can run the server yourself per the instructions above.
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
### Unit tests
|
||||
|
||||
Unit are run by calling `pnpm run test` from the `server/` directory.
|
||||
You need to run `pnpm install` (in `server/`) before _once_.
|
||||
Unit tests are run with `mise //server:test`.
|
||||
You need to run `mise //server:install` before _once_.
|
||||
|
||||
### End to end tests
|
||||
|
||||
@@ -17,8 +17,7 @@ make e2e
|
||||
|
||||
Before you can run the tests, you need to run the following commands _once_:
|
||||
|
||||
- `pnpm install`
|
||||
- `pnpm --filter @immich/sdk --filter @immich/cli build`
|
||||
- `mise //e2e:ci-setup` (installs e2e, SDK, and CLI dependencies)
|
||||
- `mise //:open-api`
|
||||
|
||||
Once the test environment is running, the e2e tests can be run via:
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ run = "pnpm install --filter documentation --frozen-lockfile"
|
||||
|
||||
[tasks.start]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "docusaurus --port 3005"
|
||||
run = "docusaurus start --port 3005"
|
||||
|
||||
[tasks.build]
|
||||
env._.path = "./node_modules/.bin"
|
||||
|
||||
@@ -83,9 +83,7 @@ volumes:
|
||||
model_cache:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
pnpm_cache:
|
||||
pnpm_store_server:
|
||||
pnpm_store_web:
|
||||
build_cache:
|
||||
server_node_modules:
|
||||
web_node_modules:
|
||||
github_node_modules:
|
||||
|
||||
@@ -840,6 +840,7 @@
|
||||
"copy_error": "Copy error",
|
||||
"copy_file_path": "Copy file path",
|
||||
"copy_image": "Copy Image",
|
||||
"copy_json": "Copy JSON",
|
||||
"copy_link": "Copy link",
|
||||
"copy_link_to_clipboard": "Copy link to clipboard",
|
||||
"copy_password": "Copy password",
|
||||
@@ -979,6 +980,8 @@
|
||||
"downloading_media": "Downloading media",
|
||||
"drag_to_reorder": "Drag to reorder",
|
||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||
"duplicate": "Duplicate",
|
||||
"duplicate_workflow": "Duplicate workflow",
|
||||
"duplicates": "Duplicates",
|
||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
|
||||
"duration": "Duration",
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools."aqua:flutter/flutter"]]
|
||||
version = "3.41.9"
|
||||
version = "3.44.0"
|
||||
backend = "aqua:flutter/flutter"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
|
||||
checksum = "blake3:fb03aa5d9790205c948922ec3f0751c16e4575b09d6ae9dd4fbeb664a69f0e00"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.0-stable.zip"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.0-stable.zip"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.0-stable.zip"
|
||||
|
||||
[[tools.flutter]]
|
||||
version = "3.41.9-stable"
|
||||
backend = "asdf:flutter"
|
||||
|
||||
@@ -16,7 +16,7 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
"aqua:flutter/flutter" = "3.41.9"
|
||||
"aqua:flutter/flutter" = "3.44.0"
|
||||
pnpm = "10.33.4"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
|
||||
Vendored
-1
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.41.9",
|
||||
"dart.lineLength": 120,
|
||||
"[dart]": {
|
||||
"editor.rulers": [
|
||||
|
||||
@@ -5,3 +5,7 @@ android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,58 +1,23 @@
|
||||
PODS:
|
||||
- background_downloader (0.0.1):
|
||||
- Flutter
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- cupertino_http (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- flutter_udid (0.0.1):
|
||||
- Flutter
|
||||
- KeychainAccess
|
||||
- flutter_web_auth_2 (5.0.0):
|
||||
- Flutter
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
- geolocator_apple (1.2.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- home_widget (0.0.1):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- KeychainAccess (4.2.2)
|
||||
- local_auth_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- MapLibre (6.14.0)
|
||||
- maplibre_gl (0.0.1):
|
||||
- Flutter
|
||||
- MapLibre (= 6.14.0)
|
||||
- native_video_player (1.0.0):
|
||||
- Flutter
|
||||
- network_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- photo_manager (3.9.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- share_handler_ios (0.0.14):
|
||||
- Flutter
|
||||
- share_handler_ios/share_handler_ios_models (= 0.0.14)
|
||||
@@ -61,144 +26,56 @@ PODS:
|
||||
- Flutter
|
||||
- share_handler_ios_models
|
||||
- share_handler_ios_models (0.0.9)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
|
||||
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
|
||||
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- KeychainAccess
|
||||
- MapLibre
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
background_downloader:
|
||||
:path: ".symlinks/plugins/background_downloader/ios"
|
||||
bonsoir_darwin:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
cupertino_http:
|
||||
:path: ".symlinks/plugins/cupertino_http/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
flutter_udid:
|
||||
:path: ".symlinks/plugins/flutter_udid/ios"
|
||||
flutter_web_auth_2:
|
||||
:path: ".symlinks/plugins/flutter_web_auth_2/ios"
|
||||
fluttertoast:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
geolocator_apple:
|
||||
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
||||
home_widget:
|
||||
:path: ".symlinks/plugins/home_widget/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
local_auth_darwin:
|
||||
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
||||
maplibre_gl:
|
||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||
native_video_player:
|
||||
:path: ".symlinks/plugins/native_video_player/ios"
|
||||
network_info_plus:
|
||||
:path: ".symlinks/plugins/network_info_plus/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
photo_manager:
|
||||
:path: ".symlinks/plugins/photo_manager/darwin"
|
||||
share_handler_ios:
|
||||
:path: ".symlinks/plugins/share_handler_ios/ios"
|
||||
share_handler_ios_models:
|
||||
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
|
||||
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
flutter_udid: 92a5d31fe0526b7b6002a2318df702e12e7eb300
|
||||
flutter_web_auth_2: 646fc9df97a01c59e5eea99b237da2c6360f8439
|
||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
||||
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
|
||||
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
|
||||
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
|
||||
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
|
||||
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
|
||||
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
||||
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
|
||||
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
|
||||
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -129,6 +130,7 @@
|
||||
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
|
||||
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
|
||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -193,6 +195,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
|
||||
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
|
||||
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
|
||||
@@ -247,6 +250,7 @@
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -360,6 +364,9 @@
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
@@ -463,6 +470,7 @@
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
|
||||
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
|
||||
);
|
||||
@@ -1285,7 +1293,17 @@
|
||||
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
|
||||
productName = StructuredFieldValues;
|
||||
};
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
+19
-2
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
@@ -19,6 +18,24 @@
|
||||
"version" : "7.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "keychainaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kishikawakatsumi/KeychainAccess",
|
||||
"state" : {
|
||||
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
|
||||
"version" : "4.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "maplibre-gl-native-distribution",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
|
||||
"state" : {
|
||||
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
|
||||
"version" : "6.14.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sqlite-data",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -146,5 +163,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
"version" : 2
|
||||
}
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Immich.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
@@ -19,6 +18,24 @@
|
||||
"version" : "7.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "keychainaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kishikawakatsumi/KeychainAccess",
|
||||
"state" : {
|
||||
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
|
||||
"version" : "4.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "maplibre-gl-native-distribution",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
|
||||
"state" : {
|
||||
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
|
||||
"version" : "6.14.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sqlite-data",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -146,5 +163,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
"version" : 2
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||
const String kDownloadGroupImage = 'group_image';
|
||||
const String kDownloadGroupVideo = 'group_video';
|
||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
const String kShareDownloadGroup = 'group_share';
|
||||
|
||||
// Timeline constants
|
||||
const int kTimelineNoneSegmentSize = 120;
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/album_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/backup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/image_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
const defaultConfig = AppConfig();
|
||||
|
||||
class AppConfig {
|
||||
final LogLevel logLevel;
|
||||
final ThemeConfig theme;
|
||||
final CleanupConfig cleanup;
|
||||
final MapConfig map;
|
||||
@@ -18,8 +29,10 @@ class AppConfig {
|
||||
final SlideshowConfig slideshow;
|
||||
final AlbumConfig album;
|
||||
final BackupConfig backup;
|
||||
final NetworkConfig network;
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
this.theme = const .new(),
|
||||
this.cleanup = const .new(),
|
||||
this.map = const .new(),
|
||||
@@ -29,9 +42,11 @@ class AppConfig {
|
||||
this.slideshow = const .new(),
|
||||
this.album = const .new(),
|
||||
this.backup = const .new(),
|
||||
this.network = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
LogLevel? logLevel,
|
||||
ThemeConfig? theme,
|
||||
CleanupConfig? cleanup,
|
||||
MapConfig? map,
|
||||
@@ -41,7 +56,9 @@ class AppConfig {
|
||||
SlideshowConfig? slideshow,
|
||||
AlbumConfig? album,
|
||||
BackupConfig? backup,
|
||||
NetworkConfig? network,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
theme: theme ?? this.theme,
|
||||
cleanup: cleanup ?? this.cleanup,
|
||||
map: map ?? this.map,
|
||||
@@ -51,12 +68,14 @@ class AppConfig {
|
||||
slideshow: slideshow ?? this.slideshow,
|
||||
album: album ?? this.album,
|
||||
backup: backup ?? this.backup,
|
||||
network: network ?? this.network,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is AppConfig &&
|
||||
other.logLevel == logLevel &&
|
||||
other.theme == theme &&
|
||||
other.cleanup == cleanup &&
|
||||
other.map == map &&
|
||||
@@ -65,12 +84,118 @@ class AppConfig {
|
||||
other.viewer == viewer &&
|
||||
other.slideshow == slideshow &&
|
||||
other.album == album &&
|
||||
other.backup == backup);
|
||||
other.backup == backup &&
|
||||
other.network == network);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
|
||||
int get hashCode =>
|
||||
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network)';
|
||||
|
||||
T read<T extends Object>(MetadataKey<T> key) =>
|
||||
(switch (key) {
|
||||
.logLevel => logLevel,
|
||||
.themePrimaryColor => theme.primaryColor,
|
||||
.themeMode => theme.mode,
|
||||
.themeDynamic => theme.dynamicTheme,
|
||||
.themeColorfulInterface => theme.colorfulInterface,
|
||||
.imagePreferRemote => image.preferRemote,
|
||||
.imageLoadOriginal => image.loadOriginal,
|
||||
.viewerLoopVideo => viewer.loopVideo,
|
||||
.viewerLoadOriginalVideo => viewer.loadOriginalVideo,
|
||||
.viewerAutoPlayVideo => viewer.autoPlayVideo,
|
||||
.viewerTapToNavigate => viewer.tapToNavigate,
|
||||
.networkAutoEndpointSwitching => network.autoEndpointSwitching,
|
||||
.networkPreferredWifiName => network.preferredWifiName,
|
||||
.networkLocalEndpoint => network.localEndpoint,
|
||||
.networkExternalEndpointList => network.externalEndpointList,
|
||||
.networkCustomHeaders => network.customHeaders,
|
||||
.albumSortMode => album.sortMode,
|
||||
.albumIsReverse => album.isReverse,
|
||||
.albumIsGrid => album.isGrid,
|
||||
.backupEnabled => backup.enabled,
|
||||
.backupUseCellularForVideos => backup.useCellularForVideos,
|
||||
.backupUseCellularForPhotos => backup.useCellularForPhotos,
|
||||
.backupRequireCharging => backup.requireCharging,
|
||||
.backupTriggerDelay => backup.triggerDelay,
|
||||
.backupSyncAlbums => backup.syncAlbums,
|
||||
.timelineTilesPerRow => timeline.tilesPerRow,
|
||||
.timelineGroupAssetsBy => timeline.groupAssetsBy,
|
||||
.timelineStorageIndicator => timeline.storageIndicator,
|
||||
.mapShowFavoriteOnly => map.favoritesOnly,
|
||||
.mapRelativeDate => map.relativeDays,
|
||||
.mapIncludeArchived => map.includeArchived,
|
||||
.mapThemeMode => map.themeMode,
|
||||
.mapWithPartners => map.withPartners,
|
||||
.cleanupKeepFavorites => cleanup.keepFavorites,
|
||||
.cleanupKeepMediaType => cleanup.keepMediaType,
|
||||
.cleanupKeepAlbumIds => cleanup.keepAlbumIds,
|
||||
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
|
||||
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
|
||||
.slideshowTransition => slideshow.transition,
|
||||
.slideshowRepeat => slideshow.repeat,
|
||||
.slideshowDuration => slideshow.duration,
|
||||
.slideshowLook => slideshow.look,
|
||||
.slideshowDirection => slideshow.direction,
|
||||
})
|
||||
as T;
|
||||
|
||||
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) {
|
||||
var config = const AppConfig();
|
||||
for (final MapEntry(key: key, value: value) in entries.entries) {
|
||||
config = config.write(key, value);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
|
||||
return switch (key) {
|
||||
.logLevel => copyWith(logLevel: value as LogLevel),
|
||||
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
|
||||
.themeMode => copyWith(theme: theme.copyWith(mode: value as ThemeMode)),
|
||||
.themeDynamic => copyWith(theme: theme.copyWith(dynamicTheme: value as bool)),
|
||||
.themeColorfulInterface => copyWith(theme: theme.copyWith(colorfulInterface: value as bool)),
|
||||
.imagePreferRemote => copyWith(image: image.copyWith(preferRemote: value as bool)),
|
||||
.imageLoadOriginal => copyWith(image: image.copyWith(loadOriginal: value as bool)),
|
||||
.viewerLoopVideo => copyWith(viewer: viewer.copyWith(loopVideo: value as bool)),
|
||||
.viewerLoadOriginalVideo => copyWith(viewer: viewer.copyWith(loadOriginalVideo: value as bool)),
|
||||
.viewerAutoPlayVideo => copyWith(viewer: viewer.copyWith(autoPlayVideo: value as bool)),
|
||||
.viewerTapToNavigate => copyWith(viewer: viewer.copyWith(tapToNavigate: value as bool)),
|
||||
.networkAutoEndpointSwitching => copyWith(network: network.copyWith(autoEndpointSwitching: value as bool)),
|
||||
.networkPreferredWifiName => copyWith(network: network.copyWith(preferredWifiName: (value as String))),
|
||||
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: (value as String))),
|
||||
.networkExternalEndpointList => copyWith(network: network.copyWith(externalEndpointList: value as List<String>)),
|
||||
.networkCustomHeaders => copyWith(network: network.copyWith(customHeaders: value as Map<String, String>)),
|
||||
.albumSortMode => copyWith(album: album.copyWith(sortMode: value as AlbumSortMode)),
|
||||
.albumIsReverse => copyWith(album: album.copyWith(isReverse: value as bool)),
|
||||
.albumIsGrid => copyWith(album: album.copyWith(isGrid: value as bool)),
|
||||
.backupEnabled => copyWith(backup: backup.copyWith(enabled: value as bool)),
|
||||
.backupUseCellularForVideos => copyWith(backup: backup.copyWith(useCellularForVideos: value as bool)),
|
||||
.backupUseCellularForPhotos => copyWith(backup: backup.copyWith(useCellularForPhotos: value as bool)),
|
||||
.backupRequireCharging => copyWith(backup: backup.copyWith(requireCharging: value as bool)),
|
||||
.backupTriggerDelay => copyWith(backup: backup.copyWith(triggerDelay: value as int)),
|
||||
.backupSyncAlbums => copyWith(backup: backup.copyWith(syncAlbums: value as bool)),
|
||||
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
|
||||
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
|
||||
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
|
||||
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
|
||||
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
||||
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
|
||||
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
|
||||
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
|
||||
.cleanupKeepFavorites => copyWith(cleanup: cleanup.copyWith(keepFavorites: value as bool)),
|
||||
.cleanupKeepMediaType => copyWith(cleanup: cleanup.copyWith(keepMediaType: value as AssetKeepType)),
|
||||
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
|
||||
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
|
||||
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
|
||||
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
|
||||
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
|
||||
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
|
||||
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
|
||||
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
class NetworkConfig {
|
||||
final bool autoEndpointSwitching;
|
||||
final String? preferredWifiName;
|
||||
final String? localEndpoint;
|
||||
final String preferredWifiName;
|
||||
final String localEndpoint;
|
||||
final List<String> externalEndpointList;
|
||||
final Map<String, String> customHeaders;
|
||||
|
||||
const NetworkConfig({
|
||||
this.autoEndpointSwitching = false,
|
||||
this.preferredWifiName,
|
||||
this.localEndpoint,
|
||||
this.preferredWifiName = '',
|
||||
this.localEndpoint = '',
|
||||
this.externalEndpointList = const [],
|
||||
this.customHeaders = const {},
|
||||
});
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
class SystemConfig {
|
||||
final LogLevel logLevel;
|
||||
final NetworkConfig network;
|
||||
|
||||
const SystemConfig({this.logLevel = .info, this.network = const .new()});
|
||||
|
||||
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) =>
|
||||
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(logLevel, network);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)';
|
||||
}
|
||||
@@ -1,142 +1,105 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
enum MetadataDomain<T extends Object> {
|
||||
appConfig<AppConfig>('config.app'),
|
||||
systemConfig<SystemConfig>('config.system');
|
||||
enum MetadataScope {
|
||||
user, // keys with this scope are deleted on logout
|
||||
system;
|
||||
|
||||
final String prefix;
|
||||
const MetadataDomain(this.prefix);
|
||||
const MetadataScope();
|
||||
}
|
||||
|
||||
enum MetadataKey<T extends Object> {
|
||||
// Theme
|
||||
themePrimaryColor<ImmichColorPreset>(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)),
|
||||
themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
|
||||
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
|
||||
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
themeDynamic<bool>(),
|
||||
themeColorfulInterface<bool>(),
|
||||
|
||||
// Image
|
||||
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false),
|
||||
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false),
|
||||
imagePreferRemote<bool>(),
|
||||
imageLoadOriginal<bool>(),
|
||||
|
||||
// Viewer
|
||||
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true),
|
||||
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false),
|
||||
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
|
||||
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
|
||||
viewerLoopVideo<bool>(),
|
||||
viewerLoadOriginalVideo<bool>(),
|
||||
viewerAutoPlayVideo<bool>(),
|
||||
viewerTapToNavigate<bool>(),
|
||||
|
||||
// Network
|
||||
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
|
||||
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
|
||||
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
|
||||
networkExternalEndpointList<List<String>>(
|
||||
.systemConfig,
|
||||
'network.externalEndpointList',
|
||||
[],
|
||||
_ListCodec(_PrimitiveCodec.string),
|
||||
),
|
||||
networkAutoEndpointSwitching<bool>(scope: .system),
|
||||
networkPreferredWifiName<String>(scope: .system),
|
||||
networkLocalEndpoint<String>(scope: .system),
|
||||
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(
|
||||
.systemConfig,
|
||||
'network.customHeaders',
|
||||
{},
|
||||
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
scope: .system,
|
||||
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(
|
||||
.appConfig,
|
||||
'album.sortMode',
|
||||
AlbumSortMode.mostRecent,
|
||||
_EnumCodec(AlbumSortMode.values),
|
||||
),
|
||||
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
|
||||
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
|
||||
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
|
||||
albumIsReverse<bool>(),
|
||||
albumIsGrid<bool>(),
|
||||
|
||||
// Backup
|
||||
backupEnabled<bool>(.appConfig, 'backup.enabled', false),
|
||||
backupUseCellularForVideos<bool>(.appConfig, 'backup.useCellularForVideos', false),
|
||||
backupUseCellularForPhotos<bool>(.appConfig, 'backup.useCellularForPhotos', false),
|
||||
backupRequireCharging<bool>(.appConfig, 'backup.requireCharging', false),
|
||||
backupTriggerDelay<int>(.appConfig, 'backup.triggerDelay', 30),
|
||||
backupSyncAlbums<bool>(.appConfig, 'backup.syncAlbums', false),
|
||||
backupEnabled<bool>(),
|
||||
backupUseCellularForVideos<bool>(),
|
||||
backupUseCellularForPhotos<bool>(),
|
||||
backupRequireCharging<bool>(),
|
||||
backupTriggerDelay<int>(),
|
||||
backupSyncAlbums<bool>(),
|
||||
|
||||
// Timeline
|
||||
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(
|
||||
.appConfig,
|
||||
'timeline.groupAssetsBy',
|
||||
GroupAssetsBy.day,
|
||||
_EnumCodec(GroupAssetsBy.values),
|
||||
),
|
||||
timelineStorageIndicator<bool>(.appConfig, 'timeline.storageIndicator', true),
|
||||
timelineTilesPerRow<int>(),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
|
||||
timelineStorageIndicator<bool>(),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)),
|
||||
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
|
||||
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(.appConfig, 'map.showFavoriteOnly', false),
|
||||
mapRelativeDate<int>(.appConfig, 'map.relativeDate', 0),
|
||||
mapIncludeArchived<bool>(.appConfig, 'map.includeArchived', false),
|
||||
mapThemeMode<ThemeMode>(.appConfig, 'map.themeMode', .system, _EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(.appConfig, 'map.withPartners', false),
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
mapRelativeDate<int>(),
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
|
||||
// Cleanup
|
||||
cleanupKeepFavorites<bool>(.appConfig, 'cleanup.keepFavorites', true),
|
||||
cleanupKeepMediaType<AssetKeepType>(
|
||||
.appConfig,
|
||||
'cleanup.keepMediaType',
|
||||
AssetKeepType.none,
|
||||
_EnumCodec(AssetKeepType.values),
|
||||
),
|
||||
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
|
||||
cleanupKeepFavorites<bool>(),
|
||||
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
|
||||
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(),
|
||||
cleanupDefaultsInitialized<bool>(),
|
||||
|
||||
// Slideshow
|
||||
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
|
||||
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
|
||||
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
|
||||
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(
|
||||
.appConfig,
|
||||
'slideshow.direction',
|
||||
SlideshowDirection.forward,
|
||||
_EnumCodec(SlideshowDirection.values),
|
||||
);
|
||||
slideshowTransition<bool>(),
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
final T defaultValue;
|
||||
final MetadataScope scope;
|
||||
final _MetadataCodec<T>? _codecOverride;
|
||||
|
||||
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]);
|
||||
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
|
||||
|
||||
String get key => '${domain.prefix}.$name';
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw) ?? defaultValue;
|
||||
|
||||
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
|
||||
T decode(String raw) => _codec.decode(raw);
|
||||
}
|
||||
|
||||
sealed class _MetadataCodec<T extends Object> {
|
||||
const _MetadataCodec();
|
||||
|
||||
String encode(T value);
|
||||
T? decode(String raw);
|
||||
T decode(String raw);
|
||||
|
||||
static const Map<Type, _MetadataCodec<Object>> _primitives = {
|
||||
int: _PrimitiveCodec.integer,
|
||||
@@ -146,12 +109,10 @@ sealed class _MetadataCodec<T extends Object> {
|
||||
DateTime: _DateTimeCodec(),
|
||||
};
|
||||
|
||||
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) {
|
||||
final codec = _primitives[sample.runtimeType];
|
||||
static _MetadataCodec<T> forType<T extends Object>(Type runtimeType) {
|
||||
final codec = _primitives[runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError(
|
||||
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
|
||||
);
|
||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the MetadataKey.');
|
||||
}
|
||||
return codec as _MetadataCodec<T>;
|
||||
}
|
||||
@@ -166,7 +127,7 @@ final class _EnumCodec<T extends Enum> extends _MetadataCodec<T> {
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw);
|
||||
T decode(String raw) => values.firstWhere((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
@@ -176,7 +137,7 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime? decode(String raw) => DateTime.tryParse(raw);
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
|
||||
@@ -193,29 +154,26 @@ final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V>? decode(String raw) {
|
||||
Map<K, V> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
if (k == null || v == null) {
|
||||
return null;
|
||||
}
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,32 +187,29 @@ final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
|
||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
||||
|
||||
@override
|
||||
List<T>? decode(String raw) {
|
||||
List<T> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
final result = <T>[];
|
||||
for (final item in decoded) {
|
||||
if (item is! String) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
final element = _elementCodec.decode(item);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
result.add(element);
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
|
||||
final T? Function(String) _parse;
|
||||
final T Function(String) _parse;
|
||||
|
||||
const _PrimitiveCodec._(this._parse);
|
||||
|
||||
@@ -262,12 +217,12 @@ final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T? decode(String raw) => _parse(raw);
|
||||
T decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = _PrimitiveCodec<int>._(int.tryParse);
|
||||
static const real = _PrimitiveCodec<double>._(double.tryParse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse);
|
||||
static const integer = _PrimitiveCodec<int>._(int.parse);
|
||||
static const real = _PrimitiveCodec<double>._(double.parse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
|
||||
static const string = _PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String? _identity(String s) => s;
|
||||
static String _identity(String s) => s;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,7 @@ class AssetService {
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
|
||||
const AssetService({
|
||||
required RemoteAssetRepository remoteAssetRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
}) : _remoteAssetRepository = remoteAssetRepository,
|
||||
_localAssetRepository = localAssetRepository;
|
||||
const AssetService({required this._remoteAssetRepository, required this._localAssetRepository});
|
||||
|
||||
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||
|
||||
@@ -61,11 +61,9 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
BackgroundWorkerBgService({required Drift drift, required DriftLogger driftLogger})
|
||||
: _drift = drift,
|
||||
_driftLogger = driftLogger,
|
||||
_backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(drift))]);
|
||||
BackgroundWorkerBgService({required this._drift, required this._driftLogger})
|
||||
: _backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,18 +21,13 @@ class HashService {
|
||||
final _log = Logger('HashService');
|
||||
|
||||
HashService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
bool Function()? cancelChecker,
|
||||
required this._localAlbumRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
this._cancelChecker,
|
||||
int? batchSize,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_cancelChecker = cancelChecker,
|
||||
_nativeSyncApi = nativeSyncApi,
|
||||
_batchSize = batchSize ?? kBatchHashFileLimit;
|
||||
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
|
||||
|
||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||
|
||||
|
||||
@@ -28,18 +28,13 @@ class LocalSyncService {
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required AssetMediaRepository assetMediaRepository,
|
||||
required IPermissionRepository permissionRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_assetMediaRepository = assetMediaRepository,
|
||||
_permissionRepository = permissionRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
required this._localAlbumRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._assetMediaRepository,
|
||||
required this._permissionRepository,
|
||||
});
|
||||
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
|
||||
@@ -56,7 +56,7 @@ class LogService {
|
||||
}) async {
|
||||
final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
|
||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||
final level = instance._metadataRepository.systemConfig.logLevel;
|
||||
final level = instance._metadataRepository.appConfig.logLevel;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ typedef MapQuery = ({MapMarkerSource markerSource});
|
||||
class MapFactory {
|
||||
final DriftMapRepository _mapRepository;
|
||||
|
||||
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
|
||||
const MapFactory({required this._mapRepository});
|
||||
|
||||
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
|
||||
MapService(_mapRepository.remote(ownerIds, options));
|
||||
|
||||
@@ -9,7 +9,7 @@ final AppSetting = SettingsService(storeService: StoreService.I);
|
||||
class SettingsService {
|
||||
final StoreService _storeService;
|
||||
|
||||
const SettingsService({required StoreService storeService}) : _storeService = storeService;
|
||||
const SettingsService({required this._storeService});
|
||||
|
||||
T get<T>(Setting<T> setting) => _storeService.get(setting.storeKey, setting.defaultValue);
|
||||
|
||||
|
||||
@@ -41,24 +41,16 @@ class SyncStreamService {
|
||||
final bool Function()? _cancelChecker;
|
||||
|
||||
SyncStreamService({
|
||||
required SyncApiRepository syncApiRepository,
|
||||
required SyncStreamRepository syncStreamRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required AssetMediaRepository assetMediaRepository,
|
||||
required IPermissionRepository permissionRepository,
|
||||
required SyncMigrationRepository syncMigrationRepository,
|
||||
required ApiService api,
|
||||
bool Function()? cancelChecker,
|
||||
}) : _syncApiRepository = syncApiRepository,
|
||||
_syncStreamRepository = syncStreamRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_assetMediaRepository = assetMediaRepository,
|
||||
_permissionRepository = permissionRepository,
|
||||
_syncMigrationRepository = syncMigrationRepository,
|
||||
_api = api,
|
||||
_cancelChecker = cancelChecker;
|
||||
required this._syncApiRepository,
|
||||
required this._syncStreamRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._assetMediaRepository,
|
||||
required this._permissionRepository,
|
||||
required this._syncMigrationRepository,
|
||||
required this._api,
|
||||
this._cancelChecker,
|
||||
});
|
||||
|
||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||
|
||||
|
||||
@@ -41,11 +41,7 @@ class TimelineFactory {
|
||||
final DriftTimelineRepository _timelineRepository;
|
||||
final MetadataRepository _metadataRepository;
|
||||
|
||||
const TimelineFactory({
|
||||
required DriftTimelineRepository timelineRepository,
|
||||
required MetadataRepository metadataRepository,
|
||||
}) : _timelineRepository = timelineRepository,
|
||||
_metadataRepository = metadataRepository;
|
||||
const TimelineFactory({required this._timelineRepository, required this._metadataRepository});
|
||||
|
||||
GroupAssetsBy get groupBy {
|
||||
final group = _metadataRepository.appConfig.timeline.groupAssetsBy;
|
||||
@@ -108,12 +104,7 @@ class TimelineService {
|
||||
TimelineService(TimelineQuery query)
|
||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||
|
||||
TimelineService._({
|
||||
required TimelineAssetSource assetSource,
|
||||
required TimelineBucketSource bucketSource,
|
||||
required this.origin,
|
||||
}) : _assetSource = assetSource,
|
||||
_bucketSource = bucketSource {
|
||||
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||
_mutex.run(() async {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
|
||||
@@ -12,9 +12,7 @@ class UserService {
|
||||
final UserApiRepository _userApiRepository;
|
||||
final StoreService _storeService;
|
||||
|
||||
UserService({required UserApiRepository userApiRepository, required StoreService storeService})
|
||||
: _userApiRepository = userApiRepository,
|
||||
_storeService = storeService;
|
||||
UserService({required this._userApiRepository, required this._storeService});
|
||||
|
||||
UserDto getMyUser() {
|
||||
return _storeService.get(StoreKey.currentUser);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class MetadataRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
final Map<MetadataKey, Object> _cache = {};
|
||||
|
||||
MetadataRepository._(this._db) : super(_db);
|
||||
|
||||
@@ -25,153 +23,50 @@ class MetadataRepository extends DriftDatabaseRepository {
|
||||
AppConfig _appConfig = const .new();
|
||||
AppConfig get appConfig => _appConfig;
|
||||
|
||||
SystemConfig _systemConfig = const .new();
|
||||
SystemConfig get systemConfig => _systemConfig;
|
||||
|
||||
static Future<MetadataRepository> ensureInitialized(Drift db) async {
|
||||
if (_instance == null) {
|
||||
final instance = MetadataRepository._(db);
|
||||
await instance._hydrate();
|
||||
await instance.refresh();
|
||||
_instance = instance;
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static Future<void> refresh() async {
|
||||
instance._cache.clear();
|
||||
instance._appConfig = const .new();
|
||||
instance._systemConfig = const .new();
|
||||
await instance._hydrate();
|
||||
}
|
||||
|
||||
Future<void> _hydrate() async => _hydrateCache(await _db.select(_db.metadataEntity).get());
|
||||
|
||||
T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue;
|
||||
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
|
||||
|
||||
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
|
||||
if (_read(key) == value) {
|
||||
if (value == _appConfig.read(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.key, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
_updateCache(key, value);
|
||||
}
|
||||
|
||||
Future<void> delete<T extends Object>(MetadataKey<T> key) async {
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.key))).go();
|
||||
_updateCache(key, key.defaultValue);
|
||||
}
|
||||
|
||||
Stream<AppConfig> watchAppConfig() => _watchDomain(.appConfig).distinct();
|
||||
|
||||
Stream<SystemConfig> watchSystemConfig() => _watchDomain(.systemConfig).distinct();
|
||||
|
||||
Stream<T> _watchDomain<T extends Object>(MetadataDomain<T> domain) {
|
||||
final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'));
|
||||
return query.watch().map((rows) {
|
||||
_hydrateCache(rows);
|
||||
return domain.config(this);
|
||||
});
|
||||
}
|
||||
|
||||
void _hydrateCache(List<MetadataEntityData> rows) {
|
||||
final keyMap = MetadataKey.asKeyMap();
|
||||
for (final row in rows) {
|
||||
final key = keyMap[row.key];
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
_updateCache(key, key.decode(row.value));
|
||||
if (value == defaultConfig.read(key)) {
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
|
||||
} else {
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
}
|
||||
|
||||
_appConfig = _appConfig.write(key, value);
|
||||
}
|
||||
|
||||
void _updateCache<T extends Object>(MetadataKey<T> key, T value) {
|
||||
if (_cache[key] == value) {
|
||||
return;
|
||||
}
|
||||
_cache[key] = value;
|
||||
key.domain.rebuild(this);
|
||||
}
|
||||
}
|
||||
|
||||
extension<T extends Object> on MetadataDomain<T> {
|
||||
T config(MetadataRepository repo) => switch (this) {
|
||||
.appConfig => repo._appConfig as T,
|
||||
.systemConfig => repo._systemConfig as T,
|
||||
};
|
||||
|
||||
void rebuild(MetadataRepository repo) {
|
||||
switch (this) {
|
||||
case .appConfig:
|
||||
repo._appConfig = .new(
|
||||
theme: .new(
|
||||
mode: repo._read(.themeMode),
|
||||
primaryColor: repo._read(.themePrimaryColor),
|
||||
dynamicTheme: repo._read(.themeDynamic),
|
||||
colorfulInterface: repo._read(.themeColorfulInterface),
|
||||
),
|
||||
cleanup: .new(
|
||||
keepFavorites: repo._read(.cleanupKeepFavorites),
|
||||
keepMediaType: repo._read(.cleanupKeepMediaType),
|
||||
keepAlbumIds: repo._read(.cleanupKeepAlbumIds),
|
||||
cutoffDaysAgo: repo._read(.cleanupCutoffDaysAgo),
|
||||
defaultsInitialized: repo._read(.cleanupDefaultsInitialized),
|
||||
),
|
||||
map: .new(
|
||||
relativeDays: repo._read(.mapRelativeDate),
|
||||
favoritesOnly: repo._read(.mapShowFavoriteOnly),
|
||||
includeArchived: repo._read(.mapIncludeArchived),
|
||||
themeMode: repo._read(.mapThemeMode),
|
||||
withPartners: repo._read(.mapWithPartners),
|
||||
),
|
||||
timeline: .new(
|
||||
tilesPerRow: repo._read(.timelineTilesPerRow),
|
||||
groupAssetsBy: repo._read(.timelineGroupAssetsBy),
|
||||
storageIndicator: repo._read(.timelineStorageIndicator),
|
||||
),
|
||||
image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)),
|
||||
viewer: .new(
|
||||
loopVideo: repo._read(.viewerLoopVideo),
|
||||
loadOriginalVideo: repo._read(.viewerLoadOriginalVideo),
|
||||
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
|
||||
tapToNavigate: repo._read(.viewerTapToNavigate),
|
||||
),
|
||||
slideshow: .new(
|
||||
transition: repo._read(.slideshowTransition),
|
||||
repeat: repo._read(.slideshowRepeat),
|
||||
duration: repo._read(.slideshowDuration),
|
||||
look: repo._read(.slideshowLook),
|
||||
direction: repo._read(.slideshowDirection),
|
||||
),
|
||||
album: .new(
|
||||
sortMode: repo._read(.albumSortMode),
|
||||
isReverse: repo._read(.albumIsReverse),
|
||||
isGrid: repo._read(.albumIsGrid),
|
||||
),
|
||||
backup: .new(
|
||||
enabled: repo._read(.backupEnabled),
|
||||
useCellularForVideos: repo._read(.backupUseCellularForVideos),
|
||||
useCellularForPhotos: repo._read(.backupUseCellularForPhotos),
|
||||
requireCharging: repo._read(.backupRequireCharging),
|
||||
triggerDelay: repo._read(.backupTriggerDelay),
|
||||
syncAlbums: repo._read(.backupSyncAlbums),
|
||||
),
|
||||
);
|
||||
case .systemConfig:
|
||||
repo._systemConfig = .new(
|
||||
logLevel: repo._read(.logLevel),
|
||||
network: .new(
|
||||
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
|
||||
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
|
||||
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
|
||||
externalEndpointList: repo._read(.networkExternalEndpointList),
|
||||
customHeaders: repo._read(.networkCustomHeaders),
|
||||
),
|
||||
);
|
||||
}
|
||||
Stream<AppConfig> watchConfig() => _db.select(_db.metadataEntity).watch().map((rows) {
|
||||
_applyOverrides(rows);
|
||||
return _appConfig;
|
||||
});
|
||||
|
||||
void _applyOverrides(List<MetadataEntityData> rows) {
|
||||
_appConfig = AppConfig.fromEntries(
|
||||
rows.fold({}, (overrides, row) {
|
||||
final metadataKey = MetadataKey.values.firstWhereOrNull((key) => key.name == row.key);
|
||||
if (metadataKey == null) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
return {...overrides, metadataKey: metadataKey.decode(row.value)};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
_searchController = TextEditingController();
|
||||
_searchFocusNode = FocusNode();
|
||||
|
||||
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
_enableSyncUploadAlbum.value = ref.read(appConfigProvider).backup.syncAlbums;
|
||||
ref.read(backupAlbumProvider.notifier).getAll();
|
||||
|
||||
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
return;
|
||||
}
|
||||
|
||||
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
final enableSyncUploadAlbum = ref.read(appConfigProvider).backup.syncAlbums;
|
||||
final selectedAlbums = ref
|
||||
.read(backupAlbumProvider)
|
||||
.where((a) => a.backupSelection == BackupSelection.selected)
|
||||
|
||||
@@ -19,7 +19,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
bool hasPopped = false;
|
||||
final previousBackup = ref.read(metadataProvider).appConfig.backup;
|
||||
final previousBackup = ref.read(appConfigProvider).backup;
|
||||
final previousCellularForVideos = previousBackup.useCellularForVideos;
|
||||
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
|
||||
return PopScope(
|
||||
|
||||
@@ -22,7 +22,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
||||
final headers = useState<List<SettingsHeader>>([]);
|
||||
final setInitialHeaders = useState(false);
|
||||
|
||||
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
final storedHeaders = ref.read(metadataProvider).appConfig.network.customHeaders;
|
||||
if (!setInitialHeaders.value) {
|
||||
storedHeaders.forEach((k, v) {
|
||||
final header = SettingsHeader();
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
@@ -36,7 +36,7 @@ class BootstrapErrorWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext _) {
|
||||
final immichTheme = MetadataKey.themePrimaryColor.defaultValue.themeOfPreset;
|
||||
final immichTheme = defaultConfig.theme.primaryColor.themeOfPreset;
|
||||
|
||||
return EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
/// - Prompt to delete the asset locally
|
||||
@@ -39,6 +40,8 @@ class DeleteLocalActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.invalidate(localAlbumProvider);
|
||||
|
||||
final successMessage = 'delete_local_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -14,7 +14,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class _SharePreparingDialog extends StatelessWidget {
|
||||
const _SharePreparingDialog();
|
||||
final ValueNotifier<double?> progress;
|
||||
|
||||
const _SharePreparingDialog({required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,8 +24,24 @@ class _SharePreparingDialog extends StatelessWidget {
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()),
|
||||
Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()),
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: ValueListenableBuilder<double?>(
|
||||
valueListenable: progress,
|
||||
builder: (context, value, _) {
|
||||
final percent = value == null ? null : (value * 100).clamp(0, 100);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(value: value, minHeight: 8.0),
|
||||
if (percent != null)
|
||||
Container(margin: const EdgeInsets.only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -43,32 +61,39 @@ class ShareActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final cancelCompleter = Completer<void>();
|
||||
const preparingDialog = _SharePreparingDialog();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
final preparingDialog = _SharePreparingDialog(progress: progress);
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then((
|
||||
ActionResult result,
|
||||
) {
|
||||
if (cancelCompleter.isCompleted || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(actionProvider.notifier)
|
||||
.shareAssets(
|
||||
source,
|
||||
context,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
.then((ActionResult result) {
|
||||
if (cancelCompleter.isCompleted || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
buildContext.pop();
|
||||
});
|
||||
buildContext.pop();
|
||||
});
|
||||
|
||||
// show a loading spinner with a "Preparing" message
|
||||
// Show download progress with a "Preparing" message
|
||||
return preparingDialog;
|
||||
},
|
||||
barrierDismissible: false,
|
||||
@@ -77,6 +102,7 @@ class ShareActionButton extends ConsumerWidget {
|
||||
if (!cancelCompleter.isCompleted) {
|
||||
cancelCompleter.complete();
|
||||
}
|
||||
progress.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -296,16 +296,12 @@ class _ThumbnailRenderBox extends RenderBox {
|
||||
bool isRepaintBoundary = true;
|
||||
|
||||
_ThumbnailRenderBox({
|
||||
required ui.Image? image,
|
||||
required ui.Image? previousImage,
|
||||
required double fadeValue,
|
||||
required BoxFit fit,
|
||||
required Gradient placeholderGradient,
|
||||
}) : _image = image,
|
||||
_previousImage = previousImage,
|
||||
_fadeValue = fadeValue,
|
||||
_fit = fit,
|
||||
_placeholderGradient = placeholderGradient;
|
||||
required this._image,
|
||||
required this._previousImage,
|
||||
required this._fadeValue,
|
||||
required this._fit,
|
||||
required this._placeholderGradient,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
|
||||
@@ -62,14 +62,11 @@ class RenderFixedRow extends RenderBox
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
||||
RenderFixedRow({
|
||||
List<RenderBox>? children,
|
||||
required double height,
|
||||
required List<double> widths,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
}) : _height = height,
|
||||
_widths = widths,
|
||||
_spacing = spacing,
|
||||
_textDirection = textDirection {
|
||||
required this._height,
|
||||
required this._widths,
|
||||
required this._spacing,
|
||||
required this._textDirection,
|
||||
}) {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
|
||||
@@ -578,9 +578,7 @@ class _SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> _animation;
|
||||
final Widget _child;
|
||||
|
||||
const _SlideFadeTransition({required Animation<double> animation, required Widget child})
|
||||
: _animation = animation,
|
||||
_child = child;
|
||||
const _SlideFadeTransition({required this._animation, required this._child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -397,7 +397,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final grid = CustomScrollView(
|
||||
primary: true,
|
||||
physics: _scrollPhysics,
|
||||
cacheExtent: maxHeight * 2,
|
||||
scrollCacheExtent: .pixels(maxHeight * 2),
|
||||
slivers: [
|
||||
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
|
||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||
@@ -503,7 +503,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
|
||||
final List<Segment> _segments;
|
||||
|
||||
const _SliverSegmentedList({required List<Segment> segments, required super.delegate}) : _segments = segments;
|
||||
const _SliverSegmentedList({required this._segments, required super.delegate});
|
||||
|
||||
@override
|
||||
_RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) =>
|
||||
@@ -527,8 +527,7 @@ class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
_RenderSliverTimelineBoxAdaptor({required super.childManager, required List<Segment> segments})
|
||||
: _segments = segments;
|
||||
_RenderSliverTimelineBoxAdaptor({required super.childManager, required this._segments});
|
||||
|
||||
int getMinChildIndexForScrollOffset(double offset) =>
|
||||
_segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ?? 0;
|
||||
|
||||
@@ -130,7 +130,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
await _apiService.updateHeaders();
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
final headerMap = _ref.read(metadataProvider).appConfig.network.customHeaders;
|
||||
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
|
||||
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
|
||||
|
||||
@@ -187,11 +187,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
String? getSavedWifiName() {
|
||||
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName;
|
||||
return _ref.read(metadataProvider).appConfig.network.preferredWifiName;
|
||||
}
|
||||
|
||||
String? getSavedLocalEndpoint() {
|
||||
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
|
||||
return _ref.read(metadataProvider).appConfig.network.localEndpoint;
|
||||
}
|
||||
|
||||
/// Returns the current server endpoint (with /api) URL from the store
|
||||
|
||||
@@ -465,11 +465,17 @@ class ActionNotifier extends Notifier<void> {
|
||||
ActionSource source,
|
||||
BuildContext context, {
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
final ids = _getAssets(source).toList(growable: false);
|
||||
|
||||
try {
|
||||
await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter);
|
||||
await _service.shareAssets(
|
||||
ids,
|
||||
context,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to share assets', error, stack);
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
|
||||
final metadataProvider = Provider.autoDispose<MetadataRepository>((_) => MetadataRepository.instance);
|
||||
|
||||
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
|
||||
final repo = ref.watch(metadataProvider);
|
||||
final subscription = repo.watchAppConfig().listen((event) => ref.state = event);
|
||||
final subscription = repo.watchConfig().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.appConfig;
|
||||
});
|
||||
|
||||
final systemConfigProvider = Provider.autoDispose<SystemConfig>((ref) {
|
||||
final repo = ref.watch(metadataProvider);
|
||||
final subscription = repo.watchSystemConfig().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.systemConfig;
|
||||
});
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
final assetMediaRepositoryProvider = Provider(
|
||||
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
|
||||
);
|
||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider)));
|
||||
|
||||
class AssetMediaRepository {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
||||
const AssetMediaRepository(this._nativeSyncApi);
|
||||
|
||||
Future<bool> _androidSupportsTrash() async {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -107,10 +105,29 @@ class AssetMediaRepository {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: make this more efficient
|
||||
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async {
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
final downloadedXFiles = <XFile>[];
|
||||
final tempFiles = <File>[];
|
||||
final totalAssets = assets.length;
|
||||
var processedAssets = 0;
|
||||
|
||||
void updateProgress([double currentAssetProgress = 0.0]) {
|
||||
if (totalAssets <= 0) {
|
||||
onAssetDownloadProgress?.call(1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
final normalizedAssetProgress = currentAssetProgress.clamp(0.0, 1.0);
|
||||
final overallProgress = ((processedAssets + normalizedAssetProgress) / totalAssets).clamp(0.0, 1.0);
|
||||
onAssetDownloadProgress?.call(overallProgress);
|
||||
}
|
||||
|
||||
updateProgress();
|
||||
|
||||
for (var asset in assets) {
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
@@ -127,6 +144,8 @@ class AssetMediaRepository {
|
||||
if (localId != null && !asset.isEdited) {
|
||||
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
|
||||
downloadedXFiles.add(XFile(f!.path));
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
if (CurrentPlatform.isIOS) {
|
||||
tempFiles.add(f);
|
||||
}
|
||||
@@ -134,22 +153,50 @@ class AssetMediaRepository {
|
||||
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
|
||||
if (remoteId == null) {
|
||||
_log.warning("Asset has no remote ID for sharing: $asset");
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final name = asset.name;
|
||||
final tempFile = await File('${tempDir.path}/$name').create();
|
||||
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true);
|
||||
final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
|
||||
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
|
||||
final task = DownloadTask(
|
||||
taskId: taskId,
|
||||
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
filename: sanitizedFilename,
|
||||
baseDirectory: BaseDirectory.temporary,
|
||||
group: kShareDownloadGroup,
|
||||
updates: Updates.statusAndProgress,
|
||||
);
|
||||
final statusUpdate = await FileDownloader().download(
|
||||
task,
|
||||
onProgress: (value) {
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
unawaited(FileDownloader().cancelTaskWithId(taskId));
|
||||
return;
|
||||
}
|
||||
updateProgress(value);
|
||||
},
|
||||
);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe("Download for $name failed", res.toLoggerString());
|
||||
continue;
|
||||
if (cancelCompleter != null && cancelCompleter.isCompleted) {
|
||||
await _cleanupTempFiles(tempFiles);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await tempFile.writeAsBytes(res.bodyBytes);
|
||||
downloadedXFiles.add(XFile(tempFile.path));
|
||||
tempFiles.add(tempFile);
|
||||
if (statusUpdate.status == TaskStatus.complete) {
|
||||
final filePath = await task.filePath();
|
||||
final file = File(filePath);
|
||||
tempFiles.add(file);
|
||||
downloadedXFiles.add(XFile(filePath));
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
continue;
|
||||
}
|
||||
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
|
||||
processedAssets++;
|
||||
updateProgress();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>(
|
||||
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
|
||||
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(appConfigProvider)),
|
||||
);
|
||||
|
||||
class AuthRepository {
|
||||
final Drift _drift;
|
||||
final MetadataRepository _metadata;
|
||||
final AppConfig _config;
|
||||
|
||||
const AuthRepository(this._drift, this._metadata);
|
||||
const AuthRepository(this._drift, this._config);
|
||||
|
||||
Future<void> clearLocalData() async {
|
||||
await SyncStreamRepository(_drift).reset();
|
||||
}
|
||||
|
||||
bool getEndpointSwitchingFeature() {
|
||||
return _metadata.systemConfig.network.autoEndpointSwitching;
|
||||
return _config.network.autoEndpointSwitching;
|
||||
}
|
||||
|
||||
String? getPreferredWifiName() {
|
||||
return _metadata.systemConfig.network.preferredWifiName;
|
||||
return _config.network.preferredWifiName;
|
||||
}
|
||||
|
||||
String? getLocalEndpoint() {
|
||||
return _metadata.systemConfig.network.localEndpoint;
|
||||
return _config.network.localEndpoint;
|
||||
}
|
||||
|
||||
List<AuxilaryEndpoint> getExternalEndpointList() {
|
||||
return _metadata.systemConfig.network.externalEndpointList
|
||||
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
|
||||
.toList();
|
||||
return _config.network.externalEndpointList.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,8 +269,18 @@ class ActionService {
|
||||
await _assetApiRepository.unStack(stackIds);
|
||||
}
|
||||
|
||||
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) {
|
||||
return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter);
|
||||
Future<int> shareAssets(
|
||||
List<BaseAsset> assets,
|
||||
BuildContext context, {
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) {
|
||||
return _assetMediaRepository.shareAssets(
|
||||
assets,
|
||||
context,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
|
||||
|
||||
@@ -177,9 +177,9 @@ class ApiService {
|
||||
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
|
||||
urls.add(serverEndpoint);
|
||||
}
|
||||
final network = MetadataRepository.instance.systemConfig.network;
|
||||
final network = MetadataRepository.instance.appConfig.network;
|
||||
final localEndpoint = network.localEndpoint;
|
||||
if (localEndpoint != null) {
|
||||
if (localEndpoint.isNotEmpty) {
|
||||
urls.add(localEndpoint);
|
||||
}
|
||||
for (final url in network.externalEndpointList) {
|
||||
@@ -191,7 +191,7 @@ class ApiService {
|
||||
}
|
||||
|
||||
static Map<String, String> getRequestHeaders() {
|
||||
return MetadataRepository.instance.systemConfig.network.customHeaders;
|
||||
return MetadataRepository.instance.appConfig.network.customHeaders;
|
||||
}
|
||||
|
||||
ApiClient get apiClient => _apiClient;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
@@ -136,15 +138,11 @@ Future<void> _migrateTo26(Drift drift) async {
|
||||
|
||||
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
|
||||
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
|
||||
if (raw == null) {
|
||||
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
|
||||
if (mode == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final mode = AlbumSortMode.values.firstWhere(
|
||||
(e) => e.storeIndex == raw,
|
||||
orElse: () => MetadataKey.albumSortMode.defaultValue,
|
||||
);
|
||||
|
||||
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
|
||||
}
|
||||
|
||||
@@ -208,7 +206,11 @@ class _StoreMigrator {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.elementAtOrNull(index) ?? newKey.defaultValue;
|
||||
final enumValue = values.elementAtOrNull(index);
|
||||
if (enumValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_cache[newKey] = enumValue;
|
||||
_migratedStoreIds.add(legacyKey.id);
|
||||
}
|
||||
@@ -223,7 +225,11 @@ class _StoreMigrator {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.firstWhere((e) => e.name == name, orElse: () => newKey.defaultValue);
|
||||
final enumValue = values.firstWhereOrNull((e) => e.name == name);
|
||||
if (enumValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_cache[newKey] = enumValue;
|
||||
_migratedStoreIds.add(legacyKey.id);
|
||||
}
|
||||
@@ -267,9 +273,12 @@ class _StoreMigrator {
|
||||
Future<void> complete() async {
|
||||
await _db.batch((batch) {
|
||||
for (final entry in _cache.entries) {
|
||||
if (entry.value == defaultConfig.read(entry.key)) {
|
||||
continue;
|
||||
}
|
||||
batch.insert(
|
||||
_db.metadataEntity,
|
||||
MetadataEntityCompanion(key: Value(entry.key.key), value: Value(entry.key.encode(entry.value))),
|
||||
MetadataEntityCompanion(key: Value(entry.key.name), value: Value(entry.key.encode(entry.value))),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
|
||||
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
|
||||
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
|
||||
useValueChanged(
|
||||
preferRemote.value,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
|
||||
class LanguageSettings extends HookConsumerWidget {
|
||||
@@ -84,7 +85,7 @@ class LanguageSettings extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: filteredLocaleEntries.value.length,
|
||||
itemExtent: 64.0,
|
||||
cacheExtent: 100,
|
||||
scrollCacheExtent: const .pixels(100),
|
||||
itemBuilder: (context, index) {
|
||||
final countryName = filteredLocaleEntries.value[index].key;
|
||||
final localeValue = filteredLocaleEntries.value[index].value;
|
||||
|
||||
@@ -36,10 +36,6 @@ class ExternalNetworkPreference extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
handleReorder(int oldIndex, int newIndex) {
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
|
||||
final entry = entries.value.removeAt(oldIndex);
|
||||
entries.value.insert(newIndex, entry);
|
||||
entries.value = [...entries.value];
|
||||
@@ -68,7 +64,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
final urls = ref.read(metadataProvider).systemConfig.network.externalEndpointList;
|
||||
final urls = ref.read(appConfigProvider).network.externalEndpointList;
|
||||
|
||||
if (urls.isEmpty) {
|
||||
return null;
|
||||
@@ -113,7 +109,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: entries.value.length,
|
||||
onReorder: handleReorder,
|
||||
onReorderItem: handleReorder,
|
||||
itemBuilder: (context, index) {
|
||||
return EndpointInput(
|
||||
key: Key(index.toString()),
|
||||
|
||||
@@ -19,7 +19,7 @@ class NetworkingSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentEndpoint = getServerUrl();
|
||||
final featureEnabled = useState(ref.read(systemConfigProvider).network.autoEndpointSwitching);
|
||||
final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching);
|
||||
useValueChanged<bool, void>(featureEnabled.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value);
|
||||
});
|
||||
|
||||
@@ -78,6 +78,15 @@ alias = "migration"
|
||||
description = "Generate database migrations"
|
||||
run = "dart run drift_dev make-migrations"
|
||||
|
||||
[tasks.install]
|
||||
alias = "install"
|
||||
description = "Install flutter dependencies"
|
||||
run = "flutter pub get"
|
||||
|
||||
[tasks.start]
|
||||
alias = "start"
|
||||
description = "Start flutter app"
|
||||
run = "flutter run"
|
||||
|
||||
# Internal tasks
|
||||
[tasks."i18n:loader"]
|
||||
|
||||
@@ -276,8 +276,6 @@ class TimeBucketAssetResponseDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'city',
|
||||
'country',
|
||||
'createdAt',
|
||||
'duration',
|
||||
'fileCreatedAt',
|
||||
|
||||
@@ -10,9 +10,16 @@ class ImmichFormController extends ChangeNotifier {
|
||||
FutureOr<void> Function()? onSubmit;
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
bool _isDisposed = false;
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> submit() async {
|
||||
if (_isLoading) {
|
||||
return;
|
||||
@@ -27,7 +34,9 @@ class ImmichFormController extends ChangeNotifier {
|
||||
await onSubmit?.call();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_isDisposed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,13 +47,7 @@ class ImmichForm extends StatefulWidget {
|
||||
final String? submitText;
|
||||
final IconData? submitIcon;
|
||||
|
||||
const ImmichForm({
|
||||
super.key,
|
||||
this.onSubmit,
|
||||
this.submitText,
|
||||
this.submitIcon,
|
||||
required this.builder,
|
||||
});
|
||||
const ImmichForm({super.key, this.onSubmit, this.submitText, this.submitIcon, required this.builder});
|
||||
|
||||
@override
|
||||
State<ImmichForm> createState() => _ImmichFormState();
|
||||
|
||||
+12
-12
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
||||
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "93.0.0"
|
||||
version: "96.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
||||
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.1"
|
||||
version: "10.2.0"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1088,10 +1088,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1719,10 +1719,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1775,10 +1775,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1996,5 +1996,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: "3.41.9"
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: "3.44.0"
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ publish_to: 'none'
|
||||
version: 3.0.0+3047
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
flutter: 3.41.9
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
flutter: 3.44.0
|
||||
|
||||
dependencies:
|
||||
async: ^2.13.1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
@@ -39,7 +39,7 @@ void main() {
|
||||
registerFallbackValue(LogLevel.info);
|
||||
|
||||
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
|
||||
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
|
||||
when(() => mockMetadataRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.fine));
|
||||
when(() => mockMetadataRepository.write<LogLevel, LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
|
||||
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
|
||||
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
|
||||
@@ -59,7 +59,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('Sets log level based on the metadata repository', () {
|
||||
verify(() => mockMetadataRepository.systemConfig).called(1);
|
||||
verify(() => mockMetadataRepository.appConfig).called(1);
|
||||
expect(Logger.root.level, Level.FINE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ void main() {
|
||||
|
||||
setUp(() async {
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
await MetadataRepository.refresh();
|
||||
await MetadataRepository.instance.refresh();
|
||||
});
|
||||
|
||||
group('defaults', () {
|
||||
@@ -31,8 +31,8 @@ void main() {
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
test('systemConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.systemConfig.logLevel, LogLevel.info);
|
||||
test('appConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.appConfig.logLevel, LogLevel.info);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,16 +46,14 @@ void main() {
|
||||
await sut.write(.themeMode, ThemeMode.light);
|
||||
await sut.write(.logLevel, LogLevel.severe);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.light);
|
||||
expect(sut.systemConfig.logLevel, LogLevel.severe);
|
||||
expect(sut.appConfig.logLevel, LogLevel.severe);
|
||||
});
|
||||
});
|
||||
|
||||
group('delete', () {
|
||||
test('removes the row and reverts to default', () async {
|
||||
await sut.write(.themeMode, ThemeMode.dark);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await sut.delete(.themeMode);
|
||||
await sut.write(.themeMode, ThemeMode.system);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
final rows = await ctx.db.select(ctx.db.metadataEntity).get();
|
||||
@@ -63,13 +61,15 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('refresh', () {
|
||||
group('delete', () {});
|
||||
|
||||
group('sync', () {
|
||||
test('picks up rows that were inserted directly into the DB', () async {
|
||||
await ctx.db
|
||||
.into(ctx.db.metadataEntity)
|
||||
.insert(
|
||||
MetadataEntityCompanion.insert(
|
||||
key: MetadataKey.themeMode.key,
|
||||
key: MetadataKey.themeMode.name,
|
||||
value: ThemeMode.dark.name,
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
@@ -78,7 +78,7 @@ void main() {
|
||||
// Cache hasn't seen this row yet — view still returns the default.
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
await MetadataRepository.instance.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ void main() {
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
await MetadataRepository.instance.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
@@ -103,32 +103,20 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
await MetadataRepository.instance.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
});
|
||||
|
||||
group('watch', () {
|
||||
test('watchAppConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
||||
final expectation = expectLater(sut.watchConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
||||
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('watchAppConfig does not emit when only system-config rows change', () async {
|
||||
final emissions = <ThemeMode>[];
|
||||
// skip(1) drops the on-subscribe replay so we only capture emissions caused by the write below.
|
||||
final sub = sut.watchAppConfig().skip(1).listen((c) => emissions.add(c.theme.mode));
|
||||
|
||||
await sut.write(MetadataKey.logLevel, LogLevel.severe);
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(emissions, isEmpty);
|
||||
});
|
||||
|
||||
test('watchSystemConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchSystemConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
|
||||
test('watchConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
|
||||
await sut.write(MetadataKey.logLevel, LogLevel.warning);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
|
||||
void main() {
|
||||
group('MetadataKey', () {
|
||||
test('every key round-trips its default value losslessly', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
final encoded = key.encode(key.defaultValue);
|
||||
for (final key in MetadataKey.values) {
|
||||
test('verify codec for $key', () {
|
||||
final defaultValue = defaultConfig.read(key);
|
||||
final encoded = key.encode(defaultValue);
|
||||
final decoded = key.decode(encoded);
|
||||
expect(decoded, key.defaultValue, reason: 'round-trip failed for ${key.name}');
|
||||
}
|
||||
});
|
||||
|
||||
test('decode falls back to the default value when the raw input is unparseable', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
// String keys can decode any string. So skip them
|
||||
if (key.defaultValue is String) {
|
||||
continue;
|
||||
}
|
||||
expect(
|
||||
key.decode('not a valid encoding for any key'),
|
||||
key.defaultValue,
|
||||
reason: 'fallback failed for ${key.name}',
|
||||
);
|
||||
}
|
||||
});
|
||||
expect(decoded, defaultValue, reason: 'round-trip failed for ${key.name}');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25324,8 +25324,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city",
|
||||
"country",
|
||||
"createdAt",
|
||||
"duration",
|
||||
"fileCreatedAt",
|
||||
|
||||
@@ -2612,9 +2612,9 @@ export type TagUpdateDto = {
|
||||
};
|
||||
export type TimeBucketAssetResponseDto = {
|
||||
/** Array of city names extracted from EXIF GPS data */
|
||||
city: (string | null)[];
|
||||
city?: (string | null)[];
|
||||
/** Array of country names extracted from EXIF GPS data */
|
||||
country: (string | null)[];
|
||||
country?: (string | null)[];
|
||||
/** Array of UTC timestamps when each asset was originally uploaded to Immich */
|
||||
createdAt: string[];
|
||||
/** Array of video/gif durations in milliseconds (null for static images) */
|
||||
|
||||
Generated
+34
-28
@@ -609,7 +609,7 @@ importers:
|
||||
version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
|
||||
'@nestjs/cli':
|
||||
specifier: ^11.0.2
|
||||
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
|
||||
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
|
||||
'@nestjs/schematics':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
|
||||
@@ -618,7 +618,7 @@ importers:
|
||||
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)
|
||||
'@swc/core':
|
||||
specifier: ^1.4.14
|
||||
version: 1.15.33(@swc/helpers@0.5.21)
|
||||
version: 1.15.33(@swc/helpers@0.5.22)
|
||||
'@types/archiver':
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
@@ -738,7 +738,7 @@ importers:
|
||||
version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
unplugin-swc:
|
||||
specifier: ^1.4.5
|
||||
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.21))(rollup@4.60.4)
|
||||
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4)
|
||||
vite-tsconfig-paths:
|
||||
specifier: ^6.0.0
|
||||
version: 6.1.1(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0))
|
||||
@@ -758,8 +758,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../packages/sdk
|
||||
'@immich/ui':
|
||||
specifier: ^0.79.0
|
||||
version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
specifier: ^0.79.2
|
||||
version: 0.79.2(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
'@mapbox/mapbox-gl-rtl-text':
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0
|
||||
@@ -1691,6 +1691,10 @@ packages:
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.29.7':
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3204,8 +3208,8 @@ packages:
|
||||
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
||||
hasBin: true
|
||||
|
||||
'@immich/ui@0.79.0':
|
||||
resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
|
||||
'@immich/ui@0.79.2':
|
||||
resolution: {integrity: sha512-tnpYhYHrjrFJK18QglRMzPUtHv6q5V6tW38HiAraQJBv7MCg+yaJDrdF8omM2L5F311FGlv1PZLJYvmR4e49PA==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.13.0
|
||||
svelte: ^5.0.0
|
||||
@@ -4982,8 +4986,8 @@ packages:
|
||||
'@swc/counter@0.1.3':
|
||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||
'@swc/helpers@0.5.22':
|
||||
resolution: {integrity: sha512-/e2Ly3Docn9kYByap6TV4oquJ3wQuz3c+kC74riqtkwU9CwTMeuj6t2rW+bRr4pyOx/CYQM4wr0RgaKQwGEz0A==}
|
||||
|
||||
'@swc/types@0.1.26':
|
||||
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
|
||||
@@ -13787,6 +13791,8 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.29.2': {}
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
@@ -15879,7 +15885,7 @@ snapshots:
|
||||
pg-connection-string: 2.13.0
|
||||
postgres: 3.4.9
|
||||
|
||||
'@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||
'@immich/ui@0.79.2(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.12.1
|
||||
'@mdi/js': 7.4.47
|
||||
@@ -16035,7 +16041,7 @@ snapshots:
|
||||
|
||||
'@internationalized/date@3.12.1':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.21
|
||||
'@swc/helpers': 0.5.22
|
||||
|
||||
'@ioredis/commands@1.5.1': {}
|
||||
|
||||
@@ -16438,7 +16444,7 @@ snapshots:
|
||||
bullmq: 5.76.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
|
||||
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
||||
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
|
||||
@@ -16449,17 +16455,17 @@ snapshots:
|
||||
chokidar: 4.0.3
|
||||
cli-table3: 0.6.5
|
||||
commander: 4.1.1
|
||||
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||
glob: 13.0.6
|
||||
node-emoji: 1.11.0
|
||||
ora: 5.4.1
|
||||
tsconfig-paths: 4.2.0
|
||||
tsconfig-paths-webpack-plugin: 4.2.0
|
||||
typescript: 5.9.3
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
webpack-node-externals: 3.0.0
|
||||
optionalDependencies:
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
|
||||
transitivePeerDependencies:
|
||||
- '@minify-html/node'
|
||||
- '@swc/css'
|
||||
@@ -17438,7 +17444,7 @@ snapshots:
|
||||
|
||||
'@slorber/react-helmet-async@1.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@babel/runtime': 7.29.7
|
||||
invariant: 2.2.4
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.6
|
||||
@@ -17647,7 +17653,7 @@ snapshots:
|
||||
'@swc/core-win32-x64-msvc@1.15.33':
|
||||
optional: true
|
||||
|
||||
'@swc/core@1.15.33(@swc/helpers@0.5.21)':
|
||||
'@swc/core@1.15.33(@swc/helpers@0.5.22)':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/types': 0.1.26
|
||||
@@ -17664,11 +17670,11 @@ snapshots:
|
||||
'@swc/core-win32-arm64-msvc': 1.15.33
|
||||
'@swc/core-win32-ia32-msvc': 1.15.33
|
||||
'@swc/core-win32-x64-msvc': 1.15.33
|
||||
'@swc/helpers': 0.5.21
|
||||
'@swc/helpers': 0.5.22
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
'@swc/helpers@0.5.22':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -21084,7 +21090,7 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
chalk: 4.1.2
|
||||
@@ -21099,7 +21105,7 @@ snapshots:
|
||||
semver: 7.8.0
|
||||
tapable: 2.3.3
|
||||
typescript: 5.9.3
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
|
||||
form-data-encoder@2.1.4: {}
|
||||
|
||||
@@ -25761,15 +25767,15 @@ snapshots:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.47.1
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
optionalDependencies:
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
|
||||
esbuild: 0.28.0
|
||||
lightningcss: 1.32.0
|
||||
|
||||
@@ -26176,10 +26182,10 @@ snapshots:
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.21))(rollup@4.60.4):
|
||||
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.4)
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
|
||||
load-tsconfig: 0.2.5
|
||||
unplugin: 2.3.11
|
||||
transitivePeerDependencies:
|
||||
@@ -26578,7 +26584,7 @@ snapshots:
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0):
|
||||
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0):
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.9
|
||||
@@ -26602,7 +26608,7 @@ snapshots:
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.3
|
||||
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||
watchpack: 2.5.1
|
||||
webpack-sources: 3.4.1
|
||||
transitivePeerDependencies:
|
||||
|
||||
+12
-11
@@ -1,14 +1,10 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS dev
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
PNPM_HOME=/buildcache/pnpm-store
|
||||
|
||||
RUN npm install --global corepack@latest && \
|
||||
corepack enable pnpm && \
|
||||
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
||||
COPY --from=ghcr.io/jdx/mise:2026.5.11@sha256:2ba959e4827f845fe0c4cfb4814089e790dc513040ef74f9e14925f446412a51 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||
echo "cache-dir=/buildcache/pnpm-cache" >> /usr/local/etc/npmrc && \
|
||||
echo "# Retry configuration - default is 2" >> /usr/local/etc/npmrc && \
|
||||
@@ -16,12 +12,17 @@ RUN npm install --global corepack@latest && \
|
||||
mkdir -p /buildcache/pnpm-store /buildcache/pnpm-cache /buildcache/node-gyp && \
|
||||
chmod -R o+rw /buildcache
|
||||
|
||||
ENV CI=1
|
||||
ENV PNPM_HOME=/buildcache/pnpm-store
|
||||
ENV MISE_DATA_DIR=/buildcache/mise
|
||||
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/mise.toml
|
||||
ENV PATH="${MISE_DATA_DIR}/shims:${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin"
|
||||
ENV IMMICH_ENV=development
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=all
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \
|
||||
IMMICH_ENV=development \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
|
||||
|
||||
FROM dev AS dev-container-server
|
||||
|
||||
@@ -107,8 +107,8 @@ const TimeBucketAssetResponseSchema = z
|
||||
livePhotoVideoId: z
|
||||
.array(z.string().nullable())
|
||||
.describe('Array of live photo video asset IDs (null for non-live photos)'),
|
||||
city: z.array(z.string().nullable()).describe('Array of city names extracted from EXIF GPS data'),
|
||||
country: z.array(z.string().nullable()).describe('Array of country names extracted from EXIF GPS data'),
|
||||
city: z.array(z.string().nullable()).optional().describe('Array of city names extracted from EXIF GPS data'),
|
||||
country: z.array(z.string().nullable()).optional().describe('Array of country names extracted from EXIF GPS data'),
|
||||
latitude: z
|
||||
.array(z.number().nullable())
|
||||
.optional()
|
||||
|
||||
@@ -384,8 +384,6 @@ with
|
||||
asset."fileCreatedAt" at time zone 'utc' as "fileCreatedAt",
|
||||
asset."createdAt" at time zone 'utc' as "createdAt",
|
||||
encode("asset"."thumbhash", 'base64') as "thumbhash",
|
||||
"asset_exif"."city",
|
||||
"asset_exif"."country",
|
||||
"asset_exif"."projectionType",
|
||||
coalesce(
|
||||
case
|
||||
@@ -398,6 +396,8 @@ with
|
||||
end,
|
||||
1
|
||||
) as "ratio",
|
||||
"asset_exif"."city",
|
||||
"asset_exif"."country",
|
||||
"stack"
|
||||
from
|
||||
"asset"
|
||||
@@ -432,8 +432,6 @@ with
|
||||
),
|
||||
"agg" as (
|
||||
select
|
||||
coalesce(array_agg("city"), '{}') as "city",
|
||||
coalesce(array_agg("country"), '{}') as "country",
|
||||
coalesce(array_agg("duration"), '{}') as "duration",
|
||||
coalesce(array_agg("id"), '{}') as "id",
|
||||
coalesce(array_agg("visibility"), '{}') as "visibility",
|
||||
@@ -449,6 +447,8 @@ with
|
||||
coalesce(array_agg("ratio"), '{}') as "ratio",
|
||||
coalesce(array_agg("status"), '{}') as "status",
|
||||
coalesce(array_agg("thumbhash"), '{}') as "thumbhash",
|
||||
coalesce(array_agg("city"), '{}') as "city",
|
||||
coalesce(array_agg("country"), '{}') as "country",
|
||||
coalesce(json_agg("stack"), '[]') as "stack"
|
||||
from
|
||||
"cte"
|
||||
|
||||
@@ -786,8 +786,6 @@ export class AssetRepository {
|
||||
sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
|
||||
sql`asset."createdAt" at time zone 'utc'`.as('createdAt'),
|
||||
eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'),
|
||||
'asset_exif.city',
|
||||
'asset_exif.country',
|
||||
'asset_exif.projectionType',
|
||||
eb.fn
|
||||
.coalesce(
|
||||
@@ -801,6 +799,9 @@ export class AssetRepository {
|
||||
)
|
||||
.as('ratio'),
|
||||
])
|
||||
.$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) =>
|
||||
qb.select(['asset_exif.city', 'asset_exif.country']),
|
||||
)
|
||||
.$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude']))
|
||||
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||
@@ -875,8 +876,6 @@ export class AssetRepository {
|
||||
qb
|
||||
.selectFrom('cte')
|
||||
.select((eb) => [
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'),
|
||||
@@ -894,6 +893,12 @@ export class AssetRepository {
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
|
||||
])
|
||||
.$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) =>
|
||||
qb.select((eb) => [
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
|
||||
]),
|
||||
)
|
||||
.$if(!!options.withCoordinates, (qb) =>
|
||||
qb.select((eb) => [
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['latitude']), sql.lit('{}')).as('latitude'),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { JobItem, JobSource } from 'src/types';
|
||||
import { JobItem, JobSource, UploadFile } from 'src/types';
|
||||
|
||||
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
|
||||
|
||||
@@ -42,7 +42,7 @@ type EventMap = {
|
||||
AlbumInvite: [{ id: string; userId: string; senderName: string }];
|
||||
|
||||
// asset events
|
||||
AssetCreate: [{ asset: Asset }];
|
||||
AssetCreate: [{ asset: Asset; file: UploadFile }];
|
||||
AssetTag: [{ assetId: string }];
|
||||
AssetUntag: [{ assetId: string }];
|
||||
AssetHide: [{ assetId: string; userId: string }];
|
||||
|
||||
@@ -339,7 +339,6 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
expect(mocks.asset.create).toHaveBeenCalled();
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
file.originalPath,
|
||||
expect.any(Date),
|
||||
|
||||
@@ -146,17 +146,79 @@ export class AssetMediaService extends BaseService {
|
||||
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
|
||||
);
|
||||
}
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId: auth.user.id,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: dto.filename || file.originalName,
|
||||
});
|
||||
|
||||
if (dto.metadata?.length) {
|
||||
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
|
||||
}
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: { assetId: asset.id, fileSizeInByte: file.size },
|
||||
lockedPropertiesBehavior: 'override',
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, asset.id);
|
||||
}
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
await this.eventRepository.emit('AssetCreate', { asset, file });
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
} catch (error: any) {
|
||||
return this.handleUploadError(error, auth, file, sidecarFile);
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,84 +347,6 @@ export class AssetMediaService extends BaseService {
|
||||
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
|
||||
}
|
||||
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw error;
|
||||
}
|
||||
|
||||
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: dto.filename || file.originalName,
|
||||
});
|
||||
|
||||
if (dto.metadata?.length) {
|
||||
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
|
||||
}
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: { assetId: asset.id, fileSizeInByte: file.size },
|
||||
lockedPropertiesBehavior: 'override',
|
||||
});
|
||||
|
||||
await this.eventRepository.emit('AssetCreate', { asset });
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
|
||||
@@ -66,6 +66,10 @@ export class TimelineService extends BaseService {
|
||||
await this.requireAccess({ auth, permission: Permission.TagRead, ids: [dto.tagId] });
|
||||
}
|
||||
|
||||
if (auth.sharedLink && !auth.sharedLink.showExif) {
|
||||
dto.withCoordinates = false;
|
||||
}
|
||||
|
||||
if (dto.withPartners) {
|
||||
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
|
||||
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Updateable } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
@@ -11,6 +11,7 @@ import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences }
|
||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@@ -230,6 +231,11 @@ export class UserService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetCreate' })
|
||||
async onAssetCreate({ asset, file }: ArgOf<'AssetCreate'>) {
|
||||
await this.userRepository.updateUsage(asset.ownerId, file.size);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask })
|
||||
async handleUserSyncUsage(): Promise<JobStatus> {
|
||||
await this.userRepository.syncUsage();
|
||||
|
||||
@@ -43,9 +43,11 @@ describe(AssetService.name, () => {
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const fileSizeInByte = 12_345;
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
await ctx.newExif({ assetId: asset.id, fileSizeInByte });
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
await expect(
|
||||
@@ -56,7 +58,7 @@ describe(AssetService.name, () => {
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
},
|
||||
mediumFactory.uploadFile(),
|
||||
mediumFactory.uploadFile({ size: fileSizeInByte }),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
id: expect.any(String),
|
||||
@@ -65,6 +67,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetCreate', {
|
||||
asset: expect.objectContaining({}),
|
||||
file: expect.objectContaining({ size: fileSizeInByte }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { AssetVisibility, SharedLinkType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { TimelineService } from 'src/services/timeline.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
@@ -207,4 +208,32 @@ describe(TimelineService.name, () => {
|
||||
expect(response2).toEqual(expect.objectContaining({ id: [asset2.id, asset1.id], isFavorite: [true, false] }));
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip geodata metadata if shared link without exif', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
localDateTime: new Date('1970-02-12'),
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
|
||||
const { id: sharedLinkId } = await sharedLinkRepo.create({
|
||||
allowUpload: false,
|
||||
key: Buffer.from('123'),
|
||||
type: SharedLinkType.Album,
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
});
|
||||
|
||||
await ctx.newExif({ assetId: asset.id, city: 'Austin', country: 'USA' });
|
||||
const auth = factory.auth({ sharedLink: { id: sharedLinkId, showExif: false } });
|
||||
const rawResponse = await sut.getTimeBucket(auth, { albumId: album.id, timeBucket: '1970-02-01', isTrashed: true });
|
||||
const response = JSON.parse(rawResponse);
|
||||
expect(response).not.toEqual(expect.objectContaining({ city: expect.any(Array), country: expect.any(Array) }));
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.79.0",
|
||||
"@immich/ui": "^0.79.2",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
|
||||
@@ -149,29 +149,35 @@
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const { insetInlineStart, top, rasterWidth, rasterHeight, rasterScale } = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
if (maxRasterPixels === 0) {
|
||||
const { insetInlineStart, top, displayWidth, displayHeight, rasterWidth, rasterHeight, rasterScale } = $derived.by(
|
||||
() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
if (maxRasterPixels === 0) {
|
||||
return {
|
||||
insetInlineStart: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
displayWidth: width + 'px',
|
||||
displayHeight: height + 'px',
|
||||
rasterWidth: width + 'px',
|
||||
rasterHeight: height + 'px',
|
||||
rasterScale: 1,
|
||||
};
|
||||
}
|
||||
const nativeRatio = imageDimensions.width / width;
|
||||
const budgetRatio = Math.sqrt(maxRasterPixels / Math.max(width * height, 1));
|
||||
const rasterRatio = Math.max(1, Math.min(nativeRatio, budgetRatio));
|
||||
return {
|
||||
insetInlineStart: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
rasterWidth: width + 'px',
|
||||
rasterHeight: height + 'px',
|
||||
rasterScale: 1,
|
||||
displayWidth: width + 'px',
|
||||
displayHeight: height + 'px',
|
||||
rasterWidth: width * rasterRatio + 'px',
|
||||
rasterHeight: height * rasterRatio + 'px',
|
||||
rasterScale: 1 / rasterRatio,
|
||||
};
|
||||
}
|
||||
const nativeRatio = imageDimensions.width / width;
|
||||
const budgetRatio = Math.sqrt(maxRasterPixels / Math.max(width * height, 1));
|
||||
const rasterRatio = Math.max(1, Math.min(nativeRatio, budgetRatio));
|
||||
return {
|
||||
insetInlineStart: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
rasterWidth: width * rasterRatio + 'px',
|
||||
rasterHeight: height * rasterRatio + 'px',
|
||||
rasterScale: 1 / rasterRatio,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { status } = $derived(adaptiveImageLoader);
|
||||
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
|
||||
@@ -216,69 +222,78 @@
|
||||
{@render backdrop?.()}
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute"
|
||||
class="pointer-events-none absolute overflow-hidden"
|
||||
style:inset-inline-start={insetInlineStart}
|
||||
style:top
|
||||
style:width={rasterWidth}
|
||||
style:height={rasterHeight}
|
||||
style:transform="scale({rasterScale})"
|
||||
style:transform-origin="0 0"
|
||||
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
|
||||
style:width={displayWidth}
|
||||
style:height={displayHeight}
|
||||
>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<Thumbhash base64ThumbHash={asset.thumbhash} class="absolute size-full" />
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
<div
|
||||
style:width={rasterWidth}
|
||||
style:height={rasterHeight}
|
||||
style:transform="scale({rasterScale})"
|
||||
style:transform-origin="0 0"
|
||||
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
|
||||
>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
role="presentation"
|
||||
bind:ref={thumbnailElement}
|
||||
/>
|
||||
{/if}
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<Thumbhash base64ThumbHash={asset.thumbhash} class="absolute size-full" />
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="absolute size-full text-xl" />
|
||||
{/if}
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
role="presentation"
|
||||
bind:ref={thumbnailElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
bind:ref={previewElement}
|
||||
/>
|
||||
{/if}
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="absolute size-full text-xl" />
|
||||
{/if}
|
||||
|
||||
{#if show.original}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
bind:ref={originalElement}
|
||||
/>
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
bind:ref={previewElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.original}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
bind:ref={originalElement}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if overlays}
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
{@render overlays()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Image from '$lib/components/Image.svelte';
|
||||
import type { AdaptiveImageLoader, ImageQuality } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
adaptiveImageLoader: AdaptiveImageLoader;
|
||||
@@ -12,20 +11,9 @@
|
||||
ref?: HTMLImageElement;
|
||||
width: string;
|
||||
height: string;
|
||||
overlays?: Snippet;
|
||||
};
|
||||
|
||||
let {
|
||||
adaptiveImageLoader,
|
||||
quality,
|
||||
src,
|
||||
alt = '',
|
||||
role,
|
||||
ref = $bindable(),
|
||||
width,
|
||||
height,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
let { adaptiveImageLoader, quality, src, alt = '', role, ref = $bindable(), width, height }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#key adaptiveImageLoader}
|
||||
@@ -42,6 +30,5 @@
|
||||
draggable={false}
|
||||
data-testid={quality}
|
||||
/>
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
@@ -67,3 +67,11 @@ export function calculateViewerAssetViewportProximity(
|
||||
timelineManager.visibleWindow.bottom + headerHeight,
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateViewerAssetIsInOrNearViewport(
|
||||
timelineManager: TimelineManager,
|
||||
positionTop: number,
|
||||
positionHeight: number,
|
||||
) {
|
||||
return isInOrNearViewport(calculateViewerAssetViewportProximity(timelineManager, positionTop, positionHeight));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './typ
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
|
||||
export class TimelineMonth {
|
||||
#viewportProximity: ViewportProximity = $state(ViewportProximity.FarFromViewport);
|
||||
#isInOrNearViewport = $state(false);
|
||||
#isInViewport = $state(false);
|
||||
isLoaded: boolean = $state(false);
|
||||
timelineDays: TimelineDay[] = $state([]);
|
||||
readonly timelineManager: TimelineManager;
|
||||
@@ -85,24 +86,28 @@ export class TimelineMonth {
|
||||
}
|
||||
|
||||
set viewportProximity(newValue: ViewportProximity) {
|
||||
const old = this.#viewportProximity;
|
||||
if (old === newValue) {
|
||||
return;
|
||||
const isInOrNearViewport = isInOrNearViewportUtil(newValue);
|
||||
if (this.#isInOrNearViewport !== isInOrNearViewport) {
|
||||
this.#isInOrNearViewport = isInOrNearViewport;
|
||||
if (isInOrNearViewport) {
|
||||
void this.timelineManager.loadTimelineMonth(this.yearMonth);
|
||||
} else {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
this.#viewportProximity = newValue;
|
||||
if (isInOrNearViewportUtil(newValue)) {
|
||||
void this.timelineManager.loadTimelineMonth(this.yearMonth);
|
||||
} else {
|
||||
this.cancel();
|
||||
|
||||
const isInViewport = isInViewportUtil(newValue);
|
||||
if (this.#isInViewport !== isInViewport) {
|
||||
this.#isInViewport = isInViewport;
|
||||
}
|
||||
}
|
||||
|
||||
get isInOrNearViewport() {
|
||||
return isInOrNearViewportUtil(this.#viewportProximity);
|
||||
return this.#isInOrNearViewport;
|
||||
}
|
||||
|
||||
get isInViewport() {
|
||||
return isInViewportUtil(this.#viewportProximity);
|
||||
return this.#isInViewport;
|
||||
}
|
||||
|
||||
get lastTimelineDay() {
|
||||
@@ -179,8 +184,8 @@ export class TimelineMonth {
|
||||
);
|
||||
|
||||
const timelineAsset: TimelineAsset = {
|
||||
city: bucketAssets.city[i],
|
||||
country: bucketAssets.country[i],
|
||||
city: bucketAssets.city?.[i] ?? null,
|
||||
country: bucketAssets.country?.[i] ?? null,
|
||||
duration: bucketAssets.duration[i],
|
||||
id: bucketAssets.id[i],
|
||||
visibility: bucketAssets.visibility[i],
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import {
|
||||
ViewportProximity,
|
||||
calculateViewerAssetViewportProximity,
|
||||
isInOrNearViewport,
|
||||
} from './internal/intersection-support.svelte';
|
||||
import { calculateViewerAssetIsInOrNearViewport } from './internal/intersection-support.svelte';
|
||||
import type { TimelineDay } from './timeline-day.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export class ViewerAsset {
|
||||
readonly #group: TimelineDay;
|
||||
|
||||
#viewportProximity = $derived.by(() => {
|
||||
#isInOrNearViewport = $derived.by(() => {
|
||||
if (!this.position) {
|
||||
return ViewportProximity.FarFromViewport;
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = this.#group.timelineMonth.timelineManager;
|
||||
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
|
||||
|
||||
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
|
||||
return calculateViewerAssetIsInOrNearViewport(store, positionTop, this.position.height);
|
||||
});
|
||||
|
||||
get isInOrNearViewport() {
|
||||
return isInOrNearViewport(this.#viewportProximity);
|
||||
return this.#isInOrNearViewport;
|
||||
}
|
||||
|
||||
position: CommonPosition | undefined = $state.raw();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleCreateWorkflow } from '$lib/services/workflow.service';
|
||||
import { WorkflowTrigger, type WorkflowResponseDto } from '@immich/sdk';
|
||||
import { Field, FormModal, Input, Textarea, VStack } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
workflow: WorkflowResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { workflow, onClose }: Props = $props();
|
||||
|
||||
let name = $state(workflow.name ?? '');
|
||||
let description = $state(workflow.description ?? '');
|
||||
let trigger = $state<WorkflowTrigger>(workflow.trigger);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const response = await handleCreateWorkflow({
|
||||
name,
|
||||
description,
|
||||
trigger,
|
||||
steps: workflow.steps,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
await goto(Route.viewWorkflow({ id: response.id }));
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title={$t('duplicate_workflow')}
|
||||
{onClose}
|
||||
{onSubmit}
|
||||
disabled={!name || !trigger}
|
||||
size="medium"
|
||||
submitText={$t('create')}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
<Field label={$t('name')} required>
|
||||
<Input placeholder={$t('workflow_name')} bind:value={name} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('description')}>
|
||||
<Textarea grow placeholder={$t('workflow_description')} bind:value={description} />
|
||||
</Field>
|
||||
</VStack>
|
||||
</FormModal>
|
||||
@@ -42,7 +42,7 @@ import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
|
||||
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
||||
import { downloadUrl } from '$lib/utils/asset-utils';
|
||||
import { downloadUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@ import { toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { downloadBlob } from '$lib/utils/asset-utils';
|
||||
import { copyToClipboard, downloadJson } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
|
||||
@@ -19,7 +17,7 @@ export const getSystemConfigActions = (
|
||||
title: $t('copy_to_clipboard'),
|
||||
description: $t('admin.copy_config_to_clipboard_description'),
|
||||
icon: mdiContentCopy,
|
||||
onAction: () => handleCopyToClipboard(config),
|
||||
onAction: () => copyToClipboard(config),
|
||||
shortcuts: { shift: true, key: 'c' },
|
||||
};
|
||||
|
||||
@@ -27,7 +25,7 @@ export const getSystemConfigActions = (
|
||||
title: $t('export_as_json'),
|
||||
description: $t('admin.export_config_as_json_description'),
|
||||
icon: mdiDownload,
|
||||
onAction: () => handleDownloadConfig(config),
|
||||
onAction: () => downloadJson(config, 'immich-config.json'),
|
||||
shortcuts: [
|
||||
{ shift: true, key: 's' },
|
||||
{ shift: true, key: 'd' },
|
||||
@@ -65,31 +63,6 @@ export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) =
|
||||
}
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
|
||||
const jsonReplacer = (_key: string, value: unknown) =>
|
||||
value instanceof Object && !Array.isArray(value)
|
||||
? Object.keys(value)
|
||||
.sort()
|
||||
// eslint-disable-next-line unicorn/no-array-reduce
|
||||
.reduce((sorted: { [key: string]: unknown }, key) => {
|
||||
sorted[key] = (value as { [key: string]: unknown })[key];
|
||||
return sorted;
|
||||
}, {})
|
||||
: value;
|
||||
|
||||
export const handleCopyToClipboard = async (config: SystemConfigDto) => {
|
||||
await copyToClipboard(JSON.stringify(config, jsonReplacer, 2));
|
||||
};
|
||||
|
||||
export const handleDownloadConfig = (config: SystemConfigDto) => {
|
||||
const blob = new Blob([JSON.stringify(config, jsonReplacer, 2)], { type: 'application/json' });
|
||||
const downloadKey = 'immich-config.json';
|
||||
downloadManager.add(downloadKey, blob.size);
|
||||
downloadManager.update(downloadKey, blob.size);
|
||||
downloadBlob(blob, downloadKey);
|
||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
||||
};
|
||||
|
||||
export const handleUploadConfig = () => {
|
||||
const input = globalThis.document.createElement('input');
|
||||
input.setAttribute('type', 'file');
|
||||
|
||||
@@ -10,12 +10,25 @@ import {
|
||||
type WorkflowUpdateDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiCodeJson, mdiDelete, mdiFileDocumentMultipleOutline, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
|
||||
import {
|
||||
mdiCodeJson,
|
||||
mdiContentCopy,
|
||||
mdiContentDuplicate,
|
||||
mdiDeleteOutline,
|
||||
mdiDownload,
|
||||
mdiFileDocumentMultipleOutline,
|
||||
mdiPause,
|
||||
mdiPencil,
|
||||
mdiPlay,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
|
||||
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { copyToClipboard, downloadJson } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
|
||||
@@ -47,10 +60,50 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
|
||||
const ToggleEnabled: ActionItem = {
|
||||
title: workflow.enabled ? $t('disable') : $t('enable'),
|
||||
icon: workflow.enabled ? mdiPause : mdiPlay,
|
||||
color: workflow.enabled ? 'danger' : 'primary',
|
||||
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
|
||||
};
|
||||
|
||||
const CopyJson: ActionItem = {
|
||||
title: $t('copy_json'),
|
||||
icon: mdiContentCopy,
|
||||
onAction: () =>
|
||||
copyToClipboard(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
enabled: workflow.enabled,
|
||||
trigger: workflow.trigger,
|
||||
steps: workflow.steps,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
const Download: ActionItem = {
|
||||
title: $t('download'),
|
||||
icon: mdiDownload,
|
||||
onAction: () =>
|
||||
downloadJson(
|
||||
{
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
enabled: workflow.enabled,
|
||||
trigger: workflow.trigger,
|
||||
steps: workflow.steps,
|
||||
},
|
||||
'workflow.json',
|
||||
),
|
||||
};
|
||||
|
||||
const Duplicate: ActionItem = {
|
||||
title: $t('duplicate'),
|
||||
icon: mdiContentDuplicate,
|
||||
onAction: async () => modalManager.show(WorkflowDuplicateModal, { workflow }),
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
title: $t('edit'),
|
||||
icon: mdiPencil,
|
||||
@@ -59,14 +112,12 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
|
||||
|
||||
const Delete: ActionItem = {
|
||||
title: $t('delete'),
|
||||
icon: mdiDelete,
|
||||
icon: mdiDeleteOutline,
|
||||
color: 'danger',
|
||||
onAction: async () => {
|
||||
await handleDeleteWorkflow(workflow);
|
||||
},
|
||||
onAction: () => handleDeleteWorkflow(workflow),
|
||||
};
|
||||
|
||||
return { ToggleEnabled, Edit, Delete };
|
||||
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete };
|
||||
};
|
||||
|
||||
export const getWorkflowShowSchemaAction = (
|
||||
@@ -85,10 +136,10 @@ export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
||||
try {
|
||||
const response = await createWorkflow({ workflowCreateDto: dto });
|
||||
eventManager.emit('WorkflowCreate', response);
|
||||
return true;
|
||||
toastManager.success();
|
||||
return response;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+39
-2
@@ -24,6 +24,7 @@ import { init, register, t } from 'svelte-i18n';
|
||||
import { derived, get } from 'svelte/store';
|
||||
import { defaultLang, locales } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -249,17 +250,53 @@ export const getProfileImageUrl = (user: UserResponseDto) =>
|
||||
export const getPeopleThumbnailUrl = (person: PersonResponseDto, updatedAt?: string) =>
|
||||
createUrl(getPeopleThumbnailPath(person.id), { updatedAt: updatedAt ?? person.updatedAt });
|
||||
|
||||
export const copyToClipboard = async (secret: string) => {
|
||||
export const copyToClipboard = async (secret: string | unknown) => {
|
||||
const $t = get(t);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
const value = typeof secret === 'string' ? secret : JSON.stringify(secret, jsonReplacer, 2);
|
||||
await navigator.clipboard.writeText(value);
|
||||
toastManager.info($t('copied_to_clipboard'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_copy_to_clipboard'));
|
||||
}
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
|
||||
const jsonReplacer = (_key: string, value: unknown) =>
|
||||
value instanceof Object && !Array.isArray(value)
|
||||
? Object.keys(value)
|
||||
.sort()
|
||||
// eslint-disable-next-line unicorn/no-array-reduce
|
||||
.reduce((sorted: { [key: string]: unknown }, key) => {
|
||||
sorted[key] = (value as { [key: string]: unknown })[key];
|
||||
return sorted;
|
||||
}, {})
|
||||
: value;
|
||||
|
||||
export const downloadUrl = (url: string, filename: string) => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const downloadBlob = (data: Blob, filename: string) => downloadUrl(URL.createObjectURL(data), filename);
|
||||
|
||||
export const downloadJson = (data: unknown, filename: string) => {
|
||||
const blob = new Blob([JSON.stringify(data, jsonReplacer, 2)], { type: 'application/json' });
|
||||
const downloadKey = filename;
|
||||
downloadManager.add(downloadKey, blob.size);
|
||||
downloadManager.update(downloadKey, blob.size);
|
||||
downloadBlob(blob, downloadKey);
|
||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
||||
};
|
||||
|
||||
export const oauth = {
|
||||
isCallback: (location: Location) => {
|
||||
const search = location.search;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { downloadRequest, withError } from '$lib/utils';
|
||||
import { downloadBlob, downloadRequest, withError } from '$lib/utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
@@ -73,32 +73,6 @@ export const removeTag = async ({
|
||||
return assetIds;
|
||||
};
|
||||
|
||||
export const downloadBlob = (data: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(data);
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const downloadUrl = (url: string, filename: string) => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const downloadArchive = async (fileName: string, options: Omit<DownloadInfoDto, 'archiveSize'>) => {
|
||||
const archiveSize = authManager.authenticated ? authManager.preferences.download.archiveSize : undefined;
|
||||
const dto = { ...options, archiveSize };
|
||||
|
||||
@@ -16,12 +16,11 @@
|
||||
CardTitle,
|
||||
CodeBlock,
|
||||
Container,
|
||||
ContextMenuButton,
|
||||
Icon,
|
||||
IconButton,
|
||||
MenuItemType,
|
||||
menuManager,
|
||||
} from '@immich/ui';
|
||||
import { mdiClose, mdiDotsVertical, mdiFlashOutline } from '@mdi/js';
|
||||
import { mdiClose, mdiFlashOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
@@ -36,7 +35,7 @@
|
||||
|
||||
const expandedIds = new SvelteSet<string>();
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
const onToggleExpand = (id: string) => {
|
||||
if (expandedIds.has(id)) {
|
||||
expandedIds.delete(id);
|
||||
} else {
|
||||
@@ -44,21 +43,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
|
||||
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
|
||||
void menuManager.show({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
position: 'top-left',
|
||||
items: [
|
||||
ToggleEnabled,
|
||||
Edit,
|
||||
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => toggleExpanded(workflow.id)),
|
||||
MenuItemType.Divider,
|
||||
Delete,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const { Create, UseTemplate } = $derived(getWorkflowsActions($t));
|
||||
|
||||
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
|
||||
@@ -91,6 +75,8 @@
|
||||
{:else}
|
||||
<div class="my-6 flex flex-col gap-3">
|
||||
{#each workflows as workflow (workflow.id)}
|
||||
{@const { ToggleEnabled, Duplicate, Edit, Delete } = getWorkflowActions($t, workflow)}
|
||||
|
||||
<Card class="group shadow-none transition-colors hover:border-primary">
|
||||
<CardHeader>
|
||||
<a
|
||||
@@ -128,17 +114,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
icon={mdiDotsVertical}
|
||||
aria-label={$t('menu')}
|
||||
onclick={(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
showWorkflowMenu(event, workflow);
|
||||
}}
|
||||
<ContextMenuButton
|
||||
position="top-left"
|
||||
items={[
|
||||
ToggleEnabled,
|
||||
Edit,
|
||||
Duplicate,
|
||||
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => onToggleExpand(workflow.id)),
|
||||
MenuItemType.Divider,
|
||||
Delete,
|
||||
]}
|
||||
/>
|
||||
</a>
|
||||
|
||||
@@ -152,7 +137,7 @@
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onclick={() => toggleExpanded(workflow.id)}
|
||||
onclick={() => onToggleExpand(workflow.id)}
|
||||
>
|
||||
{$t('close')}
|
||||
</Button>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate, goto, invalidate } from '$app/navigation';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
|
||||
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
|
||||
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
|
||||
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
|
||||
import {
|
||||
ActionBar,
|
||||
AppShell,
|
||||
@@ -44,12 +43,10 @@
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { flushSync } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
|
||||
import WorkflowStepCard from './WorkflowStepCard.svelte';
|
||||
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
|
||||
import WorkflowSummary from './WorkflowSummary.svelte';
|
||||
|
||||
type WorkflowJsonContent = Required<
|
||||
@@ -57,12 +54,6 @@
|
||||
>;
|
||||
|
||||
type EditMode = 'visual' | 'json';
|
||||
type StepDragImage = {
|
||||
description?: string;
|
||||
isFilter: boolean;
|
||||
label: string;
|
||||
stepNumber: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
@@ -70,20 +61,16 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let { id, enabled, name, description, trigger, steps } = $derived(data.workflow);
|
||||
let { id, enabled, name, description, trigger } = $derived(data.workflow);
|
||||
let steps = $state(data.workflow.steps);
|
||||
let savedWorkflow = $state(cloneDeep(data.workflow));
|
||||
let allowNavigation = $state(false);
|
||||
let isShowingNavigationDialog = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let editMode = $state<EditMode>('visual');
|
||||
let draggedIndex = $state<number | null>(null);
|
||||
let dragHandleHoverIndex = $state<number | null>(null);
|
||||
let dragImageElement = $state<HTMLElement | null>(null);
|
||||
let dragImage = $state<StepDragImage>({ isFilter: false, label: '', stepNumber: 1 });
|
||||
let dropTargetIndex = $state<number | null>(null);
|
||||
|
||||
const workflowSummary = $derived({ name, description, trigger, steps });
|
||||
const workflowJsonContent = $derived<WorkflowJsonContent>({ description, enabled, name, steps, trigger });
|
||||
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
|
||||
|
||||
const hasChanges = $derived(
|
||||
enabled !== savedWorkflow.enabled ||
|
||||
@@ -96,7 +83,7 @@
|
||||
const handleAddStep = async () => {
|
||||
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
|
||||
if (step) {
|
||||
steps = [...steps, step];
|
||||
steps.push(step);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,10 +94,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const replaceStep = (index: number, step: WorkflowStepDto) => {
|
||||
steps = steps.map((current, i) => (i === index ? cloneDeep(step) : current));
|
||||
};
|
||||
|
||||
const handleEditStep = async (index: number) => {
|
||||
const step = steps[index];
|
||||
if (!step) {
|
||||
@@ -119,76 +102,27 @@
|
||||
|
||||
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) });
|
||||
if (result) {
|
||||
replaceStep(index, result);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number, event: DragEvent) => {
|
||||
draggedIndex = index;
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(index));
|
||||
|
||||
const step = steps[index];
|
||||
const method = step ? pluginManager.getMethod(step.method) : undefined;
|
||||
dragImage = {
|
||||
description: method?.description,
|
||||
isFilter: method?.uiHints?.includes('filter') ?? false,
|
||||
label: step ? pluginManager.getMethodLabel(step.method) : '',
|
||||
stepNumber: index + 1,
|
||||
};
|
||||
flushSync();
|
||||
|
||||
if (dragImageElement) {
|
||||
event.dataTransfer.setDragImage(dragImageElement, 16, 22);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (index: number, event: DragEvent) => {
|
||||
if (draggedIndex === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
if (dropTargetIndex !== index) {
|
||||
dropTargetIndex = index;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (index: number) => {
|
||||
if (dropTargetIndex === index) {
|
||||
dropTargetIndex = null;
|
||||
steps[index] = result;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (index: number, event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const from = draggedIndex;
|
||||
draggedIndex = null;
|
||||
dropTargetIndex = null;
|
||||
if (from === null || from === index) {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = Number(event.dataTransfer.getData('text/plain'));
|
||||
|
||||
const next = [...steps];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(index, 0, moved);
|
||||
steps = next;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
draggedIndex = null;
|
||||
dragHandleHoverIndex = null;
|
||||
dropTargetIndex = null;
|
||||
};
|
||||
|
||||
const handleDeleteStep = async (index: number) => {
|
||||
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
|
||||
if (confirmed) {
|
||||
steps.splice(index, 1);
|
||||
steps = [...steps];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,6 +151,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onWorkflowDelete = async (response: WorkflowResponseDto) => {
|
||||
if (id === response.id) {
|
||||
await goto(Route.workflows());
|
||||
}
|
||||
};
|
||||
|
||||
const confirmNavigation = async () => {
|
||||
if (!hasChanges) {
|
||||
return true;
|
||||
@@ -273,60 +213,73 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { Download, Duplicate, CopyJson, Delete } = $derived(
|
||||
getWorkflowActions($t, { ...savedWorkflow, name, description, enabled, trigger, steps }),
|
||||
);
|
||||
</script>
|
||||
|
||||
<OnEvents {onWorkflowUpdate} />
|
||||
<OnEvents {onWorkflowUpdate} {onWorkflowDelete} />
|
||||
|
||||
<AppShell class="">
|
||||
<AppShellBar>
|
||||
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
|
||||
<ActionBar
|
||||
shape="round"
|
||||
static
|
||||
{onClose}
|
||||
translations={{ close: $t('back') }}
|
||||
closeIcon={mdiArrowLeft}
|
||||
actions={[Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
|
||||
>
|
||||
<ControlBarHeader>
|
||||
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
|
||||
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
|
||||
</ControlBarHeader>
|
||||
<ControlBarContent class="flex items-center justify-end gap-6">
|
||||
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
|
||||
{#if hasChanges}
|
||||
<Button
|
||||
variant={editMode === 'visual' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'visual' ? 'primary' : 'secondary'}
|
||||
variant="filled"
|
||||
size="small"
|
||||
leadingIcon={mdiFormatListBulletedSquare}
|
||||
aria-pressed={editMode === 'visual'}
|
||||
onclick={() => (editMode = 'visual')}
|
||||
shape="round"
|
||||
color="primary"
|
||||
leadingIcon={mdiContentSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
loading={isSaving}
|
||||
onclick={saveWorkflow}
|
||||
>
|
||||
{$t('visual')}
|
||||
{$t('save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={editMode === 'json' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'json' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
leadingIcon={mdiCodeJson}
|
||||
aria-pressed={editMode === 'json'}
|
||||
onclick={() => (editMode = 'json')}
|
||||
shape="round"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="filled"
|
||||
size="small"
|
||||
color="primary"
|
||||
leadingIcon={mdiContentSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
loading={isSaving}
|
||||
onclick={saveWorkflow}
|
||||
>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
{/if}
|
||||
</ControlBarContent>
|
||||
</ActionBar>
|
||||
</AppShellBar>
|
||||
|
||||
<Container size="medium" class="pt-8 pb-24" center>
|
||||
<VStack gap={4}>
|
||||
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
|
||||
<Button
|
||||
variant={editMode === 'visual' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'visual' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
leadingIcon={mdiFormatListBulletedSquare}
|
||||
aria-pressed={editMode === 'visual'}
|
||||
onclick={() => (editMode = 'visual')}
|
||||
shape="round"
|
||||
>
|
||||
{$t('visual')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={editMode === 'json' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'json' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
leadingIcon={mdiCodeJson}
|
||||
aria-pressed={editMode === 'json'}
|
||||
onclick={() => (editMode = 'json')}
|
||||
shape="round"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if editMode === 'visual'}
|
||||
<Card class="shadow-none" expandable>
|
||||
<CardHeader>
|
||||
@@ -354,9 +307,8 @@
|
||||
bind:value={() => name ?? '', (value) => (name = value || null)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={$t('description')} for="workflow-description">
|
||||
<Field label={$t('description')}>
|
||||
<Textarea
|
||||
id="workflow-description"
|
||||
grow
|
||||
placeholder={$t('workflow_description')}
|
||||
bind:value={() => description ?? '', (value) => (description = value || null)}
|
||||
@@ -392,23 +344,14 @@
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{#each steps as step, index (index)}
|
||||
{#each steps as step, index (step.method + index)}
|
||||
<WorkflowStepCard
|
||||
{step}
|
||||
{index}
|
||||
isDragging={draggedIndex === index}
|
||||
isDragHandleHovered={dragHandleHoverIndex === index}
|
||||
isDropTarget={dropTargetIndex === index && draggedIndex !== null && draggedIndex !== index}
|
||||
onEdit={handleEditStep}
|
||||
onDelete={handleDeleteStep}
|
||||
onInsertBefore={handleInsertStep}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragHandleEnter={(i) => (dragHandleHoverIndex = i)}
|
||||
onDragHandleLeave={() => (dragHandleHoverIndex = null)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -428,12 +371,5 @@
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
<WorkflowStepDragImage
|
||||
bind:ref={dragImageElement}
|
||||
description={dragImage.description}
|
||||
isFilter={dragImage.isFilter}
|
||||
label={dragImage.label}
|
||||
stepNumber={dragImage.stepNumber}
|
||||
/>
|
||||
<WorkflowSummary workflow={workflowSummary} />
|
||||
</AppShell>
|
||||
|
||||
@@ -10,49 +10,29 @@
|
||||
mdiPlus,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { mount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
|
||||
|
||||
type Props = {
|
||||
step: WorkflowStepDto;
|
||||
index: number;
|
||||
isDragging: boolean;
|
||||
isDragHandleHovered: boolean;
|
||||
isDropTarget: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onInsertBefore: (index: number) => void;
|
||||
onDragStart: (index: number, event: DragEvent) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (index: number, event: DragEvent) => void;
|
||||
onDragLeave: (index: number) => void;
|
||||
onDrop: (index: number, event: DragEvent) => void;
|
||||
onDragHandleEnter: (index: number) => void;
|
||||
onDragHandleLeave: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
step,
|
||||
index,
|
||||
isDragging,
|
||||
isDragHandleHovered,
|
||||
isDropTarget,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onInsertBefore,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragHandleEnter,
|
||||
onDragHandleLeave,
|
||||
}: Props = $props();
|
||||
let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props();
|
||||
|
||||
const method = $derived(pluginManager.getMethod(step.method));
|
||||
const isFilter = $derived(method?.uiHints?.includes('filter') ?? false);
|
||||
const configEntries = $derived(
|
||||
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
|
||||
);
|
||||
let dragImage = $state<Element>();
|
||||
let isDropTarget = $state(false);
|
||||
let hoverDrag = $state(false);
|
||||
|
||||
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
|
||||
|
||||
@@ -73,14 +53,61 @@
|
||||
if (value.length === 0) {
|
||||
return $t('none');
|
||||
}
|
||||
const items = value.map((v) => (v !== null && typeof v === 'object' ? '{…}' : String(v)));
|
||||
const items = value.map((v) => (v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v)));
|
||||
const joined = items.join(' · ');
|
||||
if (joined.length <= 28) {
|
||||
return `"${joined}"`;
|
||||
}
|
||||
return $t('items_count', { values: { count: value.length } });
|
||||
}
|
||||
return '{…}';
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number, event: DragEvent) => {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(index));
|
||||
|
||||
mount(WorkflowStepDragImage, {
|
||||
target: document.body,
|
||||
props: {
|
||||
description: method?.description,
|
||||
isFilter: method?.uiHints?.includes('filter') ?? false,
|
||||
label: step ? pluginManager.getMethodLabel(step.method) : '',
|
||||
stepNumber: index + 1,
|
||||
},
|
||||
});
|
||||
|
||||
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
|
||||
event.dataTransfer.setDragImage(dragImage, 16, 22);
|
||||
};
|
||||
|
||||
const handleDrop = (index: number, event: DragEvent) => {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
const from = Number(event.dataTransfer.getData('text/plain'));
|
||||
if (from === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
onDrop(index, event);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDropTarget = true;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dragImage?.remove();
|
||||
dragImage = undefined;
|
||||
isDropTarget = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -102,17 +129,17 @@
|
||||
|
||||
<div
|
||||
class="w-full transition-all"
|
||||
class:opacity-40={isDragging}
|
||||
class:scale-[0.99]={isDragging}
|
||||
ondragover={(event) => onDragOver(index, event)}
|
||||
ondragleave={() => onDragLeave(index)}
|
||||
ondrop={(event) => onDrop(index, event)}
|
||||
class:opacity-40={!!dragImage}
|
||||
class:scale-[0.99]={!!dragImage}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (isDropTarget = false)}
|
||||
ondrop={(event) => handleDrop(index, event)}
|
||||
role="listitem"
|
||||
>
|
||||
<Card
|
||||
class="shadow-none transition-colors {isDropTarget
|
||||
? 'border-primary ring-2 ring-primary-200'
|
||||
: isDragHandleHovered
|
||||
: hoverDrag
|
||||
? 'border-dashed border-primary'
|
||||
: ''}"
|
||||
>
|
||||
@@ -123,10 +150,10 @@
|
||||
class="flex shrink-0 cursor-grab items-center justify-center rounded-md border border-transparent p-1 text-light-400 select-none hover:border-primary-200 hover:bg-primary-50 hover:text-primary active:cursor-grabbing"
|
||||
aria-label={$t('drag_to_reorder')}
|
||||
draggable="true"
|
||||
onmouseenter={() => onDragHandleEnter(index)}
|
||||
onmouseleave={onDragHandleLeave}
|
||||
ondragstart={(event) => onDragStart(index, event)}
|
||||
ondragend={onDragEnd}
|
||||
onmouseenter={() => (hoverDrag = true)}
|
||||
onmouseleave={() => (hoverDrag = false)}
|
||||
ondragstart={(event) => handleDragStart(index, event)}
|
||||
ondragend={handleDragEnd}
|
||||
title={$t('drag_to_reorder')}
|
||||
>
|
||||
<Icon icon={mdiDragVertical} size="20" />
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
import { mdiAutoFix, mdiFilterVariant } from '@mdi/js';
|
||||
|
||||
type Props = {
|
||||
ref?: HTMLElement | null;
|
||||
description?: string;
|
||||
isFilter: boolean;
|
||||
label: string;
|
||||
stepNumber: number;
|
||||
};
|
||||
|
||||
let { ref = $bindable(null), description, isFilter, label, stepNumber }: Props = $props();
|
||||
let { description, isFilter, label, stepNumber }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
id="workflow-step-drag-image"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed top-[-1000px] left-0 flex w-80 items-center gap-2.5 rounded-lg border border-light-200 bg-light px-3 py-2.5 text-sm/5 text-dark shadow-2xl"
|
||||
>
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { getTriggerName } from '$lib/utils/workflow';
|
||||
import type { WorkflowStepDto, WorkflowTrigger } from '@immich/sdk';
|
||||
import { Icon, IconButton, Text } from '@immich/ui';
|
||||
import { Icon, IconButton, shortcut, Text } from '@immich/ui';
|
||||
import { mdiCheck, mdiClose, mdiContentCopy, mdiViewDashboardOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
type WorkflowSummaryData = {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
trigger: WorkflowTrigger;
|
||||
steps: WorkflowStepDto[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
workflow: WorkflowSummaryData;
|
||||
workflow: {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
trigger: WorkflowTrigger;
|
||||
steps: WorkflowStepDto[];
|
||||
};
|
||||
};
|
||||
|
||||
let { workflow }: Props = $props();
|
||||
@@ -23,35 +21,6 @@
|
||||
let isOpen = $state(false);
|
||||
let justCopied = $state(false);
|
||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let panelElement = $state<HTMLElement | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (panelElement && event.target instanceof Node && !panelElement.contains(event.target)) {
|
||||
isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeydown, { capture: true });
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown, { capture: true });
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
};
|
||||
});
|
||||
|
||||
const formatConfigValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -127,9 +96,10 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: () => (isOpen = false) }} />
|
||||
|
||||
{#if isOpen}
|
||||
<aside
|
||||
bind:this={panelElement}
|
||||
class="fixed inset-y-20 right-4 bottom-4 hidden max-w-lg flex-col overflow-hidden rounded-2xl border border-light-200 bg-light shadow-2xl sm:flex"
|
||||
transition:fly={{ x: 400, duration: 250 }}
|
||||
aria-label={$t('workflow_summary')}
|
||||
|
||||
@@ -76,8 +76,8 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
||||
};
|
||||
for (const asset of timelineAsset) {
|
||||
const fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt).toISO();
|
||||
bucketAssets.city.push(asset.city);
|
||||
bucketAssets.country.push(asset.country);
|
||||
bucketAssets.city?.push(asset.city);
|
||||
bucketAssets.country?.push(asset.country);
|
||||
bucketAssets.duration.push(asset.duration!);
|
||||
bucketAssets.id.push(asset.id);
|
||||
bucketAssets.visibility.push(asset.visibility);
|
||||
|
||||
Reference in New Issue
Block a user