mirror of
https://github.com/immich-app/immich.git
synced 2026-07-23 05:44:48 +03:00
Compare commits
21 Commits
feat/workf
...
v3.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd308ad930 | ||
|
|
12061f3bf8 | ||
|
|
2460d431af | ||
|
|
24556cd3fc | ||
|
|
c84ab54889 | ||
|
|
19e59ebd98 | ||
|
|
89b0d906e8 | ||
|
|
3c43e8d6c1 | ||
|
|
2ed8b2bddd | ||
|
|
9c8d718ddc | ||
|
|
20e123c839 | ||
|
|
35fcca6254 | ||
|
|
ffafc144c6 | ||
|
|
89d31aaba1 | ||
|
|
f2ddace584 | ||
|
|
99883096d6 | ||
|
|
84dff19ca9 | ||
|
|
da8774801b | ||
|
|
9057ae9759 | ||
|
|
557189d7a8 | ||
|
|
19313e75fd |
4
.github/workflows/build-mobile.yml
vendored
4
.github/workflows/build-mobile.yml
vendored
@@ -103,7 +103,7 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
run: flutter build ios --config-only --no-codesign
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.315.0
|
||||
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
|
||||
2
.github/workflows/check-openapi.yml
vendored
2
.github/workflows/check-openapi.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@ae5ef6402ebe218ac6e872c59d0fa4557694e75c # v0.1.4
|
||||
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
2
.github/workflows/cli.yml
vendored
2
.github/workflows/cli.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
|
||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
needs: [get_body, should_run]
|
||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||
container:
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:2a64c7f045cb7b580fbdf3614d7d1805f5775fec453e3d1023764180efa8c70b
|
||||
image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
|
||||
outputs:
|
||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||
steps:
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# 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).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
|
||||
# ℹ️ 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
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
suffix: ['']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
56
.github/workflows/fdroid.yml
vendored
Normal file
56
.github/workflows/fdroid.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Update F-Droid Repo
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
update-index:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout pubspec for versionCode
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: mobile/pubspec.yaml
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Update F-Droid repo
|
||||
env:
|
||||
GITLAB_TOKEN: ${{ secrets.FDROID_REPO_TOKEN }}
|
||||
RELEASE: ${{ toJSON(github.event.release) }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
VERSION_CODE=$(yq '.version' mobile/pubspec.yaml | cut -d+ -f2)
|
||||
export VERSION_CODE
|
||||
|
||||
NEW_ENTRY=$(yq -P '
|
||||
[.assets[] | select(.name == "app-release.apk")] as $matches |
|
||||
with(select($matches | length == 0); error("app-release.apk not found in release assets")) |
|
||||
{
|
||||
"url": $matches[0].browser_download_url,
|
||||
"sha256sum": ($matches[0].digest | sub("^sha256:", "")),
|
||||
"date": (.published_at | split("T") | .[0]),
|
||||
"version-code": env(VERSION_CODE)
|
||||
} | .date tag= "!!timestamp"
|
||||
' <<< "$RELEASE")
|
||||
export NEW_ENTRY
|
||||
|
||||
git clone --depth 1 "https://oauth2:${GITLAB_TOKEN}@gitlab.futo.org/fdroid/repo-v2.git" fdroid-repo
|
||||
cd fdroid-repo
|
||||
|
||||
yq -i '
|
||||
with(select([.apks[] | select(."version-code" == env(VERSION_CODE))] | length > 0); error("version-code already in index")) |
|
||||
.apks = [env(NEW_ENTRY)] + .apks
|
||||
' apps/Immich/index.yml
|
||||
|
||||
git config user.name "Immich Release Bot"
|
||||
git config user.email "bot@immich.app"
|
||||
git commit -am "Immich: add version-code ${VERSION_CODE} (${TAG})"
|
||||
git push
|
||||
2
.github/workflows/pr-label-validation.yml
vendored
2
.github/workflows/pr-label-validation.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Require PR to have a changelog label
|
||||
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
|
||||
uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5.6.0
|
||||
with:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
mode: exactly
|
||||
|
||||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
@@ -17,6 +17,6 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/prepare-release.yml
vendored
2
.github/workflows/prepare-release.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
# TODO move to mise
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- name: Bump version
|
||||
env:
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
/web/ @danieldietzler
|
||||
/machine-learning/ @mertalev
|
||||
/e2e/ @danieldietzler
|
||||
/mobile/ @shenlong-tanwen @santoshakil
|
||||
/mobile/ @shenlong-tanwen @santoshakil @agg23
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# System Integrity
|
||||
|
||||
## Integrity report
|
||||
|
||||
At a [customizable interval](https://my.immich.app/admin/system-settings?isOpen=integrity-checks), Immich runs integrity checks to ensure that your library is still intact and there are no corrupt files.
|
||||
There are three kind of issues Immich checks for:
|
||||
|
||||
- Untracked files: the path was found in Immich's directories but it is not referenced in Immich's database
|
||||
- Missing files: the path is found in the Immich internal database, but does not actually exist on disk
|
||||
- Checksum mismatches: the checksum of the file stored in Immich's database does not match the actual file's checksum anymore
|
||||
|
||||
All three run nightly at 3am by default. For the "Checksum files" check, there are additional time and progress limits, as those are the most taxing checks. With these additional limits, Immich can slowly check checksums of your files over the course of a couple of days.
|
||||
|
||||
You can see the results of these checks on the [maintenance](https://my.immich.app/admin/maintenance) page. Here, you can also trigger a full scan (a _check_) for specific jobs, or all of them. In addition, you can also _refresh_ checks. This will only look at items that have currently been reported on, and check if those have been fixed.
|
||||
|
||||
### Common causes
|
||||
|
||||
Most common are untracked files. In many cases those are corrupted thumbnails or encoded videos that have been partially generated at some point and never got cleaned up properly. These are usually fine to delete, as both can always be regenerated at a later point. Other files will need to be investigated on a case-by-case basis by checking they already exist in Immich and thinking about how they might have gotten untracked.
|
||||
|
||||
:::info
|
||||
You might want to run the _missing_ jobs for thumbnails and encoded videos (https://my.immich.app/admin/queues) to make sure all your assets have proper thumbnails and encoded videos. Watch the server logs while running the jobs, in case there are any issues with some assets.
|
||||
:::
|
||||
|
||||
Missing files are files where Immich references them internally, but they don't actually exist on disk in that location. It could be that you deleted a file on disk from the internal library (don't do that, Immich doesn't support it). It could also be that there are issues with your file storage. Please carefully investigate missing files, and never hesitate to reach out on [our Discord](https://discord.immich.app) if you have any questions!
|
||||
|
||||
Checksum mismatches are often indicative of file system corruption. It could also be that you previously edited a file from the internal library on the disk, which is also not supported and will cause a checksum mismatch. Again, the recommended action is to look at any reported item individually, check it out, try to remember if you changed it or some metadata on it at some point. If you edited the file, the supported resolution is to delete the mismatched asset from Immich and reupload it as a new asset.
|
||||
|
||||
## Folder checks
|
||||
|
||||
:::info
|
||||
@@ -40,7 +65,7 @@ The above error messages show that the server has previously (successfully) writ
|
||||
|
||||
### Ignoring the checks
|
||||
|
||||
:::warning
|
||||
:::danger
|
||||
The checks are designed to catch common problems that we have seen users have in the past, and often indicate there's something wrong that you should solve. If you know what you're doing and you want to disable them you can set the following environment variable:
|
||||
:::
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
The mobile app can be downloaded from the following places:
|
||||
|
||||
- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities).
|
||||
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||
- [Apple App Store](https://apps.apple.com/us/app/immich/id1613945652)
|
||||
- [F-Droid](https://f-droid.org/packages/app.alextran.immich)
|
||||
- [GitHub Releases (apk)](https://github.com/immich-app/immich/releases)
|
||||
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||
- [GitHub Releases (APK)](https://github.com/immich-app/immich/releases)
|
||||
- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities).
|
||||
- [F-Droid](https://app.futo.org/fdroid/repo/)
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v3.0.2",
|
||||
"url": "https://docs.v3.0.2.archive.immich.app"
|
||||
"label": "v3.0.3",
|
||||
"url": "https://docs.v3.0.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.7.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -796,5 +796,22 @@ describe('/albums', () => {
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.share access'));
|
||||
});
|
||||
|
||||
it('should not allow an editor to change the role of an owner', async () => {
|
||||
const album = await utils.createAlbum(user1.accessToken, {
|
||||
albumName: 'testAlbum',
|
||||
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
|
||||
});
|
||||
|
||||
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Editor);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/albums/${album.id}/user/${user1.userId}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ role: AlbumUserRole.Editor });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('User is owner'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
10
i18n/en.json
10
i18n/en.json
@@ -1621,7 +1621,7 @@
|
||||
"person": "Person",
|
||||
"person_age_months": "{months, plural, one {# month} other {# months}} old",
|
||||
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
|
||||
"person_age_years": "{years, plural, other {# years}} old",
|
||||
"person_age_years": "{years, plural, one {# year} other {# years}} old",
|
||||
"person_birthdate": "Born on {date}",
|
||||
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
||||
"person_recognized": "Person recognized",
|
||||
@@ -1787,7 +1787,6 @@
|
||||
"restore_all": "Restore all",
|
||||
"restore_user": "Restore user",
|
||||
"restored_asset": "Restored asset",
|
||||
"result": "Result",
|
||||
"resume": "Resume",
|
||||
"resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}",
|
||||
"review_duplicates": "Review duplicates",
|
||||
@@ -2274,13 +2273,6 @@
|
||||
"workflow_info": "Workflow info",
|
||||
"workflow_json": "Workflow JSON",
|
||||
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
|
||||
"workflow_logging_completed": "Completed",
|
||||
"workflow_logging_disable": "Disable logging",
|
||||
"workflow_logging_disabled_description": "Logging is currently disabled for this workflow.",
|
||||
"workflow_logging_enable": "Enable logging",
|
||||
"workflow_logging_error_step": "Error on step #{step}",
|
||||
"workflow_logging_halted": "Halted",
|
||||
"workflow_logging_halted_step": "Halted on step #{step}",
|
||||
"workflow_name": "Workflow name",
|
||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
||||
"workflow_summary": "Workflow summary",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "3.0.2"
|
||||
version = "3.0.3"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
2
machine-learning/uv.lock
generated
2
machine-learning/uv.lock
generated
@@ -974,7 +974,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "3.0.2"
|
||||
version = "3.0.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -22,8 +22,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3055,
|
||||
"android.injected.version.name" => "3.0.2",
|
||||
"android.injected.version.code" => 3056,
|
||||
"android.injected.version.name" => "3.0.3",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3055,
|
||||
"android.injected.version.name" => "3.0.2",
|
||||
"android.injected.version.code" => 3056,
|
||||
"android.injected.version.name" => "3.0.3",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.0.2</string>
|
||||
<string>3.0.3</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -26,7 +26,6 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
const String kShareDownloadGroup = 'group_share';
|
||||
|
||||
// Timeline constants
|
||||
const int kTimelineNoneSegmentSize = 120;
|
||||
const int kTimelineAssetLoadBatchSize = 1024;
|
||||
const int kTimelineAssetLoadOppositeSize = 64;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
@@ -685,16 +684,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
List<Bucket> _generateBuckets(int count) {
|
||||
final buckets = List.filled(
|
||||
(count / kTimelineNoneSegmentSize).ceil(),
|
||||
const Bucket(assetCount: kTimelineNoneSegmentSize),
|
||||
);
|
||||
if (count % kTimelineNoneSegmentSize != 0) {
|
||||
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
List<Bucket> _generateBuckets(int count) => count == 0 ? const [] : [Bucket(assetCount: count)];
|
||||
|
||||
extension on Expression<DateTime> {
|
||||
Expression<String> dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) {
|
||||
|
||||
@@ -99,6 +99,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
case TimelineReloadEvent():
|
||||
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
if (asset != _asset) {
|
||||
_isZoomed = false;
|
||||
_viewer.setZoomed(false);
|
||||
setState(() => _asset = asset);
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -81,6 +81,12 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
static const _viewerOverlayStyle = SystemUiOverlayStyle(
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
);
|
||||
|
||||
late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
late final _pageController = PageController(initialPage: widget.initialIndex);
|
||||
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||
@@ -280,49 +286,52 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
_setSystemUIMode(controls, details);
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: IgnorePointer(
|
||||
ignoring: !showingControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showingControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollEndNotification>(
|
||||
onNotification: _onScrollEnd,
|
||||
child: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: isZoomed
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: _totalAssets,
|
||||
itemBuilder: (context, index) =>
|
||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||
),
|
||||
),
|
||||
return AnnotatedRegion(
|
||||
value: _viewerOverlayStyle,
|
||||
child: Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: IgnorePointer(
|
||||
ignoring: !showingControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showingControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollEndNotification>(
|
||||
onNotification: _onScrollEnd,
|
||||
child: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: isZoomed
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: _totalAssets,
|
||||
itemBuilder: (context, index) =>
|
||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,6 +317,8 @@ class BackgroundUploadService {
|
||||
priority: priority,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: requiresWiFi,
|
||||
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
|
||||
fields: entity.isLivePhoto ? {'visibility': api.AssetVisibility.hidden.value} : null,
|
||||
cloudId: entity.isLivePhoto ? null : asset.cloudId,
|
||||
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
|
||||
@@ -336,8 +338,7 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
|
||||
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
|
||||
final fields = {'livePhotoVideoId': livePhotoVideoId};
|
||||
|
||||
final requiresWiFi = _shouldRequireWiFi(asset);
|
||||
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
|
||||
@@ -100,7 +101,7 @@ class ForegroundUploadService {
|
||||
final requireWifi = _shouldRequireWiFi(asset);
|
||||
return requireWifi && !hasWifi;
|
||||
},
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -126,7 +127,7 @@ class ForegroundUploadService {
|
||||
continue;
|
||||
}
|
||||
|
||||
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
|
||||
await uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ class ForegroundUploadService {
|
||||
await _executeWithWorkerPool<LocalAsset>(
|
||||
items: localAssets,
|
||||
cancelToken: cancelToken,
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,7 +234,8 @@ class ForegroundUploadService {
|
||||
await Future.wait(workerFutures);
|
||||
}
|
||||
|
||||
Future<void> _uploadSingleAsset(
|
||||
@visibleForTesting
|
||||
Future<void> uploadSingleAsset(
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
|
||||
@@ -41,7 +41,6 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'SyncAssetV1': {'isEdited': false},
|
||||
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
|
||||
'MemoriesResponse': {'duration': 5},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
};
|
||||
|
||||
void upgradeDto(dynamic value, String targetType) {
|
||||
|
||||
@@ -393,10 +393,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _zoomAnimation.value,
|
||||
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
|
||||
child: Transform.translate(
|
||||
offset: _panAnimation.value,
|
||||
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
|
||||
@@ -496,10 +496,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _zoomAnimation.value,
|
||||
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
|
||||
child: Transform.translate(
|
||||
offset: _panAnimation.value,
|
||||
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
|
||||
@@ -472,10 +472,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _zoomAnimation.value,
|
||||
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
|
||||
child: Transform.translate(
|
||||
offset: _panAnimation.value,
|
||||
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
|
||||
6
mobile/openapi/README.md
generated
6
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 3.0.2
|
||||
- API version: 3.0.3
|
||||
- Generator version: 7.22.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
@@ -341,7 +341,6 @@ Class | Method | HTTP request | Description
|
||||
*WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow
|
||||
*WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow
|
||||
*WorkflowsApi* | [**getWorkflowForShare**](doc//WorkflowsApi.md#getworkflowforshare) | **GET** /workflows/{id}/share | Retrieve a workflow
|
||||
*WorkflowsApi* | [**getWorkflowLogs**](doc//WorkflowsApi.md#getworkflowlogs) | **GET** /workflows/{id}/logs | Retrieve a workflow
|
||||
*WorkflowsApi* | [**getWorkflowTriggers**](doc//WorkflowsApi.md#getworkflowtriggers) | **GET** /workflows/triggers | List all workflow triggers
|
||||
*WorkflowsApi* | [**searchWorkflows**](doc//WorkflowsApi.md#searchworkflows) | **GET** /workflows | List all workflows
|
||||
*WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow
|
||||
@@ -712,10 +711,7 @@ Class | Method | HTTP request | Description
|
||||
- [VideoCodec](doc//VideoCodec.md)
|
||||
- [VideoContainer](doc//VideoContainer.md)
|
||||
- [WorkflowCreateDto](doc//WorkflowCreateDto.md)
|
||||
- [WorkflowLogEntryDto](doc//WorkflowLogEntryDto.md)
|
||||
- [WorkflowLogEntryDtoLastStep](doc//WorkflowLogEntryDtoLastStep.md)
|
||||
- [WorkflowResponseDto](doc//WorkflowResponseDto.md)
|
||||
- [WorkflowResult](doc//WorkflowResult.md)
|
||||
- [WorkflowShareResponseDto](doc//WorkflowShareResponseDto.md)
|
||||
- [WorkflowShareStepDto](doc//WorkflowShareStepDto.md)
|
||||
- [WorkflowStepDto](doc//WorkflowStepDto.md)
|
||||
|
||||
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@@ -432,10 +432,7 @@ part 'model/version_check_state_response_dto.dart';
|
||||
part 'model/video_codec.dart';
|
||||
part 'model/video_container.dart';
|
||||
part 'model/workflow_create_dto.dart';
|
||||
part 'model/workflow_log_entry_dto.dart';
|
||||
part 'model/workflow_log_entry_dto_last_step.dart';
|
||||
part 'model/workflow_response_dto.dart';
|
||||
part 'model/workflow_result.dart';
|
||||
part 'model/workflow_share_response_dto.dart';
|
||||
part 'model/workflow_share_step_dto.dart';
|
||||
part 'model/workflow_step_dto.dart';
|
||||
|
||||
104
mobile/openapi/lib/api/workflows_api.dart
generated
104
mobile/openapi/lib/api/workflows_api.dart
generated
@@ -239,95 +239,6 @@ class WorkflowsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve a workflow
|
||||
///
|
||||
/// Retrieve a workflow details without ids, default values, etc.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [DateTime] before:
|
||||
/// Filter by runs before a date/time
|
||||
///
|
||||
/// * [int] limit:
|
||||
/// Maximum number of logs
|
||||
///
|
||||
/// * [WorkflowResult] result:
|
||||
/// Filter by run result
|
||||
Future<Response> getWorkflowLogsWithHttpInfo(String id, { DateTime? before, int? limit, WorkflowResult? result, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows/{id}/logs'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (before != null) {
|
||||
queryParams.addAll(_queryParams('', 'before', before));
|
||||
}
|
||||
if (limit != null) {
|
||||
queryParams.addAll(_queryParams('', 'limit', limit));
|
||||
}
|
||||
if (result != null) {
|
||||
queryParams.addAll(_queryParams('', 'result', result));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve a workflow
|
||||
///
|
||||
/// Retrieve a workflow details without ids, default values, etc.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [DateTime] before:
|
||||
/// Filter by runs before a date/time
|
||||
///
|
||||
/// * [int] limit:
|
||||
/// Maximum number of logs
|
||||
///
|
||||
/// * [WorkflowResult] result:
|
||||
/// Filter by run result
|
||||
Future<List<WorkflowLogEntryDto>?> getWorkflowLogs(String id, { DateTime? before, int? limit, WorkflowResult? result, Future<void>? abortTrigger, }) async {
|
||||
final response = await getWorkflowLogsWithHttpInfo(id, before: before, limit: limit, result: result, abortTrigger: abortTrigger,);
|
||||
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<WorkflowLogEntryDto>') as List)
|
||||
.cast<WorkflowLogEntryDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all workflow triggers
|
||||
///
|
||||
/// Retrieve a list of all available workflow triggers.
|
||||
@@ -397,15 +308,12 @@ class WorkflowsApi {
|
||||
/// * [String] id:
|
||||
/// Workflow ID
|
||||
///
|
||||
/// * [bool] logging:
|
||||
/// Workflow logs run results
|
||||
///
|
||||
/// * [String] name:
|
||||
/// Workflow name
|
||||
///
|
||||
/// * [WorkflowTrigger] trigger:
|
||||
/// Workflow trigger type
|
||||
Future<Response> searchWorkflowsWithHttpInfo({ String? description, bool? enabled, String? id, bool? logging, String? name, WorkflowTrigger? trigger, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> searchWorkflowsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows';
|
||||
|
||||
@@ -425,9 +333,6 @@ class WorkflowsApi {
|
||||
if (id != null) {
|
||||
queryParams.addAll(_queryParams('', 'id', id));
|
||||
}
|
||||
if (logging != null) {
|
||||
queryParams.addAll(_queryParams('', 'logging', logging));
|
||||
}
|
||||
if (name != null) {
|
||||
queryParams.addAll(_queryParams('', 'name', name));
|
||||
}
|
||||
@@ -465,16 +370,13 @@ class WorkflowsApi {
|
||||
/// * [String] id:
|
||||
/// Workflow ID
|
||||
///
|
||||
/// * [bool] logging:
|
||||
/// Workflow logs run results
|
||||
///
|
||||
/// * [String] name:
|
||||
/// Workflow name
|
||||
///
|
||||
/// * [WorkflowTrigger] trigger:
|
||||
/// Workflow trigger type
|
||||
Future<List<WorkflowResponseDto>?> searchWorkflows({ String? description, bool? enabled, String? id, bool? logging, String? name, WorkflowTrigger? trigger, Future<void>? abortTrigger, }) async {
|
||||
final response = await searchWorkflowsWithHttpInfo(description: description, enabled: enabled, id: id, logging: logging, name: name, trigger: trigger, abortTrigger: abortTrigger,);
|
||||
Future<List<WorkflowResponseDto>?> searchWorkflows({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, Future<void>? abortTrigger, }) async {
|
||||
final response = await searchWorkflowsWithHttpInfo(description: description, enabled: enabled, id: id, name: name, trigger: trigger, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
6
mobile/openapi/lib/api_client.dart
generated
6
mobile/openapi/lib/api_client.dart
generated
@@ -909,14 +909,8 @@ class ApiClient {
|
||||
return VideoContainerTypeTransformer().decode(value);
|
||||
case 'WorkflowCreateDto':
|
||||
return WorkflowCreateDto.fromJson(value);
|
||||
case 'WorkflowLogEntryDto':
|
||||
return WorkflowLogEntryDto.fromJson(value);
|
||||
case 'WorkflowLogEntryDtoLastStep':
|
||||
return WorkflowLogEntryDtoLastStep.fromJson(value);
|
||||
case 'WorkflowResponseDto':
|
||||
return WorkflowResponseDto.fromJson(value);
|
||||
case 'WorkflowResult':
|
||||
return WorkflowResultTypeTransformer().decode(value);
|
||||
case 'WorkflowShareResponseDto':
|
||||
return WorkflowShareResponseDto.fromJson(value);
|
||||
case 'WorkflowShareStepDto':
|
||||
|
||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@@ -214,9 +214,6 @@ String parameterToString(dynamic value) {
|
||||
if (value is VideoContainer) {
|
||||
return VideoContainerTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is WorkflowResult) {
|
||||
return WorkflowResultTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is WorkflowTrigger) {
|
||||
return WorkflowTriggerTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
3
mobile/openapi/lib/model/permission.dart
generated
3
mobile/openapi/lib/model/permission.dart
generated
@@ -172,7 +172,6 @@ class Permission {
|
||||
static const workflowPeriodRead = Permission._(r'workflow.read');
|
||||
static const workflowPeriodUpdate = Permission._(r'workflow.update');
|
||||
static const workflowPeriodDelete = Permission._(r'workflow.delete');
|
||||
static const workflowPeriodLogs = Permission._(r'workflow.logs');
|
||||
static const adminUserPeriodCreate = Permission._(r'adminUser.create');
|
||||
static const adminUserPeriodRead = Permission._(r'adminUser.read');
|
||||
static const adminUserPeriodUpdate = Permission._(r'adminUser.update');
|
||||
@@ -331,7 +330,6 @@ class Permission {
|
||||
workflowPeriodRead,
|
||||
workflowPeriodUpdate,
|
||||
workflowPeriodDelete,
|
||||
workflowPeriodLogs,
|
||||
adminUserPeriodCreate,
|
||||
adminUserPeriodRead,
|
||||
adminUserPeriodUpdate,
|
||||
@@ -525,7 +523,6 @@ class PermissionTypeTransformer {
|
||||
case r'workflow.read': return Permission.workflowPeriodRead;
|
||||
case r'workflow.update': return Permission.workflowPeriodUpdate;
|
||||
case r'workflow.delete': return Permission.workflowPeriodDelete;
|
||||
case r'workflow.logs': return Permission.workflowPeriodLogs;
|
||||
case r'adminUser.create': return Permission.adminUserPeriodCreate;
|
||||
case r'adminUser.read': return Permission.adminUserPeriodRead;
|
||||
case r'adminUser.update': return Permission.adminUserPeriodUpdate;
|
||||
|
||||
19
mobile/openapi/lib/model/workflow_create_dto.dart
generated
19
mobile/openapi/lib/model/workflow_create_dto.dart
generated
@@ -15,7 +15,6 @@ class WorkflowCreateDto {
|
||||
WorkflowCreateDto({
|
||||
this.description = const Optional.absent(),
|
||||
this.enabled = const Optional.absent(),
|
||||
this.logging = const Optional.absent(),
|
||||
this.name = const Optional.absent(),
|
||||
this.steps = const Optional.present(const []),
|
||||
required this.trigger,
|
||||
@@ -33,15 +32,6 @@ class WorkflowCreateDto {
|
||||
///
|
||||
Optional<bool?> enabled;
|
||||
|
||||
/// Workflow logs run results
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
Optional<bool?> logging;
|
||||
|
||||
/// Workflow name
|
||||
Optional<String?> name;
|
||||
|
||||
@@ -53,7 +43,6 @@ class WorkflowCreateDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowCreateDto &&
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
other.logging == logging &&
|
||||
other.name == name &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.trigger == trigger;
|
||||
@@ -63,13 +52,12 @@ class WorkflowCreateDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(logging == null ? 0 : logging!.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(trigger.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowCreateDto[description=$description, enabled=$enabled, logging=$logging, name=$name, steps=$steps, trigger=$trigger]';
|
||||
String toString() => 'WorkflowCreateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -81,10 +69,6 @@ class WorkflowCreateDto {
|
||||
final value = this.enabled.value;
|
||||
json[r'enabled'] = value;
|
||||
}
|
||||
if (this.logging.isPresent) {
|
||||
final value = this.logging.value;
|
||||
json[r'logging'] = value;
|
||||
}
|
||||
if (this.name.isPresent) {
|
||||
final value = this.name.value;
|
||||
json[r'name'] = value;
|
||||
@@ -108,7 +92,6 @@ class WorkflowCreateDto {
|
||||
return WorkflowCreateDto(
|
||||
description: json.containsKey(r'description') ? Optional.present(mapValueOfType<String>(json, r'description')) : const Optional.absent(),
|
||||
enabled: json.containsKey(r'enabled') ? Optional.present(mapValueOfType<bool>(json, r'enabled')) : const Optional.absent(),
|
||||
logging: json.containsKey(r'logging') ? Optional.present(mapValueOfType<bool>(json, r'logging')) : const Optional.absent(),
|
||||
name: json.containsKey(r'name') ? Optional.present(mapValueOfType<String>(json, r'name')) : const Optional.absent(),
|
||||
steps: json.containsKey(r'steps') ? Optional.present(WorkflowStepDto.listFromJson(json[r'steps'])) : const Optional.absent(),
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
|
||||
150
mobile/openapi/lib/model/workflow_log_entry_dto.dart
generated
150
mobile/openapi/lib/model/workflow_log_entry_dto.dart
generated
@@ -1,150 +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 WorkflowLogEntryDto {
|
||||
/// Returns a new [WorkflowLogEntryDto] instance.
|
||||
WorkflowLogEntryDto({
|
||||
required this.at,
|
||||
required this.id,
|
||||
this.lastStep = const Optional.absent(),
|
||||
required this.result,
|
||||
this.triggerDataId = const Optional.absent(),
|
||||
});
|
||||
|
||||
/// Workflow run date/time
|
||||
String at;
|
||||
|
||||
/// Workflow log entry ID
|
||||
String id;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
Optional<WorkflowLogEntryDtoLastStep?> lastStep;
|
||||
|
||||
WorkflowResult result;
|
||||
|
||||
/// Workflow trigger data ID
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
Optional<String?> triggerDataId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowLogEntryDto &&
|
||||
other.at == at &&
|
||||
other.id == id &&
|
||||
other.lastStep == lastStep &&
|
||||
other.result == result &&
|
||||
other.triggerDataId == triggerDataId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(at.hashCode) +
|
||||
(id.hashCode) +
|
||||
(lastStep == null ? 0 : lastStep!.hashCode) +
|
||||
(result.hashCode) +
|
||||
(triggerDataId == null ? 0 : triggerDataId!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowLogEntryDto[at=$at, id=$id, lastStep=$lastStep, result=$result, triggerDataId=$triggerDataId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'at'] = this.at;
|
||||
json[r'id'] = this.id;
|
||||
if (this.lastStep.isPresent) {
|
||||
final value = this.lastStep.value;
|
||||
json[r'lastStep'] = value;
|
||||
}
|
||||
json[r'result'] = this.result;
|
||||
if (this.triggerDataId.isPresent) {
|
||||
final value = this.triggerDataId.value;
|
||||
json[r'triggerDataId'] = value;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowLogEntryDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowLogEntryDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowLogEntryDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowLogEntryDto(
|
||||
at: mapValueOfType<String>(json, r'at')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
lastStep: json.containsKey(r'lastStep') ? Optional.present(WorkflowLogEntryDtoLastStep.fromJson(json[r'lastStep'])) : const Optional.absent(),
|
||||
result: WorkflowResult.fromJson(json[r'result'])!,
|
||||
triggerDataId: json.containsKey(r'triggerDataId') ? Optional.present(mapValueOfType<String>(json, r'triggerDataId')) : const Optional.absent(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowLogEntryDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowLogEntryDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowLogEntryDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowLogEntryDto> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowLogEntryDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowLogEntryDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowLogEntryDto-objects as value to a dart map
|
||||
static Map<String, List<WorkflowLogEntryDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowLogEntryDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowLogEntryDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'at',
|
||||
'id',
|
||||
'result',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,112 +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 WorkflowLogEntryDtoLastStep {
|
||||
/// Returns a new [WorkflowLogEntryDtoLastStep] instance.
|
||||
WorkflowLogEntryDtoLastStep({
|
||||
required this.index,
|
||||
required this.method,
|
||||
});
|
||||
|
||||
/// Index of the step in the workflow
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int index;
|
||||
|
||||
/// Method of the step
|
||||
String method;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowLogEntryDtoLastStep &&
|
||||
other.index == index &&
|
||||
other.method == method;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(index.hashCode) +
|
||||
(method.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowLogEntryDtoLastStep[index=$index, method=$method]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'index'] = this.index;
|
||||
json[r'method'] = this.method;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowLogEntryDtoLastStep] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowLogEntryDtoLastStep? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowLogEntryDtoLastStep");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowLogEntryDtoLastStep(
|
||||
index: mapValueOfType<int>(json, r'index')!,
|
||||
method: mapValueOfType<String>(json, r'method')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowLogEntryDtoLastStep> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowLogEntryDtoLastStep>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowLogEntryDtoLastStep.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowLogEntryDtoLastStep> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowLogEntryDtoLastStep>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowLogEntryDtoLastStep.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowLogEntryDtoLastStep-objects as value to a dart map
|
||||
static Map<String, List<WorkflowLogEntryDtoLastStep>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowLogEntryDtoLastStep>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowLogEntryDtoLastStep.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'index',
|
||||
'method',
|
||||
};
|
||||
}
|
||||
|
||||
11
mobile/openapi/lib/model/workflow_response_dto.dart
generated
11
mobile/openapi/lib/model/workflow_response_dto.dart
generated
@@ -17,7 +17,6 @@ class WorkflowResponseDto {
|
||||
required this.description,
|
||||
required this.enabled,
|
||||
required this.id,
|
||||
required this.logging,
|
||||
required this.name,
|
||||
this.steps = const [],
|
||||
required this.trigger,
|
||||
@@ -36,9 +35,6 @@ class WorkflowResponseDto {
|
||||
/// Workflow ID
|
||||
String id;
|
||||
|
||||
/// Workflow logs run results
|
||||
bool logging;
|
||||
|
||||
/// Workflow name
|
||||
String? name;
|
||||
|
||||
@@ -56,7 +52,6 @@ class WorkflowResponseDto {
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
other.id == id &&
|
||||
other.logging == logging &&
|
||||
other.name == name &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.trigger == trigger &&
|
||||
@@ -69,14 +64,13 @@ class WorkflowResponseDto {
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled.hashCode) +
|
||||
(id.hashCode) +
|
||||
(logging.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(trigger.hashCode) +
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowResponseDto[createdAt=$createdAt, description=$description, enabled=$enabled, id=$id, logging=$logging, name=$name, steps=$steps, trigger=$trigger, updatedAt=$updatedAt]';
|
||||
String toString() => 'WorkflowResponseDto[createdAt=$createdAt, description=$description, enabled=$enabled, id=$id, name=$name, steps=$steps, trigger=$trigger, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -88,7 +82,6 @@ class WorkflowResponseDto {
|
||||
}
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'id'] = this.id;
|
||||
json[r'logging'] = this.logging;
|
||||
if (this.name != null) {
|
||||
json[r'name'] = this.name;
|
||||
} else {
|
||||
@@ -113,7 +106,6 @@ class WorkflowResponseDto {
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
logging: mapValueOfType<bool>(json, r'logging')!,
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
steps: WorkflowStepDto.listFromJson(json[r'steps']),
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
@@ -169,7 +161,6 @@ class WorkflowResponseDto {
|
||||
'description',
|
||||
'enabled',
|
||||
'id',
|
||||
'logging',
|
||||
'name',
|
||||
'steps',
|
||||
'trigger',
|
||||
|
||||
88
mobile/openapi/lib/model/workflow_result.dart
generated
88
mobile/openapi/lib/model/workflow_result.dart
generated
@@ -1,88 +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;
|
||||
|
||||
/// Workflow run result
|
||||
class WorkflowResult {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const WorkflowResult._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const completed = WorkflowResult._(r'completed');
|
||||
static const halted = WorkflowResult._(r'halted');
|
||||
static const error = WorkflowResult._(r'error');
|
||||
|
||||
/// List of all possible values in this [enum][WorkflowResult].
|
||||
static const values = <WorkflowResult>[
|
||||
completed,
|
||||
halted,
|
||||
error,
|
||||
];
|
||||
|
||||
static WorkflowResult? fromJson(dynamic value) => WorkflowResultTypeTransformer().decode(value);
|
||||
|
||||
static List<WorkflowResult> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowResult>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowResult.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [WorkflowResult] to String,
|
||||
/// and [decode] dynamic data back to [WorkflowResult].
|
||||
class WorkflowResultTypeTransformer {
|
||||
factory WorkflowResultTypeTransformer() => _instance ??= const WorkflowResultTypeTransformer._();
|
||||
|
||||
const WorkflowResultTypeTransformer._();
|
||||
|
||||
String encode(WorkflowResult data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a WorkflowResult.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
WorkflowResult? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'completed': return WorkflowResult.completed;
|
||||
case r'halted': return WorkflowResult.halted;
|
||||
case r'error': return WorkflowResult.error;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [WorkflowResultTypeTransformer] instance.
|
||||
static WorkflowResultTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
19
mobile/openapi/lib/model/workflow_update_dto.dart
generated
19
mobile/openapi/lib/model/workflow_update_dto.dart
generated
@@ -15,7 +15,6 @@ class WorkflowUpdateDto {
|
||||
WorkflowUpdateDto({
|
||||
this.description = const Optional.absent(),
|
||||
this.enabled = const Optional.absent(),
|
||||
this.logging = const Optional.absent(),
|
||||
this.name = const Optional.absent(),
|
||||
this.steps = const Optional.present(const []),
|
||||
this.trigger = const Optional.absent(),
|
||||
@@ -33,15 +32,6 @@ class WorkflowUpdateDto {
|
||||
///
|
||||
Optional<bool?> enabled;
|
||||
|
||||
/// Workflow logs run results
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
Optional<bool?> logging;
|
||||
|
||||
/// Workflow name
|
||||
Optional<String?> name;
|
||||
|
||||
@@ -59,7 +49,6 @@ class WorkflowUpdateDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
other.logging == logging &&
|
||||
other.name == name &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.trigger == trigger;
|
||||
@@ -69,13 +58,12 @@ class WorkflowUpdateDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(logging == null ? 0 : logging!.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(trigger == null ? 0 : trigger!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowUpdateDto[description=$description, enabled=$enabled, logging=$logging, name=$name, steps=$steps, trigger=$trigger]';
|
||||
String toString() => 'WorkflowUpdateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -87,10 +75,6 @@ class WorkflowUpdateDto {
|
||||
final value = this.enabled.value;
|
||||
json[r'enabled'] = value;
|
||||
}
|
||||
if (this.logging.isPresent) {
|
||||
final value = this.logging.value;
|
||||
json[r'logging'] = value;
|
||||
}
|
||||
if (this.name.isPresent) {
|
||||
final value = this.name.value;
|
||||
json[r'name'] = value;
|
||||
@@ -117,7 +101,6 @@ class WorkflowUpdateDto {
|
||||
return WorkflowUpdateDto(
|
||||
description: json.containsKey(r'description') ? Optional.present(mapValueOfType<String>(json, r'description')) : const Optional.absent(),
|
||||
enabled: json.containsKey(r'enabled') ? Optional.present(mapValueOfType<bool>(json, r'enabled')) : const Optional.absent(),
|
||||
logging: json.containsKey(r'logging') ? Optional.present(mapValueOfType<bool>(json, r'logging')) : const Optional.absent(),
|
||||
name: json.containsKey(r'name') ? Optional.present(mapValueOfType<String>(json, r'name')) : const Optional.absent(),
|
||||
steps: json.containsKey(r'steps') ? Optional.present(WorkflowStepDto.listFromJson(json[r'steps'])) : const Optional.absent(),
|
||||
trigger: json.containsKey(r'trigger') ? Optional.present(WorkflowTrigger.fromJson(json[r'trigger'])) : const Optional.absent(),
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.0.2+3055
|
||||
version: 3.0.3+3056
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -7,3 +8,5 @@ class MockSyncApi extends Mock implements SyncApi {}
|
||||
class MockServerApi extends Mock implements ServerApi {}
|
||||
|
||||
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
|
||||
|
||||
class MockConnectivityApi extends Mock implements ConnectivityApi {}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
import '../../../fixtures/asset.stub.dart';
|
||||
|
||||
/// Timeline displaying a single asset that we can swap
|
||||
class _SwappableTimelineService extends TimelineService {
|
||||
BaseAsset asset;
|
||||
_SwappableTimelineService(this.asset)
|
||||
: super((
|
||||
assetSource: (_, __) async => [],
|
||||
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
|
||||
@override
|
||||
int get totalAssets => 1;
|
||||
@override
|
||||
BaseAsset? getAssetSafe(int index) => index == 0 ? asset : null;
|
||||
}
|
||||
|
||||
class _ZoomedNotifier extends AssetViewerStateNotifier {
|
||||
@override
|
||||
AssetViewerState build() {
|
||||
super.build();
|
||||
return AssetViewerState(currentAsset: LocalAssetStub.image1, isZoomed: true);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('resets zoom when the displayed asset is replaced on reload', (tester) async {
|
||||
final timeline = _SwappableTimelineService(LocalAssetStub.image1);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(timeline),
|
||||
assetViewerProvider.overrideWith(_ZoomedNotifier.new),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
path: translationsPath,
|
||||
startLocale: locales.values.first,
|
||||
fallbackLocale: locales.values.first,
|
||||
saveLocale: false,
|
||||
useFallbackTranslations: true,
|
||||
assetLoader: const CodegenLoader(),
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
home: const Material(child: AssetPage(index: 0, heroOffset: 0)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
// Ignore any image load errors
|
||||
tester.takeException();
|
||||
|
||||
expect(container.read(assetViewerProvider).isZoomed, isTrue);
|
||||
|
||||
// Swap the asset, simulating a delete
|
||||
timeline.asset = LocalAssetStub.image2;
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
|
||||
await tester.idle();
|
||||
await tester.pump();
|
||||
tester.takeException();
|
||||
|
||||
expect(container.read(assetViewerProvider).isZoomed, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
import '../../../fixtures/asset.stub.dart';
|
||||
|
||||
class _SeededAssetViewerNotifier extends AssetViewerStateNotifier {
|
||||
@override
|
||||
AssetViewerState build() {
|
||||
super.build();
|
||||
return AssetViewerState(currentAsset: LocalAssetStub.image1);
|
||||
}
|
||||
}
|
||||
|
||||
TimelineService _stubTimelineService() {
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async => [LocalAssetStub.image1],
|
||||
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('status bar icons are light while the asset viewer is open in light mode', (tester) async {
|
||||
// Emulate arriving from a light-themed page whose AppBar set dark status
|
||||
// bar icons (the state the viewer is opened from in light mode).
|
||||
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
|
||||
|
||||
await tester.pumpWidget(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
path: translationsPath,
|
||||
startLocale: locales.values.first,
|
||||
fallbackLocale: locales.values.first,
|
||||
saveLocale: false,
|
||||
useFallbackTranslations: true,
|
||||
assetLoader: const CodegenLoader(),
|
||||
child: ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_stubTimelineService()),
|
||||
assetViewerProvider.overrideWith(_SeededAssetViewerNotifier.new),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
home: const Material(child: AssetViewer(initialIndex: 0)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Let post-frame callbacks and the initial frames run without waiting for
|
||||
// infinite animations (loading spinners) to settle.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
|
||||
// Asset thumbnails cannot load in the test environment (no platform
|
||||
// channels / real HTTP). Those image errors are irrelevant to the system
|
||||
// chrome behaviour under test, so drain them.
|
||||
tester.takeException();
|
||||
|
||||
expect(
|
||||
SystemChrome.latestStyle?.statusBarIconBrightness,
|
||||
Brightness.light,
|
||||
reason:
|
||||
'The asset viewer draws over a black background, so status bar '
|
||||
'icons must be light regardless of the app theme',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -118,8 +118,24 @@ void main() {
|
||||
expect(task, isNotNull);
|
||||
// For live photos, extension should be changed to match the video file
|
||||
expect(task!.fields['filename'], equals('OriginalLivePhoto.mov'));
|
||||
expect(task.fields['visibility'], equals('hidden'));
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
|
||||
test('should not set visibility for a regular photo', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/file.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'Regular.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('visibility'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('getLivePhotoUploadTask', () {
|
||||
@@ -140,7 +156,7 @@ void main() {
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
|
||||
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
|
||||
expect(task.fields['visibility'], equals('hidden'));
|
||||
expect(task.fields.containsKey('visibility'), isFalse);
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
|
||||
@@ -334,7 +350,7 @@ void main() {
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields.containsKey('metadata'), isTrue);
|
||||
expect(task.fields['livePhotoVideoId'], equals('video-123'));
|
||||
expect(task.fields['visibility'], equals('hidden'));
|
||||
expect(task.fields.containsKey('visibility'), isFalse);
|
||||
|
||||
final metadata = jsonDecode(task.fields['metadata']!) as List;
|
||||
expect(metadata, hasLength(1));
|
||||
|
||||
128
mobile/test/services/foreground_upload.service_test.dart
Normal file
128
mobile/test/services/foreground_upload.service_test.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../api.mocks.dart';
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late ForegroundUploadService sut;
|
||||
late MockUploadRepository mockUploadRepository;
|
||||
late MockStorageRepository mockStorageRepository;
|
||||
late MockDriftBackupRepository mockBackupRepository;
|
||||
late MockConnectivityApi mockConnectivityApi;
|
||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(MethodCall methodCall) async => 'test',
|
||||
);
|
||||
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
await SettingsRepository.ensureInitialized(db);
|
||||
|
||||
await Store.put(StoreKey.serverEndpoint, 'http://demo.immich.app');
|
||||
await Store.put(StoreKey.deviceId, 'device-id');
|
||||
|
||||
registerFallbackValue(File('file'));
|
||||
registerFallbackValue(<String, String>{});
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockUploadRepository = MockUploadRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockBackupRepository = MockDriftBackupRepository();
|
||||
mockConnectivityApi = MockConnectivityApi();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
|
||||
sut = ForegroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockStorageRepository,
|
||||
mockBackupRepository,
|
||||
mockConnectivityApi,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
});
|
||||
|
||||
List<Map<String, String>> captureFields() {
|
||||
final captured = <Map<String, String>>[];
|
||||
when(
|
||||
() => mockUploadRepository.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final fields = invocation.namedArguments[#fields] as Map<String, String>;
|
||||
captured.add(Map.of(fields));
|
||||
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
group('uploadSingleAsset', () {
|
||||
test('should upload the motion part hidden and keep the still image visible', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final stillFile = File('/path/to/still.heic');
|
||||
final videoFile = File('/path/to/motion.mov');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => videoFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'live.heic');
|
||||
|
||||
final captured = captureFields();
|
||||
|
||||
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||
|
||||
expect(captured, hasLength(2));
|
||||
expect(captured[0]['visibility'], equals('hidden'));
|
||||
expect(captured[0].containsKey('livePhotoVideoId'), isFalse);
|
||||
expect(captured[1].containsKey('visibility'), isFalse);
|
||||
expect(captured[1]['livePhotoVideoId'], equals('remote-1'));
|
||||
});
|
||||
|
||||
test('should not set visibility for a regular photo', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final stillFile = File('/path/to/photo.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
|
||||
final captured = captureFields();
|
||||
|
||||
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||
|
||||
expect(captured, hasLength(1));
|
||||
expect(captured[0].containsKey('visibility'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7139,9 +7139,9 @@
|
||||
"in": "query",
|
||||
"description": "Filter by date",
|
||||
"schema": {
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"format": "date",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -7308,9 +7308,9 @@
|
||||
"in": "query",
|
||||
"description": "Filter by date",
|
||||
"schema": {
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"format": "date",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -15854,15 +15854,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "logging",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Workflow logs run results",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"required": false,
|
||||
@@ -16176,95 +16167,6 @@
|
||||
"x-immich-state": "Deprecated"
|
||||
}
|
||||
},
|
||||
"/workflows/{id}/logs": {
|
||||
"get": {
|
||||
"description": "Retrieve a workflow details without ids, default values, etc.",
|
||||
"operationId": "getWorkflowLogs",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "before",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by runs before a date/time",
|
||||
"schema": {
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"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})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Maximum number of logs",
|
||||
"schema": {
|
||||
"maximum": 9007199254740991,
|
||||
"exclusiveMinimum": true,
|
||||
"default": 50,
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by run result",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkflowResult"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkflowLogEntryDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Retrieve a workflow",
|
||||
"tags": [
|
||||
"Workflows"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.0.0",
|
||||
"state": "Added"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "workflow.logs"
|
||||
}
|
||||
},
|
||||
"/workflows/{id}/share": {
|
||||
"get": {
|
||||
"description": "Retrieve a workflow details without ids, default values, etc.",
|
||||
@@ -16321,7 +16223,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
@@ -21113,7 +21015,6 @@
|
||||
"workflow.read",
|
||||
"workflow.update",
|
||||
"workflow.delete",
|
||||
"workflow.logs",
|
||||
"adminUser.create",
|
||||
"adminUser.read",
|
||||
"adminUser.update",
|
||||
@@ -27954,10 +27855,6 @@
|
||||
"description": "Workflow enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logging": {
|
||||
"description": "Workflow logs run results",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Workflow name",
|
||||
"nullable": true,
|
||||
@@ -27979,56 +27876,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"WorkflowLogEntryDto": {
|
||||
"properties": {
|
||||
"at": {
|
||||
"description": "Workflow run date/time",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Workflow log entry ID",
|
||||
"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})$",
|
||||
"type": "string"
|
||||
},
|
||||
"lastStep": {
|
||||
"description": "Last step ran, if the workflow ended early",
|
||||
"properties": {
|
||||
"index": {
|
||||
"description": "Index of the step in the workflow",
|
||||
"exclusiveMinimum": true,
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"description": "Method of the step",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"index"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/components/schemas/WorkflowResult"
|
||||
},
|
||||
"triggerDataId": {
|
||||
"description": "Workflow trigger data ID",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"at",
|
||||
"id",
|
||||
"result"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"WorkflowResponseDto": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
@@ -28050,10 +27897,6 @@
|
||||
"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})$",
|
||||
"type": "string"
|
||||
},
|
||||
"logging": {
|
||||
"description": "Workflow logs run results",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Workflow name",
|
||||
"nullable": true,
|
||||
@@ -28080,7 +27923,6 @@
|
||||
"description",
|
||||
"enabled",
|
||||
"id",
|
||||
"logging",
|
||||
"name",
|
||||
"steps",
|
||||
"trigger",
|
||||
@@ -28088,15 +27930,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"WorkflowResult": {
|
||||
"description": "Workflow run result",
|
||||
"enum": [
|
||||
"completed",
|
||||
"halted",
|
||||
"error"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"WorkflowShareResponseDto": {
|
||||
"properties": {
|
||||
"description": {
|
||||
@@ -28221,10 +28054,6 @@
|
||||
"description": "Workflow enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logging": {
|
||||
"description": "Workflow logs run results",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Workflow name",
|
||||
"nullable": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"description": "Monorepo for Immich",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 3.0.2
|
||||
* 3.0.3
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
@@ -2791,8 +2791,6 @@ export type WorkflowResponseDto = {
|
||||
enabled: boolean;
|
||||
/** Workflow ID */
|
||||
id: string;
|
||||
/** Workflow logs run results */
|
||||
logging: boolean;
|
||||
/** Workflow name */
|
||||
name: string | null;
|
||||
/** Workflow steps */
|
||||
@@ -2807,8 +2805,6 @@ export type WorkflowCreateDto = {
|
||||
description?: string | null;
|
||||
/** Workflow enabled */
|
||||
enabled?: boolean;
|
||||
/** Workflow logs run results */
|
||||
logging?: boolean;
|
||||
/** Workflow name */
|
||||
name?: string | null;
|
||||
steps?: WorkflowStepDto[];
|
||||
@@ -2826,30 +2822,12 @@ export type WorkflowUpdateDto = {
|
||||
description?: string | null;
|
||||
/** Workflow enabled */
|
||||
enabled?: boolean;
|
||||
/** Workflow logs run results */
|
||||
logging?: boolean;
|
||||
/** Workflow name */
|
||||
name?: string | null;
|
||||
steps?: WorkflowStepDto[];
|
||||
/** Workflow trigger type */
|
||||
trigger?: WorkflowTrigger;
|
||||
};
|
||||
export type WorkflowLogEntryDto = {
|
||||
/** Workflow run date/time */
|
||||
at: string;
|
||||
/** Workflow log entry ID */
|
||||
id: string;
|
||||
/** Last step ran, if the workflow ended early */
|
||||
lastStep?: {
|
||||
/** Index of the step in the workflow */
|
||||
index: number;
|
||||
/** Method of the step */
|
||||
method: string;
|
||||
};
|
||||
result: WorkflowResult;
|
||||
/** Workflow trigger data ID */
|
||||
triggerDataId?: string;
|
||||
};
|
||||
export type WorkflowShareStepDto = {
|
||||
/** Step configuration */
|
||||
config: {
|
||||
@@ -6999,11 +6977,10 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) {
|
||||
/**
|
||||
* List all workflows
|
||||
*/
|
||||
export function searchWorkflows({ description, enabled, id, logging, name, trigger }: {
|
||||
export function searchWorkflows({ description, enabled, id, name, trigger }: {
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
id?: string;
|
||||
logging?: boolean;
|
||||
name?: string;
|
||||
trigger?: WorkflowTrigger;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -7014,7 +6991,6 @@ export function searchWorkflows({ description, enabled, id, logging, name, trigg
|
||||
description,
|
||||
enabled,
|
||||
id,
|
||||
logging,
|
||||
name,
|
||||
trigger
|
||||
}))}`, {
|
||||
@@ -7087,26 +7063,6 @@ export function updateWorkflow({ id, workflowUpdateDto }: {
|
||||
body: workflowUpdateDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Retrieve a workflow
|
||||
*/
|
||||
export function getWorkflowLogs({ before, id, limit, result }: {
|
||||
before?: string;
|
||||
id: string;
|
||||
limit?: number;
|
||||
result?: WorkflowResult;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: WorkflowLogEntryDto[];
|
||||
}>(`/workflows/${encodeURIComponent(id)}/logs${QS.query(QS.explode({
|
||||
before,
|
||||
limit,
|
||||
result
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Retrieve a workflow
|
||||
*/
|
||||
@@ -7354,7 +7310,6 @@ export enum Permission {
|
||||
WorkflowRead = "workflow.read",
|
||||
WorkflowUpdate = "workflow.update",
|
||||
WorkflowDelete = "workflow.delete",
|
||||
WorkflowLogs = "workflow.logs",
|
||||
AdminUserCreate = "adminUser.create",
|
||||
AdminUserRead = "adminUser.read",
|
||||
AdminUserUpdate = "adminUser.update",
|
||||
@@ -7731,11 +7686,6 @@ export enum AssetOrderBy {
|
||||
TakenAt = "takenAt",
|
||||
CreatedAt = "createdAt"
|
||||
}
|
||||
export enum WorkflowResult {
|
||||
Completed = "completed",
|
||||
Halted = "halted",
|
||||
Error = "error"
|
||||
}
|
||||
export enum ReleaseType {
|
||||
Major = "major",
|
||||
Premajor = "premajor",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202607132058@sha256:968709a3668167f11ef8ece5962e72ea8a4231e841f98bd5e0f35da4cc295cc7 AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
|
||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
mise //:plugins
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202607061334@sha256:c07ef6510ff2ccb6ead28434ade7c7a0c08aef33d7d3466b14beccdd431e86f7
|
||||
FROM ghcr.io/immich-app/base-server-prod:202607132058@sha256:85cca651310842265015f26a6710bb7420a54a8fd1ec22d519b7b620f5f93bb1
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202607132058@sha256:968709a3668167f11ef8ece5962e72ea8a4231e841f98bd5e0f35da4cc295cc7 AS dev
|
||||
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.7.2@sha256:51d92371cc08e3c4f0e52f58bb3ec1975a6dc966b7430a4da37dcfc6ae772f30 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
WorkflowCreateDto,
|
||||
WorkflowGetLogsDto,
|
||||
WorkflowLogEntryDto,
|
||||
WorkflowResponseDto,
|
||||
WorkflowSearchDto,
|
||||
WorkflowShareResponseDto,
|
||||
@@ -115,19 +113,4 @@ export class WorkflowController {
|
||||
deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
|
||||
@Get(':id/logs')
|
||||
@Authenticated({ permission: Permission.WorkflowLogs })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve a workflow',
|
||||
description: 'Retrieve a workflow details without ids, default values, etc.',
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
getWorkflowLogs(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: WorkflowGetLogsDto,
|
||||
): Promise<WorkflowLogEntryDto[]> {
|
||||
return this.service.getLogs(auth, id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { HistoryBuilder } from 'src/decorators';
|
||||
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetOrderWithRandomSchema, MemoryType, MemoryTypeSchema } from 'src/enum';
|
||||
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
|
||||
import { isoDatetimeToDate, isoDateToDate, nonEmptyPartial, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const MemorySearchSchema = z
|
||||
.object({
|
||||
type: MemoryTypeSchema.optional(),
|
||||
for: isoDatetimeToDate.optional().describe('Filter by date'),
|
||||
for: isoDateToDate.optional().describe('Filter by date'),
|
||||
isTrashed: stringToBool.optional().describe('Include trashed memories'),
|
||||
isSaved: stringToBool.optional().describe('Filter by saved status'),
|
||||
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { WorkflowResultSchema, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import { isoDatetimeToDate } from 'src/validation';
|
||||
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import z from 'zod';
|
||||
|
||||
const WorkflowTriggerResponseSchema = z
|
||||
@@ -18,7 +17,6 @@ const WorkflowSearchSchema = z
|
||||
name: z.string().optional().describe('Workflow name'),
|
||||
description: z.string().optional().describe('Workflow description'),
|
||||
enabled: z.boolean().optional().describe('Workflow enabled'),
|
||||
logging: z.boolean().optional().describe('Workflow logs run results'),
|
||||
})
|
||||
.meta({ id: 'WorkflowSearchDto' });
|
||||
|
||||
@@ -44,7 +42,6 @@ const WorkflowCreateSchema = z
|
||||
name: z.string().nullable().optional().describe('Workflow name'),
|
||||
description: z.string().nullable().optional().describe('Workflow description'),
|
||||
enabled: z.boolean().optional().describe('Workflow enabled'),
|
||||
logging: z.boolean().optional().describe('Workflow logs run results'),
|
||||
steps: z.array(WorkflowStepSchema).optional(),
|
||||
})
|
||||
.meta({ id: 'WorkflowCreateDto' });
|
||||
@@ -55,7 +52,6 @@ const WorkflowUpdateSchema = z
|
||||
name: z.string().nullable().optional().describe('Workflow name'),
|
||||
description: z.string().nullable().optional().describe('Workflow description'),
|
||||
enabled: z.boolean().optional().describe('Workflow enabled'),
|
||||
logging: z.boolean().optional().describe('Workflow logs run results'),
|
||||
steps: z.array(WorkflowStepSchema).optional(),
|
||||
})
|
||||
.meta({ id: 'WorkflowUpdateDto' });
|
||||
@@ -69,7 +65,6 @@ const WorkflowResponseSchema = z
|
||||
createdAt: z.string().describe('Creation date'),
|
||||
updatedAt: z.string().describe('Update date'),
|
||||
enabled: z.boolean().describe('Workflow enabled'),
|
||||
logging: z.boolean().describe('Workflow logs run results'),
|
||||
steps: z.array(WorkflowStepSchema).describe('Workflow steps'),
|
||||
})
|
||||
.meta({ id: 'WorkflowResponseDto' });
|
||||
@@ -83,36 +78,12 @@ const WorkflowShareResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'WorkflowShareResponseDto' });
|
||||
|
||||
const WorkflowLogEntrySchema = z
|
||||
.object({
|
||||
id: z.uuidv4().describe('Workflow log entry ID'),
|
||||
at: z.string().describe('Workflow run date/time'),
|
||||
result: WorkflowResultSchema.describe('Workflow run result'),
|
||||
triggerDataId: z.uuid().optional().describe('Workflow trigger data ID'),
|
||||
lastStep: z
|
||||
.object({
|
||||
method: z.string().describe('Method of the step'),
|
||||
index: z.int().positive().describe('Index of the step in the workflow'),
|
||||
})
|
||||
.optional()
|
||||
.describe('Last step ran, if the workflow ended early'),
|
||||
})
|
||||
.meta({ id: 'WorkflowLogEntryDto' });
|
||||
|
||||
const WorkflowGetLogsSchema = z.object({
|
||||
result: WorkflowResultSchema.optional().describe('Filter by run result'),
|
||||
before: isoDatetimeToDate.optional().describe('Filter by runs before a date/time'),
|
||||
limit: z.coerce.number().int().positive().default(50).describe('Maximum number of logs'),
|
||||
});
|
||||
|
||||
export class WorkflowTriggerResponseDto extends createZodDto(WorkflowTriggerResponseSchema) {}
|
||||
export class WorkflowSearchDto extends createZodDto(WorkflowSearchSchema) {}
|
||||
export class WorkflowCreateDto extends createZodDto(WorkflowCreateSchema) {}
|
||||
export class WorkflowUpdateDto extends createZodDto(WorkflowUpdateSchema) {}
|
||||
export class WorkflowResponseDto extends createZodDto(WorkflowResponseSchema) {}
|
||||
export class WorkflowShareResponseDto extends createZodDto(WorkflowShareResponseSchema) {}
|
||||
export class WorkflowLogEntryDto extends createZodDto(WorkflowLogEntrySchema) {}
|
||||
export class WorkflowGetLogsDto extends createZodDto(WorkflowGetLogsSchema) {}
|
||||
|
||||
type Workflow = {
|
||||
id: string;
|
||||
@@ -122,7 +93,6 @@ type Workflow = {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
logging: boolean;
|
||||
};
|
||||
|
||||
type WorkflowStep = {
|
||||
@@ -137,7 +107,6 @@ export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): Wor
|
||||
id: workflow.id,
|
||||
enabled: workflow.enabled,
|
||||
trigger: workflow.trigger,
|
||||
logging: workflow.logging,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
createdAt: workflow.createdAt.toISOString(),
|
||||
|
||||
@@ -298,7 +298,6 @@ export enum Permission {
|
||||
WorkflowRead = 'workflow.read',
|
||||
WorkflowUpdate = 'workflow.update',
|
||||
WorkflowDelete = 'workflow.delete',
|
||||
WorkflowLogs = 'workflow.logs',
|
||||
|
||||
AdminUserCreate = 'adminUser.create',
|
||||
AdminUserRead = 'adminUser.read',
|
||||
@@ -1235,14 +1234,3 @@ export enum CalendarHeatmapType {
|
||||
Upload = 'Upload',
|
||||
Taken = 'Taken',
|
||||
}
|
||||
|
||||
export enum WorkflowResult {
|
||||
Completed = 'completed',
|
||||
Halted = 'halted',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export const WorkflowResultSchema = z
|
||||
.enum(WorkflowResult)
|
||||
.describe('Workflow run result')
|
||||
.meta({ id: 'WorkflowResult' });
|
||||
|
||||
@@ -9,7 +9,6 @@ select
|
||||
"workflow"."enabled",
|
||||
"workflow"."createdAt",
|
||||
"workflow"."updatedAt",
|
||||
"workflow"."logging",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -42,7 +41,6 @@ select
|
||||
"workflow"."enabled",
|
||||
"workflow"."createdAt",
|
||||
"workflow"."updatedAt",
|
||||
"workflow"."logging",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -71,7 +69,6 @@ select
|
||||
"workflow"."id",
|
||||
"workflow"."name",
|
||||
"workflow"."trigger",
|
||||
"workflow"."logging",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -99,40 +96,6 @@ where
|
||||
"id" = $2
|
||||
and "enabled" = $3
|
||||
|
||||
-- WorkflowRepository.getLogs
|
||||
select
|
||||
"workflow_log"."id",
|
||||
"workflow_log"."createdAt",
|
||||
"workflow_log"."halted",
|
||||
"workflow_log"."error",
|
||||
"workflow_log"."workflowId",
|
||||
"workflow_log"."workflowStepId",
|
||||
"workflow_log"."triggerDataId",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"plugin_method"."pluginId",
|
||||
"plugin_method"."name" as "methodName",
|
||||
"workflow_step"."order"
|
||||
from
|
||||
"workflow_step"
|
||||
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
|
||||
where
|
||||
"workflow_step"."id" = "workflow_log"."workflowStepId"
|
||||
) as obj
|
||||
) as "step"
|
||||
from
|
||||
"workflow_log"
|
||||
where
|
||||
"workflow_log"."workflowId" = $1
|
||||
order by
|
||||
"workflow_log"."createdAt" desc
|
||||
limit
|
||||
$2
|
||||
|
||||
-- WorkflowRepository.delete
|
||||
delete from "workflow"
|
||||
where
|
||||
|
||||
@@ -4,10 +4,8 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { WorkflowGetLogsDto, WorkflowSearchDto } from 'src/dtos/workflow.dto';
|
||||
import { WorkflowResult } from 'src/enum';
|
||||
import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
|
||||
import { DB } from 'src/schema';
|
||||
import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table';
|
||||
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
|
||||
import { WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@@ -28,7 +26,6 @@ export class WorkflowRepository {
|
||||
'workflow.enabled',
|
||||
'workflow.createdAt',
|
||||
'workflow.updatedAt',
|
||||
'workflow.logging',
|
||||
])
|
||||
.select((eb) => [
|
||||
jsonArrayFrom(
|
||||
@@ -67,7 +64,7 @@ export class WorkflowRepository {
|
||||
getForWorkflowRun(id: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.select(['workflow.id', 'workflow.name', 'workflow.trigger', 'workflow.logging'])
|
||||
.select(['workflow.id', 'workflow.name', 'workflow.trigger'])
|
||||
.select((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
@@ -100,9 +97,6 @@ export class WorkflowRepository {
|
||||
|
||||
update(id: string, dto: Updateable<WorkflowTable>, steps?: WorkflowStepUpsert[]) {
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
if (dto.logging === false) {
|
||||
await tx.deleteFrom('workflow_log').where('workflowId', '=', id).execute();
|
||||
}
|
||||
if (Object.values(dto).some((prop) => prop !== undefined)) {
|
||||
await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow();
|
||||
}
|
||||
@@ -110,44 +104,6 @@ export class WorkflowRepository {
|
||||
});
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { result: undefined }] })
|
||||
getLogs(id: string, dto: WorkflowGetLogsDto) {
|
||||
return this.db
|
||||
.selectFrom('workflow_log')
|
||||
.select([
|
||||
'workflow_log.id',
|
||||
'workflow_log.createdAt',
|
||||
'workflow_log.halted',
|
||||
'workflow_log.error',
|
||||
'workflow_log.workflowId',
|
||||
'workflow_log.workflowStepId',
|
||||
'workflow_log.triggerDataId',
|
||||
])
|
||||
.where('workflow_log.workflowId', '=', id)
|
||||
.select((eb) => [
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('workflow_step')
|
||||
.whereRef('workflow_step.id', '=', 'workflow_log.workflowStepId')
|
||||
.innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId')
|
||||
.select(['plugin_method.pluginId', 'plugin_method.name as methodName', 'workflow_step.order']),
|
||||
).as('step'),
|
||||
])
|
||||
.$if(dto.result === WorkflowResult.Error, (qb) => qb.where('workflow_log.error', '=', true))
|
||||
.$if(dto.result === WorkflowResult.Halted, (qb) => qb.where('workflow_log.halted', '=', true))
|
||||
.$if(dto.result === WorkflowResult.Completed, (qb) =>
|
||||
qb.where('workflow_log.halted', '=', false).where('workflow_log.error', '=', false),
|
||||
)
|
||||
.$if(dto.before !== undefined, (qb) => qb.where('workflow_log.createdAt', '<', dto.before!))
|
||||
.orderBy('workflow_log.createdAt', 'desc')
|
||||
.limit(dto.limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
log(dto: Insertable<WorkflowLogTable>) {
|
||||
return this.db.insertInto('workflow_log').values(dto).execute();
|
||||
}
|
||||
|
||||
async updateStep(id: string, dto: Updateable<WorkflowStepTable>) {
|
||||
await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute();
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ import {
|
||||
VideoStreamSessionTable,
|
||||
VideoStreamVariantTable,
|
||||
} from 'src/schema/tables/video-stream.table';
|
||||
import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table';
|
||||
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
|
||||
import { WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@@ -278,5 +277,4 @@ export interface DB {
|
||||
|
||||
workflow: WorkflowTable;
|
||||
workflow_step: WorkflowStepTable;
|
||||
workflow_log: WorkflowLogTable;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// (#29836) Reset the visibility of any still images that are hidden and have a motion part
|
||||
await sql`
|
||||
UPDATE "asset"
|
||||
SET "visibility" = 'timeline'
|
||||
WHERE "type" = 'IMAGE'
|
||||
AND "visibility" = 'hidden'
|
||||
AND "livePhotoVideoId" IS NOT NULL
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Not implemented: the previous 'hidden' value was itself the bug.
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" ADD "logging" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||
await sql`CREATE TABLE "workflow_log" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"workflowId" uuid NOT NULL,
|
||||
"error" boolean NOT NULL,
|
||||
"halted" boolean NOT NULL,
|
||||
"workflowStepId" uuid,
|
||||
"triggerDataId" uuid,
|
||||
CONSTRAINT "workflow_log_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_log_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "workflow_step" ("id") ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT "workflow_log_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_log_workflowId_idx" ON "workflow_log" ("workflowId");`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_log_workflowStepId_idx" ON "workflow_log" ("workflowStepId");`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" DROP COLUMN "logging";`.execute(db);
|
||||
await sql`DROP TABLE "workflow_log";`.execute(db);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
} from '@immich/sql-tools';
|
||||
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
|
||||
import { WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@Table('workflow_log')
|
||||
export class WorkflowLogTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', index: true })
|
||||
workflowId!: string;
|
||||
|
||||
@Column({ type: 'boolean' })
|
||||
error!: boolean;
|
||||
|
||||
@Column({ type: 'boolean' })
|
||||
halted!: boolean;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowStepTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true })
|
||||
workflowStepId!: string | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
triggerDataId!: string | null;
|
||||
}
|
||||
@@ -41,7 +41,4 @@ export class WorkflowTable {
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
logging!: Generated<boolean>;
|
||||
}
|
||||
|
||||
@@ -663,6 +663,7 @@ describe(AlbumService.name, () => {
|
||||
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.albumUser.update.mockResolvedValue();
|
||||
|
||||
await sut.updateUser(AuthFactory.create(owner), album.id, user.id, { role: AlbumUserRole.Viewer });
|
||||
|
||||
@@ -335,6 +335,14 @@ export class AlbumService extends BaseService {
|
||||
|
||||
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
|
||||
|
||||
const album = await this.findOrFail(id, userId, { withAssets: false });
|
||||
const owner = album.albumUsers[0];
|
||||
|
||||
if (owner.user.id === userId) {
|
||||
throw new BadRequestException('User is owner');
|
||||
}
|
||||
|
||||
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
|
||||
}
|
||||
|
||||
|
||||
@@ -2232,6 +2232,26 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include hevc tag when target is hevc and using hwa', async () => {
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR10 });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: {
|
||||
targetVideoCodec: VideoCodec.Hevc,
|
||||
accel: TranscodeHardwareAcceleration.Nvenc,
|
||||
},
|
||||
});
|
||||
await sut.handleVideoConversion({ id: 'video-id' });
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.arrayContaining(['-c:v', 'hevc_nvenc', '-tag:v', 'hvc1']),
|
||||
twoPass: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy audio stream when audio matches target', async () => {
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.audioStreamAac });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } });
|
||||
|
||||
@@ -118,7 +118,11 @@ export class SearchService extends BaseService {
|
||||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
|
||||
const items = await this.searchRepository.searchRandom(dto.size || 250, {
|
||||
...dto,
|
||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||
userIds,
|
||||
});
|
||||
return items.map((item) => mapAsset(item, { auth }));
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const dummy = () => {
|
||||
};
|
||||
|
||||
type ExecuteOptions<T extends WorkflowType> = {
|
||||
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T>; entityId?: string }>;
|
||||
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
|
||||
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -331,7 +331,6 @@ export class WorkflowExecutionService extends BaseService {
|
||||
return {
|
||||
data: { asset } as any,
|
||||
authUserId: asset.ownerId,
|
||||
entityId: asset.id,
|
||||
};
|
||||
},
|
||||
write: async (auth, changes) => {
|
||||
@@ -399,12 +398,11 @@ export class WorkflowExecutionService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
const { read, write } = handler;
|
||||
const readResult = await read(type);
|
||||
let data = readResult.data;
|
||||
|
||||
for (const step of workflow.steps) {
|
||||
try {
|
||||
try {
|
||||
const { read, write } = handler;
|
||||
const readResult = await read(type);
|
||||
let data = readResult.data;
|
||||
for (const step of workflow.steps) {
|
||||
const payload: WorkflowEventPayload<typeof type> = {
|
||||
trigger: workflow.trigger,
|
||||
type,
|
||||
@@ -455,45 +453,14 @@ export class WorkflowExecutionService extends BaseService {
|
||||
|
||||
const shouldContinue = result?.workflow?.continue ?? true;
|
||||
if (!shouldContinue) {
|
||||
if (workflow.logging) {
|
||||
await this.workflowRepository.log({
|
||||
workflowId,
|
||||
error: false,
|
||||
halted: true,
|
||||
workflowStepId: step.id,
|
||||
triggerDataId: readResult.entityId,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.debug(`Workflow ${workflowId} stopped on step ${step.id}`);
|
||||
return;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error executing workflow ${workflowId}:`, error);
|
||||
|
||||
if (workflow.logging) {
|
||||
await this.workflowRepository.log({
|
||||
workflowId,
|
||||
error: true,
|
||||
halted: false,
|
||||
workflowStepId: step.id,
|
||||
triggerDataId: readResult.entityId,
|
||||
});
|
||||
}
|
||||
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
if (workflow.logging) {
|
||||
await this.workflowRepository.log({
|
||||
workflowId,
|
||||
error: false,
|
||||
halted: false,
|
||||
triggerDataId: readResult.entityId,
|
||||
});
|
||||
this.logger.debug(`Workflow ${workflowId} executed successfully`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error executing workflow ${workflowId}:`, error);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
this.logger.debug(`Workflow ${workflowId} executed successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,13 @@ import {
|
||||
mapWorkflow,
|
||||
mapWorkflowShare,
|
||||
WorkflowCreateDto,
|
||||
WorkflowGetLogsDto,
|
||||
WorkflowLogEntryDto,
|
||||
WorkflowResponseDto,
|
||||
WorkflowSearchDto,
|
||||
WorkflowShareResponseDto,
|
||||
WorkflowTriggerResponseDto,
|
||||
WorkflowUpdateDto,
|
||||
} from 'src/dtos/workflow.dto';
|
||||
import { Permission, WorkflowResult } from 'src/enum';
|
||||
import { Permission } from 'src/enum';
|
||||
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
|
||||
@@ -84,23 +82,6 @@ export class WorkflowService extends BaseService {
|
||||
await this.workflowRepository.delete(id);
|
||||
}
|
||||
|
||||
async getLogs(auth: AuthDto, id: string, dto: WorkflowGetLogsDto): Promise<WorkflowLogEntryDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.WorkflowLogs, ids: [id] });
|
||||
const logs = await this.workflowRepository.getLogs(id, dto);
|
||||
return logs.map((entry) => ({
|
||||
id: entry.id,
|
||||
at: entry.createdAt.toISOString(),
|
||||
result: entry.error ? WorkflowResult.Error : entry.halted ? WorkflowResult.Halted : WorkflowResult.Completed,
|
||||
triggerDataId: entry.triggerDataId ?? undefined,
|
||||
lastStep: entry.step
|
||||
? {
|
||||
index: entry.step.order,
|
||||
method: `${entry.step.pluginId}#${entry.step.methodName}`,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
private async resolveAndValidateSteps<T extends { method: string }>(steps: T[], trigger: WorkflowTrigger) {
|
||||
const methods = await this.pluginRepository.getForValidation();
|
||||
const results: Array<T & { pluginMethod: PluginMethodSearchResponse }> = [];
|
||||
|
||||
@@ -324,8 +324,7 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
|
||||
case Permission.WorkflowRead:
|
||||
case Permission.WorkflowUpdate:
|
||||
case Permission.WorkflowDelete:
|
||||
case Permission.WorkflowLogs: {
|
||||
case Permission.WorkflowDelete: {
|
||||
return access.workflow.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,8 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
options.push('-keyint_min', `${this.getGopSize()}`);
|
||||
}
|
||||
}
|
||||
const isHvc = (videoCodec === 'copy' ? videoStream.codecName : videoCodec) === VideoCodec.Hevc;
|
||||
const isHvc =
|
||||
this.config.targetVideoCodec === VideoCodec.Hevc && (videoCodec !== 'copy' || videoStream.codecName === 'hevc');
|
||||
if (isHvc) {
|
||||
options.push('-tag:v', 'hvc1');
|
||||
}
|
||||
|
||||
@@ -196,4 +196,19 @@ describe(SearchService.name, () => {
|
||||
expect(suggestions).toEqual(['Canon', null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchRandom', () => {
|
||||
it('should filter out locked assets in a default session', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const response = await sut.searchRandom(auth, {});
|
||||
|
||||
expect(response.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,6 +7,16 @@ import { preferencesFactory } from '@test-data/factories/preferences-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
import AssetViewerNavBar from './AssetViewerNavBar.svelte';
|
||||
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
|
||||
return {
|
||||
featureFlagsManager: {
|
||||
init: vi.fn(),
|
||||
loadFeatureFlags: vi.fn(),
|
||||
value: { smartSearch: true, trash: true },
|
||||
} as never,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AssetViewerNavBar component', () => {
|
||||
const additionalProps = {
|
||||
preAction: () => {},
|
||||
@@ -24,15 +34,6 @@ describe('AssetViewerNavBar component', () => {
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
|
||||
return {
|
||||
featureFlagsManager: {
|
||||
init: vi.fn(),
|
||||
loadFeatureFlags: vi.fn(),
|
||||
value: { smartSearch: true, trash: true },
|
||||
} as never,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -4,16 +4,14 @@ import { renderWithTooltips } from '$tests/helpers';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import DeleteAction from './DeleteAction.svelte';
|
||||
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
|
||||
});
|
||||
|
||||
let asset: AssetResponseDto;
|
||||
|
||||
describe('DeleteAction component', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
|
||||
});
|
||||
});
|
||||
|
||||
describe('given an asset which is not trashed yet', () => {
|
||||
beforeEach(() => {
|
||||
asset = assetFactory.build({ isTrashed: false });
|
||||
|
||||
@@ -4,6 +4,11 @@ import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
|
||||
vi.mock('$lib/utils/navigation', () => ({
|
||||
currentUrlReplaceAssetId: vi.fn(),
|
||||
isSharedLinkRoute: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -26,10 +31,6 @@ vi.hoisted(() => {
|
||||
describe('Thumbnail component', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||
vi.mock('$lib/utils/navigation', () => ({
|
||||
currentUrlReplaceAssetId: vi.fn(),
|
||||
isSharedLinkRoute: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should only contain a single tabbable element (the container)', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('RecentAlbums component', () => {
|
||||
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
|
||||
render(RecentAlbums);
|
||||
|
||||
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
|
||||
expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce();
|
||||
|
||||
// wtf
|
||||
await tick();
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('FormatMessage component', () => {
|
||||
|
||||
it('throws an error when locale is empty', async () => {
|
||||
await locale.set(undefined);
|
||||
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
|
||||
expect(() => render(FormatMessage, { key: '' as Translations })).toThrow();
|
||||
await locale.set('en');
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { DateTime } from 'luxon';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
|
||||
type MemoryIndex = {
|
||||
@@ -131,7 +130,7 @@ class MemoryManager {
|
||||
}
|
||||
|
||||
private async load() {
|
||||
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
|
||||
const memories = await searchMemories({ $for: DateTime.now().toFormat('yyyy-MM-dd') });
|
||||
this.memories = memories.filter((memory) => memory.assets.length > 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('should load months in viewport', () => {
|
||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBuckets).toHaveBeenCalledOnce();
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -133,13 +133,13 @@ describe('TimelineManager', () => {
|
||||
it('loads a month', async () => {
|
||||
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
|
||||
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
});
|
||||
|
||||
it('ignores invalid months', async () => {
|
||||
await timelineManager.loadTimelineMonth({ year: 2023, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||
expect(sdkMock.getTimeBucket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels month loading', async () => {
|
||||
@@ -147,7 +147,7 @@ describe('TimelineManager', () => {
|
||||
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||
month?.cancel();
|
||||
expect(abortSpy).toBeCalledTimes(1);
|
||||
expect(abortSpy).toHaveBeenCalledOnce();
|
||||
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
});
|
||||
@@ -157,10 +157,10 @@ describe('TimelineManager', () => {
|
||||
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
|
||||
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
|
||||
]);
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
|
||||
|
||||
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('allows loading a canceled month', async () => {
|
||||
@@ -283,7 +283,7 @@ describe('TimelineManager', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||
timelineManager.upsertAssets([asset]);
|
||||
|
||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||
expect(updateAssetsSpy).toHaveBeenCalledWith([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
@@ -642,8 +642,8 @@ describe('TimelineManager', () => {
|
||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
|
||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||
expect(loadTimelineMonthSpy).not.toHaveBeenCalled();
|
||||
expect(previousMonthSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips removed assets', async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const { trigger, selectedKey, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BasicModal title={$t('add_step')} {onClose} size="medium">
|
||||
<BasicModal title={$t('add_step')} onClose={() => onClose()} size="medium">
|
||||
{#await searchPluginMethods({ trigger })}
|
||||
<div class="flex w-full place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type WorkflowLogEntryDto, type WorkflowResponseDto, WorkflowResult, getWorkflowLogs } from '@immich/sdk';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHeading,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
HStack,
|
||||
Icon,
|
||||
VStack,
|
||||
Button,
|
||||
Select,
|
||||
type SelectOption,
|
||||
} from '@immich/ui';
|
||||
import { mdiHistory, mdiOpenInNew } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
|
||||
type Props = {
|
||||
workflow: WorkflowResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { workflow, onClose }: Props = $props();
|
||||
|
||||
let entries: WorkflowLogEntryDto[] = $state([]);
|
||||
let placeholder: HTMLElement | undefined = $state();
|
||||
let filter: WorkflowResult | undefined = $state();
|
||||
let before: string | undefined = $state();
|
||||
let hasNext = $state(true);
|
||||
let loading = $state(false);
|
||||
|
||||
const setLogging = (logging: boolean) =>
|
||||
handleUpdateWorkflow(workflow.id, { logging }).then((success) => {
|
||||
if (success) {
|
||||
workflow = { ...workflow, logging };
|
||||
reset();
|
||||
}
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
entries = [];
|
||||
hasNext = true;
|
||||
before = undefined;
|
||||
};
|
||||
|
||||
const setFilter = (option: SelectOption<WorkflowResult>) => {
|
||||
reset();
|
||||
filter = option.value;
|
||||
void getLogs();
|
||||
};
|
||||
|
||||
const getLogs = async () => {
|
||||
if (!hasNext || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
const limit = 50;
|
||||
const results = await getWorkflowLogs({ id: workflow.id, result: filter, before, limit });
|
||||
entries.push(...results);
|
||||
if (results.length < limit) {
|
||||
hasNext = false;
|
||||
} else {
|
||||
before = results.at(-1)?.at;
|
||||
}
|
||||
loading = false;
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries.find((entry) => entry.target === placeholder);
|
||||
if (entry?.isIntersecting) {
|
||||
void getLogs();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (placeholder) {
|
||||
observer.disconnect();
|
||||
observer.observe(placeholder);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal title={$t('logs')} icon={mdiHistory} {onClose} size="medium">
|
||||
<ModalBody>
|
||||
{#if workflow.logging}
|
||||
<Table striped>
|
||||
<TableHeader>
|
||||
<TableHeading>{$t('date')}</TableHeading>
|
||||
<TableHeading>{$t('result')}</TableHeading>
|
||||
</TableHeader>
|
||||
<TableBody class="max-h-100">
|
||||
{#each entries as entry (entry.id)}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<HStack class="justify-center">
|
||||
<p>
|
||||
{DateTime.fromISO(entry.at).toLocaleString(DateTime.DATETIME_MED, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</p>
|
||||
{#if entry.triggerDataId}
|
||||
<a href={Route.viewAsset({ id: entry.triggerDataId })} target="_blank">
|
||||
<Icon icon={mdiOpenInNew} size="20" class="text-primary" />
|
||||
</a>
|
||||
{/if}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack class="justify-center">
|
||||
{#if entry.result === WorkflowResult.Completed}
|
||||
<p class="rounded-full bg-green-700 px-3 py-1 text-xs text-white">
|
||||
{$t('workflow_logging_completed')}
|
||||
</p>
|
||||
{:else if entry.result === WorkflowResult.Halted}
|
||||
<p class="rounded-full bg-gray-600 px-3 py-1 text-xs text-white">
|
||||
{#if entry.lastStep}
|
||||
{$t('workflow_logging_halted_step', { values: { step: entry.lastStep.index + 1 } })}
|
||||
{:else}
|
||||
{$t('workflow_logging_halted')}
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="rounded-full bg-red-500 px-3 py-1 text-xs text-white">
|
||||
{#if entry.lastStep}
|
||||
{$t('workflow_logging_error_step', { values: { step: entry.lastStep.index + 1 } })}
|
||||
{:else}
|
||||
{$t('error')}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{#if hasNext}
|
||||
<TableRow><TableCell><div bind:this={placeholder}>...</div></TableCell></TableRow>
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div class="mt-5 flex gap-5">
|
||||
<Select
|
||||
onSelect={setFilter}
|
||||
class="flex-1"
|
||||
placeholder={$t('filter')}
|
||||
options={[
|
||||
{ value: WorkflowResult.Completed, label: $t('workflow_logging_completed') },
|
||||
{ value: WorkflowResult.Halted, label: $t('workflow_logging_halted') },
|
||||
{ value: WorkflowResult.Error, label: $t('error') },
|
||||
]}
|
||||
/>
|
||||
<Button class="flex-1" onclick={() => setLogging(false)}>{$t('workflow_logging_disable')}</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<VStack class="gap-5 py-5">
|
||||
<p class="text-md text-center">{$t('workflow_logging_disabled_description')}</p>
|
||||
<Button onclick={() => setLogging(true)}>{$t('workflow_logging_enable')}</Button>
|
||||
</VStack>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
@@ -2,17 +2,15 @@ import type { ServerConfigDto } from '@immich/sdk';
|
||||
import { asUrl } from '$lib/services/shared-link.service';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
|
||||
describe('SharedLinkService', () => {
|
||||
beforeAll(() => {
|
||||
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
|
||||
serverConfigManager: {
|
||||
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
|
||||
init: vi.fn(),
|
||||
loadServerConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
|
||||
serverConfigManager: {
|
||||
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
|
||||
init: vi.fn(),
|
||||
loadServerConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SharedLinkService', () => {
|
||||
describe('asUrl', () => {
|
||||
it('should properly encode characters in slug', () => {
|
||||
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
mdiDeleteOutline,
|
||||
mdiDownload,
|
||||
mdiFileDocumentMultipleOutline,
|
||||
mdiHistory,
|
||||
mdiPause,
|
||||
mdiPencil,
|
||||
mdiPlay,
|
||||
@@ -27,7 +26,6 @@ import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
|
||||
import WorkflowLogsModal from '$lib/modals/WorkflowLogsModal.svelte';
|
||||
import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { copyToClipboard, downloadJson } from '$lib/utils';
|
||||
@@ -119,13 +117,7 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
|
||||
onAction: () => handleDeleteWorkflow(workflow),
|
||||
};
|
||||
|
||||
const Logs: ActionItem = {
|
||||
title: $t('check_logs'),
|
||||
icon: mdiHistory,
|
||||
onAction: () => modalManager.show(WorkflowLogsModal, { workflow }),
|
||||
};
|
||||
|
||||
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete, Logs };
|
||||
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete };
|
||||
};
|
||||
|
||||
export const getWorkflowShowSchemaAction = (
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getAlbumDateRange, getShortDateRange } from './date-time';
|
||||
|
||||
vitest.mock('$lib/stores/preferences.store', () => ({
|
||||
locale: writable('en'),
|
||||
}));
|
||||
|
||||
describe('getShortDateRange', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TZ', 'UTC');
|
||||
@@ -41,10 +45,6 @@ describe('getShortDateRange', () => {
|
||||
describe('getAlbumDate', () => {
|
||||
beforeAll(() => {
|
||||
process.env.TZ = 'UTC';
|
||||
|
||||
vitest.mock('$lib/stores/preferences.store', () => ({
|
||||
locale: writable('en'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should work with only a start date', () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Executor Queue test', function () {
|
||||
// The last task will be executed after 200ms and will finish at 400ms
|
||||
void eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
|
||||
|
||||
expect(finished).not.toBeCalled();
|
||||
expect(finished).not.toHaveBeenCalled();
|
||||
expect(started).toHaveBeenCalledTimes(3);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
{:else}
|
||||
<div class="my-6 flex flex-col gap-3">
|
||||
{#each workflows as workflow (workflow.id)}
|
||||
{@const { ToggleEnabled, Duplicate, Logs, Edit, Delete } = getWorkflowActions($t, workflow)}
|
||||
{@const { ToggleEnabled, Duplicate, Edit, Delete } = getWorkflowActions($t, workflow)}
|
||||
|
||||
<Card class="group shadow-none transition-colors hover:border-primary">
|
||||
<CardHeader>
|
||||
@@ -120,7 +120,6 @@
|
||||
ToggleEnabled,
|
||||
Edit,
|
||||
Duplicate,
|
||||
Logs,
|
||||
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => onToggleExpand(workflow.id)),
|
||||
MenuItemType.Divider,
|
||||
Delete,
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
|
||||
$effect(() => console.log(steps));
|
||||
|
||||
const { Download, Duplicate, CopyJson, Delete, Logs } = $derived(
|
||||
const { Download, Duplicate, CopyJson, Delete } = $derived(
|
||||
getWorkflowActions($t, { ...savedWorkflow, name, description, enabled, trigger, steps }),
|
||||
);
|
||||
</script>
|
||||
@@ -276,7 +276,7 @@
|
||||
{onClose}
|
||||
translations={{ close: $t('back') }}
|
||||
closeIcon={mdiArrowLeft}
|
||||
actions={[Logs, Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
|
||||
actions={[Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
|
||||
>
|
||||
<ControlBarHeader>
|
||||
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
|
||||
|
||||
Reference in New Issue
Block a user