mirror of
https://github.com/immich-app/immich.git
synced 2026-05-23 16:12:30 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e0dd0f61b | |||
| 815ff677fc | |||
| 915d865ce2 | |||
| c28e5f90b6 | |||
| 4383473ed6 | |||
| 77701dd5a3 |
@@ -91,7 +91,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -159,14 +159,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Comment APK download link on PR
|
- name: Comment APK download link on PR
|
||||||
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
|
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
|
||||||
uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||||
env:
|
env:
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
|
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
id: mobile-android-apk
|
||||||
message-id: 'mobile-android-apk'
|
token: ${{ steps.token.outputs.token }}
|
||||||
message: |
|
body: |
|
||||||
📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}`
|
📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}`
|
||||||
|
|
||||||
Download: ${{ env.APK_URL }}
|
Download: ${{ env.APK_URL }}
|
||||||
@@ -216,7 +216,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ jobs:
|
|||||||
run: mise //mobile:codegen:pigeon
|
run: mise //mobile:codegen:pigeon
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
|
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.3'
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for breaking API changes
|
- name: Check for breaking API changes
|
||||||
uses: oasdiff/oasdiff-action/breaking@26ccb332c67a45ca649de9faf60552ef1b8260d9 # v0.0.46
|
uses: oasdiff/oasdiff-action/breaking@6147a58e5d1249a12f42fc864ab791d571a30015 # v0.0.47
|
||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||||
revision: open-api/immich-openapi-specs.json
|
revision: open-api/immich-openapi-specs.json
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -213,12 +213,11 @@ jobs:
|
|||||||
run: 'mise run //deployment:tf apply'
|
run: 'mise run //deployment:tf apply'
|
||||||
|
|
||||||
- name: Comment
|
- name: Comment
|
||||||
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||||
if: ${{ steps.parameters.outputs.event == 'pr' }}
|
if: ${{ steps.parameters.outputs.event == 'pr' }}
|
||||||
with:
|
with:
|
||||||
|
id: docs-pr-url
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
|
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
|
||||||
body: |
|
body: |
|
||||||
📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
|
📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
|
||||||
emojis: 'rocket'
|
|
||||||
body-include: '<!-- Docs PR URL -->'
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -44,9 +44,8 @@ jobs:
|
|||||||
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
||||||
|
|
||||||
- name: Comment
|
- name: Comment
|
||||||
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||||
with:
|
with:
|
||||||
|
id: docs-pr-url
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
number: ${{ github.event.number }}
|
|
||||||
delete: true
|
delete: true
|
||||||
body-include: '<!-- Docs PR URL -->'
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
if: ${{ inputs.skip != true }}
|
if: ${{ inputs.skip != true }}
|
||||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ jobs:
|
|||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
security-events: write
|
security-events: write
|
||||||
|
secrets: inherit
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ jobs:
|
|||||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
id: preview-status
|
||||||
message-id: 'preview-status'
|
token: ${{ steps.token.outputs.token }}
|
||||||
message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
|
body: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
|
||||||
|
|
||||||
remove-label:
|
remove-label:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -48,16 +48,16 @@ jobs:
|
|||||||
name: 'preview'
|
name: 'preview'
|
||||||
})
|
})
|
||||||
|
|
||||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||||
if: ${{ github.event.pull_request.head.repo.fork }}
|
if: ${{ github.event.pull_request.head.repo.fork }}
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
id: preview-status
|
||||||
message-id: 'preview-status'
|
token: ${{ steps.token.outputs.token }}
|
||||||
message: 'PRs from forks cannot have preview environments.'
|
body: 'PRs from forks cannot have preview environments.'
|
||||||
|
|
||||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.token.outputs.token }}
|
id: preview-status
|
||||||
message-id: 'preview-status'
|
token: ${{ steps.token.outputs.token }}
|
||||||
message: 'Preview environment has been removed.'
|
body: 'Preview environment has been removed.'
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -76,7 +76,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -298,7 +298,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -550,7 +550,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -587,7 +587,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -618,7 +618,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -669,7 +669,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
@@ -727,7 +727,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
|
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ After making any changes in the `server/src/schema`, a database migration need t
|
|||||||
1. Run the command
|
1. Run the command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run migrations:generate <migration-name>
|
mise //server:migrations generate <migration-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Check if the migration file makes sense.
|
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:
|
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
|
||||||
|
|
||||||
```bash
|
```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.
|
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:
|
The Dev Container supports multiple ways to run tests:
|
||||||
|
|
||||||
#### Using Mise Commands (Recommended)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run tests for specific components
|
# Server
|
||||||
mise run checklist # in `server/`, `web/`, `packages/cli`
|
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
|
### Additional Commands
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# API generation
|
# API generation
|
||||||
make open-api # Generate OpenAPI specs
|
mise //:open-api # Generate OpenAPI specs
|
||||||
make open-api-typescript # Generate TypeScript SDK
|
mise //:open-api-typescript # Generate TypeScript SDK
|
||||||
make open-api-dart # Generate Dart SDK
|
mise //:open-api-dart # Generate Dart SDK
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
mise sql # Sync database schema
|
mise //server:sql # Sync database schema
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debugging
|
### Debugging
|
||||||
|
|||||||
@@ -8,34 +8,42 @@ When contributing code through a pull request, please check the following:
|
|||||||
|
|
||||||
## Web Checks
|
## Web Checks
|
||||||
|
|
||||||
- [ ] `pnpm run lint` (linting via ESLint)
|
- [ ] `mise //web:lint` (linting via ESLint)
|
||||||
- [ ] `pnpm run format` (formatting via Prettier)
|
- [ ] `mise //web:format` (formatting via Prettier)
|
||||||
- [ ] `pnpm run check:svelte` (Type checking via SvelteKit)
|
- [ ] `mise //web:check-svelte` (type checking via SvelteKit)
|
||||||
- [ ] `pnpm run check:typescript` (check typescript)
|
- [ ] `mise //web:check-typescript` (type checking via `tsc`)
|
||||||
- [ ] `pnpm test` (unit tests)
|
- [ ] `mise //web:test` (unit tests)
|
||||||
|
|
||||||
:::tip AIO
|
:::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
|
## 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.
|
- [ ] 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
|
## Server Checks
|
||||||
|
|
||||||
- [ ] `pnpm run lint` (linting via ESLint)
|
- [ ] `mise //server:lint` (linting via ESLint)
|
||||||
- [ ] `pnpm run format` (formatting via Prettier)
|
- [ ] `mise //server:format` (formatting via Prettier)
|
||||||
- [ ] `pnpm run check` (Type checking via `tsc`)
|
- [ ] `mise //server:check` (type checking via `tsc`)
|
||||||
- [ ] `pnpm test` (unit tests)
|
- [ ] `mise //server:test` (unit tests)
|
||||||
|
|
||||||
:::tip AIO
|
:::tip AIO
|
||||||
Run all server checks with `pnpm run check:all`
|
Run all server checks with `mise //server:checklist`
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::tip Auto Fix
|
:::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
|
## 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`.
|
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
|
## 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.
|
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.
|
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
|
### Server and web apps
|
||||||
|
|
||||||
1. Clone the project repo.
|
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
|
#### Connect web to a remote backend
|
||||||
|
|
||||||
If you only want to do web development connected to an existing, remote backend, follow these steps:
|
If you only want to do web development connected to an existing, remote backend, run from the repo root:
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```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:
|
If you're using PowerShell on Windows you may need to set the env var separately like so:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:IMMICH_SERVER_URL = "https://demo.immich.app/"
|
$env:IMMICH_SERVER_URL = "https://demo.immich.app/"
|
||||||
pnpm run dev
|
mise //web:start
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `@immich/ui`
|
#### `@immich/ui`
|
||||||
@@ -90,20 +95,16 @@ To see local changes to `@immich/ui` in Immich, do the following:
|
|||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
|
|
||||||
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
|
1. Run `mise //mobile:install` to install Flutter dependencies.
|
||||||
2. Change to the immich (root) directory and trust the mise config with `mise trust`.
|
2. Run `mise //mobile:translation` to generate the translation file.
|
||||||
3. Install tools with mise: `mise install`.
|
3. Change to the `mobile/` directory and run `flutter run` to start the app.
|
||||||
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.
|
|
||||||
|
|
||||||
#### Translation
|
#### 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
|
```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.
|
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 tests
|
||||||
|
|
||||||
Unit are run by calling `pnpm run test` from the `server/` directory.
|
Unit tests are run with `mise //server:test`.
|
||||||
You need to run `pnpm install` (in `server/`) before _once_.
|
You need to run `mise //server:install` before _once_.
|
||||||
|
|
||||||
### End to end tests
|
### End to end tests
|
||||||
|
|
||||||
@@ -17,8 +17,7 @@ make e2e
|
|||||||
|
|
||||||
Before you can run the tests, you need to run the following commands _once_:
|
Before you can run the tests, you need to run the following commands _once_:
|
||||||
|
|
||||||
- `pnpm install`
|
- `mise //e2e:ci-setup` (installs e2e, SDK, and CLI dependencies)
|
||||||
- `pnpm --filter @immich/sdk --filter @immich/cli build`
|
|
||||||
- `mise //:open-api`
|
- `mise //:open-api`
|
||||||
|
|
||||||
Once the test environment is running, the e2e tests can be run via:
|
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]
|
[tasks.start]
|
||||||
env._.path = "./node_modules/.bin"
|
env._.path = "./node_modules/.bin"
|
||||||
run = "docusaurus --port 3005"
|
run = "docusaurus start --port 3005"
|
||||||
|
|
||||||
[tasks.build]
|
[tasks.build]
|
||||||
env._.path = "./node_modules/.bin"
|
env._.path = "./node_modules/.bin"
|
||||||
|
|||||||
@@ -976,7 +976,6 @@
|
|||||||
"downloading_asset_filename": "Downloading asset {filename}",
|
"downloading_asset_filename": "Downloading asset {filename}",
|
||||||
"downloading_from_icloud": "Downloading from iCloud",
|
"downloading_from_icloud": "Downloading from iCloud",
|
||||||
"downloading_media": "Downloading media",
|
"downloading_media": "Downloading media",
|
||||||
"drag_to_reorder": "Drag to reorder",
|
|
||||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||||
"duplicates": "Duplicates",
|
"duplicates": "Duplicates",
|
||||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
|
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
|
||||||
@@ -2255,7 +2254,6 @@
|
|||||||
"step_delete_confirm": "Are you sure you want to delete this step?",
|
"step_delete_confirm": "Are you sure you want to delete this step?",
|
||||||
"step_details": "Step details",
|
"step_details": "Step details",
|
||||||
"steps": "Steps",
|
"steps": "Steps",
|
||||||
"steps_count": "{count, plural, one {# step} other {# steps}}",
|
|
||||||
"stop_casting": "Stop casting",
|
"stop_casting": "Stop casting",
|
||||||
"stop_motion_photo": "Stop Motion Photo",
|
"stop_motion_photo": "Stop Motion Photo",
|
||||||
"stop_photo_sharing": "Stop sharing your photos?",
|
"stop_photo_sharing": "Stop sharing your photos?",
|
||||||
@@ -2417,7 +2415,6 @@
|
|||||||
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
||||||
"use_current_connection": "Use current connection",
|
"use_current_connection": "Use current connection",
|
||||||
"use_custom_date_range": "Use custom date range instead",
|
"use_custom_date_range": "Use custom date range instead",
|
||||||
"use_template": "Use template",
|
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"user_has_been_deleted": "This user has been deleted.",
|
"user_has_been_deleted": "This user has been deleted.",
|
||||||
"user_id": "User ID",
|
"user_id": "User ID",
|
||||||
@@ -2479,7 +2476,6 @@
|
|||||||
"week": "Week",
|
"week": "Week",
|
||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
"welcome_to_immich": "Welcome to Immich",
|
"welcome_to_immich": "Welcome to Immich",
|
||||||
"when": "When",
|
|
||||||
"width": "Width",
|
"width": "Width",
|
||||||
"wifi_name": "Wi-Fi Name",
|
"wifi_name": "Wi-Fi Name",
|
||||||
"workflow": "Workflow",
|
"workflow": "Workflow",
|
||||||
@@ -2492,7 +2488,6 @@
|
|||||||
"workflow_name": "Workflow name",
|
"workflow_name": "Workflow name",
|
||||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
||||||
"workflow_summary": "Workflow summary",
|
"workflow_summary": "Workflow summary",
|
||||||
"workflow_templates": "Workflow templates",
|
|
||||||
"workflow_update_success": "Workflow updated successfully",
|
"workflow_update_success": "Workflow updated successfully",
|
||||||
"workflow_updated": "Workflow updated",
|
"workflow_updated": "Workflow updated",
|
||||||
"workflows": "Workflows",
|
"workflows": "Workflows",
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
constraints {
|
||||||
|
implementation("androidx.glance:glance-appwidget") {
|
||||||
|
version { strictly libs.versions.glance.get() }
|
||||||
|
because 'home_widget requests 1.+ which can resolve to pre-releases incompatible with our compileSdk/AGP'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
implementation libs.okhttp
|
implementation libs.okhttp
|
||||||
implementation libs.cronet.embedded
|
implementation libs.cronet.embedded
|
||||||
implementation libs.media3.datasource.okhttp
|
implementation libs.media3.datasource.okhttp
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:immich_mobile/domain/models/config/album_config.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/cleanup_config.dart';
|
||||||
import 'package:immich_mobile/domain/models/config/image_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/map_config.dart';
|
||||||
@@ -16,6 +17,7 @@ class AppConfig {
|
|||||||
final ViewerConfig viewer;
|
final ViewerConfig viewer;
|
||||||
final SlideshowConfig slideshow;
|
final SlideshowConfig slideshow;
|
||||||
final AlbumConfig album;
|
final AlbumConfig album;
|
||||||
|
final BackupConfig backup;
|
||||||
|
|
||||||
const AppConfig({
|
const AppConfig({
|
||||||
this.theme = const .new(),
|
this.theme = const .new(),
|
||||||
@@ -26,6 +28,7 @@ class AppConfig {
|
|||||||
this.viewer = const .new(),
|
this.viewer = const .new(),
|
||||||
this.slideshow = const .new(),
|
this.slideshow = const .new(),
|
||||||
this.album = const .new(),
|
this.album = const .new(),
|
||||||
|
this.backup = const .new(),
|
||||||
});
|
});
|
||||||
|
|
||||||
AppConfig copyWith({
|
AppConfig copyWith({
|
||||||
@@ -37,6 +40,7 @@ class AppConfig {
|
|||||||
ViewerConfig? viewer,
|
ViewerConfig? viewer,
|
||||||
SlideshowConfig? slideshow,
|
SlideshowConfig? slideshow,
|
||||||
AlbumConfig? album,
|
AlbumConfig? album,
|
||||||
|
BackupConfig? backup,
|
||||||
}) => .new(
|
}) => .new(
|
||||||
theme: theme ?? this.theme,
|
theme: theme ?? this.theme,
|
||||||
cleanup: cleanup ?? this.cleanup,
|
cleanup: cleanup ?? this.cleanup,
|
||||||
@@ -46,6 +50,7 @@ class AppConfig {
|
|||||||
viewer: viewer ?? this.viewer,
|
viewer: viewer ?? this.viewer,
|
||||||
slideshow: slideshow ?? this.slideshow,
|
slideshow: slideshow ?? this.slideshow,
|
||||||
album: album ?? this.album,
|
album: album ?? this.album,
|
||||||
|
backup: backup ?? this.backup,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -59,12 +64,13 @@ class AppConfig {
|
|||||||
other.image == image &&
|
other.image == image &&
|
||||||
other.viewer == viewer &&
|
other.viewer == viewer &&
|
||||||
other.slideshow == slideshow &&
|
other.slideshow == slideshow &&
|
||||||
other.album == album);
|
other.album == album &&
|
||||||
|
other.backup == backup);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album);
|
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album)';
|
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
class BackupConfig {
|
||||||
|
final bool enabled;
|
||||||
|
final bool useCellularForVideos;
|
||||||
|
final bool useCellularForPhotos;
|
||||||
|
final bool requireCharging;
|
||||||
|
final int triggerDelay;
|
||||||
|
final bool syncAlbums;
|
||||||
|
|
||||||
|
const BackupConfig({
|
||||||
|
this.enabled = false,
|
||||||
|
this.useCellularForVideos = false,
|
||||||
|
this.useCellularForPhotos = false,
|
||||||
|
this.requireCharging = false,
|
||||||
|
this.triggerDelay = 30,
|
||||||
|
this.syncAlbums = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
BackupConfig copyWith({
|
||||||
|
bool? enabled,
|
||||||
|
bool? useCellularForVideos,
|
||||||
|
bool? useCellularForPhotos,
|
||||||
|
bool? requireCharging,
|
||||||
|
int? triggerDelay,
|
||||||
|
bool? syncAlbums,
|
||||||
|
}) => BackupConfig(
|
||||||
|
enabled: enabled ?? this.enabled,
|
||||||
|
useCellularForVideos: useCellularForVideos ?? this.useCellularForVideos,
|
||||||
|
useCellularForPhotos: useCellularForPhotos ?? this.useCellularForPhotos,
|
||||||
|
requireCharging: requireCharging ?? this.requireCharging,
|
||||||
|
triggerDelay: triggerDelay ?? this.triggerDelay,
|
||||||
|
syncAlbums: syncAlbums ?? this.syncAlbums,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
(other is BackupConfig &&
|
||||||
|
other.enabled == enabled &&
|
||||||
|
other.useCellularForVideos == useCellularForVideos &&
|
||||||
|
other.useCellularForPhotos == useCellularForPhotos &&
|
||||||
|
other.requireCharging == requireCharging &&
|
||||||
|
other.triggerDelay == triggerDelay &&
|
||||||
|
other.syncAlbums == syncAlbums);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(enabled, useCellularForVideos, useCellularForPhotos, requireCharging, triggerDelay, syncAlbums);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'BackupConfig(enabled: $enabled, useCellularForVideos: $useCellularForVideos, useCellularForPhotos: $useCellularForPhotos, requireCharging: $requireCharging, triggerDelay: $triggerDelay, syncAlbums: $syncAlbums)';
|
||||||
|
}
|
||||||
@@ -62,6 +62,14 @@ enum MetadataKey<T extends Object> {
|
|||||||
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
|
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
|
||||||
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
|
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
|
||||||
// Timeline
|
// Timeline
|
||||||
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
|
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
|
||||||
timelineGroupAssetsBy<GroupAssetsBy>(
|
timelineGroupAssetsBy<GroupAssetsBy>(
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
|
||||||
enum Setting<T> {
|
enum Setting<T> {
|
||||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
|
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false);
|
||||||
enableBackup<bool>(StoreKey.enableBackup, false);
|
|
||||||
|
|
||||||
const Setting(this.storeKey, this.defaultValue);
|
const Setting(this.storeKey, this.defaultValue);
|
||||||
|
|
||||||
|
|||||||
@@ -6,26 +6,25 @@ enum StoreKey<T> {
|
|||||||
version<int>._(0),
|
version<int>._(0),
|
||||||
currentUser<UserDto>._(2),
|
currentUser<UserDto>._(2),
|
||||||
deviceId<String>._(4),
|
deviceId<String>._(4),
|
||||||
backupRequireCharging<bool>._(7),
|
|
||||||
backupTriggerDelay<int>._(8),
|
|
||||||
serverUrl<String>._(10),
|
serverUrl<String>._(10),
|
||||||
accessToken<String>._(11),
|
accessToken<String>._(11),
|
||||||
serverEndpoint<String>._(12),
|
serverEndpoint<String>._(12),
|
||||||
advancedTroubleshooting<bool>._(114),
|
advancedTroubleshooting<bool>._(114),
|
||||||
enableHapticFeedback<bool>._(126),
|
enableHapticFeedback<bool>._(126),
|
||||||
syncAlbums<bool>._(131),
|
|
||||||
|
|
||||||
manageLocalMediaAndroid<bool>._(137),
|
manageLocalMediaAndroid<bool>._(137),
|
||||||
// Read-only Mode settings
|
// Read-only Mode settings
|
||||||
readonlyModeEnabled<bool>._(138),
|
readonlyModeEnabled<bool>._(138),
|
||||||
|
|
||||||
// Experimental stuff
|
|
||||||
enableBackup<bool>._(1003),
|
|
||||||
useWifiForUploadVideos<bool>._(1004),
|
|
||||||
useWifiForUploadPhotos<bool>._(1005),
|
|
||||||
syncMigrationStatus<String>._(1013),
|
syncMigrationStatus<String>._(1013),
|
||||||
|
|
||||||
// Legacy keys that have been migrated to the new metadata store
|
// Legacy keys that have been migrated to the new metadata store
|
||||||
|
legacyBackupRequireCharging<bool>._(7),
|
||||||
|
legacyBackupTriggerDelay<int>._(8),
|
||||||
|
legacySyncAlbums<bool>._(131),
|
||||||
|
legacyEnableBackup<bool>._(1003),
|
||||||
|
legacyUseWifiForUploadVideos<bool>._(1004),
|
||||||
|
legacyUseWifiForUploadPhotos<bool>._(1005),
|
||||||
legacySelectedAlbumSortOrder<int>._(113),
|
legacySelectedAlbumSortOrder<int>._(113),
|
||||||
legacySelectedAlbumSortReverse<bool>._(123),
|
legacySelectedAlbumSortReverse<bool>._(123),
|
||||||
legacyAlbumGridView<bool>._(140),
|
legacyAlbumGridView<bool>._(140),
|
||||||
|
|||||||
@@ -11,15 +11,14 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:immich_mobile/services/auth.service.dart';
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/services/localization.service.dart';
|
import 'package:immich_mobile/services/localization.service.dart';
|
||||||
@@ -39,16 +38,15 @@ class BackgroundWorkerFgService {
|
|||||||
Future<void> saveNotificationMessage(String title, String body) =>
|
Future<void> saveNotificationMessage(String title, String body) =>
|
||||||
_foregroundHostApi.saveNotificationMessage(title, body);
|
_foregroundHostApi.saveNotificationMessage(title, body);
|
||||||
|
|
||||||
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
|
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) {
|
||||||
BackgroundWorkerSettings(
|
final backup = MetadataRepository.instance.appConfig.backup;
|
||||||
minimumDelaySeconds:
|
return _foregroundHostApi.configure(
|
||||||
minimumDelaySeconds ??
|
BackgroundWorkerSettings(
|
||||||
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
|
minimumDelaySeconds: minimumDelaySeconds ?? backup.triggerDelay,
|
||||||
requiresCharging:
|
requiresCharging: requireCharging ?? backup.requireCharging,
|
||||||
requireCharging ??
|
),
|
||||||
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> disable() => _foregroundHostApi.disable();
|
Future<void> disable() => _foregroundHostApi.disable();
|
||||||
}
|
}
|
||||||
@@ -71,7 +69,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
BackgroundWorkerFlutterApi.setUp(this);
|
BackgroundWorkerFlutterApi.setUp(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false;
|
bool get _isBackupEnabled => MetadataRepository.instance.appConfig.backup.enabled;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -152,6 +152,14 @@ extension<T extends Object> on MetadataDomain<T> {
|
|||||||
isReverse: repo._read(.albumIsReverse),
|
isReverse: repo._read(.albumIsReverse),
|
||||||
isGrid: repo._read(.albumIsGrid),
|
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:
|
case .systemConfig:
|
||||||
repo._systemConfig = .new(
|
repo._systemConfig = .new(
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
|||||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
|
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
|
||||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||||||
_searchController = TextEditingController();
|
_searchController = TextEditingController();
|
||||||
_searchFocusNode = FocusNode();
|
_searchFocusNode = FocusNode();
|
||||||
|
|
||||||
_enableSyncUploadAlbum.value = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||||
ref.read(backupAlbumProvider.notifier).getAll();
|
ref.read(backupAlbumProvider.notifier).getAll();
|
||||||
|
|
||||||
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||||
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final enableSyncUploadAlbum = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||||
final selectedAlbums = ref
|
final selectedAlbums = ref
|
||||||
.read(backupAlbumProvider)
|
.read(backupAlbumProvider)
|
||||||
.where((a) => a.backupSelection == BackupSelection.selected)
|
.where((a) => a.backupSelection == BackupSelection.selected)
|
||||||
@@ -103,7 +103,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
final isBackupEnabled = MetadataRepository.instance.appConfig.backup.enabled;
|
||||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
|
||||||
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||||
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
|
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import 'dart:async';
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
|
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
@@ -21,18 +19,20 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
bool hasPopped = false;
|
bool hasPopped = false;
|
||||||
final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
|
final previousBackup = ref.read(metadataProvider).appConfig.backup;
|
||||||
final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
|
final previousCellularForVideos = previousBackup.useCellularForVideos;
|
||||||
|
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
|
||||||
return PopScope(
|
return PopScope(
|
||||||
onPopInvokedWithResult: (didPop, result) async {
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
// There is an issue with Flutter where the pop event
|
// There is an issue with Flutter where the pop event
|
||||||
// can be triggered multiple times, so we guard it with _hasPopped
|
// can be triggered multiple times, so we guard it with _hasPopped
|
||||||
|
|
||||||
final currentWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
|
final currentBackup = ref.read(metadataProvider).appConfig.backup;
|
||||||
final currentWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
|
final currentCellularForVideos = currentBackup.useCellularForVideos;
|
||||||
|
final currentCellularForPhotos = currentBackup.useCellularForPhotos;
|
||||||
|
|
||||||
if (currentWifiReqForVideos == previousWifiReqForVideos &&
|
if (currentCellularForVideos == previousCellularForVideos &&
|
||||||
currentWifiReqForPhotos == previousWifiReqForPhotos) {
|
currentCellularForPhotos == previousCellularForPhotos) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||||
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
final isBackupEnabled = MetadataRepository.instance.appConfig.backup.enabled;
|
||||||
if (!isBackupEnabled) {
|
if (!isBackupEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
@@ -340,7 +341,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
|||||||
await backgroundManager.hashAssets();
|
await backgroundManager.hashAssets();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Store.get(StoreKey.syncAlbums, false)) {
|
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
|
||||||
await backgroundManager.syncLinkedAlbum();
|
await backgroundManager.syncLinkedAlbum();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -369,7 +370,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
|
Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
|
||||||
final isEnableBackup = Store.get(StoreKey.enableBackup, false);
|
final isEnableBackup = MetadataRepository.instance.appConfig.backup.enabled;
|
||||||
|
|
||||||
if (isEnableBackup) {
|
if (isEnableBackup) {
|
||||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||||
|
|
||||||
class BackupToggleButton extends ConsumerStatefulWidget {
|
class BackupToggleButton extends ConsumerStatefulWidget {
|
||||||
final VoidCallback onStart;
|
final VoidCallback onStart;
|
||||||
@@ -31,7 +31,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
|||||||
end: 1,
|
end: 1,
|
||||||
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
|
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
|
||||||
|
|
||||||
_isEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
_isEnabled = ref.read(metadataProvider).appConfig.backup.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -41,7 +41,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onToggle(bool value) async {
|
Future<void> _onToggle(bool value) async {
|
||||||
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.enableBackup, value);
|
await ref.read(metadataProvider).write(MetadataKey.backupEnabled, value);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isEnabled = value;
|
_isEnabled = value;
|
||||||
|
|||||||
@@ -5,16 +5,15 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
|||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden }
|
enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden }
|
||||||
@@ -108,7 +107,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
|||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
final backgroundManager = _ref.read(backgroundSyncProvider);
|
final backgroundManager = _ref.read(backgroundSyncProvider);
|
||||||
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
final isAlbumLinkedSyncEnable = _ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bool syncSuccess = false;
|
bool syncSuccess = false;
|
||||||
@@ -138,7 +137,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _resumeBackup() async {
|
Future<void> _resumeBackup() async {
|
||||||
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
final isEnableBackup = _ref.read(metadataProvider).appConfig.backup.enabled;
|
||||||
|
|
||||||
if (isEnableBackup) {
|
if (isEnableBackup) {
|
||||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar
|
|||||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/utils/debounce.dart';
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
@@ -192,7 +193,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
|
final isSyncAlbumEnabled = _ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||||
try {
|
try {
|
||||||
unawaited(
|
unawaited(
|
||||||
_ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) {
|
_ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) {
|
||||||
@@ -213,7 +214,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
|
final isSyncAlbumEnabled = _ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||||
try {
|
try {
|
||||||
unawaited(
|
unawaited(
|
||||||
_ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) {
|
_ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) {
|
||||||
|
|||||||
@@ -5,13 +5,7 @@ enum AppSettingsEnum<T> {
|
|||||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||||
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
|
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||||
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
|
||||||
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
|
||||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
|
||||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
|
|
||||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
|
||||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
|
|
||||||
|
|
||||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:immich_mobile/services/network.service.dart';
|
import 'package:immich_mobile/services/network.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
@@ -25,7 +25,6 @@ final authServiceProvider = Provider(
|
|||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
ref.watch(networkServiceProvider),
|
ref.watch(networkServiceProvider),
|
||||||
ref.watch(backgroundSyncProvider),
|
ref.watch(backgroundSyncProvider),
|
||||||
ref.watch(appSettingsServiceProvider),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -35,7 +34,6 @@ class AuthService {
|
|||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final NetworkService _networkService;
|
final NetworkService _networkService;
|
||||||
final BackgroundSyncManager _backgroundSyncManager;
|
final BackgroundSyncManager _backgroundSyncManager;
|
||||||
final AppSettingsService _appSettingsService;
|
|
||||||
final _log = Logger("AuthService");
|
final _log = Logger("AuthService");
|
||||||
|
|
||||||
AuthService(
|
AuthService(
|
||||||
@@ -44,7 +42,6 @@ class AuthService {
|
|||||||
this._apiService,
|
this._apiService,
|
||||||
this._networkService,
|
this._networkService,
|
||||||
this._backgroundSyncManager,
|
this._backgroundSyncManager,
|
||||||
this._appSettingsService,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Validates the provided server URL by resolving and setting the endpoint.
|
/// Validates the provided server URL by resolving and setting the endpoint.
|
||||||
@@ -103,7 +100,7 @@ class AuthService {
|
|||||||
_log.severe("Error clearing local data", error, stackTrace);
|
_log.severe("Error clearing local data", error, stackTrace);
|
||||||
});
|
});
|
||||||
|
|
||||||
await _appSettingsService.setSetting(AppSettingsEnum.enableBackup, false);
|
await MetadataRepository.instance.write(MetadataKey.backupEnabled, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
@@ -31,7 +30,6 @@ final backgroundUploadServiceProvider = Provider((ref) {
|
|||||||
ref.watch(storageRepositoryProvider),
|
ref.watch(storageRepositoryProvider),
|
||||||
ref.watch(localAssetRepository),
|
ref.watch(localAssetRepository),
|
||||||
ref.watch(backupRepositoryProvider),
|
ref.watch(backupRepositoryProvider),
|
||||||
ref.watch(appSettingsServiceProvider),
|
|
||||||
ref.watch(assetMediaRepositoryProvider),
|
ref.watch(assetMediaRepositoryProvider),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,7 +103,6 @@ class BackgroundUploadService {
|
|||||||
this._storageRepository,
|
this._storageRepository,
|
||||||
this._localAssetRepository,
|
this._localAssetRepository,
|
||||||
this._backupRepository,
|
this._backupRepository,
|
||||||
this._appSettingsService,
|
|
||||||
this._assetMediaRepository,
|
this._assetMediaRepository,
|
||||||
) {
|
) {
|
||||||
_uploadRepository.onUploadStatus = _onUploadCallback;
|
_uploadRepository.onUploadStatus = _onUploadCallback;
|
||||||
@@ -116,7 +113,6 @@ class BackgroundUploadService {
|
|||||||
final StorageRepository _storageRepository;
|
final StorageRepository _storageRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final DriftBackupRepository _backupRepository;
|
final DriftBackupRepository _backupRepository;
|
||||||
final AppSettingsService _appSettingsService;
|
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
final Logger _logger = Logger('BackgroundUploadService');
|
final Logger _logger = Logger('BackgroundUploadService');
|
||||||
|
|
||||||
@@ -363,15 +359,14 @@ class BackgroundUploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldRequireWiFi(LocalAsset asset) {
|
bool _shouldRequireWiFi(LocalAsset asset) {
|
||||||
bool requiresWiFi = true;
|
final backup = MetadataRepository.instance.appConfig.backup;
|
||||||
|
if (asset.isVideo && backup.useCellularForVideos) {
|
||||||
if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) {
|
return false;
|
||||||
requiresWiFi = false;
|
|
||||||
} else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) {
|
|
||||||
requiresWiFi = false;
|
|
||||||
}
|
}
|
||||||
|
if (!asset.isVideo && backup.useCellularForPhotos) {
|
||||||
return requiresWiFi;
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<UploadTask> buildUploadTask(
|
Future<UploadTask> buildUploadTask(
|
||||||
|
|||||||
@@ -7,18 +7,17 @@ import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
|
||||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
@@ -39,7 +38,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
|||||||
ref.watch(storageRepositoryProvider),
|
ref.watch(storageRepositoryProvider),
|
||||||
ref.watch(backupRepositoryProvider),
|
ref.watch(backupRepositoryProvider),
|
||||||
ref.watch(connectivityApiProvider),
|
ref.watch(connectivityApiProvider),
|
||||||
ref.watch(appSettingsServiceProvider),
|
|
||||||
ref.watch(assetMediaRepositoryProvider),
|
ref.watch(assetMediaRepositoryProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -55,7 +53,6 @@ class ForegroundUploadService {
|
|||||||
this._storageRepository,
|
this._storageRepository,
|
||||||
this._backupRepository,
|
this._backupRepository,
|
||||||
this._connectivityApi,
|
this._connectivityApi,
|
||||||
this._appSettingsService,
|
|
||||||
this._assetMediaRepository,
|
this._assetMediaRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,7 +60,6 @@ class ForegroundUploadService {
|
|||||||
final StorageRepository _storageRepository;
|
final StorageRepository _storageRepository;
|
||||||
final DriftBackupRepository _backupRepository;
|
final DriftBackupRepository _backupRepository;
|
||||||
final ConnectivityApi _connectivityApi;
|
final ConnectivityApi _connectivityApi;
|
||||||
final AppSettingsService _appSettingsService;
|
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
final Logger _logger = Logger('ForegroundUploadService');
|
final Logger _logger = Logger('ForegroundUploadService');
|
||||||
|
|
||||||
@@ -455,14 +451,13 @@ class ForegroundUploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldRequireWiFi(LocalAsset asset) {
|
bool _shouldRequireWiFi(LocalAsset asset) {
|
||||||
bool requiresWiFi = true;
|
final backup = MetadataRepository.instance.appConfig.backup;
|
||||||
|
if (asset.isVideo && backup.useCellularForVideos) {
|
||||||
if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) {
|
return false;
|
||||||
requiresWiFi = false;
|
|
||||||
} else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) {
|
|
||||||
requiresWiFi = false;
|
|
||||||
}
|
}
|
||||||
|
if (!asset.isVideo && backup.useCellularForPhotos) {
|
||||||
return requiresWiFi;
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,13 @@ Future<void> _migrateTo26(Drift drift) async {
|
|||||||
await _migrateAlbumSortMode(migrator);
|
await _migrateAlbumSortMode(migrator);
|
||||||
await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, MetadataKey.albumIsReverse);
|
await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, MetadataKey.albumIsReverse);
|
||||||
await migrator.migrateBool(StoreKey.legacyAlbumGridView, MetadataKey.albumIsGrid);
|
await migrator.migrateBool(StoreKey.legacyAlbumGridView, MetadataKey.albumIsGrid);
|
||||||
|
// Backup
|
||||||
|
await migrator.migrateBool(StoreKey.legacyEnableBackup, MetadataKey.backupEnabled);
|
||||||
|
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadVideos, MetadataKey.backupUseCellularForVideos);
|
||||||
|
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadPhotos, MetadataKey.backupUseCellularForPhotos);
|
||||||
|
await migrator.migrateBool(StoreKey.legacyBackupRequireCharging, MetadataKey.backupRequireCharging);
|
||||||
|
await migrator.migrateInt(StoreKey.legacyBackupTriggerDelay, MetadataKey.backupTriggerDelay);
|
||||||
|
await migrator.migrateBool(StoreKey.legacySyncAlbums, MetadataKey.backupSyncAlbums);
|
||||||
await migrator.complete();
|
await migrator.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_svg/svg.dart';
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
@@ -193,64 +192,51 @@ class _BackupIndicator extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) {
|
Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) {
|
||||||
final backupStateStream = ref.watch(settingsProvider).watch(Setting.enableBackup);
|
final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled));
|
||||||
final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
|
final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
|
||||||
final isDarkTheme = context.isDarkTheme;
|
final isDarkTheme = context.isDarkTheme;
|
||||||
final iconColor = isDarkTheme ? Colors.white : Colors.black;
|
final iconColor = isDarkTheme ? Colors.white : Colors.black;
|
||||||
final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
|
final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
|
||||||
|
|
||||||
return StreamBuilder(
|
if (!backupEnabled) {
|
||||||
stream: backupStateStream,
|
return _BadgeLabel(
|
||||||
initialData: false,
|
Icon(Icons.cloud_off_rounded, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()),
|
||||||
builder: (ctx, snapshot) {
|
);
|
||||||
final backupEnabled = snapshot.data ?? false;
|
}
|
||||||
|
|
||||||
if (!backupEnabled) {
|
if (hasError) {
|
||||||
return _BadgeLabel(
|
return _BadgeLabel(
|
||||||
Icon(
|
Icon(
|
||||||
Icons.cloud_off_rounded,
|
Icons.warning_rounded,
|
||||||
size: 9,
|
size: 12,
|
||||||
color: iconColor,
|
color: context.colorScheme.error,
|
||||||
semanticLabel: 'backup_controller_page_backup'.tr(),
|
semanticLabel: 'backup_controller_page_backup'.tr(),
|
||||||
|
),
|
||||||
|
backgroundColor: context.colorScheme.errorContainer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUploading) {
|
||||||
|
return _BadgeLabel(
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(3.5),
|
||||||
|
child: Theme(
|
||||||
|
data: context.themeData.copyWith(
|
||||||
|
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
|
||||||
),
|
),
|
||||||
);
|
child: CircularProgressIndicator(
|
||||||
}
|
strokeWidth: 2,
|
||||||
|
strokeCap: StrokeCap.round,
|
||||||
if (hasError) {
|
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
|
||||||
return _BadgeLabel(
|
semanticsLabel: 'backup_controller_page_backup'.tr(),
|
||||||
Icon(
|
|
||||||
Icons.warning_rounded,
|
|
||||||
size: 12,
|
|
||||||
color: context.colorScheme.error,
|
|
||||||
semanticLabel: 'backup_controller_page_backup'.tr(),
|
|
||||||
),
|
),
|
||||||
backgroundColor: context.colorScheme.errorContainer,
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isUploading) {
|
return _BadgeLabel(
|
||||||
return _BadgeLabel(
|
Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()),
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(3.5),
|
|
||||||
child: Theme(
|
|
||||||
data: context.themeData.copyWith(
|
|
||||||
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
|
|
||||||
),
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
strokeCap: StrokeCap.round,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
|
|
||||||
semanticsLabel: 'backup_controller_page_backup'.tr(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _BadgeLabel(
|
|
||||||
Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
@@ -186,7 +187,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
await backgroundManager.syncRemote();
|
await backgroundManager.syncRemote();
|
||||||
await backgroundManager.hashAssets();
|
await backgroundManager.hashAssets();
|
||||||
|
|
||||||
if (Store.get(StoreKey.syncAlbums, false)) {
|
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
|
||||||
await backgroundManager.syncLinkedAlbum();
|
await backgroundManager.syncLinkedAlbum();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,17 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
|
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||||
@@ -31,8 +30,8 @@ class DriftBackupSettings extends ConsumerWidget {
|
|||||||
title: "network_requirements".t(context: context),
|
title: "network_requirements".t(context: context),
|
||||||
icon: Icons.cell_tower,
|
icon: Icons.cell_tower,
|
||||||
),
|
),
|
||||||
const _UseWifiForUploadVideosButton(),
|
const _UseCellularForVideosButton(),
|
||||||
const _UseWifiForUploadPhotosButton(),
|
const _UseCellularForPhotosButton(),
|
||||||
if (CurrentPlatform.isAndroid) ...[
|
if (CurrentPlatform.isAndroid) ...[
|
||||||
const Divider(),
|
const Divider(),
|
||||||
SettingGroupTitle(
|
SettingGroupTitle(
|
||||||
@@ -99,64 +98,58 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final albumSyncEnable = ref.watch(appConfigProvider.select((c) => c.backup.syncAlbums));
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 8.0),
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
children: [
|
children: [
|
||||||
StreamBuilder(
|
Column(
|
||||||
stream: Store.watch(StoreKey.syncAlbums),
|
children: [
|
||||||
initialData: Store.tryGet(StoreKey.syncAlbums) ?? false,
|
SettingListTile(
|
||||||
builder: (context, snapshot) {
|
title: "sync_albums".t(context: context),
|
||||||
final albumSyncEnable = snapshot.data ?? false;
|
subtitle: "sync_upload_album_setting_subtitle".t(context: context),
|
||||||
return Column(
|
trailing: Switch(
|
||||||
children: [
|
value: albumSyncEnable,
|
||||||
SettingListTile(
|
onChanged: (bool newValue) async {
|
||||||
title: "sync_albums".t(context: context),
|
await ref.read(metadataProvider).write(MetadataKey.backupSyncAlbums, newValue);
|
||||||
subtitle: "sync_upload_album_setting_subtitle".t(context: context),
|
|
||||||
trailing: Switch(
|
|
||||||
value: albumSyncEnable,
|
|
||||||
onChanged: (bool newValue) async {
|
|
||||||
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
|
|
||||||
|
|
||||||
if (newValue == true) {
|
if (newValue == true) {
|
||||||
await _manageLinkedAlbums();
|
await _manageLinkedAlbums();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
opacity: albumSyncEnable ? 1.0 : 0.0,
|
opacity: albumSyncEnable ? 1.0 : 0.0,
|
||||||
child: albumSyncEnable
|
child: albumSyncEnable
|
||||||
? SettingListTile(
|
? SettingListTile(
|
||||||
onTap: _manualSyncAlbums,
|
onTap: _manualSyncAlbums,
|
||||||
contentPadding: const EdgeInsets.only(left: 32, right: 16),
|
contentPadding: const EdgeInsets.only(left: 32, right: 16),
|
||||||
title: "organize_into_albums".t(context: context),
|
title: "organize_into_albums".t(context: context),
|
||||||
subtitle: "organize_into_albums_description".t(context: context),
|
subtitle: "organize_into_albums_description".t(context: context),
|
||||||
trailing: isAlbumSyncInProgress
|
trailing: isAlbumSyncInProgress
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: IconButton(
|
: IconButton(
|
||||||
onPressed: _manualSyncAlbums,
|
onPressed: _manualSyncAlbums,
|
||||||
icon: const Icon(Icons.sync_rounded),
|
icon: const Icon(Icons.sync_rounded),
|
||||||
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
|
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
iconSize: 20,
|
iconSize: 20,
|
||||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -164,60 +157,34 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsSwitchTile extends ConsumerStatefulWidget {
|
class _BackupSwitchTile extends ConsumerWidget {
|
||||||
final AppSettingsEnum<bool> appSettingsEnum;
|
final MetadataKey<bool> metadataKey;
|
||||||
|
final bool Function(AppConfig) selector;
|
||||||
final String titleKey;
|
final String titleKey;
|
||||||
final String subtitleKey;
|
final String subtitleKey;
|
||||||
final void Function(bool?)? onChanged;
|
final void Function(bool)? onChanged;
|
||||||
|
|
||||||
const _SettingsSwitchTile({
|
const _BackupSwitchTile({
|
||||||
required this.appSettingsEnum,
|
required this.metadataKey,
|
||||||
|
required this.selector,
|
||||||
required this.titleKey,
|
required this.titleKey,
|
||||||
required this.subtitleKey,
|
required this.subtitleKey,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _SettingsSwitchTileState();
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
}
|
final value = ref.watch(appConfigProvider.select(selector));
|
||||||
|
|
||||||
class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
|
|
||||||
late final Stream<bool?> valueStream;
|
|
||||||
late final StreamSubscription<bool?> subscription;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
valueStream = Store.watch(widget.appSettingsEnum.storeKey).asBroadcastStream();
|
|
||||||
subscription = valueStream.listen((value) {
|
|
||||||
widget.onChanged?.call(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
subscription.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 8.0),
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
child: SettingListTile(
|
child: SettingListTile(
|
||||||
title: widget.titleKey.t(context: context),
|
title: titleKey.t(context: context),
|
||||||
subtitle: widget.subtitleKey.t(context: context),
|
subtitle: subtitleKey.t(context: context),
|
||||||
trailing: StreamBuilder(
|
trailing: Switch(
|
||||||
stream: valueStream,
|
value: value,
|
||||||
initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
|
onChanged: (bool newValue) async {
|
||||||
builder: (context, snapshot) {
|
await ref.read(metadataProvider).write(metadataKey, newValue);
|
||||||
final value = snapshot.data ?? false;
|
onChanged?.call(newValue);
|
||||||
return Switch(
|
|
||||||
value: value,
|
|
||||||
onChanged: (bool newValue) async {
|
|
||||||
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -225,26 +192,28 @@ class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
class _UseCellularForVideosButton extends StatelessWidget {
|
||||||
const _UseWifiForUploadVideosButton();
|
const _UseCellularForVideosButton();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
return const _SettingsSwitchTile(
|
return _BackupSwitchTile(
|
||||||
appSettingsEnum: AppSettingsEnum.useCellularForUploadVideos,
|
metadataKey: MetadataKey.backupUseCellularForVideos,
|
||||||
|
selector: (c) => c.backup.useCellularForVideos,
|
||||||
titleKey: "videos",
|
titleKey: "videos",
|
||||||
subtitleKey: "network_requirement_videos_upload",
|
subtitleKey: "network_requirement_videos_upload",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UseWifiForUploadPhotosButton extends ConsumerWidget {
|
class _UseCellularForPhotosButton extends StatelessWidget {
|
||||||
const _UseWifiForUploadPhotosButton();
|
const _UseCellularForPhotosButton();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
return const _SettingsSwitchTile(
|
return _BackupSwitchTile(
|
||||||
appSettingsEnum: AppSettingsEnum.useCellularForUploadPhotos,
|
metadataKey: MetadataKey.backupUseCellularForPhotos,
|
||||||
|
selector: (c) => c.backup.useCellularForPhotos,
|
||||||
titleKey: "photos",
|
titleKey: "photos",
|
||||||
subtitleKey: "network_requirement_photos_upload",
|
subtitleKey: "network_requirement_photos_upload",
|
||||||
);
|
);
|
||||||
@@ -256,29 +225,22 @@ class _BackupOnlyWhenChargingButton extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return _SettingsSwitchTile(
|
final fgService = ref.read(backgroundWorkerFgServiceProvider);
|
||||||
appSettingsEnum: AppSettingsEnum.backupRequireCharging,
|
return _BackupSwitchTile(
|
||||||
|
metadataKey: MetadataKey.backupRequireCharging,
|
||||||
|
selector: (c) => c.backup.requireCharging,
|
||||||
titleKey: "charging",
|
titleKey: "charging",
|
||||||
subtitleKey: "charging_requirement_mobile_backup",
|
subtitleKey: "charging_requirement_mobile_backup",
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
ref.read(backgroundWorkerFgServiceProvider).configure(requireCharging: value ?? false);
|
fgService.configure(requireCharging: value);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BackupDelaySlider extends ConsumerStatefulWidget {
|
class _BackupDelaySlider extends ConsumerWidget {
|
||||||
const _BackupDelaySlider();
|
const _BackupDelaySlider();
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<_BackupDelaySlider> createState() => _BackupDelaySliderState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
|
|
||||||
late final Stream<int?> valueStream;
|
|
||||||
late final StreamSubscription<int?> subscription;
|
|
||||||
late int currentValue;
|
|
||||||
|
|
||||||
static int backupDelayToSliderValue(int ms) => switch (ms) {
|
static int backupDelayToSliderValue(int ms) => switch (ms) {
|
||||||
5 => 0,
|
5 => 0,
|
||||||
30 => 1,
|
30 => 1,
|
||||||
@@ -301,30 +263,9 @@ class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
super.initState();
|
final triggerDelay = ref.watch(appConfigProvider.select((c) => c.backup.triggerDelay));
|
||||||
final initialValue =
|
final currentValue = backupDelayToSliderValue(triggerDelay);
|
||||||
Store.tryGet(AppSettingsEnum.backupTriggerDelay.storeKey) ?? AppSettingsEnum.backupTriggerDelay.defaultValue;
|
|
||||||
currentValue = backupDelayToSliderValue(initialValue);
|
|
||||||
|
|
||||||
valueStream = Store.watch(AppSettingsEnum.backupTriggerDelay.storeKey).asBroadcastStream();
|
|
||||||
subscription = valueStream.listen((value) {
|
|
||||||
if (mounted && value != null) {
|
|
||||||
setState(() {
|
|
||||||
currentValue = backupDelayToSliderValue(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
subscription.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -339,14 +280,13 @@ class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
|
|||||||
),
|
),
|
||||||
Slider(
|
Slider(
|
||||||
value: currentValue.toDouble(),
|
value: currentValue.toDouble(),
|
||||||
onChanged: (double v) {
|
onChanged: (double v) async {
|
||||||
setState(() {
|
final seconds = backupDelayToSeconds(v.toInt());
|
||||||
currentValue = v.toInt();
|
await ref.read(metadataProvider).write(MetadataKey.backupTriggerDelay, seconds);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onChangeEnd: (double v) async {
|
onChangeEnd: (double v) async {
|
||||||
final milliseconds = backupDelayToSeconds(v.toInt());
|
final seconds = backupDelayToSeconds(v.toInt());
|
||||||
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.backupTriggerDelay, milliseconds);
|
await ref.read(metadataProvider).write(MetadataKey.backupTriggerDelay, seconds);
|
||||||
},
|
},
|
||||||
max: 3.0,
|
max: 3.0,
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ alias = "migration"
|
|||||||
description = "Generate database migrations"
|
description = "Generate database migrations"
|
||||||
run = "dart run drift_dev make-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
|
# Internal tasks
|
||||||
[tasks."i18n:loader"]
|
[tasks."i18n:loader"]
|
||||||
|
|||||||
Generated
-3
@@ -205,7 +205,6 @@ Class | Method | HTTP request | Description
|
|||||||
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
|
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
|
||||||
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
||||||
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
||||||
*PluginsApi* | [**getTemplates**](doc//PluginsApi.md#gettemplates) | **GET** /plugins/templates | Retrieve workflow templates
|
|
||||||
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
|
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
|
||||||
*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins
|
*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins
|
||||||
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
||||||
@@ -492,8 +491,6 @@ Class | Method | HTTP request | Description
|
|||||||
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||||
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
|
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
|
||||||
- [PluginResponseDto](doc//PluginResponseDto.md)
|
- [PluginResponseDto](doc//PluginResponseDto.md)
|
||||||
- [PluginTemplateResponseDto](doc//PluginTemplateResponseDto.md)
|
|
||||||
- [PluginTemplateStepResponseDto](doc//PluginTemplateStepResponseDto.md)
|
|
||||||
- [PurchaseResponse](doc//PurchaseResponse.md)
|
- [PurchaseResponse](doc//PurchaseResponse.md)
|
||||||
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
||||||
- [QueueCommand](doc//QueueCommand.md)
|
- [QueueCommand](doc//QueueCommand.md)
|
||||||
|
|||||||
Generated
-2
@@ -237,8 +237,6 @@ part 'model/pin_code_setup_dto.dart';
|
|||||||
part 'model/places_response_dto.dart';
|
part 'model/places_response_dto.dart';
|
||||||
part 'model/plugin_method_response_dto.dart';
|
part 'model/plugin_method_response_dto.dart';
|
||||||
part 'model/plugin_response_dto.dart';
|
part 'model/plugin_response_dto.dart';
|
||||||
part 'model/plugin_template_response_dto.dart';
|
|
||||||
part 'model/plugin_template_step_response_dto.dart';
|
|
||||||
part 'model/purchase_response.dart';
|
part 'model/purchase_response.dart';
|
||||||
part 'model/purchase_update.dart';
|
part 'model/purchase_update.dart';
|
||||||
part 'model/queue_command.dart';
|
part 'model/queue_command.dart';
|
||||||
|
|||||||
Generated
-51
@@ -73,57 +73,6 @@ class PluginsApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve workflow templates
|
|
||||||
///
|
|
||||||
/// Retrieve premade workflow templates provided by installed plugins
|
|
||||||
///
|
|
||||||
/// Note: This method returns the HTTP [Response].
|
|
||||||
Future<Response> getTemplatesWithHttpInfo() async {
|
|
||||||
// ignore: prefer_const_declarations
|
|
||||||
final apiPath = r'/plugins/templates';
|
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
|
||||||
Object? postBody;
|
|
||||||
|
|
||||||
final queryParams = <QueryParam>[];
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
final formParams = <String, String>{};
|
|
||||||
|
|
||||||
const contentTypes = <String>[];
|
|
||||||
|
|
||||||
|
|
||||||
return apiClient.invokeAPI(
|
|
||||||
apiPath,
|
|
||||||
'GET',
|
|
||||||
queryParams,
|
|
||||||
postBody,
|
|
||||||
headerParams,
|
|
||||||
formParams,
|
|
||||||
contentTypes.isEmpty ? null : contentTypes.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve workflow templates
|
|
||||||
///
|
|
||||||
/// Retrieve premade workflow templates provided by installed plugins
|
|
||||||
Future<List<PluginTemplateResponseDto>?> getTemplates() async {
|
|
||||||
final response = await getTemplatesWithHttpInfo();
|
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
|
||||||
}
|
|
||||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
|
||||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
|
||||||
// FormatException when trying to decode an empty string.
|
|
||||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
|
||||||
final responseBody = await _decodeBodyBytes(response);
|
|
||||||
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTemplateResponseDto>') as List)
|
|
||||||
.cast<PluginTemplateResponseDto>()
|
|
||||||
.toList(growable: false);
|
|
||||||
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve plugin methods
|
/// Retrieve plugin methods
|
||||||
///
|
///
|
||||||
/// Retrieve a list of plugin methods
|
/// Retrieve a list of plugin methods
|
||||||
|
|||||||
Generated
-4
@@ -520,10 +520,6 @@ class ApiClient {
|
|||||||
return PluginMethodResponseDto.fromJson(value);
|
return PluginMethodResponseDto.fromJson(value);
|
||||||
case 'PluginResponseDto':
|
case 'PluginResponseDto':
|
||||||
return PluginResponseDto.fromJson(value);
|
return PluginResponseDto.fromJson(value);
|
||||||
case 'PluginTemplateResponseDto':
|
|
||||||
return PluginTemplateResponseDto.fromJson(value);
|
|
||||||
case 'PluginTemplateStepResponseDto':
|
|
||||||
return PluginTemplateStepResponseDto.fromJson(value);
|
|
||||||
case 'PurchaseResponse':
|
case 'PurchaseResponse':
|
||||||
return PurchaseResponse.fromJson(value);
|
return PurchaseResponse.fromJson(value);
|
||||||
case 'PurchaseUpdate':
|
case 'PurchaseUpdate':
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class PluginTemplateResponseDto {
|
|
||||||
/// Returns a new [PluginTemplateResponseDto] instance.
|
|
||||||
PluginTemplateResponseDto({
|
|
||||||
required this.description,
|
|
||||||
required this.id,
|
|
||||||
required this.name,
|
|
||||||
required this.pluginName,
|
|
||||||
this.steps = const [],
|
|
||||||
required this.trigger,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Template description
|
|
||||||
String description;
|
|
||||||
|
|
||||||
/// Template identifier (pluginName#templateName)
|
|
||||||
String id;
|
|
||||||
|
|
||||||
/// Template name
|
|
||||||
String name;
|
|
||||||
|
|
||||||
/// Owning plugin name
|
|
||||||
String pluginName;
|
|
||||||
|
|
||||||
/// Workflow steps
|
|
||||||
List<PluginTemplateStepResponseDto> steps;
|
|
||||||
|
|
||||||
WorkflowTrigger trigger;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
|
|
||||||
other.description == description &&
|
|
||||||
other.id == id &&
|
|
||||||
other.name == name &&
|
|
||||||
other.pluginName == pluginName &&
|
|
||||||
_deepEquality.equals(other.steps, steps) &&
|
|
||||||
other.trigger == trigger;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(description.hashCode) +
|
|
||||||
(id.hashCode) +
|
|
||||||
(name.hashCode) +
|
|
||||||
(pluginName.hashCode) +
|
|
||||||
(steps.hashCode) +
|
|
||||||
(trigger.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'PluginTemplateResponseDto[description=$description, id=$id, name=$name, pluginName=$pluginName, steps=$steps, trigger=$trigger]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
json[r'description'] = this.description;
|
|
||||||
json[r'id'] = this.id;
|
|
||||||
json[r'name'] = this.name;
|
|
||||||
json[r'pluginName'] = this.pluginName;
|
|
||||||
json[r'steps'] = this.steps;
|
|
||||||
json[r'trigger'] = this.trigger;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [PluginTemplateResponseDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static PluginTemplateResponseDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "PluginTemplateResponseDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return PluginTemplateResponseDto(
|
|
||||||
description: mapValueOfType<String>(json, r'description')!,
|
|
||||||
id: mapValueOfType<String>(json, r'id')!,
|
|
||||||
name: mapValueOfType<String>(json, r'name')!,
|
|
||||||
pluginName: mapValueOfType<String>(json, r'pluginName')!,
|
|
||||||
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
|
|
||||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<PluginTemplateResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <PluginTemplateResponseDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = PluginTemplateResponseDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, PluginTemplateResponseDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, PluginTemplateResponseDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = PluginTemplateResponseDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of PluginTemplateResponseDto-objects as value to a dart map
|
|
||||||
static Map<String, List<PluginTemplateResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<PluginTemplateResponseDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = PluginTemplateResponseDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
'description',
|
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
'pluginName',
|
|
||||||
'steps',
|
|
||||||
'trigger',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class PluginTemplateStepResponseDto {
|
|
||||||
/// Returns a new [PluginTemplateStepResponseDto] instance.
|
|
||||||
PluginTemplateStepResponseDto({
|
|
||||||
this.config = const {},
|
|
||||||
this.enabled,
|
|
||||||
required this.method,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Step configuration
|
|
||||||
Map<String, Object>? config;
|
|
||||||
|
|
||||||
/// Whether the step is enabled
|
|
||||||
///
|
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
|
||||||
/// does not include a default value (using the "default:" property), however, the generated
|
|
||||||
/// source code must fall back to having a nullable type.
|
|
||||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
|
||||||
///
|
|
||||||
bool? enabled;
|
|
||||||
|
|
||||||
/// Step plugin method
|
|
||||||
String method;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateStepResponseDto &&
|
|
||||||
_deepEquality.equals(other.config, config) &&
|
|
||||||
other.enabled == enabled &&
|
|
||||||
other.method == method;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(config == null ? 0 : config!.hashCode) +
|
|
||||||
(enabled == null ? 0 : enabled!.hashCode) +
|
|
||||||
(method.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'PluginTemplateStepResponseDto[config=$config, enabled=$enabled, method=$method]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
if (this.config != null) {
|
|
||||||
json[r'config'] = this.config;
|
|
||||||
} else {
|
|
||||||
// json[r'config'] = null;
|
|
||||||
}
|
|
||||||
if (this.enabled != null) {
|
|
||||||
json[r'enabled'] = this.enabled;
|
|
||||||
} else {
|
|
||||||
// json[r'enabled'] = null;
|
|
||||||
}
|
|
||||||
json[r'method'] = this.method;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [PluginTemplateStepResponseDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static PluginTemplateStepResponseDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "PluginTemplateStepResponseDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return PluginTemplateStepResponseDto(
|
|
||||||
config: mapCastOfType<String, Object>(json, r'config'),
|
|
||||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
|
||||||
method: mapValueOfType<String>(json, r'method')!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<PluginTemplateStepResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <PluginTemplateStepResponseDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = PluginTemplateStepResponseDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, PluginTemplateStepResponseDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, PluginTemplateStepResponseDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = PluginTemplateStepResponseDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of PluginTemplateStepResponseDto-objects as value to a dart map
|
|
||||||
static Map<String, List<PluginTemplateStepResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<PluginTemplateStepResponseDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = PluginTemplateStepResponseDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
'config',
|
|
||||||
'method',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import 'package:mocktail/mocktail.dart';
|
|||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
|
|
||||||
const _kAccessToken = '#ThisIsAToken';
|
const _kAccessToken = '#ThisIsAToken';
|
||||||
const _kEnableBackup = false;
|
const _kAdvancedTroubleshooting = false;
|
||||||
const _kVersion = 2;
|
const _kVersion = 2;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -22,13 +22,13 @@ void main() {
|
|||||||
mockDriftStoreRepo = MockDriftStoreRepository();
|
mockDriftStoreRepo = MockDriftStoreRepository();
|
||||||
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
|
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
|
||||||
registerFallbackValue(StoreKey.accessToken);
|
registerFallbackValue(StoreKey.accessToken);
|
||||||
registerFallbackValue(StoreKey.backupTriggerDelay);
|
registerFallbackValue(StoreKey.version);
|
||||||
registerFallbackValue(StoreKey.enableBackup);
|
registerFallbackValue(StoreKey.advancedTroubleshooting);
|
||||||
|
|
||||||
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
|
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
|
||||||
(_) async => [
|
(_) async => [
|
||||||
const StoreDto(StoreKey.accessToken, _kAccessToken),
|
const StoreDto(StoreKey.accessToken, _kAccessToken),
|
||||||
const StoreDto(StoreKey.enableBackup, _kEnableBackup),
|
const StoreDto(StoreKey.advancedTroubleshooting, _kAdvancedTroubleshooting),
|
||||||
const StoreDto(StoreKey.version, _kVersion),
|
const StoreDto(StoreKey.version, _kVersion),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -46,7 +46,7 @@ void main() {
|
|||||||
test('Populates the internal cache on init', () {
|
test('Populates the internal cache on init', () {
|
||||||
verify(() => mockDriftStoreRepo.getAll()).called(1);
|
verify(() => mockDriftStoreRepo.getAll()).called(1);
|
||||||
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
|
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
|
||||||
expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup);
|
expect(sut.tryGet(StoreKey.advancedTroubleshooting), _kAdvancedTroubleshooting);
|
||||||
expect(sut.tryGet(StoreKey.version), _kVersion);
|
expect(sut.tryGet(StoreKey.version), _kVersion);
|
||||||
// Other keys should be null
|
// Other keys should be null
|
||||||
expect(sut.tryGet(StoreKey.currentUser), isNull);
|
expect(sut.tryGet(StoreKey.currentUser), isNull);
|
||||||
@@ -147,7 +147,7 @@ void main() {
|
|||||||
await sut.clear();
|
await sut.clear();
|
||||||
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
|
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
|
||||||
expect(sut.tryGet(StoreKey.accessToken), isNull);
|
expect(sut.tryGet(StoreKey.accessToken), isNull);
|
||||||
expect(sut.tryGet(StoreKey.enableBackup), isNull);
|
expect(sut.tryGet(StoreKey.advancedTroubleshooting), isNull);
|
||||||
expect(sut.tryGet(StoreKey.version), isNull);
|
expect(sut.tryGet(StoreKey.version), isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import '../../fixtures/user.stub.dart';
|
|||||||
|
|
||||||
const _kTestAccessToken = "#TestToken";
|
const _kTestAccessToken = "#TestToken";
|
||||||
const _kTestVersion = 10;
|
const _kTestVersion = 10;
|
||||||
const _kTestBackupRequireCharging = false;
|
const _kTestAdvancedTroubleshooting = false;
|
||||||
final _kTestUser = UserStub.admin;
|
final _kTestUser = UserStub.admin;
|
||||||
|
|
||||||
Future<void> _populateStore(Drift db) async {
|
Future<void> _populateStore(Drift db) async {
|
||||||
@@ -21,8 +21,8 @@ Future<void> _populateStore(Drift db) async {
|
|||||||
batch.insert(
|
batch.insert(
|
||||||
db.storeEntity,
|
db.storeEntity,
|
||||||
StoreEntityCompanion(
|
StoreEntityCompanion(
|
||||||
id: Value(StoreKey.backupRequireCharging.id),
|
id: Value(StoreKey.advancedTroubleshooting.id),
|
||||||
intValue: const Value(_kTestBackupRequireCharging ? 1 : 0),
|
intValue: const Value(_kTestAdvancedTroubleshooting ? 1 : 0),
|
||||||
stringValue: const Value(null),
|
stringValue: const Value(null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -76,11 +76,11 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('converts bool', () async {
|
test('converts bool', () async {
|
||||||
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
bool? advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting);
|
||||||
expect(backupRequireCharging, isNull);
|
expect(advancedTroubleshooting, isNull);
|
||||||
await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging);
|
await sut.upsert(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting);
|
||||||
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting);
|
||||||
expect(backupRequireCharging, _kTestBackupRequireCharging);
|
expect(advancedTroubleshooting, _kTestAdvancedTroubleshooting);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('converts user', () async {
|
test('converts user', () async {
|
||||||
@@ -98,11 +98,11 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('delete()', () async {
|
test('delete()', () async {
|
||||||
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
bool? advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting);
|
||||||
expect(backupRequireCharging, isFalse);
|
expect(advancedTroubleshooting, isFalse);
|
||||||
await sut.delete(StoreKey.backupRequireCharging);
|
await sut.delete(StoreKey.advancedTroubleshooting);
|
||||||
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting);
|
||||||
expect(backupRequireCharging, isNull);
|
expect(advancedTroubleshooting, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteAll()', () async {
|
test('deleteAll()', () async {
|
||||||
@@ -147,13 +147,13 @@ void main() {
|
|||||||
emitsInOrder([
|
emitsInOrder([
|
||||||
[
|
[
|
||||||
const StoreDto<Object>(StoreKey.version, _kTestVersion),
|
const StoreDto<Object>(StoreKey.version, _kTestVersion),
|
||||||
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
|
|
||||||
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
||||||
|
const StoreDto<Object>(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
|
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
|
||||||
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
|
|
||||||
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
||||||
|
const StoreDto<Object>(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting),
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ void main() {
|
|||||||
late MockApiService apiService;
|
late MockApiService apiService;
|
||||||
late MockNetworkService networkService;
|
late MockNetworkService networkService;
|
||||||
late MockBackgroundSyncManager backgroundSyncManager;
|
late MockBackgroundSyncManager backgroundSyncManager;
|
||||||
late MockAppSettingService appSettingsService;
|
|
||||||
late Drift db;
|
late Drift db;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
@@ -30,15 +29,12 @@ void main() {
|
|||||||
apiService = MockApiService();
|
apiService = MockApiService();
|
||||||
networkService = MockNetworkService();
|
networkService = MockNetworkService();
|
||||||
backgroundSyncManager = MockBackgroundSyncManager();
|
backgroundSyncManager = MockBackgroundSyncManager();
|
||||||
appSettingsService = MockAppSettingService();
|
|
||||||
|
|
||||||
sut = AuthService(
|
sut = AuthService(
|
||||||
authApiRepository,
|
authApiRepository,
|
||||||
authRepository,
|
authRepository,
|
||||||
apiService,
|
apiService,
|
||||||
networkService,
|
networkService,
|
||||||
backgroundSyncManager,
|
backgroundSyncManager,
|
||||||
appSettingsService,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
registerFallbackValue(Uri());
|
registerFallbackValue(Uri());
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.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/metadata.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
|
||||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
import '../domain/service.mock.dart';
|
|
||||||
import '../fixtures/asset.stub.dart';
|
import '../fixtures/asset.stub.dart';
|
||||||
import '../infrastructure/repository.mock.dart';
|
import '../infrastructure/repository.mock.dart';
|
||||||
import '../mocks/asset_entity.mock.dart';
|
import '../mocks/asset_entity.mock.dart';
|
||||||
@@ -29,13 +27,10 @@ void main() {
|
|||||||
late MockStorageRepository mockStorageRepository;
|
late MockStorageRepository mockStorageRepository;
|
||||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||||
late MockDriftBackupRepository mockBackupRepository;
|
late MockDriftBackupRepository mockBackupRepository;
|
||||||
late MockAppSettingsService mockAppSettingsService;
|
|
||||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||||
late Drift db;
|
late Drift db;
|
||||||
|
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
registerFallbackValue(AppSettingsEnum.useCellularForUploadPhotos);
|
|
||||||
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||||
@@ -54,18 +49,13 @@ void main() {
|
|||||||
mockStorageRepository = MockStorageRepository();
|
mockStorageRepository = MockStorageRepository();
|
||||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||||
mockBackupRepository = MockDriftBackupRepository();
|
mockBackupRepository = MockDriftBackupRepository();
|
||||||
mockAppSettingsService = MockAppSettingsService();
|
|
||||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||||
|
|
||||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false);
|
|
||||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false);
|
|
||||||
|
|
||||||
sut = BackgroundUploadService(
|
sut = BackgroundUploadService(
|
||||||
mockUploadRepository,
|
mockUploadRepository,
|
||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -181,7 +171,6 @@ void main() {
|
|||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
@@ -232,7 +221,6 @@ void main() {
|
|||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutAndroid.dispose());
|
addTearDown(() => sutAndroid.dispose());
|
||||||
@@ -273,7 +261,6 @@ void main() {
|
|||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
@@ -314,7 +301,6 @@ void main() {
|
|||||||
mockStorageRepository,
|
mockStorageRepository,
|
||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAppSettingsService,
|
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|||||||
@@ -11,9 +11,6 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Album ID",
|
"description": "Album ID",
|
||||||
"x-nestjs_zod-parent-metadata": {
|
|
||||||
"description": "Activity search"
|
|
||||||
},
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
@@ -25,9 +22,6 @@
|
|||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Asset ID (if activity is for an asset)",
|
"description": "Asset ID (if activity is for an asset)",
|
||||||
"x-nestjs_zod-parent-metadata": {
|
|
||||||
"description": "Activity search"
|
|
||||||
},
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
@@ -38,9 +32,6 @@
|
|||||||
"name": "level",
|
"name": "level",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"x-nestjs_zod-parent-metadata": {
|
|
||||||
"description": "Activity search"
|
|
||||||
},
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/ReactionLevel"
|
"$ref": "#/components/schemas/ReactionLevel"
|
||||||
}
|
}
|
||||||
@@ -49,9 +40,6 @@
|
|||||||
"name": "type",
|
"name": "type",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"x-nestjs_zod-parent-metadata": {
|
|
||||||
"description": "Activity search"
|
|
||||||
},
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/ReactionType"
|
"$ref": "#/components/schemas/ReactionType"
|
||||||
}
|
}
|
||||||
@@ -61,9 +49,6 @@
|
|||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Filter by user ID",
|
"description": "Filter by user ID",
|
||||||
"x-nestjs_zod-parent-metadata": {
|
|
||||||
"description": "Activity search"
|
|
||||||
},
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
@@ -187,9 +172,6 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Album ID",
|
"description": "Album ID",
|
||||||
"x-nestjs_zod-parent-metadata": {
|
|
||||||
"description": "Activity"
|
|
||||||
},
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
@@ -201,9 +183,6 @@
|
|||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Asset ID (if activity is for an asset)",
|
"description": "Asset ID (if activity is for an asset)",
|
||||||
"x-nestjs_zod-parent-metadata": {
|
|
||||||
"description": "Activity"
|
|
||||||
},
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
@@ -8839,50 +8818,6 @@
|
|||||||
"x-immich-permission": "plugin.read"
|
"x-immich-permission": "plugin.read"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/plugins/templates": {
|
|
||||||
"get": {
|
|
||||||
"description": "Retrieve premade workflow templates provided by installed plugins",
|
|
||||||
"operationId": "getTemplates",
|
|
||||||
"parameters": [],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/PluginTemplateResponseDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": "Retrieve workflow templates",
|
|
||||||
"tags": [
|
|
||||||
"Plugins"
|
|
||||||
],
|
|
||||||
"x-immich-history": [
|
|
||||||
{
|
|
||||||
"version": "v3.0.0",
|
|
||||||
"state": "Added"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"x-immich-permission": "plugin.read"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/plugins/{id}": {
|
"/plugins/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Retrieve information about a specific plugin by its ID.",
|
"description": "Retrieve information about a specific plugin by its ID.",
|
||||||
@@ -20196,69 +20131,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"PluginTemplateResponseDto": {
|
|
||||||
"properties": {
|
|
||||||
"description": {
|
|
||||||
"description": "Template description",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"description": "Template identifier (pluginName#templateName)",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"description": "Template name",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"pluginName": {
|
|
||||||
"description": "Owning plugin name",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"description": "Workflow steps",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/PluginTemplateStepResponseDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
|
||||||
"trigger": {
|
|
||||||
"$ref": "#/components/schemas/WorkflowTrigger",
|
|
||||||
"description": "Workflow trigger"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"description",
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"pluginName",
|
|
||||||
"steps",
|
|
||||||
"trigger"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"PluginTemplateStepResponseDto": {
|
|
||||||
"properties": {
|
|
||||||
"config": {
|
|
||||||
"additionalProperties": {},
|
|
||||||
"description": "Step configuration",
|
|
||||||
"nullable": true,
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"description": "Whether the step is enabled",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"method": {
|
|
||||||
"description": "Step plugin method",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"config",
|
|
||||||
"method"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"PurchaseResponse": {
|
"PurchaseResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"hideBuyButtonUntil": {
|
"hideBuyButtonUntil": {
|
||||||
|
|||||||
@@ -254,26 +254,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
|
||||||
"templates": [
|
|
||||||
{
|
|
||||||
"name": "Archive screenshots to album",
|
|
||||||
"description": "Add uploads with \"screenshot\" in the filename to an album and archive them",
|
|
||||||
"trigger": "AssetCreate",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"method": "immich-plugin-core#assetFileFilter",
|
|
||||||
"config": { "pattern": "screenshot", "matchType": "contains", "caseSensitive": false }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "immich-plugin-core#assetAddToAlbums",
|
|
||||||
"config": { "albumIds": [] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "immich-plugin-core#assetArchive",
|
|
||||||
"config": { "inverse": false }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1514,30 +1514,6 @@ export type PluginResponseDto = {
|
|||||||
/** Plugin version */
|
/** Plugin version */
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
export type PluginTemplateStepResponseDto = {
|
|
||||||
/** Step configuration */
|
|
||||||
config: {
|
|
||||||
[key: string]: any;
|
|
||||||
} | null;
|
|
||||||
/** Whether the step is enabled */
|
|
||||||
enabled?: boolean;
|
|
||||||
/** Step plugin method */
|
|
||||||
method: string;
|
|
||||||
};
|
|
||||||
export type PluginTemplateResponseDto = {
|
|
||||||
/** Template description */
|
|
||||||
description: string;
|
|
||||||
/** Template identifier (pluginName#templateName) */
|
|
||||||
id: string;
|
|
||||||
/** Template name */
|
|
||||||
name: string;
|
|
||||||
/** Owning plugin name */
|
|
||||||
pluginName: string;
|
|
||||||
/** Workflow steps */
|
|
||||||
steps: PluginTemplateStepResponseDto[];
|
|
||||||
/** Workflow trigger */
|
|
||||||
trigger: WorkflowTrigger;
|
|
||||||
};
|
|
||||||
export type QueueResponseDto = {
|
export type QueueResponseDto = {
|
||||||
/** Whether the queue is paused */
|
/** Whether the queue is paused */
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
@@ -5266,17 +5242,6 @@ export function searchPluginMethods({ description, enabled, id, name, pluginName
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Retrieve workflow templates
|
|
||||||
*/
|
|
||||||
export function getTemplates(opts?: Oazapfts.RequestOpts) {
|
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
|
||||||
status: 200;
|
|
||||||
data: PluginTemplateResponseDto[];
|
|
||||||
}>("/plugins/templates", {
|
|
||||||
...opts
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a plugin
|
* Retrieve a plugin
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
PluginMethodSearchDto,
|
PluginMethodSearchDto,
|
||||||
PluginResponseDto,
|
PluginResponseDto,
|
||||||
PluginSearchDto,
|
PluginSearchDto,
|
||||||
PluginTemplateResponseDto,
|
|
||||||
} from 'src/dtos/plugin.dto';
|
} from 'src/dtos/plugin.dto';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { Authenticated } from 'src/middleware/auth.guard';
|
import { Authenticated } from 'src/middleware/auth.guard';
|
||||||
@@ -40,17 +39,6 @@ export class PluginController {
|
|||||||
return this.service.searchMethods(dto);
|
return this.service.searchMethods(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('templates')
|
|
||||||
@Authenticated({ permission: Permission.PluginRead })
|
|
||||||
@Endpoint({
|
|
||||||
summary: 'Retrieve workflow templates',
|
|
||||||
description: 'Retrieve premade workflow templates provided by installed plugins',
|
|
||||||
history: HistoryBuilder.v3(),
|
|
||||||
})
|
|
||||||
getTemplates(): Promise<PluginTemplateResponseDto[]> {
|
|
||||||
return this.service.getTemplates();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Authenticated({ permission: Permission.PluginRead })
|
@Authenticated({ permission: Permission.PluginRead })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
|
|||||||
@@ -36,18 +36,16 @@ const ActivityStatisticsResponseSchema = z
|
|||||||
})
|
})
|
||||||
.meta({ id: 'ActivityStatisticsResponseDto' });
|
.meta({ id: 'ActivityStatisticsResponseDto' });
|
||||||
|
|
||||||
const ActivitySchema = z
|
const ActivitySchema = z.object({
|
||||||
.object({
|
albumId: z.uuidv4().describe('Album ID'),
|
||||||
albumId: z.uuidv4().describe('Album ID'),
|
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
|
||||||
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
|
});
|
||||||
})
|
|
||||||
.describe('Activity');
|
|
||||||
|
|
||||||
const ActivitySearchSchema = ActivitySchema.extend({
|
const ActivitySearchSchema = ActivitySchema.extend({
|
||||||
type: ReactionTypeSchema.optional(),
|
type: ReactionTypeSchema.optional(),
|
||||||
level: ReactionLevelSchema.optional(),
|
level: ReactionLevelSchema.optional(),
|
||||||
userId: z.uuidv4().optional().describe('Filter by user ID'),
|
userId: z.uuidv4().optional().describe('Filter by user ID'),
|
||||||
}).describe('Activity search');
|
});
|
||||||
|
|
||||||
const ActivityCreateSchema = ActivitySchema.extend({
|
const ActivityCreateSchema = ActivitySchema.extend({
|
||||||
type: ReactionTypeSchema,
|
type: ReactionTypeSchema,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { JsonSchemaSchema } from 'src/dtos/json-schema.dto';
|
import { JsonSchemaSchema } from 'src/dtos/json-schema.dto';
|
||||||
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
import { WorkflowTypeSchema } from 'src/enum';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
|
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
|
||||||
@@ -23,23 +23,6 @@ const PluginManifestMethodSchema = z
|
|||||||
})
|
})
|
||||||
.meta({ id: 'PluginManifestMethodDto' });
|
.meta({ id: 'PluginManifestMethodDto' });
|
||||||
|
|
||||||
const PluginManifestTemplateStepSchema = z
|
|
||||||
.object({
|
|
||||||
method: z.string().min(1).describe('Step plugin method (pluginName#methodName)'),
|
|
||||||
config: z.record(z.string(), z.unknown()).nullable().optional().describe('Step configuration'),
|
|
||||||
enabled: z.boolean().optional().describe('Whether the step is enabled'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'PluginManifestTemplateStepDto' });
|
|
||||||
|
|
||||||
const PluginManifestTemplateSchema = z
|
|
||||||
.object({
|
|
||||||
name: z.string().min(1).describe('Template name'),
|
|
||||||
description: z.string().min(1).describe('Template description'),
|
|
||||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
|
||||||
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'PluginManifestTemplateDto' });
|
|
||||||
|
|
||||||
const PluginManifestSchema = z
|
const PluginManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -56,7 +39,6 @@ const PluginManifestSchema = z
|
|||||||
wasmPath: z.string().min(1).describe('WASM file path'),
|
wasmPath: z.string().min(1).describe('WASM file path'),
|
||||||
author: z.string().min(1).describe('Plugin author'),
|
author: z.string().min(1).describe('Plugin author'),
|
||||||
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
|
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
|
||||||
templates: z.array(PluginManifestTemplateSchema).optional().default([]).describe('Workflow templates'),
|
|
||||||
})
|
})
|
||||||
.meta({ id: 'PluginManifestDto' });
|
.meta({ id: 'PluginManifestDto' });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
|
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
|
||||||
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||||
import { asMethodString } from 'src/utils/workflow';
|
import { asMethodString } from 'src/utils/workflow';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
@@ -43,25 +43,6 @@ const PluginResponseSchema = z
|
|||||||
})
|
})
|
||||||
.meta({ id: 'PluginResponseDto' });
|
.meta({ id: 'PluginResponseDto' });
|
||||||
|
|
||||||
const PluginTemplateStepResponseSchema = z
|
|
||||||
.object({
|
|
||||||
method: z.string().describe('Step plugin method'),
|
|
||||||
config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'),
|
|
||||||
enabled: z.boolean().optional().describe('Whether the step is enabled'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'PluginTemplateStepResponseDto' });
|
|
||||||
|
|
||||||
const PluginTemplateResponseSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string().describe('Template identifier (pluginName#templateName)'),
|
|
||||||
pluginName: z.string().describe('Owning plugin name'),
|
|
||||||
name: z.string().describe('Template name'),
|
|
||||||
description: z.string().describe('Template description'),
|
|
||||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
|
||||||
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
|
|
||||||
})
|
|
||||||
.meta({ id: 'PluginTemplateResponseDto' });
|
|
||||||
|
|
||||||
const PluginMethodSearchSchema = z
|
const PluginMethodSearchSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.uuidv4().optional().describe('Plugin method ID'),
|
id: z.uuidv4().optional().describe('Plugin method ID'),
|
||||||
@@ -80,34 +61,6 @@ export class PluginSearchDto extends createZodDto(PluginSearchSchema) {}
|
|||||||
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
|
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
|
||||||
export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {}
|
export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {}
|
||||||
export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {}
|
export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {}
|
||||||
export class PluginTemplateResponseDto extends createZodDto(PluginTemplateResponseSchema) {}
|
|
||||||
|
|
||||||
export type PluginTemplate = {
|
|
||||||
pluginName: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
trigger: WorkflowTrigger;
|
|
||||||
steps: Array<{
|
|
||||||
method: string;
|
|
||||||
config?: Record<string, unknown> | null;
|
|
||||||
enabled?: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const mapTemplate = (template: PluginTemplate): PluginTemplateResponseDto => {
|
|
||||||
return {
|
|
||||||
id: `${template.pluginName}#${template.name}`,
|
|
||||||
pluginName: template.pluginName,
|
|
||||||
name: template.name,
|
|
||||||
description: template.description,
|
|
||||||
trigger: template.trigger,
|
|
||||||
steps: template.steps.map((step) => ({
|
|
||||||
method: step.method,
|
|
||||||
config: step.config ?? null,
|
|
||||||
enabled: step.enabled,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type Plugin = {
|
type Plugin = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { join } from 'node:path';
|
|
||||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
|
||||||
import {
|
import {
|
||||||
mapMethod,
|
mapMethod,
|
||||||
mapPlugin,
|
mapPlugin,
|
||||||
mapTemplate,
|
|
||||||
PluginMethodResponseDto,
|
PluginMethodResponseDto,
|
||||||
PluginMethodSearchDto,
|
PluginMethodSearchDto,
|
||||||
PluginResponseDto,
|
PluginResponseDto,
|
||||||
PluginSearchDto,
|
PluginSearchDto,
|
||||||
PluginTemplate,
|
|
||||||
PluginTemplateResponseDto,
|
|
||||||
} from 'src/dtos/plugin.dto';
|
} from 'src/dtos/plugin.dto';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { isMethodCompatible } from 'src/utils/workflow';
|
import { isMethodCompatible } from 'src/utils/workflow';
|
||||||
@@ -36,32 +31,4 @@ export class PluginService extends BaseService {
|
|||||||
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
|
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
|
||||||
.map((method) => mapMethod(method));
|
.map((method) => mapMethod(method));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTemplates(): Promise<PluginTemplateResponseDto[]> {
|
|
||||||
const templates = await this.loadTemplates();
|
|
||||||
return templates.map((template) => mapTemplate(template));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadTemplates(): Promise<PluginTemplate[]> {
|
|
||||||
const { resourcePaths } = this.configRepository.getEnv();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const templates: PluginTemplate[] = [];
|
|
||||||
const dto = await this.storageRepository.readJsonFile(join(resourcePaths.corePlugin, 'manifest.json'));
|
|
||||||
const result = PluginManifestDto.schema.safeParse(dto);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const template of result.data.templates) {
|
|
||||||
templates.push({ ...template, pluginName: result.data.name });
|
|
||||||
}
|
|
||||||
|
|
||||||
return templates;
|
|
||||||
} catch {
|
|
||||||
this.logger.warn(`Failed to load plugin templates from folder: ${resourcePaths.corePlugin}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
getTemplates,
|
|
||||||
getWorkflowTriggers,
|
getWorkflowTriggers,
|
||||||
searchPluginMethods,
|
searchPluginMethods,
|
||||||
WorkflowTrigger,
|
WorkflowTrigger,
|
||||||
type PluginMethodResponseDto,
|
type PluginMethodResponseDto,
|
||||||
type PluginTemplateResponseDto,
|
|
||||||
type WorkflowTriggerResponseDto,
|
type WorkflowTriggerResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -18,7 +16,6 @@ class PluginManager {
|
|||||||
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
|
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
|
||||||
#methods = $state<PluginMethodResponseDto[]>([]);
|
#methods = $state<PluginMethodResponseDto[]>([]);
|
||||||
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
|
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
|
||||||
#templates = $state<PluginTemplateResponseDto[]>([]);
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
eventManager.on({
|
eventManager.on({
|
||||||
@@ -36,10 +33,6 @@ class PluginManager {
|
|||||||
return this.#triggers;
|
return this.#triggers;
|
||||||
}
|
}
|
||||||
|
|
||||||
get templates() {
|
|
||||||
return this.#templates;
|
|
||||||
}
|
|
||||||
|
|
||||||
ready() {
|
ready() {
|
||||||
return this.initialize();
|
return this.initialize();
|
||||||
}
|
}
|
||||||
@@ -77,11 +70,7 @@ class PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async load() {
|
private async load() {
|
||||||
const [methods, triggers, templates] = await Promise.all([
|
const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]);
|
||||||
searchPluginMethods({}),
|
|
||||||
getWorkflowTriggers(),
|
|
||||||
getTemplates(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.#methods = methods;
|
this.#methods = methods;
|
||||||
for (const method of this.#methods) {
|
for (const method of this.#methods) {
|
||||||
@@ -89,7 +78,6 @@ class PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.#triggers = triggers;
|
this.#triggers = triggers;
|
||||||
this.#templates = templates;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
const { trigger, selectedKey, onClose }: Props = $props();
|
const { trigger, selectedKey, onClose }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BasicModal title={$t('add_step')} {onClose} size="medium">
|
<BasicModal title={$t('add_step')} {onClose}>
|
||||||
{#await searchPluginMethods({ trigger })}
|
{#await searchPluginMethods({ trigger })}
|
||||||
<div class="flex w-full place-content-center place-items-center">
|
<div class="flex w-full place-content-center place-items-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if method}
|
{#if method}
|
||||||
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="medium">
|
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="grow text-start">
|
<div class="grow text-start">
|
||||||
<Text fontWeight="medium">{method.title}</Text>
|
<Text fontWeight="medium">{method.title}</Text>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if method}
|
{#if method}
|
||||||
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="medium">
|
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="grow text-start">
|
<div class="grow text-start">
|
||||||
<Text fontWeight="medium">{method.title}</Text>
|
<Text fontWeight="medium">{method.title}</Text>
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
|
||||||
import { handleCreateWorkflow } from '$lib/services/workflow.service';
|
|
||||||
import { type PluginTemplateResponseDto } from '@immich/sdk';
|
|
||||||
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
|
|
||||||
import { mdiFlashOutline } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { onClose }: Props = $props();
|
|
||||||
|
|
||||||
let selected = $state<PluginTemplateResponseDto>();
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
|
||||||
if (!selected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const response = await handleCreateWorkflow({
|
|
||||||
trigger: selected.trigger,
|
|
||||||
steps: selected.steps,
|
|
||||||
name: selected.name,
|
|
||||||
description: selected.description,
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
if (response) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FormModal title={$t('workflow_templates')} {onClose} {onSubmit} disabled={!selected} size="medium">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#each pluginManager.templates as template (template.id)}
|
|
||||||
<ListButton selected={selected?.id === template.id} onclick={() => (selected = template)}>
|
|
||||||
<div class="flex w-full items-center gap-3 text-start">
|
|
||||||
<div
|
|
||||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary"
|
|
||||||
>
|
|
||||||
<Icon icon={mdiFlashOutline} size="18" />
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 grow">
|
|
||||||
<Text fontWeight="medium">{template.name}</Text>
|
|
||||||
<Text size="tiny" color="muted">{template.description}</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ListButton>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
const onSubmit = () => onClose(selected);
|
const onSubmit = () => onClose(selected);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="medium">
|
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each pluginManager.triggers as item (item.trigger)}
|
{#each pluginManager.triggers as item (item.trigger)}
|
||||||
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
|
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import {
|
|||||||
type WorkflowUpdateDto,
|
type WorkflowUpdateDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||||
import { mdiCodeJson, mdiDelete, mdiFileDocumentMultipleOutline, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
|
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
|
||||||
import type { MessageFormatter } from 'svelte-i18n';
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
|
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
@@ -34,13 +33,7 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const UseTemplate: ActionItem = {
|
return { Create };
|
||||||
title: $t('use_template'),
|
|
||||||
icon: mdiFileDocumentMultipleOutline,
|
|
||||||
onAction: () => modalManager.show(WorkflowTemplatePicker, {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return { Create, UseTemplate };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
|
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
|
||||||
@@ -79,13 +72,12 @@ export const getWorkflowShowSchemaAction = (
|
|||||||
onAction: onToggle,
|
onAction: onToggle,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await createWorkflow({ workflowCreateDto: dto });
|
const response = await createWorkflow({ workflowCreateDto: dto });
|
||||||
eventManager.emit('WorkflowCreate', response);
|
eventManager.emit('WorkflowCreate', response);
|
||||||
return response;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_create'));
|
handleError(error, $t('errors.unable_to_create'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,26 @@
|
|||||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||||
|
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
|
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
|
||||||
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
|
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
CardBody,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
Container,
|
Container,
|
||||||
Icon,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
MenuItemType,
|
MenuItemType,
|
||||||
menuManager,
|
menuManager,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
} from '@immich/ui';
|
} from '@immich/ui';
|
||||||
import { mdiClose, mdiDotsVertical, mdiFlashOutline } from '@mdi/js';
|
import { mdiClose, mdiDotsVertical } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@@ -44,6 +46,20 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTriggerLabel = (triggerType: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
AssetCreate: $t('asset_created'),
|
||||||
|
PersonRecognized: $t('person_recognized'),
|
||||||
|
};
|
||||||
|
return labels[triggerType] || triggerType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (createdAt: string) =>
|
||||||
|
new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(createdAt));
|
||||||
|
|
||||||
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
|
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
|
||||||
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
|
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
|
||||||
void menuManager.show({
|
void menuManager.show({
|
||||||
@@ -59,7 +75,7 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const { Create, UseTemplate } = $derived(getWorkflowsActions($t));
|
const { Create } = $derived(getWorkflowsActions($t));
|
||||||
|
|
||||||
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
|
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
|
||||||
await goto(Route.viewWorkflow(response));
|
await goto(Route.viewWorkflow(response));
|
||||||
@@ -76,7 +92,13 @@
|
|||||||
|
|
||||||
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
|
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title} actions={[UseTemplate, Create]} scrollbar={false}>
|
{#snippet chipItem(title: string)}
|
||||||
|
<span class="rounded-xl border border-gray-200/80 bg-light px-3 py-1.5 text-sm dark:border-gray-600">
|
||||||
|
<span class="font-medium text-dark">{title}</span>
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
|
||||||
<section class="flex place-content-center sm:mx-4">
|
<section class="flex place-content-center sm:mx-4">
|
||||||
<Container center size="large" class="pb-28">
|
<Container center size="large" class="pb-28">
|
||||||
{#if workflows.length === 0}
|
{#if workflows.length === 0}
|
||||||
@@ -89,77 +111,92 @@
|
|||||||
class="mx-auto mt-10"
|
class="mx-auto mt-10"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="my-6 flex flex-col gap-3">
|
<div class="my-6 grid gap-6">
|
||||||
{#each workflows as workflow (workflow.id)}
|
{#each workflows as workflow (workflow.id)}
|
||||||
<Card class="group shadow-none transition-colors hover:border-primary">
|
<Card class="border border-light-200">
|
||||||
<CardHeader>
|
<CardHeader
|
||||||
<a
|
class={`flex flex-row gap-4 px-8 py-6 sm:items-center sm:gap-6 ${
|
||||||
href={Route.viewWorkflow({ id: workflow.id })}
|
workflow.enabled
|
||||||
class="flex items-center gap-4"
|
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
|
||||||
class:opacity-55={!workflow.enabled}
|
: 'bg-neutral-50 dark:bg-neutral-900'
|
||||||
>
|
}`}
|
||||||
<div
|
>
|
||||||
class={`flex size-11 shrink-0 items-center justify-center rounded-xl ${
|
<div class="flex-1">
|
||||||
workflow.enabled
|
<div class="flex items-center gap-3">
|
||||||
? 'bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary'
|
<span class="rounded-full {workflow.enabled ? 'size-3 bg-success' : 'size-3 rounded-full bg-muted'}"
|
||||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
|
></span>
|
||||||
}`}
|
<CardTitle>{workflow.name || $t('workflow')}</CardTitle>
|
||||||
>
|
|
||||||
<Icon icon={mdiFlashOutline} size="20" />
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if workflow.description}
|
||||||
|
<CardDescription class="mt-1 text-sm">{workflow.description}</CardDescription>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="flex items-center gap-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="hidden text-right sm:block">
|
||||||
<CardTitle class="truncate font-semibold text-dark group-hover:text-primary">
|
<Text size="tiny">{$t('created_at')}</Text>
|
||||||
{workflow.name || $t('workflow')}
|
<Text size="small" fontWeight="medium">
|
||||||
</CardTitle>
|
{formatTimestamp(workflow.createdAt)}
|
||||||
|
</Text>
|
||||||
{#if !workflow.enabled}
|
|
||||||
<Badge size="small" color="secondary">
|
|
||||||
{$t('disabled')}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if workflow.description}
|
|
||||||
<CardDescription class="mt-0.5 truncate">
|
|
||||||
{workflow.description}
|
|
||||||
</CardDescription>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
icon={mdiDotsVertical}
|
icon={mdiDotsVertical}
|
||||||
aria-label={$t('menu')}
|
aria-label={$t('menu')}
|
||||||
onclick={(event: MouseEvent) => {
|
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
showWorkflowMenu(event, workflow);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</a>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody class="space-y-6">
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<!-- Trigger Section -->
|
||||||
|
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
|
||||||
|
</div>
|
||||||
|
{@render chipItem(getTriggerLabel(workflow.trigger))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Section -->
|
||||||
|
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<Text size="tiny" color="muted" fontWeight="medium">{$t('steps')}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if workflow.steps.length === 0}
|
||||||
|
<span class="text-sm text-light-600">
|
||||||
|
{$t('no_steps')}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each workflow.steps as step, i (i)}
|
||||||
|
{@render chipItem(pluginManager.getMethodLabel(step.method))}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if expandedIds.has(workflow.id)}
|
{#if expandedIds.has(workflow.id)}
|
||||||
{#await getWorkflowForShare({ id: workflow.id }) then result}
|
{#await getWorkflowForShare({ id: workflow.id }) then result}
|
||||||
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
|
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||||
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
|
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
|
||||||
<Button
|
<Button
|
||||||
class="mt-2"
|
|
||||||
leadingIcon={mdiClose}
|
leadingIcon={mdiClose}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onclick={() => toggleExpanded(workflow.id)}
|
onclick={() => toggleExpanded(workflow.id)}>{$t('close')}</Button
|
||||||
>
|
>
|
||||||
{$t('close')}
|
</VStack>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
</CardHeader>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { beforeNavigate, goto, invalidate } from '$app/navigation';
|
import { goto, invalidate } from '$app/navigation';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||||
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
|
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||||
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
|
import type { WorkflowResponseDto, WorkflowStepDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
ActionBar,
|
ActionBar,
|
||||||
AppShell,
|
AppShell,
|
||||||
@@ -29,40 +29,26 @@
|
|||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
modalManager,
|
modalManager,
|
||||||
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
VStack,
|
VStack,
|
||||||
|
type ActionItem,
|
||||||
} from '@immich/ui';
|
} from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiArrowLeft,
|
mdiArrowLeft,
|
||||||
mdiCodeJson,
|
|
||||||
mdiContentSave,
|
mdiContentSave,
|
||||||
mdiFlashOutline,
|
mdiFlashOutline,
|
||||||
mdiFormatListBulletedSquare,
|
mdiFormatListBulletedSquare,
|
||||||
mdiInformationOutline,
|
mdiInformationOutline,
|
||||||
mdiPencilOutline,
|
mdiPencilOutline,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
|
mdiTrashCanOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { cloneDeep, isEqual } from 'lodash-es';
|
|
||||||
import { flushSync } from 'svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
|
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||||
import WorkflowStepCard from './WorkflowStepCard.svelte';
|
|
||||||
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
|
|
||||||
import WorkflowSummary from './WorkflowSummary.svelte';
|
|
||||||
|
|
||||||
type WorkflowJsonContent = Required<
|
|
||||||
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
|
|
||||||
>;
|
|
||||||
|
|
||||||
type EditMode = 'visual' | 'json';
|
|
||||||
type StepDragImage = {
|
|
||||||
description?: string;
|
|
||||||
isFilter: boolean;
|
|
||||||
label: string;
|
|
||||||
stepNumber: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@@ -71,27 +57,6 @@
|
|||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
let { id, enabled, name, description, trigger, steps } = $derived(data.workflow);
|
let { id, enabled, name, description, trigger, steps } = $derived(data.workflow);
|
||||||
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 hasChanges = $derived(
|
|
||||||
enabled !== savedWorkflow.enabled ||
|
|
||||||
name !== savedWorkflow.name ||
|
|
||||||
description !== savedWorkflow.description ||
|
|
||||||
!isEqual(trigger, savedWorkflow.trigger) ||
|
|
||||||
!isEqual(steps, savedWorkflow.steps),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddStep = async () => {
|
const handleAddStep = async () => {
|
||||||
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
|
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
|
||||||
@@ -100,90 +65,13 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInsertStep = async (index: number) => {
|
const handleEditStep = async (step: WorkflowStepDto) => {
|
||||||
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
|
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step });
|
||||||
if (step) {
|
|
||||||
steps = [...steps.slice(0, index), step, ...steps.slice(index)];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) });
|
|
||||||
if (result) {
|
if (result) {
|
||||||
replaceStep(index, result);
|
Object.assign(step, 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (index: number, event: DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const from = draggedIndex;
|
|
||||||
draggedIndex = null;
|
|
||||||
dropTargetIndex = null;
|
|
||||||
if (from === null || from === index) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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 handleDeleteStep = async (index: number) => {
|
||||||
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
|
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
@@ -192,16 +80,11 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJsonContentChange = (content: WorkflowJsonContent) => {
|
const onClose = async () => {
|
||||||
enabled = content.enabled;
|
// check for pending changes
|
||||||
name = content.name;
|
await goto(Route.workflows());
|
||||||
description = content.description;
|
|
||||||
trigger = content.trigger;
|
|
||||||
steps = cloneDeep(content.steps);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClose = () => goto(Route.workflows());
|
|
||||||
|
|
||||||
const onChangeTrigger = async () => {
|
const onChangeTrigger = async () => {
|
||||||
const newTrigger = await modalManager.show(WorkflowTriggerPicker, { selected: trigger });
|
const newTrigger = await modalManager.show(WorkflowTriggerPicker, { selected: trigger });
|
||||||
if (newTrigger) {
|
if (newTrigger) {
|
||||||
@@ -212,228 +95,163 @@
|
|||||||
const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
|
const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
|
||||||
if (id === response.id) {
|
if (id === response.id) {
|
||||||
data.workflow = response;
|
data.workflow = response;
|
||||||
savedWorkflow = cloneDeep(response);
|
|
||||||
await invalidate('workflow:data');
|
await invalidate('workflow:data');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmNavigation = async () => {
|
const Done: ActionItem = {
|
||||||
if (!hasChanges) {
|
title: $t('save'),
|
||||||
return true;
|
icon: mdiContentSave,
|
||||||
}
|
color: 'primary',
|
||||||
|
onAction: () => handleUpdateWorkflow(id, { enabled, name, description, trigger, steps }),
|
||||||
if (isShowingNavigationDialog) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isShowingNavigationDialog = true;
|
|
||||||
return await modalManager.showDialog({
|
|
||||||
prompt: $t('workflow_navigation_prompt'),
|
|
||||||
confirmColor: 'primary',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
isShowingNavigationDialog = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveWorkflow = async () => {
|
|
||||||
if (!hasChanges || isSaving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving = true;
|
|
||||||
try {
|
|
||||||
const submitted = { enabled, name, description, trigger, steps: cloneDeep(steps) };
|
|
||||||
const saved = await handleUpdateWorkflow(id, submitted);
|
|
||||||
|
|
||||||
if (saved) {
|
|
||||||
Object.assign(savedWorkflow, submitted);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isSaving = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to, willUnload }) => {
|
|
||||||
if (!hasChanges || allowNavigation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel();
|
|
||||||
|
|
||||||
if (willUnload || !to) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void confirmNavigation().then((confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
allowNavigation = true;
|
|
||||||
void goto(to.url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<OnEvents {onWorkflowUpdate} />
|
<OnEvents {onWorkflowUpdate} />
|
||||||
|
|
||||||
<AppShell class="">
|
<AppShell>
|
||||||
<AppShellBar>
|
<AppShellBar>
|
||||||
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
|
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
|
||||||
<ControlBarHeader>
|
<ControlBarHeader>
|
||||||
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
|
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
|
||||||
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
|
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
|
||||||
</ControlBarHeader>
|
</ControlBarHeader>
|
||||||
<ControlBarContent class="flex items-center justify-end gap-6">
|
<ControlBarContent class="flex justify-end">
|
||||||
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
|
<HeaderActionButton action={Done} variant="filled" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="filled"
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
leadingIcon={mdiContentSave}
|
|
||||||
disabled={!hasChanges || isSaving}
|
|
||||||
loading={isSaving}
|
|
||||||
onclick={saveWorkflow}
|
|
||||||
>
|
|
||||||
{$t('save')}
|
|
||||||
</Button>
|
|
||||||
</ControlBarContent>
|
</ControlBarContent>
|
||||||
</ActionBar>
|
</ActionBar>
|
||||||
</AppShellBar>
|
</AppShellBar>
|
||||||
|
|
||||||
<Container size="medium" class="pt-8 pb-24" center>
|
<Container size="medium" class="pt-8 pb-24" center>
|
||||||
<VStack gap={4}>
|
<VStack gap={4}>
|
||||||
{#if editMode === 'visual'}
|
<Card expandable>
|
||||||
<Card class="shadow-none" expandable>
|
<CardHeader>
|
||||||
<CardHeader>
|
<div class="flex place-items-start gap-3">
|
||||||
<div class="flex place-items-start gap-3">
|
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
||||||
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
<div class="flex flex-col">
|
||||||
<div class="flex flex-col">
|
<CardTitle>
|
||||||
<CardTitle>
|
{$t('workflow_info')}
|
||||||
{$t('workflow_info')}
|
</CardTitle>
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<VStack gap={4}>
|
<VStack gap={4}>
|
||||||
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
|
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
|
||||||
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
|
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
|
||||||
<Switch bind:checked={enabled} />
|
<Switch bind:checked={enabled} />
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label={$t('name')} required>
|
|
||||||
<Input
|
|
||||||
placeholder={$t('workflow_name')}
|
|
||||||
bind:value={() => name ?? '', (value) => (name = value || null)}
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={$t('description')} for="workflow-description">
|
</div>
|
||||||
<Textarea
|
|
||||||
id="workflow-description"
|
|
||||||
grow
|
|
||||||
placeholder={$t('workflow_description')}
|
|
||||||
bind:value={() => description ?? '', (value) => (description = value || null)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</VStack>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div class="my-4 h-px w-[98%] bg-light-200"></div>
|
<Field label={$t('name')} required>
|
||||||
|
<Input
|
||||||
<Card class="shadow-none">
|
placeholder={$t('workflow_name')}
|
||||||
<CardHeader>
|
bind:value={() => name ?? '', (value) => (name = value || null)}
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-success-50">
|
|
||||||
<Icon icon={mdiFlashOutline} size="20" class="text-success" />
|
|
||||||
</div>
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
|
||||||
<CardTitle class="truncate">{getTriggerName($t, trigger)}</CardTitle>
|
|
||||||
<CardDescription class="truncate">{getTriggerDescription($t, trigger)}</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
icon={mdiPencilOutline}
|
|
||||||
aria-label={$t('edit')}
|
|
||||||
variant="ghost"
|
|
||||||
shape="round"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
onclick={onChangeTrigger}
|
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={$t('description')} for="workflow-description">
|
||||||
|
<Textarea
|
||||||
|
id="workflow-description"
|
||||||
|
grow
|
||||||
|
placeholder={$t('workflow_description')}
|
||||||
|
bind:value={() => description ?? '', (value) => (description = value || null)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="my-4 h-px w-[98%] bg-light-200"></div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="bg-success-50">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-success" />
|
||||||
|
<div class="flex grow flex-col">
|
||||||
|
<CardTitle class="text-left text-success">{$t('trigger')}</CardTitle>
|
||||||
|
<CardDescription>{$t('trigger_description')}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<div class="flex items-center justify-end">
|
||||||
</Card>
|
<Button leadingIcon={mdiPencilOutline} size="small" color="secondary" onclick={onChangeTrigger}>
|
||||||
|
{$t('edit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
{#each steps as step, index (index)}
|
<CardBody>
|
||||||
<WorkflowStepCard
|
<div class="flex flex-col items-start">
|
||||||
{step}
|
<Text>{getTriggerName($t, trigger)}</Text>
|
||||||
{index}
|
<Text size="small" color="muted">{getTriggerDescription($t, trigger)}</Text>
|
||||||
isDragging={draggedIndex === index}
|
</div>
|
||||||
isDragHandleHovered={dragHandleHoverIndex === index}
|
</CardBody>
|
||||||
isDropTarget={dropTargetIndex === index && draggedIndex !== null && draggedIndex !== index}
|
</Card>
|
||||||
onEdit={handleEditStep}
|
|
||||||
onDelete={handleDeleteStep}
|
|
||||||
onInsertBefore={handleInsertStep}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragHandleEnter={(i) => (dragHandleHoverIndex = i)}
|
|
||||||
onDragHandleLeave={() => (dragHandleHoverIndex = null)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<Button
|
<Card>
|
||||||
size="small"
|
<CardHeader class="bg-primary-50">
|
||||||
fullWidth
|
<div class="flex items-start gap-3">
|
||||||
variant="ghost"
|
<Icon icon={mdiFormatListBulletedSquare} size="20" class="mt-1 text-primary" />
|
||||||
leadingIcon={mdiPlus}
|
<CardTitle class="text-left text-primary">{$t('steps')}</CardTitle>
|
||||||
class="border border-dashed"
|
</div>
|
||||||
onclick={handleAddStep}
|
</CardHeader>
|
||||||
>
|
|
||||||
{$t('add_step')}
|
<CardBody>
|
||||||
</Button>
|
{#if steps.length === 0}
|
||||||
{:else}
|
<Button leadingIcon={mdiPlus} onclick={handleAddStep}>{$t('add_step')}</Button>
|
||||||
<WorkflowJsonEditor jsonContent={workflowJsonContent} onContentChange={handleJsonContentChange} />
|
{:else}
|
||||||
{/if}
|
<Stack gap={2}>
|
||||||
|
{#each steps as step, index (index)}
|
||||||
|
{@const method = pluginManager.getMethod(step.method)}
|
||||||
|
{#if index > 0}
|
||||||
|
<hr />
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
// {@attach dragAndDrop({
|
||||||
|
// index,
|
||||||
|
// onDragStart: handleFilterDragStart,
|
||||||
|
// onDragEnter: handleFilterDragEnter,
|
||||||
|
// onDrop: handleFilterDrop,
|
||||||
|
// onDragEnd: handleFilterDragEnd,
|
||||||
|
// isDragging: draggedIndex === index,
|
||||||
|
// isDragOver: dragOverIndex === index,
|
||||||
|
// })}
|
||||||
|
class="flex cursor-move justify-between gap-2 rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Text>{pluginManager.getMethodLabel(step.method)}</Text>
|
||||||
|
{#if method?.description}
|
||||||
|
<Text color="muted" size="small">{method.description}</Text>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<IconButton
|
||||||
|
icon={mdiPencilOutline}
|
||||||
|
aria-label={$t('edit')}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
color="secondary"
|
||||||
|
onclick={() => handleEditStep(step)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={mdiTrashCanOutline}
|
||||||
|
aria-label={$t('delete')}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
color="danger"
|
||||||
|
onclick={() => handleDeleteStep(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<Button size="small" fullWidth variant="ghost" leadingIcon={mdiPlus} onclick={handleAddStep}>
|
||||||
|
{$t('add_step')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{/if}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<WorkflowStepDragImage
|
|
||||||
bind:ref={dragImageElement}
|
|
||||||
description={dragImage.description}
|
|
||||||
isFilter={dragImage.isFilter}
|
|
||||||
label={dragImage.label}
|
|
||||||
stepNumber={dragImage.stepNumber}
|
|
||||||
/>
|
|
||||||
<WorkflowSummary workflow={workflowSummary} />
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getWorkflow } from '@immich/sdk';
|
import { searchWorkflows } from '@immich/sdk';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
@@ -8,7 +8,7 @@ import type { PageLoad } from './$types';
|
|||||||
|
|
||||||
export const load = (async ({ url, params, depends }) => {
|
export const load = (async ({ url, params, depends }) => {
|
||||||
await authenticate(url);
|
await authenticate(url);
|
||||||
const [workflow] = await Promise.all([getWorkflow({ id: params.workflowId }), pluginManager.ready()]);
|
const [[workflow]] = await Promise.all([searchWorkflows({ id: params.workflowId }), pluginManager.ready()]);
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WorkflowTrigger, type WorkflowStepDto, type WorkflowUpdateDto } from '@immich/sdk';
|
import type { WorkflowResponseDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
@@ -12,91 +13,40 @@
|
|||||||
VStack,
|
VStack,
|
||||||
} from '@immich/ui';
|
} from '@immich/ui';
|
||||||
import { mdiCodeJson } from '@mdi/js';
|
import { mdiCodeJson } from '@mdi/js';
|
||||||
import { isEqual } from 'lodash-es';
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
type WorkflowJsonContent = Required<
|
|
||||||
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
|
|
||||||
>;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jsonContent: WorkflowJsonContent;
|
jsonContent: WorkflowResponseDto;
|
||||||
onContentChange: (content: WorkflowJsonContent) => void;
|
onApply: () => void;
|
||||||
|
onContentChange: (content: WorkflowResponseDto) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { jsonContent, onContentChange }: Props = $props();
|
let { jsonContent, onApply, onContentChange }: Props = $props();
|
||||||
|
|
||||||
let content: Content = $state({ json: jsonContent });
|
let content: Content = $derived({ json: jsonContent });
|
||||||
|
let canApply = $state(false);
|
||||||
let editorClass = $derived(themeManager.value === Theme.Dark ? 'jse-theme-dark' : '');
|
let editorClass = $derived(themeManager.value === Theme.Dark ? 'jse-theme-dark' : '');
|
||||||
|
|
||||||
const isWorkflowStep = (value: unknown): value is WorkflowStepDto => {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = value as Partial<WorkflowStepDto>;
|
|
||||||
return (
|
|
||||||
typeof step.method === 'string' &&
|
|
||||||
(step.config === null || (typeof step.config === 'object' && !Array.isArray(step.config))) &&
|
|
||||||
(step.enabled === undefined || typeof step.enabled === 'boolean')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isWorkflowJsonContent = (value: unknown): value is WorkflowJsonContent => {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workflow = value as Partial<WorkflowJsonContent>;
|
|
||||||
return (
|
|
||||||
typeof workflow.enabled === 'boolean' &&
|
|
||||||
(workflow.name === null || typeof workflow.name === 'string') &&
|
|
||||||
(workflow.description === null || typeof workflow.description === 'string') &&
|
|
||||||
Object.values(WorkflowTrigger).includes(workflow.trigger as WorkflowTrigger) &&
|
|
||||||
Array.isArray(workflow.steps) &&
|
|
||||||
workflow.steps.every(isWorkflowStep)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseContent = (updated: Content) => {
|
|
||||||
if ('json' in updated) {
|
|
||||||
return updated.json;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.parse(updated.text);
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const nextContent = jsonContent;
|
|
||||||
let isSynced = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
isSynced = isEqual(
|
|
||||||
untrack(() => parseContent(content)),
|
|
||||||
nextContent,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// The editor can be temporarily invalid while typing in text mode.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSynced) {
|
|
||||||
content = { json: nextContent };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
|
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
|
||||||
if (status.contentErrors) {
|
if (status.contentErrors) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseContent(updated);
|
canApply = true;
|
||||||
if (!isWorkflowJsonContent(parsed)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onContentChange(parsed);
|
if ('text' in updated && updated.text !== undefined) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(updated.text);
|
||||||
|
onContentChange(parsed);
|
||||||
|
} catch (error_) {
|
||||||
|
console.error('Invalid JSON in text mode:', error_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApply();
|
||||||
|
canApply = false;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -107,16 +57,17 @@
|
|||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
|
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<CardTitle>{$t('workflow_json')}</CardTitle>
|
<CardTitle>Workflow JSON</CardTitle>
|
||||||
<CardDescription>{$t('workflow_json_help')}</CardDescription>
|
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
<div class="h-[600px] w-full overflow-hidden rounded-lg border {editorClass}">
|
<div class="h-[600px] w-full overflow-hidden rounded-lg border {editorClass}">
|
||||||
<JSONEditor bind:content onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
||||||
</div>
|
</div>
|
||||||
</VStack>
|
</VStack>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
|
||||||
import type { WorkflowStepDto } from '@immich/sdk';
|
|
||||||
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
|
|
||||||
import {
|
|
||||||
mdiAutoFix,
|
|
||||||
mdiDragVertical,
|
|
||||||
mdiFilterVariant,
|
|
||||||
mdiPencilOutline,
|
|
||||||
mdiPlus,
|
|
||||||
mdiTrashCanOutline,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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 !== ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
|
|
||||||
|
|
||||||
const formatConfigValue = (value: unknown): string => {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value ? 'on' : 'off';
|
|
||||||
}
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return `"${truncate(value)}"`;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
if (value.length === 0) {
|
|
||||||
return $t('none');
|
|
||||||
}
|
|
||||||
const items = value.map((v) => (v !== null && typeof v === 'object' ? '{…}' : String(v)));
|
|
||||||
const joined = items.join(' · ');
|
|
||||||
if (joined.length <= 28) {
|
|
||||||
return `"${joined}"`;
|
|
||||||
}
|
|
||||||
return $t('items_count', { values: { count: value.length } });
|
|
||||||
}
|
|
||||||
return '{…}';
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="group/step-row flex w-full flex-col">
|
|
||||||
<div class="-mt-4 ml-18 flex w-full items-center gap-4">
|
|
||||||
<div class="relative flex w-1 shrink-0 justify-start">
|
|
||||||
<div class="h-10 w-0.5 bg-light-200"></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
|
|
||||||
aria-label={$t('add_step')}
|
|
||||||
title={$t('add_step')}
|
|
||||||
onclick={() => onInsertBefore(index)}
|
|
||||||
>
|
|
||||||
<Icon icon={mdiPlus} size="14" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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)}
|
|
||||||
role="listitem"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
class="shadow-none transition-colors {isDropTarget
|
|
||||||
? 'border-primary ring-2 ring-primary-200'
|
|
||||||
: isDragHandleHovered
|
|
||||||
? 'border-dashed border-primary'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
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}
|
|
||||||
title={$t('drag_to_reorder')}
|
|
||||||
>
|
|
||||||
<Icon icon={mdiDragVertical} size="20" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex size-10 shrink-0 items-center justify-center rounded-lg"
|
|
||||||
class:bg-primary-50={isFilter}
|
|
||||||
class:bg-warning-50={!isFilter}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
|
|
||||||
size="20"
|
|
||||||
class={isFilter ? 'text-primary' : 'text-warning'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
|
||||||
<CardTitle class="truncate">
|
|
||||||
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
|
|
||||||
{pluginManager.getMethodLabel(step.method)}
|
|
||||||
</CardTitle>
|
|
||||||
{#if method?.description}
|
|
||||||
<CardDescription class="truncate">{method.description}</CardDescription>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
|
||||||
<IconButton
|
|
||||||
icon={mdiPencilOutline}
|
|
||||||
aria-label={$t('edit')}
|
|
||||||
variant="ghost"
|
|
||||||
shape="round"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
onclick={() => onEdit(index)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={mdiTrashCanOutline}
|
|
||||||
aria-label={$t('delete')}
|
|
||||||
variant="ghost"
|
|
||||||
shape="round"
|
|
||||||
color="danger"
|
|
||||||
size="small"
|
|
||||||
onclick={() => onDelete(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{#if configEntries.length > 0}
|
|
||||||
<CardBody class="py-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
|
||||||
{#each configEntries as [key, value] (key)}
|
|
||||||
<Badge
|
|
||||||
color={isFilter ? 'info' : 'warning'}
|
|
||||||
shape="round"
|
|
||||||
size="small"
|
|
||||||
class="border font-mono {isFilter ? 'border-primary-200' : 'border-warning-200'}"
|
|
||||||
>
|
|
||||||
<span class="opacity-60">{key}</span>{formatConfigValue(value)}
|
|
||||||
</Badge>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Icon } from '@immich/ui';
|
|
||||||
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();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={ref}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg"
|
|
||||||
class:bg-primary-50={isFilter}
|
|
||||||
class:bg-warning-50={!isFilter}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
|
|
||||||
size="18"
|
|
||||||
class={isFilter ? 'text-primary' : 'text-warning'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
|
||||||
<span class="shrink-0 font-bold text-light-500">#{stepNumber}</span>
|
|
||||||
<span class="truncate font-bold">{label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if description}
|
|
||||||
<div class="mt-0.5 truncate text-xs/4 text-light-500">{description}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,176 +1,137 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
|
||||||
import { getTriggerName } from '$lib/utils/workflow';
|
import { getTriggerName } from '$lib/utils/workflow';
|
||||||
import type { WorkflowStepDto, WorkflowTrigger } from '@immich/sdk';
|
import type { WorkflowResponseDto } from '@immich/sdk';
|
||||||
import { Icon, IconButton, Text } from '@immich/ui';
|
import { Icon, IconButton, Text } from '@immich/ui';
|
||||||
import { mdiCheck, mdiClose, mdiContentCopy, mdiViewDashboardOutline } from '@mdi/js';
|
import { mdiClose, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
|
||||||
|
|
||||||
type WorkflowSummaryData = {
|
|
||||||
name: string | null;
|
|
||||||
description: string | null;
|
|
||||||
trigger: WorkflowTrigger;
|
|
||||||
steps: WorkflowStepDto[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workflow: WorkflowSummaryData;
|
workflow: WorkflowResponseDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { workflow }: Props = $props();
|
let { workflow }: Props = $props();
|
||||||
|
const { trigger, steps } = $derived(workflow);
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
let justCopied = $state(false);
|
let position = $state({ x: 0, y: 0 });
|
||||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
let isDragging = $state(false);
|
||||||
let panelElement = $state<HTMLElement | undefined>(undefined);
|
let dragOffset = $state({ x: 0, y: 0 });
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
$effect(() => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
if (!isOpen) {
|
if (!containerEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
isDragging = true;
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const rect = containerEl.getBoundingClientRect();
|
||||||
if (event.key === 'Escape') {
|
dragOffset = {
|
||||||
event.stopPropagation();
|
x: e.clientX - rect.left,
|
||||||
event.preventDefault();
|
y: e.clientY - rect.top,
|
||||||
isOpen = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
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) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value ? 'true' : 'false';
|
|
||||||
}
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return `"${value}"`;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
if (value.length === 0) {
|
|
||||||
return '[]';
|
|
||||||
}
|
|
||||||
return '[' + value.map((v) => (v !== null && typeof v === 'object' ? '{…}' : String(v))).join(', ') + ']';
|
|
||||||
}
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getConfigEntries = (config: WorkflowStepDto['config']) =>
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
Object.entries(config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== '');
|
if (!isDragging) {
|
||||||
|
return;
|
||||||
const asciiSummary = $derived.by(() => {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const title = workflow.name ?? $t('no_name');
|
|
||||||
lines.push(`${title}`);
|
|
||||||
if (workflow.description) {
|
|
||||||
lines.push(workflow.description);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('', ' WHEN', ` ⚡ ${getTriggerName($t, workflow.trigger)}`, '', ' THEN');
|
|
||||||
|
|
||||||
if (workflow.steps.length === 0) {
|
|
||||||
lines.push(` ${$t('no_steps')}`);
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [i, step] of workflow.steps.entries()) {
|
|
||||||
const method = pluginManager.getMethod(step.method);
|
|
||||||
const isFilter = method?.uiHints?.includes('filter') ?? false;
|
|
||||||
const type = isFilter ? $t('filter') : $t('action');
|
|
||||||
const label = pluginManager.getMethodLabel(step.method);
|
|
||||||
lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`);
|
|
||||||
for (const [key, value] of getConfigEntries(step.config)) {
|
|
||||||
lines.push(` ${key} = ${formatConfigValue(value)}`);
|
|
||||||
}
|
|
||||||
if (i < workflow.steps.length - 1) {
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(asciiSummary);
|
|
||||||
justCopied = true;
|
|
||||||
if (copyTimer) {
|
|
||||||
clearTimeout(copyTimer);
|
|
||||||
}
|
|
||||||
copyTimer = setTimeout(() => (justCopied = false), 1500);
|
|
||||||
} catch {
|
|
||||||
// ignore — clipboard may be unavailable
|
|
||||||
}
|
}
|
||||||
|
position = {
|
||||||
|
x: e.clientX - dragOffset.x,
|
||||||
|
y: e.clientY - dragOffset.y,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
isDragging = false;
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Initialize position to bottom-right on mount
|
||||||
|
if (globalThis.window && position.x === 0 && position.y === 0) {
|
||||||
|
position = {
|
||||||
|
x: globalThis.innerWidth - 280,
|
||||||
|
y: globalThis.innerHeight - 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<aside
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
bind:this={panelElement}
|
<div
|
||||||
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"
|
bind:this={containerEl}
|
||||||
transition:fly={{ x: 400, duration: 250 }}
|
class="fixed hidden w-64 select-none hover:cursor-grab sm:block"
|
||||||
aria-label={$t('workflow_summary')}
|
style="left: {position.x}px; top: {position.y}px;"
|
||||||
|
class:cursor-grabbing={isDragging}
|
||||||
|
onmousedown={handleMouseDown}
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<div
|
||||||
<div class="flex shrink-0 items-center justify-between border-b border-light-200 px-4 py-2.5">
|
class="rounded-xl border-2 border-transparent bg-light-50 p-4 shadow-sm transition-all hover:border-dashed hover:border-light-300 hover:shadow-xl"
|
||||||
<Text size="small" fontWeight="semi-bold" color="muted">{$t('workflow_summary')}</Text>
|
>
|
||||||
<div class="flex items-center gap-1">
|
<div class="mb-4 flex cursor-grab items-center justify-between select-none">
|
||||||
<IconButton
|
<Text size="small" fontWeight="semi-bold">{$t('workflow_summary')}</Text>
|
||||||
icon={justCopied ? mdiCheck : mdiContentCopy}
|
<div class="flex items-center gap-1">
|
||||||
size="small"
|
<IconButton
|
||||||
variant="ghost"
|
icon={mdiClose}
|
||||||
color={justCopied ? 'success' : 'secondary'}
|
size="small"
|
||||||
title={$t('copy_to_clipboard')}
|
variant="ghost"
|
||||||
aria-label={$t('copy_to_clipboard')}
|
color="secondary"
|
||||||
onclick={handleCopy}
|
title="Close summary"
|
||||||
/>
|
aria-label="Close summary"
|
||||||
<IconButton
|
onclick={(e: MouseEvent) => {
|
||||||
icon={mdiClose}
|
e.stopPropagation();
|
||||||
size="small"
|
isOpen = false;
|
||||||
variant="ghost"
|
}}
|
||||||
color="secondary"
|
/>
|
||||||
title="Close summary"
|
</div>
|
||||||
aria-label="Close summary"
|
</div>
|
||||||
onclick={() => (isOpen = false)}
|
|
||||||
/>
|
<div class="space-y-2">
|
||||||
|
<!-- Trigger -->
|
||||||
|
<div class="rounded-lg border bg-light-100 p-3">
|
||||||
|
<div class="mb-1 flex items-center gap-2">
|
||||||
|
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
|
||||||
|
<Text size="tiny" fontWeight="semi-bold">{$t('trigger')}</Text>
|
||||||
|
</div>
|
||||||
|
<p class="truncate pl-5 text-sm">{getTriggerName($t, trigger)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connector -->
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="h-3 w-0.5 bg-light-400"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
{#if steps.length > 0}
|
||||||
|
<div class="rounded-lg border bg-light-100 p-3">
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
|
||||||
|
<Text size="tiny" fontWeight="semi-bold">{$t('actions')}</Text>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 pl-5">
|
||||||
|
{#each steps as step, index (index)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="flex size-4 shrink-0 items-center justify-center rounded-full bg-light-200 text-[10px] font-medium"
|
||||||
|
>{index + 1}</span
|
||||||
|
>
|
||||||
|
<p class="truncate text-sm">{step.method}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- ASCII body — what you see is what you copy -->
|
|
||||||
<div class="flex-1 overflow-auto p-4">
|
|
||||||
<pre
|
|
||||||
class="m-0 overflow-auto rounded-lg border border-light-200 bg-light-100 px-4 py-3 font-mono text-xs/relaxed whitespace-pre">{asciiSummary}</pre>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="fixed right-6 bottom-6 hidden size-14 items-center justify-center rounded-full bg-primary text-light shadow-lg transition-colors hover:bg-primary/90 sm:flex"
|
class="fixed right-6 bottom-6 hidden size-14 items-center justify-center rounded-full bg-primary text-light shadow-lg transition-colors hover:bg-primary/90 sm:flex"
|
||||||
title={$t('workflow_summary')}
|
title={$t('workflow_summary')}
|
||||||
aria-label={$t('workflow_summary')}
|
|
||||||
onclick={() => (isOpen = true)}
|
onclick={() => (isOpen = true)}
|
||||||
>
|
>
|
||||||
<Icon icon={mdiViewDashboardOutline} size="24" />
|
<Icon icon={mdiViewDashboardOutline} size="24" />
|
||||||
|
|||||||
Reference in New Issue
Block a user