Compare commits

...

39 Commits

Author SHA1 Message Date
Min Idzelis
915db962fc saving for later 2025-06-28 15:01:39 +00:00
Min Idzelis
2406bb2951 sync before lint/check 2025-06-28 04:03:26 +00:00
Min Idzelis
e52b43c075 shellcheck 2025-06-28 03:56:55 +00:00
Min Idzelis
227819343a update lockfile after merge 2025-06-28 03:56:05 +00:00
Min Idzelis
fccf698a5c Merge remote-tracking branch 'origin/main' into chore/pnpm_alt 2025-06-28 03:50:28 +00:00
Min Idzelis
6330319ee4 update gha 2025-06-28 03:42:36 +00:00
Min Idzelis
c026a53cb7 Dockerfile changes 2025-06-28 03:17:06 +00:00
Jason Rasmussen
09cbc5d3f4 refactor: change password repository lookup (#19584) 2025-06-27 16:52:04 -04:00
Jason Rasmussen
a2a9797fab refactor: auth medium tests (#19583) 2025-06-27 15:35:19 -04:00
Matthew Momjian
3d35e65f27 fix(docs): add DB_USERNAME when needed (#19578)
add DB_USERNAME when needed
2025-06-27 13:54:27 -04:00
Jason Rasmussen
df76735f4a refactor: sync repository (#19581) 2025-06-27 13:47:06 -04:00
Jason Rasmussen
6feca56da8 feat: sync memories (#19579) 2025-06-27 12:20:13 -04:00
Alex
97aabe466e feat: action buttons place holder (#19561)
* feat: action buttons place holder

* lint

* Update base_action_button.widget.dart

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-06-27 13:33:46 +00:00
shenlong
72a53f43c8 feat: use sqlite timeline user provider (#19577)
use sqlite timeline user provider

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-06-27 08:29:27 -05:00
Jason Rasmussen
30b4f334d8 feat: upload manager (#19565) 2025-06-27 09:13:43 -04:00
Jason Rasmussen
6c6a32c63e refactor: memory medium tests (#19568) 2025-06-26 19:52:10 -04:00
Min Idzelis
1c339ff85b more progress - dockerfile 2025-06-26 22:41:24 +00:00
Jason Rasmussen
6fed223405 fix: array-max-length (#19562) 2025-06-26 19:41:48 +00:00
Jason Rasmussen
3105094a3d refactor: medium tests (#19537) 2025-06-26 15:32:06 -04:00
Jason Rasmussen
b96c95beda refactor(server): sync service (#19559) 2025-06-26 17:24:57 +00:00
Daimolean
926ff075a3 fix(web): absolute path match in external library (#19551) 2025-06-26 10:46:34 -05:00
Min Idzelis
934649c8df feat(server): check additional exif date tags (#19216)
* feat(server): check additional exif date tags

- Add support for UTC date tags (GPSDateTime, DateTimeUTC, GPSDateStamp, SonyDateTime2)
- This matches tags that exiftool-vendored uses for tzSource in extractTzOffsetFromUTCOffset()

* Review comments

* nit

* review comments

* lots of tests for exif datetime

* missed

* format

* format again

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-06-26 10:18:40 -05:00
Brandon Wees
a43159f4ba fix(mobile): skip widget updates if on android (#19553)
* fix: skip widget updates if on android

* remove dead line
2025-06-26 10:07:22 -05:00
shenlong
ea3a14ed25 feat(mobile): add album asset sync (#19522)
* feat(mobile): add album asset sync

* add SyncAlbumToAssetDeleteV1 to openapi-spec

* update delete queries to use where in statements

* clear remote album when clear remote data

* fix: bad merge

* fix: bad merge

* fix: _SyncAckV1 return type

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: wuzihao051119 <wuzihao051119@outlook.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-06-26 08:50:39 -05:00
shenlong
24a4cba953 fix: enable photo manager custom filter by default (#19520)
enable photo manager custom filter by default

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-06-25 21:28:17 -05:00
shenlong
fda22c83b9 fix: sync stream album assets type order (#19540)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-06-25 20:42:56 -05:00
Matthew Momjian
2a8019726c feat(deployment): add shm_size for PG (#19472) 2025-06-25 20:38:37 +01:00
renovate[bot]
5f76cdddc7 chore(deps): update ghcr.io/astral-sh/uv:latest docker digest to 9653efd (#19530)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 20:37:53 +01:00
bo0tzz
48be10e48b fix: don't exclude .github folder from make install-all (#19534)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-25 20:37:40 +01:00
bo0tzz
6c11ef62e8 fix: don't use @master action version (#19526) 2025-06-25 17:55:17 +00:00
shenlong
65dce58aa4 refactor(mobile): do not skip iCloud albums during local sync (#19231)
do not skip cloud albums during local sync

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-06-25 16:46:31 +00:00
Brandon Wees
64cc7239fe feat(mobile): ios widget deeplink to asset in app (#19510)
* feat: ios widget deeplinks to asset in app

* fix: casing

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-06-25 11:37:25 -05:00
renovate[bot]
5f89c2d111 chore(deps): pin actions/cache action to 5a3ec84 (#19529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 16:21:49 +00:00
Daimolean
4621ec5ea2 fix(web): load day group in asset viewer (#19523)
* fix(web): load day group in asset viewer

* fix: lint
2025-06-25 11:18:11 -05:00
Jason Rasmussen
881a96cdf9 feat: add album asset sync (#19503)
wip: fix album asset exif and some other refactorings

feat: add album assets sync

feat: album to assets relation sync

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
2025-06-25 16:10:31 +00:00
Alex
b001ba44f5 feat: generic control bottom app bar (#19524)
* feat: sliver appbar

* feat: snapping segment

* Date label font size

* lint

* fix: scrollController reinitialize multiple times

* feat: tab navigation

* chore: refactor to private widget

* feat: new control bottom app bar

* bad merge

* feat: sliver control bottom app bar
2025-06-25 16:08:02 +00:00
Zack Pollard
afb444c92c fix: only pass in is_main flag to mobile build for zizmor (#19525) 2025-06-25 17:53:08 +02:00
Zack Pollard
027c4a8b34 ci: much faster mobile builds (#19490) 2025-06-25 10:40:53 -05:00
Min Idzelis
839db1e2c4 pnpm 2025-06-24 21:37:42 +00:00
204 changed files with 50857 additions and 24245 deletions

View File

@@ -73,10 +73,7 @@ install_dependencies() {
log "Installing dependencies"
(
cd "${IMMICH_WORKSPACE}" || exit 1
run_cmd make ci-server
run_cmd make ci-sdk
run_cmd make build-sdk
run_cmd make ci-web
CI=1 run_cmd make install-all
)
log ""
}

View File

@@ -8,11 +8,6 @@ services:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override
- ..:/workspaces/immich
- cli_node_modules:/workspaces/immich/cli/node_modules
- e2e_node_modules:/workspaces/immich/e2e/node_modules
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/workspaces/immich/server/upload/upload
- /etc/localtime:/etc/localtime:ro

View File

@@ -10,8 +10,9 @@ cd "${IMMICH_WORKSPACE}/server" || (
exit 1
)
CI=1 pnpm install
while true; do
run_cmd node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch
run_cmd pnpm exec nest start --debug "0.0.0.0:9230" --watch
log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -16,7 +16,7 @@ until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_
done
while true; do
run_cmd node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}"
run_cmd pnpm exec vite dev --host 0.0.0.0 --port "${DEV_PORT}"
log "Web crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -4,6 +4,7 @@
design/
docker/
Dockerfile
!docker/scripts
docs/
!docs/package.json

View File

@@ -58,7 +58,7 @@ jobs:
contents: read
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }}
runs-on: macos-14
runs-on: mich
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -66,11 +66,34 @@ jobs:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Install missing deps
run: |
sudo add-apt-repository ppa:rmescandon/yq
sudo apt-get update
sudo apt-get install -y yq xz-utils ninja-build zstd
- name: Create the Keystore
env:
KEY_JKS: ${{ secrets.KEY_JKS }}
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'
cache: 'gradle'
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Flutter SDK
uses: subosito/flutter-action@395322a6cded4e9ed503aebd4cc1965625f8e59a # v2.20.0
@@ -79,11 +102,10 @@ jobs:
flutter-version-file: ./mobile/pubspec.yaml
cache: true
- name: Create the Keystore
env:
KEY_JKS: ${{ secrets.KEY_JKS }}
working-directory: ./mobile
run: echo $KEY_JKS | base64 -d > android/key.jks
- name: Setup Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2
with:
packages: ''
- name: Get Packages
working-directory: ./mobile
@@ -103,12 +125,30 @@ jobs:
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
if [[ $IS_MAIN == 'true' ]]; then
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
flutter build apk --debug --split-per-abi --target-platform android-arm64
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4
if: github.ref == 'refs/heads/main'
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}

View File

@@ -60,14 +60,14 @@ jobs:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
- name: Run install
run: pnpm install
- name: Check formatting
run: npm run format
run: pnpm format
- name: Run build
run: npm run build
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

View File

@@ -80,30 +80,33 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
- name: Run package manager install
run: pnpm install
- name: Run linter
run: npm run lint
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: npm run format
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: npm run check
run: pnpm check
if: ${{ !cancelled() }}
- name: Run small tests & coverage
run: npm test
run: pnpm test
if: ${{ !cancelled() }}
cli-unit-tests:
@@ -123,34 +126,37 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: npm ci && npm run build
run: pnpm install && pnpm run build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: npm ci
run: pnpm install
- name: Run linter
run: npm run lint
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: npm run format
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: npm run check
run: pnpm check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: npm run test
run: pnpm test
if: ${{ !cancelled() }}
cli-unit-tests-win:
@@ -170,27 +176,30 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: npm ci && npm run build
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: npm ci
run: pnpm install --frozen-lockfile
# Skip linter & formatter in Windows test.
- name: Run tsc
run: npm run check
run: pnpm check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: npm run test
run: pnpm test
if: ${{ !cancelled() }}
web-lint:
@@ -210,30 +219,33 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Run npm install
run: npm ci
run: pnpm install --frozen-lockfile
- name: Run linter
run: npm run lint:p
run: pnpm lint:p
if: ${{ !cancelled() }}
- name: Run formatter
run: npm run format
run: pnpm format
if: ${{ !cancelled() }}
- name: Run svelte checks
run: npm run check:svelte
run: pnpm check:svelte
if: ${{ !cancelled() }}
web-unit-tests:
@@ -253,26 +265,29 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Run npm install
run: npm ci
run: pnpm install --frozen-lockfile
- name: Run tsc
run: npm run check:typescript
run: pnpm check:typescript
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: npm run test
run: pnpm test
if: ${{ !cancelled() }}
i18n-tests:
@@ -288,18 +303,21 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Install dependencies
run: npm --prefix=web ci
run: pnpm --filter=immich-web install --frozen-lockfile
- name: Format
run: npm --prefix=web run format:i18n
run: pnpm --filter=immich-web format:i18n
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
@@ -334,32 +352,35 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: npm ci
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Run linter
run: npm run lint
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: npm run format
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: npm run check
run: pnpm check
if: ${{ !cancelled() }}
server-medium-tests:
@@ -379,18 +400,21 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
run: pnpm install --frozen-lockfile
- name: Run medium tests
run: npm run test:medium
run: pnpm test:medium
if: ${{ !cancelled() }}
e2e-tests-server-cli:
@@ -414,25 +438,28 @@ jobs:
persist-credentials: false
submodules: 'recursive'
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup cli
run: npm ci && npm run build
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: npm ci
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Docker build
@@ -440,7 +467,7 @@ jobs:
if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
run: npm run test
run: pnpm test
if: ${{ !cancelled() }}
e2e-tests-web:
@@ -464,20 +491,23 @@ jobs:
persist-credentials: false
submodules: 'recursive'
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: npm ci
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
@@ -584,18 +614,21 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './.github/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
run: pnpm install --frozen-lockfile
- name: Run formatter
run: npm run format
run: pnpm format
if: ${{ !cancelled() }}
shellcheck:
@@ -609,7 +642,7 @@ jobs:
persist-credentials: false
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@master
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
with:
ignore_paths: >-
**/open-api/**
@@ -627,18 +660,21 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: npm --prefix=server ci
run: pnpm --filter immich install --frozen-lockfile
- name: Build the app
run: npm --prefix=server run build
run: pnpm --filter immich build
- name: Run API generation
run: make open-api
@@ -690,28 +726,31 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache: 'pnpm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: npm ci
run: pnpm install --frozen-lockfile
- name: Build the app
run: npm run build
run: pnpm build
- name: Run existing migrations
run: npm run migrations:run
run: pnpm migrations:run
- name: Test npm run schema:reset command works
run: npm run schema:reset
run: pnpm schema:reset
- name: Generate new migrations
continue-on-error: true
run: npm run migrations:generate src/TestMigration
run: pnpm migrations:generate src/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
@@ -730,7 +769,7 @@ jobs:
exit 1
- name: Run SQL generation
run: npm run sync:sql
run: pnpm sync:sql
env:
DB_URL: postgres://postgres:postgres@localhost:5432/immich

View File

@@ -34,41 +34,52 @@ open-api-typescript:
cd ./open-api && bash ./bin/generate-open-api.sh typescript
sql:
npm --prefix server run sync:sql
pnpm --filter immich run sync:sql
attach-server:
docker exec -it docker_immich-server_1 sh
renovate:
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
MODULES = e2e server web cli sdk docs .github
# package names mapping function
# cli = @immich/cli
# docs = documentation
# e2e = immich-e2e
# open-api/typescript-sdk = @immich/sdk
# server = immich
# web = immich-web
map-package = $(subst sdk,@immich/sdk,$(subst cli,@immich/cli,$(subst docs,documentation,$(subst e2e,immich-e2e,$(subst server,immich,$(subst web,immich-web,$1))))))
audit-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
pnpm --filter $(call map-package,$*) audit fix
install-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
pnpm --filter $(call map-package,$*) install
ci-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) ci
pnpm --filter $(call map-package,$*) install --frozen-lockfile
build-cli: build-sdk
build-web: build-sdk
build-%: install-%
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
pnpm --filter $(call map-package,$*) run build
format-%:
npm --prefix $* run format:fix
pnpm --filter $(call map-package,$*) run format:fix
lint-%:
npm --prefix $* run lint:fix
pnpm --filter $(call map-package,$*) run lint:fix
lint-web:
pnpm --filter $(call map-package,$*) run lint:p
check-%:
npm --prefix $* run check
pnpm --filter $(call map-package,$*) run check
check-web:
npm --prefix web run check:typescript
npm --prefix web run check:svelte
pnpm --filter immich-web run check:typescript
pnpm --filter immich-web run check:svelte
test-%:
npm --prefix $* run test
pnpm --filter $(call map-package,$*) run test
test-e2e:
docker compose -f ./e2e/docker-compose.yml build
npm --prefix e2e run test
npm --prefix e2e run test:web
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
test-medium:
docker run \
--rm \
@@ -78,24 +89,39 @@ test-medium:
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
-e NODE_ENV=development \
immich-server:latest \
-c "npm ci && npm run test:medium -- --run"
-c "pnpm test:medium -- --run"
test-medium-dev:
docker exec -it immich_server /bin/sh -c "npm run test:medium"
docker exec -it immich_server /bin/sh -c "pnpm run test:medium"
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
install-all: $(foreach M,$(filter-out .github,$(MODULES)),install-$M) ;
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
audit-all: $(foreach M,$(MODULES),audit-$M) ;
hygiene-all: lint-all format-all check-all sql audit-all;
test-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),test-$M) ;
install-all:
pnpm -r --filter '!documentation' install
ci-all:
pnpm -r --filter '!documentation' install --frozen-lockfile
build-all: $(foreach M,$(filter-out e2e docs .github,$(MODULES)),build-$M) ;
check-all:
pnpm -r --filter '!documentation' run "/^(check|check\:svelte|check\:typescript)$/"
lint-all:
pnpm -r --filter '!documentation' run lint:fix
format-all:
pnpm -r --filter '!documentation' run format:fix
audit-all:
pnpm -r --filter '!documentation' audit fix
hygiene-all: audit-all
pnpm -r --filter '!documentation' run "/(format:fix|check|check:svelte|check:typescript|sql)/"
test-all:
pnpm -r --filter '!documentation' run "/^test/"
prune:
pnpm store prune
clean:
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name "svelte-kit" -type d -prune -exec rm -rf '{}' +
docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
docker compose -f ./e2e/docker-compose.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true

View File

@@ -6,8 +6,10 @@ Please see the [Immich CLI documentation](https://immich.app/docs/features/comma
Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder:
$ npm install
$ npm run build
# if you don't have node installed
$ npm install -g pnpm
$ pnpm install
$ pnpm build
Then, to build the open-api client run the following in the open-api folder:
@@ -15,8 +17,10 @@ Then, to build the open-api client run the following in the open-api folder:
To run the Immich CLI from source, run the following in the cli folder:
$ npm install
$ npm run build
# if you don't have node installed
$ npm install -g pnpm
$ pnpm install
$ pnpm build
$ ts-node .
You'll need ts-node, the easiest way to install it is to use npm:

2
cli/bin/immich Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../dist/index.js');

View File

@@ -5,7 +5,7 @@
"type": "module",
"exports": "./dist/index.js",
"bin": {
"immich": "dist/index.js"
"immich": "./bin/immich"
},
"license": "GNU Affero General Public License version 3",
"keywords": [

2825
cli/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

22
docker/.env.bak Normal file
View File

@@ -0,0 +1,22 @@
# You can find documentation for all the supported env variables at https://immich.app/docs/install/environment-variables
# The location where your uploaded files are stored
UPLOAD_LOCATION=/LUNA/ALPHA/MEDIA/PHOTOS/immich_dev_library
# The location where your database files are stored. Network shares are not supported for the database
DB_DATA_LOCATION=./postgres
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v1.71.0"
IMMICH_VERSION=release
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=postgres
# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immich

View File

@@ -24,11 +24,11 @@ services:
build:
context: ../
dockerfile: server/Dockerfile
target: dev
target: dev-docker
restart: unless-stopped
volumes:
- ../server:/usr/src/app
- ../open-api:/usr/src/open-api
- ../open-api/:/usr/src/open-api
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
- /usr/src/app/node_modules
@@ -69,7 +69,8 @@ services:
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
# user: 0:0
build:
context: ../web
context: ../
dockerfile: web/Dockerfile
command: ['/usr/src/app/bin/immich-web']
env_file:
- .env
@@ -79,7 +80,7 @@ services:
volumes:
- ../web:/usr/src/app
- ../i18n:/usr/src/i18n
- ../open-api/:/usr/src/open-api/
- ../open-api/:/usr/src/open-api
# - ../../ui:/usr/ui
- /usr/src/app/node_modules
ulimits:
@@ -134,6 +135,7 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
shm_size: 128mb
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus:
# container_name: immich_prometheus

View File

@@ -75,6 +75,7 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
shm_size: 128mb
restart: always
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics

View File

@@ -67,6 +67,7 @@ services:
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
volumes:

View File

@@ -5,7 +5,7 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
### Installation
```
$ npm install
$ pnpm install
```
### Local Development

View File

@@ -490,7 +490,7 @@ You can also scan the Postgres database file structure for errors:
<details>
<summary>Scan for file structure errors</summary>
```bash
docker exec -it immich_postgres pg_amcheck --username=postgres --heapallindexed --parent-check --rootdescend --progress --all --install-missing
docker exec -it immich_postgres pg_amcheck --username=<DB_USERNAME> --heapallindexed --parent-check --rootdescend --progress --all --install-missing
```
A normal result will end something like this and return with an exit code of `0`:

View File

@@ -57,7 +57,7 @@ Then please follow the steps in the following section for restoring the database
<TabItem value="Linux system" label="Linux system" default>
```bash title='Backup'
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | gzip > "/path/to/backup/dump.sql.gz"
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> | gzip > "/path/to/backup/dump.sql.gz"
```
```bash title='Restore'
@@ -79,7 +79,7 @@ docker compose up -d # Start remainder of Immich apps
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
```powershell title='Backup'
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres))
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME>))
```
```powershell title='Restore'
@@ -150,12 +150,10 @@ for more info read the [release notes](https://github.com/immich-app/immich/rele
- Preview images (small thumbnails and large previews) for each asset and thumbnails for recognized faces.
- Stored in `UPLOAD_LOCATION/thumbs/<userID>`.
- **Encoded Assets:**
- Videos that have been re-encoded from the original for wider compatibility. The original is not removed.
- Stored in `UPLOAD_LOCATION/encoded-video/<userID>`.
- **Postgres**
- The Immich database containing all the information to allow the system to function properly.
**Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version.
- Stored in `DB_DATA_LOCATION`.
@@ -201,7 +199,6 @@ When you turn off the storage template engine, it will leave the assets in `UPLO
- Temporarily located in `UPLOAD_LOCATION/upload/<userID>`.
- Transferred to `UPLOAD_LOCATION/library/<userID>` upon successful upload.
- **Postgres**
- The Immich database containing all the information to allow the system to function properly.
**Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version.
- Stored in `DB_DATA_LOCATION`.

View File

@@ -20,7 +20,6 @@ Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an i
Before enabling OAuth in Immich, a new client application needs to be configured in the 3rd-party authentication server. While the specifics of this setup vary from provider to provider, the general approach should be the same.
1. Create a new (Client) Application
1. The **Provider** type should be `OpenID Connect` or `OAuth2`
2. The **Client type** should be `Confidential`
3. The **Application** type should be `Web`
@@ -29,7 +28,6 @@ Before enabling OAuth in Immich, a new client application needs to be configured
2. Configure Redirect URIs/Origins
The **Sign-in redirect URIs** should include:
- `app.immich:///oauth-callback` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
- `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
@@ -37,21 +35,17 @@ Before enabling OAuth in Immich, a new client application needs to be configured
Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
Mobile
- `app.immich:///oauth-callback` (You **MUST** include this for iOS and Android mobile apps to work properly)
Localhost
- `http://localhost:2283/auth/login`
- `http://localhost:2283/user-settings`
Local IP
- `http://192.168.0.200:2283/auth/login`
- `http://192.168.0.200:2283/user-settings`
Hostname
- `https://immich.example.com/auth/login`
- `https://immich.example.com/user-settings`

View File

@@ -199,13 +199,11 @@ To use your SSH key for commit signing, see the [GitHub guide on SSH commit sign
When the Dev Container starts, it automatically:
1. **Runs post-create script** (`container-server-post-create.sh`):
- Adjusts file permissions for the `node` user
- Installs dependencies: `npm install` in all packages
- Installs dependencies: `pnpm install` in all packages
- Builds TypeScript SDK: `npm run build` in `open-api/typescript-sdk`
2. **Starts development servers** via VS Code tasks:
- `Immich API Server (Nest)` - API server with hot-reloading on port 2283
- `Immich Web Server (Vite)` - Web frontend with hot-reloading on port 3000
- Both servers watch for file changes and recompile automatically
@@ -335,14 +333,12 @@ make install-all # Install all dependencies
The Dev Container is pre-configured for debugging:
1. **API Server Debugging**:
- Set breakpoints in VS Code
- Press `F5` or use "Run and Debug" panel
- Select "Attach to Server" configuration
- Debug port: 9231
2. **Worker Debugging**:
- Use "Attach to Workers" configuration
- Debug port: 9230
@@ -428,7 +424,6 @@ While the Dev Container focuses on server and web development, you can connect m
```
2. **Configure mobile app**:
- Server URL: `http://YOUR_IP:2283/api`
- Ensure firewall allows port 2283

View File

@@ -56,7 +56,7 @@ If you only want to do web development connected to an existing, remote backend,
1. Build the Immich SDK - `cd open-api/typescript-sdk && npm i && npm run build && cd -`
2. Enter the web directory - `cd web/`
3. Install web dependencies - `npm i`
3. Install web dependencies - `pnpm i`
4. Start the web development server
```bash

View File

@@ -5,7 +5,7 @@
### Unit tests
Unit are run by calling `npm run test` from the `server/` directory.
You need to run `npm install` (in `server/`) before _once_.
You need to run `pnpm install` (in `server/`) before _once_.
### End to end tests

View File

@@ -52,9 +52,9 @@ REMOTE_BACKUP_PATH="/path/to/remote/backup/directory"
### Local
# Backup Immich database
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
# For deduplicating backup programs such as Borg or Restic, compressing the content can increase backup size by making it harder to deduplicate. If you are using a different program or still prefer to compress, you can use the following command instead:
# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
### Append to local Borg repository
borg create "$BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/

View File

@@ -75,7 +75,6 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
5. Click "**Save Changes**", you will be prompted to edit stack UI labels, just leave this blank and click "**Ok**"
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following:
- `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION`
- `DB_DATA_LOCATION`: Change this to use an Unraid share (preferably a cache pool, e.g. `/mnt/user/appdata/postgresql/data`). This uses the `appdata` share. Do also create the `postgresql` folder, by running `mkdir /mnt/user/{share_location}/postgresql/data`. If left at default it will try to use Unraid's `/boot/config/plugins/compose.manager/projects/[stack_name]/postgres` folder which it doesn't have permissions to, resulting in this container continuously restarting.

20954
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@
"dependencies": {
"@docusaurus/core": "~3.7.0",
"@docusaurus/preset-classic": "~3.7.0",
"@docusaurus/theme-common": "~3.7.0",
"@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1",
"@mdx-js/react": "^3.0.0",
@@ -26,6 +27,7 @@
"clsx": "^2.0.0",
"docusaurus-lunr-search": "^3.3.2",
"docusaurus-preset-openapi": "^0.7.5",
"lunr": "^2.3.9",
"postcss": "^8.4.25",
"prism-react-renderer": "^2.3.1",
"raw-loader": "^4.0.2",

13672
docs/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

474
e2e/package-lock.json generated
View File

@@ -34,6 +34,7 @@
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.33.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
@@ -178,6 +179,17 @@
"node": ">=18"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
@@ -823,6 +835,386 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@immich/cli": {
"resolved": "../cli",
"link": true
@@ -2447,6 +2839,20 @@
"node": ">=0.8.0"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2467,6 +2873,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -3964,6 +4381,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-builtin-module": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz",
@@ -5536,6 +5960,46 @@
"dev": true,
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5655,6 +6119,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",

View File

@@ -44,6 +44,7 @@
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.33.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",

4470
e2e/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import sharp from 'sharp';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
@@ -40,6 +41,40 @@ const today = DateTime.fromObject({
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
const createTestImageWithExif = async (filename: string, exifData: Record<string, any>) => {
// Generate unique color to ensure different checksums for each image
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
// Create a 100x100 solid color JPEG using Sharp
const imageBytes = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
// Add random suffix to filename to avoid collisions
const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`);
const filepath = join(tempDir, uniqueFilename);
await writeFile(filepath, imageBytes);
// Filter out undefined values before writing EXIF
const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined));
await exiftool.write(filepath, cleanExifData);
// Re-read the image bytes after EXIF has been written
const finalImageBytes = await readFile(filepath);
return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename };
};
describe('/asset', () => {
let admin: LoginResponseDto;
let websocket: Socket;
@@ -1190,6 +1225,411 @@ describe('/asset', () => {
});
});
describe('EXIF metadata extraction', () => {
describe('Additional date tag extraction', () => {
describe('Date-time vs time-only tag handling', () => {
it('should fall back to file timestamps when only time-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', {
TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal
// Exclude all date-time tags to force fallback to file timestamps
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
SonyDateTime2: undefined,
GPSDateStamp: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should prefer DateTimeOriginal over time-only tags', async () => {
const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', {
DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred
TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only)
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal, not TimeCreated
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
describe('GPSDateTime tag extraction', () => {
it('should extract GPSDateTime with GPS coordinates', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: 37.7749,
GPSLongitude: -122.4194,
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4);
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T12:30:00.000Z').getTime(),
);
});
});
describe('CreateDate tag extraction', () => {
it('should extract CreateDate when available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', {
CreateDate: '2023:11:15 10:30:00',
// Exclude other higher priority date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T10:30:00.000Z').getTime(),
);
});
});
describe('GPSDateStamp tag extraction', () => {
it('should fall back to file timestamps when only date-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', {
GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal
// Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation
GPSLatitude: 51.5074,
GPSLongitude: -0.1278,
// Explicitly exclude all testable date-time tags to force fallback to file timestamps
DateTimeOriginal: undefined,
CreateDate: undefined,
CreationDate: undefined,
GPSDateTime: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4);
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
});
/*
* NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files:
*
* NOT WRITABLE to JPEG:
* - MediaCreateDate: Can be read from video files but not written to JPEG
* - DateTimeCreated: Read-only tag in JPEG format
* - DateTimeUTC: Cannot be written to JPEG files
* - SonyDateTime2: Proprietary Sony tag, not writable to JPEG
* - SubSecMediaCreateDate: Tag not defined for JPEG format
* - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG
*
* WRITABLE but NOT READABLE from JPEG:
* - SubSecDateTimeOriginal: Can be written but not read back from JPEG
* - SubSecCreateDate: Can be written but not read back from JPEG
*
* EFFECTIVELY TESTABLE TAGS (writable and readable):
* - DateTimeOriginal ✓
* - CreateDate ✓
* - CreationDate ✓
* - GPSDateTime ✓
*
* The metadata service correctly handles non-readable tags and will fall back to
* file timestamps when only non-readable tags are present.
*/
describe('Date tag priority order', () => {
it('should respect the complete date tag priority order', async () => {
// Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG)
const testCases = [
{
name: 'DateTimeOriginal has highest priority among testable tags',
exifData: {
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-04-04T04:00:00.000Z',
},
{
name: 'CreateDate when DateTimeOriginal missing',
exifData: {
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-05-05T05:00:00.000Z',
},
{
name: 'CreationDate when standard EXIF tags missing',
exifData: {
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-07-07T07:00:00.000Z',
},
{
name: 'GPSDateTime when no other testable date tags present',
exifData: {
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
Make: 'SONY',
},
expectedDate: '2023-10-10T10:00:00.000Z',
},
];
for (const testCase of testCases) {
const { imageBytes, filename } = await createTestImageWithExif(
`${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`,
testCase.exifData,
);
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined();
expect(
new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(),
`Date mismatch for: ${testCase.name}`,
).toBe(new Date(testCase.expectedDate).getTime());
}
});
});
describe('Edge cases for date tag handling', () => {
it('should fall back to file timestamps with GPSDateStamp alone', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', {
GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal
// Intentionally no GPSTimeStamp
// Exclude all other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should handle all testable date tags present to verify complete priority order', async () => {
const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', {
// All TESTABLE date tags to JPEG format (writable AND readable)
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
// Note: Excluded non-testable tags:
// SubSec tags: writable but not readable from JPEG
// Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc.
// Time-only/date-only tags: already excluded from EXIF_DATE_TAGS
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal as it has the highest priority among testable tags
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-04-04T04:00:00.000Z').getTime(),
);
});
it('should use CreationDate when SubSec tags are missing', async () => {
const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', {
CreationDate: '2023:07:07 07:00:00', // WRITABLE
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE
// Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG
// Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only)
// Exclude SubSec and standard EXIF tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use CreationDate when available
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-07-07T07:00:00.000Z').getTime(),
);
});
it('should skip invalid date formats and use next valid tag', async () => {
const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', {
// Note: Testing invalid date handling with only WRITABLE tags
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date
CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date
// Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should skip invalid dates and use the first valid one (GPSDateTime)
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
});
});
describe('POST /assets/exist', () => {
it('ignores invalid deviceAssetIds', async () => {
const response = await utils.checkExistingAssets(user1.accessToken, {

View File

@@ -1,146 +0,0 @@
import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto } from 'src/fixtures';
import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
const { email, password } = signupDto.admin;
describe(`/auth/admin-sign-up`, () => {
beforeEach(async () => {
await utils.resetDatabase();
});
describe('POST /auth/admin-sign-up', () => {
it(`should sign up the admin`, async () => {
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(201);
expect(body).toEqual(signupResponseDto.admin);
});
it('should not allow a second admin to sign up', async () => {
await signUpAdmin({ signUpDto: signupDto.admin });
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(400);
expect(body).toEqual(errorDto.alreadyHasAdmin);
});
});
});
describe('/auth/*', () => {
let admin: LoginResponseDto;
beforeEach(async () => {
await utils.resetDatabase();
await signUpAdmin({ signUpDto: signupDto.admin });
admin = await login({ loginCredentialDto: loginDto.admin });
});
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(app).post('/auth/login').send({ email, password: 'incorrect' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.incorrectLogin);
});
it('should accept a correct password', async () => {
const { status, body, headers } = await request(app).post('/auth/login').send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseDto.admin);
const token = body.accessToken;
expect(token).toBeDefined();
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
`immich_access_token=${token}`,
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
'immich_auth_type=password',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
'immich_is_authenticated=true',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'SameSite=Lax',
]);
});
});
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidToken);
});
it('should accept a valid token', async () => {
const { status, body } = await request(app)
.post(`/auth/validateToken`)
.send({})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ authStatus: true });
});
});
describe('POST /auth/change-password', () => {
it('should require the current password', async () => {
const { status, body } = await request(app)
.post(`/auth/change-password`)
.send({ password: 'wrong-password', newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.wrongPassword);
});
it('should change the password', async () => {
const { status } = await request(app)
.post(`/auth/change-password`)
.send({ password, newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
await login({
loginCredentialDto: {
email: 'admin@immich.cloud',
password: 'Password1234',
},
});
});
});
describe('POST /auth/logout', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/auth/logout`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout the user', async () => {
const { status, body } = await request(app)
.post(`/auth/logout`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
});
});
});

View File

@@ -6,7 +6,7 @@ import {
createMemory,
getMemory,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
@@ -17,7 +17,6 @@ describe('/memories', () => {
let user: LoginResponseDto;
let adminAsset: AssetMediaResponseDto;
let userAsset1: AssetMediaResponseDto;
let userAsset2: AssetMediaResponseDto;
let userMemory: MemoryResponseDto;
beforeAll(async () => {
@@ -25,10 +24,9 @@ describe('/memories', () => {
admin = await utils.adminSetup();
user = await utils.userSetup(admin.accessToken, createUserDto.user1);
[adminAsset, userAsset1, userAsset2] = await Promise.all([
[adminAsset, userAsset1] = await Promise.all([
utils.createAsset(admin.accessToken),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]);
userMemory = await createMemory(
{
@@ -43,121 +41,7 @@ describe('/memories', () => {
);
});
describe('GET /memories', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/memories');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /memories', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/memories');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should validate data when type is on this day', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: {},
memoryAt: new Date(2021).toISOString(),
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
);
});
it('should create a new memory', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: { year: 2021 },
memoryAt: new Date(2021).toISOString(),
});
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
type: 'on_this_day',
data: { year: 2021 },
createdAt: expect.any(String),
updatedAt: expect.any(String),
isSaved: false,
memoryAt: expect.any(String),
ownerId: user.userId,
assets: [],
});
});
it('should create a new memory (with assets)', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: { year: 2021 },
memoryAt: new Date(2021).toISOString(),
assetIds: [userAsset1.id, userAsset2.id],
});
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
assets: expect.arrayContaining([
expect.objectContaining({ id: userAsset1.id }),
expect.objectContaining({ id: userAsset2.id }),
]),
});
expect(body.assets).toHaveLength(2);
});
it('should create a new memory and ignore assets the user does not have access to', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: { year: 2021 },
memoryAt: new Date(2021).toISOString(),
assetIds: [userAsset1.id, adminAsset.id],
});
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
assets: [expect.objectContaining({ id: userAsset1.id })],
});
expect(body.assets).toHaveLength(1);
});
});
describe('GET /memories/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/memories/${uuidDto.invalid}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.get(`/memories/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.get(`/memories/${userMemory.id}`)
@@ -176,22 +60,6 @@ describe('/memories', () => {
});
describe('PUT /memories/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/memories/${uuidDto.invalid}`).send({ isSaved: true });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.put(`/memories/${uuidDto.invalid}`)
.send({ isSaved: true })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}`)
@@ -218,23 +86,6 @@ describe('/memories', () => {
});
describe('PUT /memories/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
.send({ ids: [userAsset1.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.put(`/memories/${uuidDto.invalid}/assets`)
.send({ ids: [userAsset1.id] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
@@ -244,15 +95,6 @@ describe('/memories', () => {
expect(body).toEqual(errorDto.noPermission);
});
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
.send({ ids: [uuidDto.invalid] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
it('should require asset access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
@@ -279,23 +121,6 @@ describe('/memories', () => {
});
describe('DELETE /memories/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
.send({ ids: [userAsset1.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.delete(`/memories/${uuidDto.invalid}/assets`)
.send({ ids: [userAsset1.id] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
@@ -305,15 +130,6 @@ describe('/memories', () => {
expect(body).toEqual(errorDto.noPermission);
});
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
.send({ ids: [uuidDto.invalid] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
it('should only remove assets in the memory', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
@@ -340,21 +156,6 @@ describe('/memories', () => {
});
describe('DELETE /memories/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/memories/${uuidDto.invalid}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.delete(`/memories/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}`)

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* Script to generate test images with additional EXIF date tags
* This creates actual JPEG images with embedded metadata for testing
* Images are generated into e2e/test-assets/metadata/dates/
*/
import { execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
interface TestImage {
filename: string;
description: string;
exifTags: Record<string, string>;
}
const testImages: TestImage[] = [
{
filename: 'time-created.jpg',
description: 'Image with TimeCreated tag',
exifTags: {
TimeCreated: '2023:11:15 14:30:00',
Make: 'Canon',
Model: 'EOS R5',
},
},
{
filename: 'gps-datetime.jpg',
description: 'Image with GPSDateTime and coordinates',
exifTags: {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: '37.7749',
GPSLongitude: '-122.4194',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'datetime-utc.jpg',
description: 'Image with DateTimeUTC tag',
exifTags: {
DateTimeUTC: '2023:11:15 10:30:00',
Make: 'Nikon',
Model: 'D850',
},
},
{
filename: 'gps-datestamp.jpg',
description: 'Image with GPSDateStamp and GPSTimeStamp',
exifTags: {
GPSDateStamp: '2023:11:15',
GPSTimeStamp: '08:30:00',
GPSLatitude: '51.5074',
GPSLongitude: '-0.1278',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'sony-datetime2.jpg',
description: 'Sony camera image with SonyDateTime2 tag',
exifTags: {
SonyDateTime2: '2023:11:15 06:30:00',
Make: 'SONY',
Model: 'ILCE-7RM5',
},
},
{
filename: 'date-priority-test.jpg',
description: 'Image with multiple date tags to test priority',
exifTags: {
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
DateTimeOriginal: '2023:02:02 02:00:00',
SubSecCreateDate: '2023:03:03 03:00:00',
CreateDate: '2023:04:04 04:00:00',
CreationDate: '2023:05:05 05:00:00',
DateTimeCreated: '2023:06:06 06:00:00',
TimeCreated: '2023:07:07 07:00:00',
GPSDateTime: '2023:08:08 08:00:00',
DateTimeUTC: '2023:09:09 09:00:00',
GPSDateStamp: '2023:10:10',
SonyDateTime2: '2023:11:11 11:00:00',
},
},
{
filename: 'new-tags-only.jpg',
description: 'Image with only additional date tags (no standard tags)',
exifTags: {
TimeCreated: '2023:12:01 15:45:30',
GPSDateTime: '2023:12:01 13:45:30Z',
DateTimeUTC: '2023:12:01 13:45:30',
GPSDateStamp: '2023:12:01',
SonyDateTime2: '2023:12:01 08:45:30',
GPSLatitude: '40.7128',
GPSLongitude: '-74.0060',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
];
const generateTestImages = async (): Promise<void> => {
// Target directory: e2e/test-assets/metadata/dates/
// Current file is in: e2e/src/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates');
console.log('Generating test images with additional EXIF date tags...');
console.log(`Target directory: ${targetDir}`);
for (const image of testImages) {
try {
const imagePath = join(targetDir, image.filename);
// Create unique JPEG file using Sharp
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
const jpegData = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
writeFileSync(imagePath, jpegData);
// Build exiftool command to add EXIF data
const exifArgs = Object.entries(image.exifTags)
.map(([tag, value]) => `-${tag}="${value}"`)
.join(' ');
const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`;
console.log(`Creating ${image.filename}: ${image.description}`);
execSync(command, { stdio: 'pipe' });
// Verify the tags were written
const verifyCommand = `exiftool -json "${imagePath}"`;
const result = execSync(verifyCommand, { encoding: 'utf8' });
const metadata = JSON.parse(result)[0];
console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`);
// Log first date tag found for verification
const firstDateTag = Object.keys(image.exifTags).find(
(tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'),
);
if (firstDateTag && metadata[firstDateTag]) {
console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`);
}
} catch (error) {
console.error(`Failed to create ${image.filename}:`, (error as Error).message);
}
}
console.log('\nTest image generation complete!');
console.log('Files created in:', targetDir);
console.log('\nTo test these images:');
console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`);
};
export { generateTestImages };
// Run the generator if this file is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
generateTestImages().catch(console.error);
}

View File

@@ -59,7 +59,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends g++
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:cda0fdc9b6066975ba4c791597870d18bc3a441dfc18ab24c5e888c16e15780c /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:9653efd4380d5a0e5511e337dcfc3b8ba5bc4e6ea7fa3be7716598261d5503fa /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \

View File

@@ -2,4 +2,6 @@ org.gradle.jvmargs=-Xmx4096M
android.useAndroidX=true
android.enableJetifier=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
android.nonFinalResIds=false
org.gradle.caching=true
org.gradle.parallel=true

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@ import WidgetKit
func buildEntry(
api: ImmichAPI,
asset: SearchResult,
asset: Asset,
dateOffset: Int,
subtitle: String? = nil
)
@@ -15,7 +15,8 @@ func buildEntry(
to: Date.now
)!
let image = try await api.fetchImage(asset: asset)
return ImageEntry(date: entryDate, image: image, subtitle: subtitle)
return ImageEntry(date: entryDate, image: image, subtitle: subtitle, deepLink: asset.deepLink)
}
func generateRandomEntries(

View File

@@ -6,6 +6,7 @@ struct ImageEntry: TimelineEntry {
var image: UIImage?
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
// Resizes the stored image to a maximum width of 450 pixels
mutating func resize() {
@@ -54,6 +55,7 @@ struct ImmichWidgetView: View {
}
.padding(16)
}
.widgetURL(entry.deepLink)
}
}
}

View File

@@ -43,9 +43,13 @@ enum AssetType: String, Codable {
case other = "OTHER"
}
struct SearchResult: Codable {
struct Asset: Codable {
let id: String
let type: AssetType
var deepLink: URL? {
return URL(string: "immich://asset?id=\(id)")
}
}
struct SearchFilters: Codable {
@@ -56,7 +60,7 @@ struct SearchFilters: Codable {
struct MemoryResult: Codable {
let id: String
var assets: [SearchResult]
var assets: [Asset]
let type: String
struct MemoryData: Codable {
@@ -127,7 +131,7 @@ class ImmichAPI {
}
func fetchSearchResults(with filters: SearchFilters) async throws
-> [SearchResult]
-> [Asset]
{
// get URL
guard
@@ -147,7 +151,7 @@ class ImmichAPI {
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([SearchResult].self, from: data)
return try JSONDecoder().decode([Asset].self, from: data)
}
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
@@ -172,7 +176,7 @@ class ImmichAPI {
return try JSONDecoder().decode([MemoryResult].self, from: data)
}
func fetchImage(asset: SearchResult) async throws(WidgetError) -> UIImage {
func fetchImage(asset: Asset) async throws(WidgetError) -> UIImage {
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"

View File

@@ -0,0 +1,79 @@
enum AlbumAssetOrder {
// do not change this order!
asc,
desc,
}
enum AlbumUserRole {
// do not change this order!
editor,
viewer,
}
// Model for an album stored in the server
class Album {
final String id;
final String name;
final String ownerId;
final String description;
final DateTime createdAt;
final DateTime updatedAt;
final String? thumbnailAssetId;
final bool isActivityEnabled;
final AlbumAssetOrder order;
const Album({
required this.id,
required this.name,
required this.ownerId,
required this.description,
required this.createdAt,
required this.updatedAt,
this.thumbnailAssetId,
required this.isActivityEnabled,
required this.order,
});
@override
String toString() {
return '''Album {
id: $id,
name: $name,
ownerId: $ownerId,
description: $description,
createdAt: $createdAt,
updatedAt: $updatedAt,
isActivityEnabled: $isActivityEnabled,
order: $order,
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
}''';
}
@override
bool operator ==(Object other) {
if (other is! Album) return false;
if (identical(this, other)) return true;
return id == other.id &&
name == other.name &&
ownerId == other.ownerId &&
description == other.description &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt &&
thumbnailAssetId == other.thumbnailAssetId &&
isActivityEnabled == other.isActivityEnabled &&
order == other.order;
}
@override
int get hashCode {
return id.hashCode ^
name.hashCode ^
ownerId.hashCode ^
description.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
thumbnailAssetId.hashCode ^
isActivityEnabled.hashCode ^
order.hashCode;
}
}

View File

@@ -2,10 +2,8 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart';
@@ -17,22 +15,16 @@ class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
final NativeSyncApi _nativeSyncApi;
final Platform _platform;
final StoreService _storeService;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required NativeSyncApi nativeSyncApi,
required StoreService storeService,
Platform? platform,
}) : _localAlbumRepository = localAlbumRepository,
_nativeSyncApi = nativeSyncApi,
_storeService = storeService,
_platform = platform ?? const LocalPlatform();
bool get _ignoreIcloudAssets =>
_storeService.get(StoreKey.ignoreIcloudAssets, false) == true;
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
@@ -84,11 +76,7 @@ class LocalSyncService {
);
continue;
}
if (_ignoreIcloudAssets) {
await removeAlbum(dbAlbum);
} else {
await updateAlbum(dbAlbum, album);
}
await updateAlbum(dbAlbum, album);
}
}
@@ -106,12 +94,7 @@ class LocalSyncService {
try {
final Stopwatch stopwatch = Stopwatch()..start();
List<PlatformAlbum> deviceAlbums =
List.of(await _nativeSyncApi.getAlbums());
if (_platform.isIOS && _ignoreIcloudAssets) {
deviceAlbums.removeWhere((album) => album.isCloud);
}
final deviceAlbums = await _nativeSyncApi.getAlbums();
final dbAlbums =
await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});

View File

@@ -76,11 +76,76 @@ class SyncStreamService {
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.partnerAssetV1:
return _syncStreamRepository.updatePartnerAssetsV1(data.cast());
return _syncStreamRepository.updateAssetsV1(
data.cast(),
debugLabel: 'partner',
);
case SyncEntityType.partnerAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(
data.cast(),
debugLabel: 'partner backfill',
);
case SyncEntityType.partnerAssetDeleteV1:
return _syncStreamRepository.deletePartnerAssetsV1(data.cast());
return _syncStreamRepository.deleteAssetsV1(
data.cast(),
debugLabel: "partner",
);
case SyncEntityType.partnerAssetExifV1:
return _syncStreamRepository.updatePartnerAssetsExifV1(data.cast());
return _syncStreamRepository.updateAssetsExifV1(
data.cast(),
debugLabel: 'partner',
);
case SyncEntityType.partnerAssetExifBackfillV1:
return _syncStreamRepository.updateAssetsExifV1(
data.cast(),
debugLabel: 'partner backfill',
);
case SyncEntityType.albumV1:
return _syncStreamRepository.updateAlbumsV1(data.cast());
case SyncEntityType.albumDeleteV1:
return _syncStreamRepository.deleteAlbumsV1(data.cast());
case SyncEntityType.albumUserV1:
return _syncStreamRepository.updateAlbumUsersV1(data.cast());
case SyncEntityType.albumUserBackfillV1:
return _syncStreamRepository.updateAlbumUsersV1(
data.cast(),
debugLabel: 'backfill',
);
case SyncEntityType.albumUserDeleteV1:
return _syncStreamRepository.deleteAlbumUsersV1(data.cast());
case SyncEntityType.albumAssetV1:
return _syncStreamRepository.updateAssetsV1(
data.cast(),
debugLabel: 'album',
);
case SyncEntityType.albumAssetBackfillV1:
return _syncStreamRepository.updateAssetsV1(
data.cast(),
debugLabel: 'album backfill',
);
case SyncEntityType.albumAssetExifV1:
return _syncStreamRepository.updateAssetsExifV1(
data.cast(),
debugLabel: 'album',
);
case SyncEntityType.albumAssetExifBackfillV1:
return _syncStreamRepository.updateAssetsExifV1(
data.cast(),
debugLabel: 'album backfill',
);
case SyncEntityType.albumToAssetV1:
return _syncStreamRepository.updateAlbumToAssetsV1(data.cast());
case SyncEntityType.albumToAssetBackfillV1:
return _syncStreamRepository.updateAlbumToAssetsV1(
data.cast(),
debugLabel: 'backfill',
);
case SyncEntityType.albumToAssetDeleteV1:
return _syncStreamRepository.deleteAlbumToAssetsV1(data.cast());
// No-op. SyncAckV1 entities are checkpoints in the sync stream
// to acknowledge that the client has processed all the backfill events
case SyncEntityType.syncAckV1:
return;
default:
_logger.warning("Unknown sync data type: $type");
}

View File

@@ -45,6 +45,13 @@ class TimelineFactory {
bucketSource: () =>
_timelineRepository.watchLocalBucket(albumId, groupBy: groupBy),
);
TimelineService remoteAlbum({required String albumId}) => TimelineService(
assetSource: (offset, count) => _timelineRepository
.getRemoteBucketAssets(albumId, offset: offset, count: count),
bucketSource: () =>
_timelineRepository.watchRemoteBucket(albumId, groupBy: groupBy),
);
}
class TimelineService {

View File

@@ -1,5 +1,5 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class LocalAlbumEntity extends Table with DriftDefaultsMixin {

View File

@@ -3,7 +3,7 @@
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i1;
import 'package:immich_mobile/domain/models/local_album.model.dart' as i2;
import 'package:immich_mobile/domain/models/album/local_album.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'
as i3;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;

View File

@@ -0,0 +1,34 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class RemoteAlbumEntity extends Table with DriftDefaultsMixin {
const RemoteAlbumEntity();
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get description => text().withDefault(const Constant(''))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get ownerId =>
text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get thumbnailAssetId => text()
.references(RemoteAssetEntity, #id, onDelete: KeyAction.setNull)
.nullable()();
BoolColumn get isActivityEnabled =>
boolean().withDefault(const Constant(true))();
IntColumn get order => intEnum<AlbumAssetOrder>()();
@override
Set<Column> get primaryKey => {id};
}

View File

@@ -0,0 +1,946 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i1;
import 'package:immich_mobile/domain/models/album/album.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'
as i3;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
as i5;
import 'package:drift/internal/modular.dart' as i6;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i7;
typedef $$RemoteAlbumEntityTableCreateCompanionBuilder
= i1.RemoteAlbumEntityCompanion Function({
required String id,
required String name,
i0.Value<String> description,
i0.Value<DateTime> createdAt,
i0.Value<DateTime> updatedAt,
required String ownerId,
i0.Value<String?> thumbnailAssetId,
i0.Value<bool> isActivityEnabled,
required i2.AlbumAssetOrder order,
});
typedef $$RemoteAlbumEntityTableUpdateCompanionBuilder
= i1.RemoteAlbumEntityCompanion Function({
i0.Value<String> id,
i0.Value<String> name,
i0.Value<String> description,
i0.Value<DateTime> createdAt,
i0.Value<DateTime> updatedAt,
i0.Value<String> ownerId,
i0.Value<String?> thumbnailAssetId,
i0.Value<bool> isActivityEnabled,
i0.Value<i2.AlbumAssetOrder> order,
});
final class $$RemoteAlbumEntityTableReferences extends i0.BaseReferences<
i0.GeneratedDatabase,
i1.$RemoteAlbumEntityTable,
i1.RemoteAlbumEntityData> {
$$RemoteAlbumEntityTableReferences(
super.$_db, super.$_table, super.$_typedResult);
static i5.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$UserEntityTable>('user_entity')
.createAlias(i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumEntityTable>('remote_album_entity')
.ownerId,
i6.ReadDatabaseContainer(db)
.resultSet<i5.$UserEntityTable>('user_entity')
.id));
i5.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;
final manager = i5
.$$UserEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer($_db)
.resultSet<i5.$UserEntityTable>('user_entity'))
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_ownerIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
static i7.$RemoteAssetEntityTable _thumbnailAssetIdTable(
i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumEntityTable>('remote_album_entity')
.thumbnailAssetId,
i6.ReadDatabaseContainer(db)
.resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity')
.id));
i7.$$RemoteAssetEntityTableProcessedTableManager? get thumbnailAssetId {
final $_column = $_itemColumn<String>('thumbnail_asset_id');
if ($_column == null) return null;
final manager = i7
.$$RemoteAssetEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer($_db)
.resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'))
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_thumbnailAssetIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
}
class $$RemoteAlbumEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$RemoteAlbumEntityTable> {
$$RemoteAlbumEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get name => $composableBuilder(
column: $table.name, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get description => $composableBuilder(
column: $table.description,
builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<bool> get isActivityEnabled => $composableBuilder(
column: $table.isActivityEnabled,
builder: (column) => i0.ColumnFilters(column));
i0.ColumnWithTypeConverterFilters<i2.AlbumAssetOrder, i2.AlbumAssetOrder, int>
get order => $composableBuilder(
column: $table.order,
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
i5.$$UserEntityTableFilterComposer get ownerId {
final i5.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i6.ReadDatabaseContainer($db)
.resultSet<i5.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i5.$$UserEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer($db)
.resultSet<i5.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i7.$$RemoteAssetEntityTableFilterComposer get thumbnailAssetId {
final i7.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.thumbnailAssetId,
referencedTable: i6.ReadDatabaseContainer($db)
.resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i7.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer($db)
.resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$RemoteAlbumEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$RemoteAlbumEntityTable> {
$$RemoteAlbumEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get name => $composableBuilder(
column: $table.name, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get description => $composableBuilder(
column: $table.description,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<bool> get isActivityEnabled => $composableBuilder(
column: $table.isActivityEnabled,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<int> get order => $composableBuilder(
column: $table.order, builder: (column) => i0.ColumnOrderings(column));
i5.$$UserEntityTableOrderingComposer get ownerId {
final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i6.ReadDatabaseContainer($db)
.resultSet<i5.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i5.$$UserEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer($db)
.resultSet<i5.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i7.$$RemoteAssetEntityTableOrderingComposer get thumbnailAssetId {
final i7.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.thumbnailAssetId,
referencedTable: i6.ReadDatabaseContainer($db)
.resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i7.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer($db)
.resultSet<i7.$RemoteAssetEntityTable>(
'remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$RemoteAlbumEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$RemoteAlbumEntityTable> {
$$RemoteAlbumEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumn<String> get name =>
$composableBuilder(column: $table.name, builder: (column) => column);
i0.GeneratedColumn<String> get description => $composableBuilder(
column: $table.description, builder: (column) => column);
i0.GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
i0.GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
i0.GeneratedColumn<bool> get isActivityEnabled => $composableBuilder(
column: $table.isActivityEnabled, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.AlbumAssetOrder, int> get order =>
$composableBuilder(column: $table.order, builder: (column) => column);
i5.$$UserEntityTableAnnotationComposer get ownerId {
final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i6.ReadDatabaseContainer($db)
.resultSet<i5.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i5.$$UserEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer($db)
.resultSet<i5.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i7.$$RemoteAssetEntityTableAnnotationComposer get thumbnailAssetId {
final i7.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.thumbnailAssetId,
referencedTable: i6.ReadDatabaseContainer($db)
.resultSet<i7.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i7.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer($db)
.resultSet<i7.$RemoteAssetEntityTable>(
'remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$RemoteAlbumEntityTableTableManager extends i0.RootTableManager<
i0.GeneratedDatabase,
i1.$RemoteAlbumEntityTable,
i1.RemoteAlbumEntityData,
i1.$$RemoteAlbumEntityTableFilterComposer,
i1.$$RemoteAlbumEntityTableOrderingComposer,
i1.$$RemoteAlbumEntityTableAnnotationComposer,
$$RemoteAlbumEntityTableCreateCompanionBuilder,
$$RemoteAlbumEntityTableUpdateCompanionBuilder,
(i1.RemoteAlbumEntityData, i1.$$RemoteAlbumEntityTableReferences),
i1.RemoteAlbumEntityData,
i0.PrefetchHooks Function({bool ownerId, bool thumbnailAssetId})> {
$$RemoteAlbumEntityTableTableManager(
i0.GeneratedDatabase db, i1.$RemoteAlbumEntityTable table)
: super(i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$RemoteAlbumEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () => i1
.$$RemoteAlbumEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$RemoteAlbumEntityTableAnnotationComposer(
$db: db, $table: table),
updateCompanionCallback: ({
i0.Value<String> id = const i0.Value.absent(),
i0.Value<String> name = const i0.Value.absent(),
i0.Value<String> description = const i0.Value.absent(),
i0.Value<DateTime> createdAt = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
i0.Value<String> ownerId = const i0.Value.absent(),
i0.Value<String?> thumbnailAssetId = const i0.Value.absent(),
i0.Value<bool> isActivityEnabled = const i0.Value.absent(),
i0.Value<i2.AlbumAssetOrder> order = const i0.Value.absent(),
}) =>
i1.RemoteAlbumEntityCompanion(
id: id,
name: name,
description: description,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
),
createCompanionCallback: ({
required String id,
required String name,
i0.Value<String> description = const i0.Value.absent(),
i0.Value<DateTime> createdAt = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
required String ownerId,
i0.Value<String?> thumbnailAssetId = const i0.Value.absent(),
i0.Value<bool> isActivityEnabled = const i0.Value.absent(),
required i2.AlbumAssetOrder order,
}) =>
i1.RemoteAlbumEntityCompanion.insert(
id: id,
name: name,
description: description,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
),
withReferenceMapper: (p0) => p0
.map((e) => (
e.readTable(table),
i1.$$RemoteAlbumEntityTableReferences(db, table, e)
))
.toList(),
prefetchHooksCallback: ({ownerId = false, thumbnailAssetId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins: <
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic>>(state) {
if (ownerId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.ownerId,
referencedTable:
i1.$$RemoteAlbumEntityTableReferences._ownerIdTable(db),
referencedColumn: i1.$$RemoteAlbumEntityTableReferences
._ownerIdTable(db)
.id,
) as T;
}
if (thumbnailAssetId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.thumbnailAssetId,
referencedTable: i1.$$RemoteAlbumEntityTableReferences
._thumbnailAssetIdTable(db),
referencedColumn: i1.$$RemoteAlbumEntityTableReferences
._thumbnailAssetIdTable(db)
.id,
) as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
));
}
typedef $$RemoteAlbumEntityTableProcessedTableManager
= i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$RemoteAlbumEntityTable,
i1.RemoteAlbumEntityData,
i1.$$RemoteAlbumEntityTableFilterComposer,
i1.$$RemoteAlbumEntityTableOrderingComposer,
i1.$$RemoteAlbumEntityTableAnnotationComposer,
$$RemoteAlbumEntityTableCreateCompanionBuilder,
$$RemoteAlbumEntityTableUpdateCompanionBuilder,
(i1.RemoteAlbumEntityData, i1.$$RemoteAlbumEntityTableReferences),
i1.RemoteAlbumEntityData,
i0.PrefetchHooks Function({bool ownerId, bool thumbnailAssetId})>;
class $RemoteAlbumEntityTable extends i3.RemoteAlbumEntity
with i0.TableInfo<$RemoteAlbumEntityTable, i1.RemoteAlbumEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$RemoteAlbumEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
@override
late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
'id', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
static const i0.VerificationMeta _nameMeta =
const i0.VerificationMeta('name');
@override
late final i0.GeneratedColumn<String> name = i0.GeneratedColumn<String>(
'name', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
static const i0.VerificationMeta _descriptionMeta =
const i0.VerificationMeta('description');
@override
late final i0.GeneratedColumn<String> description =
i0.GeneratedColumn<String>('description', aliasedName, false,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const i4.Constant(''));
static const i0.VerificationMeta _createdAtMeta =
const i0.VerificationMeta('createdAt');
@override
late final i0.GeneratedColumn<DateTime> createdAt =
i0.GeneratedColumn<DateTime>('created_at', aliasedName, false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i4.currentDateAndTime);
static const i0.VerificationMeta _updatedAtMeta =
const i0.VerificationMeta('updatedAt');
@override
late final i0.GeneratedColumn<DateTime> updatedAt =
i0.GeneratedColumn<DateTime>('updated_at', aliasedName, false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i4.currentDateAndTime);
static const i0.VerificationMeta _ownerIdMeta =
const i0.VerificationMeta('ownerId');
@override
late final i0.GeneratedColumn<String> ownerId = i0.GeneratedColumn<String>(
'owner_id', aliasedName, false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES user_entity (id) ON DELETE CASCADE'));
static const i0.VerificationMeta _thumbnailAssetIdMeta =
const i0.VerificationMeta('thumbnailAssetId');
@override
late final i0.GeneratedColumn<String> thumbnailAssetId =
i0.GeneratedColumn<String>('thumbnail_asset_id', aliasedName, true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE SET NULL'));
static const i0.VerificationMeta _isActivityEnabledMeta =
const i0.VerificationMeta('isActivityEnabled');
@override
late final i0.GeneratedColumn<bool> isActivityEnabled =
i0.GeneratedColumn<bool>('is_activity_enabled', aliasedName, false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_activity_enabled" IN (0, 1))'),
defaultValue: const i4.Constant(true));
@override
late final i0.GeneratedColumnWithTypeConverter<i2.AlbumAssetOrder, int>
order = i0.GeneratedColumn<int>('order', aliasedName, false,
type: i0.DriftSqlType.int, requiredDuringInsert: true)
.withConverter<i2.AlbumAssetOrder>(
i1.$RemoteAlbumEntityTable.$converterorder);
@override
List<i0.GeneratedColumn> get $columns => [
id,
name,
description,
createdAt,
updatedAt,
ownerId,
thumbnailAssetId,
isActivityEnabled,
order
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'remote_album_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.RemoteAlbumEntityData> instance,
{bool isInserting = false}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('name')) {
context.handle(
_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta));
} else if (isInserting) {
context.missing(_nameMeta);
}
if (data.containsKey('description')) {
context.handle(
_descriptionMeta,
description.isAcceptableOrUnknown(
data['description']!, _descriptionMeta));
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
if (data.containsKey('updated_at')) {
context.handle(_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
}
if (data.containsKey('owner_id')) {
context.handle(_ownerIdMeta,
ownerId.isAcceptableOrUnknown(data['owner_id']!, _ownerIdMeta));
} else if (isInserting) {
context.missing(_ownerIdMeta);
}
if (data.containsKey('thumbnail_asset_id')) {
context.handle(
_thumbnailAssetIdMeta,
thumbnailAssetId.isAcceptableOrUnknown(
data['thumbnail_asset_id']!, _thumbnailAssetIdMeta));
}
if (data.containsKey('is_activity_enabled')) {
context.handle(
_isActivityEnabledMeta,
isActivityEnabled.isAcceptableOrUnknown(
data['is_activity_enabled']!, _isActivityEnabledMeta));
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.RemoteAlbumEntityData map(Map<String, dynamic> data,
{String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.RemoteAlbumEntityData(
id: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}id'])!,
name: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}name'])!,
description: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}description'])!,
createdAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!,
ownerId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}owner_id'])!,
thumbnailAssetId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string, data['${effectivePrefix}thumbnail_asset_id']),
isActivityEnabled: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool, data['${effectivePrefix}is_activity_enabled'])!,
order: i1.$RemoteAlbumEntityTable.$converterorder.fromSql(attachedDatabase
.typeMapping
.read(i0.DriftSqlType.int, data['${effectivePrefix}order'])!),
);
}
@override
$RemoteAlbumEntityTable createAlias(String alias) {
return $RemoteAlbumEntityTable(attachedDatabase, alias);
}
static i0.JsonTypeConverter2<i2.AlbumAssetOrder, int, int> $converterorder =
const i0.EnumIndexConverter<i2.AlbumAssetOrder>(
i2.AlbumAssetOrder.values);
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class RemoteAlbumEntityData extends i0.DataClass
implements i0.Insertable<i1.RemoteAlbumEntityData> {
final String id;
final String name;
final String description;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String? thumbnailAssetId;
final bool isActivityEnabled;
final i2.AlbumAssetOrder order;
const RemoteAlbumEntityData(
{required this.id,
required this.name,
required this.description,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
this.thumbnailAssetId,
required this.isActivityEnabled,
required this.order});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<String>(id);
map['name'] = i0.Variable<String>(name);
map['description'] = i0.Variable<String>(description);
map['created_at'] = i0.Variable<DateTime>(createdAt);
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
map['owner_id'] = i0.Variable<String>(ownerId);
if (!nullToAbsent || thumbnailAssetId != null) {
map['thumbnail_asset_id'] = i0.Variable<String>(thumbnailAssetId);
}
map['is_activity_enabled'] = i0.Variable<bool>(isActivityEnabled);
{
map['order'] = i0.Variable<int>(
i1.$RemoteAlbumEntityTable.$converterorder.toSql(order));
}
return map;
}
factory RemoteAlbumEntityData.fromJson(Map<String, dynamic> json,
{i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return RemoteAlbumEntityData(
id: serializer.fromJson<String>(json['id']),
name: serializer.fromJson<String>(json['name']),
description: serializer.fromJson<String>(json['description']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
ownerId: serializer.fromJson<String>(json['ownerId']),
thumbnailAssetId: serializer.fromJson<String?>(json['thumbnailAssetId']),
isActivityEnabled: serializer.fromJson<bool>(json['isActivityEnabled']),
order: i1.$RemoteAlbumEntityTable.$converterorder
.fromJson(serializer.fromJson<int>(json['order'])),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'name': serializer.toJson<String>(name),
'description': serializer.toJson<String>(description),
'createdAt': serializer.toJson<DateTime>(createdAt),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
'ownerId': serializer.toJson<String>(ownerId),
'thumbnailAssetId': serializer.toJson<String?>(thumbnailAssetId),
'isActivityEnabled': serializer.toJson<bool>(isActivityEnabled),
'order': serializer.toJson<int>(
i1.$RemoteAlbumEntityTable.$converterorder.toJson(order)),
};
}
i1.RemoteAlbumEntityData copyWith(
{String? id,
String? name,
String? description,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
i0.Value<String?> thumbnailAssetId = const i0.Value.absent(),
bool? isActivityEnabled,
i2.AlbumAssetOrder? order}) =>
i1.RemoteAlbumEntityData(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
thumbnailAssetId: thumbnailAssetId.present
? thumbnailAssetId.value
: this.thumbnailAssetId,
isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled,
order: order ?? this.order,
);
RemoteAlbumEntityData copyWithCompanion(i1.RemoteAlbumEntityCompanion data) {
return RemoteAlbumEntityData(
id: data.id.present ? data.id.value : this.id,
name: data.name.present ? data.name.value : this.name,
description:
data.description.present ? data.description.value : this.description,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId,
thumbnailAssetId: data.thumbnailAssetId.present
? data.thumbnailAssetId.value
: this.thumbnailAssetId,
isActivityEnabled: data.isActivityEnabled.present
? data.isActivityEnabled.value
: this.isActivityEnabled,
order: data.order.present ? data.order.value : this.order,
);
}
@override
String toString() {
return (StringBuffer('RemoteAlbumEntityData(')
..write('id: $id, ')
..write('name: $name, ')
..write('description: $description, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('ownerId: $ownerId, ')
..write('thumbnailAssetId: $thumbnailAssetId, ')
..write('isActivityEnabled: $isActivityEnabled, ')
..write('order: $order')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, name, description, createdAt, updatedAt,
ownerId, thumbnailAssetId, isActivityEnabled, order);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.RemoteAlbumEntityData &&
other.id == this.id &&
other.name == this.name &&
other.description == this.description &&
other.createdAt == this.createdAt &&
other.updatedAt == this.updatedAt &&
other.ownerId == this.ownerId &&
other.thumbnailAssetId == this.thumbnailAssetId &&
other.isActivityEnabled == this.isActivityEnabled &&
other.order == this.order);
}
class RemoteAlbumEntityCompanion
extends i0.UpdateCompanion<i1.RemoteAlbumEntityData> {
final i0.Value<String> id;
final i0.Value<String> name;
final i0.Value<String> description;
final i0.Value<DateTime> createdAt;
final i0.Value<DateTime> updatedAt;
final i0.Value<String> ownerId;
final i0.Value<String?> thumbnailAssetId;
final i0.Value<bool> isActivityEnabled;
final i0.Value<i2.AlbumAssetOrder> order;
const RemoteAlbumEntityCompanion({
this.id = const i0.Value.absent(),
this.name = const i0.Value.absent(),
this.description = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
this.ownerId = const i0.Value.absent(),
this.thumbnailAssetId = const i0.Value.absent(),
this.isActivityEnabled = const i0.Value.absent(),
this.order = const i0.Value.absent(),
});
RemoteAlbumEntityCompanion.insert({
required String id,
required String name,
this.description = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
required String ownerId,
this.thumbnailAssetId = const i0.Value.absent(),
this.isActivityEnabled = const i0.Value.absent(),
required i2.AlbumAssetOrder order,
}) : id = i0.Value(id),
name = i0.Value(name),
ownerId = i0.Value(ownerId),
order = i0.Value(order);
static i0.Insertable<i1.RemoteAlbumEntityData> custom({
i0.Expression<String>? id,
i0.Expression<String>? name,
i0.Expression<String>? description,
i0.Expression<DateTime>? createdAt,
i0.Expression<DateTime>? updatedAt,
i0.Expression<String>? ownerId,
i0.Expression<String>? thumbnailAssetId,
i0.Expression<bool>? isActivityEnabled,
i0.Expression<int>? order,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (name != null) 'name': name,
if (description != null) 'description': description,
if (createdAt != null) 'created_at': createdAt,
if (updatedAt != null) 'updated_at': updatedAt,
if (ownerId != null) 'owner_id': ownerId,
if (thumbnailAssetId != null) 'thumbnail_asset_id': thumbnailAssetId,
if (isActivityEnabled != null) 'is_activity_enabled': isActivityEnabled,
if (order != null) 'order': order,
});
}
i1.RemoteAlbumEntityCompanion copyWith(
{i0.Value<String>? id,
i0.Value<String>? name,
i0.Value<String>? description,
i0.Value<DateTime>? createdAt,
i0.Value<DateTime>? updatedAt,
i0.Value<String>? ownerId,
i0.Value<String?>? thumbnailAssetId,
i0.Value<bool>? isActivityEnabled,
i0.Value<i2.AlbumAssetOrder>? order}) {
return i1.RemoteAlbumEntityCompanion(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId,
isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled,
order: order ?? this.order,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<String>(id.value);
}
if (name.present) {
map['name'] = i0.Variable<String>(name.value);
}
if (description.present) {
map['description'] = i0.Variable<String>(description.value);
}
if (createdAt.present) {
map['created_at'] = i0.Variable<DateTime>(createdAt.value);
}
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
if (ownerId.present) {
map['owner_id'] = i0.Variable<String>(ownerId.value);
}
if (thumbnailAssetId.present) {
map['thumbnail_asset_id'] = i0.Variable<String>(thumbnailAssetId.value);
}
if (isActivityEnabled.present) {
map['is_activity_enabled'] = i0.Variable<bool>(isActivityEnabled.value);
}
if (order.present) {
map['order'] = i0.Variable<int>(
i1.$RemoteAlbumEntityTable.$converterorder.toSql(order.value));
}
return map;
}
@override
String toString() {
return (StringBuffer('RemoteAlbumEntityCompanion(')
..write('id: $id, ')
..write('name: $name, ')
..write('description: $description, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('ownerId: $ownerId, ')
..write('thumbnailAssetId: $thumbnailAssetId, ')
..write('isActivityEnabled: $isActivityEnabled, ')
..write('order: $order')
..write(')'))
.toString();
}
}

View File

@@ -0,0 +1,17 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class RemoteAlbumAssetEntity extends Table with DriftDefaultsMixin {
const RemoteAlbumAssetEntity();
TextColumn get assetId =>
text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get albumId =>
text().references(RemoteAlbumEntity, #id, onDelete: KeyAction.cascade)();
@override
Set<Column> get primaryKey => {assetId, albumId};
}

View File

@@ -0,0 +1,565 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart'
as i2;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i3;
import 'package:drift/internal/modular.dart' as i4;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i5;
typedef $$RemoteAlbumAssetEntityTableCreateCompanionBuilder
= i1.RemoteAlbumAssetEntityCompanion Function({
required String assetId,
required String albumId,
});
typedef $$RemoteAlbumAssetEntityTableUpdateCompanionBuilder
= i1.RemoteAlbumAssetEntityCompanion Function({
i0.Value<String> assetId,
i0.Value<String> albumId,
});
final class $$RemoteAlbumAssetEntityTableReferences extends i0.BaseReferences<
i0.GeneratedDatabase,
i1.$RemoteAlbumAssetEntityTable,
i1.RemoteAlbumAssetEntityData> {
$$RemoteAlbumAssetEntityTableReferences(
super.$_db, super.$_table, super.$_typedResult);
static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumAssetEntityTable>(
'remote_album_asset_entity')
.assetId,
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.id));
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
final manager = i3
.$$RemoteAssetEntityTableTableManager(
$_db,
i4.ReadDatabaseContainer($_db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'))
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
static i5.$RemoteAlbumEntityTable _albumIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumAssetEntityTable>(
'remote_album_asset_entity')
.albumId,
i4.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity')
.id));
i5.$$RemoteAlbumEntityTableProcessedTableManager get albumId {
final $_column = $_itemColumn<String>('album_id')!;
final manager = i5
.$$RemoteAlbumEntityTableTableManager(
$_db,
i4.ReadDatabaseContainer($_db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'))
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_albumIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
}
class $$RemoteAlbumAssetEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$RemoteAlbumAssetEntityTable> {
$$RemoteAlbumAssetEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i3.$$RemoteAssetEntityTableFilterComposer get assetId {
final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer($db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i3.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i4.ReadDatabaseContainer($db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i5.$$RemoteAlbumEntityTableFilterComposer get albumId {
final i5.$$RemoteAlbumEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.albumId,
referencedTable: i4.ReadDatabaseContainer($db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i5.$$RemoteAlbumEntityTableFilterComposer(
$db: $db,
$table: i4.ReadDatabaseContainer($db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$RemoteAlbumAssetEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$RemoteAlbumAssetEntityTable> {
$$RemoteAlbumAssetEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i3.$$RemoteAssetEntityTableOrderingComposer get assetId {
final i3.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer($db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i3.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i4.ReadDatabaseContainer($db)
.resultSet<i3.$RemoteAssetEntityTable>(
'remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i5.$$RemoteAlbumEntityTableOrderingComposer get albumId {
final i5.$$RemoteAlbumEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.albumId,
referencedTable: i4.ReadDatabaseContainer($db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i5.$$RemoteAlbumEntityTableOrderingComposer(
$db: $db,
$table: i4.ReadDatabaseContainer($db)
.resultSet<i5.$RemoteAlbumEntityTable>(
'remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$RemoteAlbumAssetEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$RemoteAlbumAssetEntityTable> {
$$RemoteAlbumAssetEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i3.$$RemoteAssetEntityTableAnnotationComposer get assetId {
final i3.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer($db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i3.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i4.ReadDatabaseContainer($db)
.resultSet<i3.$RemoteAssetEntityTable>(
'remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i5.$$RemoteAlbumEntityTableAnnotationComposer get albumId {
final i5.$$RemoteAlbumEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.albumId,
referencedTable: i4.ReadDatabaseContainer($db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i5.$$RemoteAlbumEntityTableAnnotationComposer(
$db: $db,
$table: i4.ReadDatabaseContainer($db)
.resultSet<i5.$RemoteAlbumEntityTable>(
'remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$RemoteAlbumAssetEntityTableTableManager extends i0.RootTableManager<
i0.GeneratedDatabase,
i1.$RemoteAlbumAssetEntityTable,
i1.RemoteAlbumAssetEntityData,
i1.$$RemoteAlbumAssetEntityTableFilterComposer,
i1.$$RemoteAlbumAssetEntityTableOrderingComposer,
i1.$$RemoteAlbumAssetEntityTableAnnotationComposer,
$$RemoteAlbumAssetEntityTableCreateCompanionBuilder,
$$RemoteAlbumAssetEntityTableUpdateCompanionBuilder,
(i1.RemoteAlbumAssetEntityData, i1.$$RemoteAlbumAssetEntityTableReferences),
i1.RemoteAlbumAssetEntityData,
i0.PrefetchHooks Function({bool assetId, bool albumId})> {
$$RemoteAlbumAssetEntityTableTableManager(
i0.GeneratedDatabase db, i1.$RemoteAlbumAssetEntityTable table)
: super(i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$RemoteAlbumAssetEntityTableFilterComposer(
$db: db, $table: table),
createOrderingComposer: () =>
i1.$$RemoteAlbumAssetEntityTableOrderingComposer(
$db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$RemoteAlbumAssetEntityTableAnnotationComposer(
$db: db, $table: table),
updateCompanionCallback: ({
i0.Value<String> assetId = const i0.Value.absent(),
i0.Value<String> albumId = const i0.Value.absent(),
}) =>
i1.RemoteAlbumAssetEntityCompanion(
assetId: assetId,
albumId: albumId,
),
createCompanionCallback: ({
required String assetId,
required String albumId,
}) =>
i1.RemoteAlbumAssetEntityCompanion.insert(
assetId: assetId,
albumId: albumId,
),
withReferenceMapper: (p0) => p0
.map((e) => (
e.readTable(table),
i1.$$RemoteAlbumAssetEntityTableReferences(db, table, e)
))
.toList(),
prefetchHooksCallback: ({assetId = false, albumId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins: <
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic>>(state) {
if (assetId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.assetId,
referencedTable: i1.$$RemoteAlbumAssetEntityTableReferences
._assetIdTable(db),
referencedColumn: i1.$$RemoteAlbumAssetEntityTableReferences
._assetIdTable(db)
.id,
) as T;
}
if (albumId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.albumId,
referencedTable: i1.$$RemoteAlbumAssetEntityTableReferences
._albumIdTable(db),
referencedColumn: i1.$$RemoteAlbumAssetEntityTableReferences
._albumIdTable(db)
.id,
) as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
));
}
typedef $$RemoteAlbumAssetEntityTableProcessedTableManager
= i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$RemoteAlbumAssetEntityTable,
i1.RemoteAlbumAssetEntityData,
i1.$$RemoteAlbumAssetEntityTableFilterComposer,
i1.$$RemoteAlbumAssetEntityTableOrderingComposer,
i1.$$RemoteAlbumAssetEntityTableAnnotationComposer,
$$RemoteAlbumAssetEntityTableCreateCompanionBuilder,
$$RemoteAlbumAssetEntityTableUpdateCompanionBuilder,
(
i1.RemoteAlbumAssetEntityData,
i1.$$RemoteAlbumAssetEntityTableReferences
),
i1.RemoteAlbumAssetEntityData,
i0.PrefetchHooks Function({bool assetId, bool albumId})>;
class $RemoteAlbumAssetEntityTable extends i2.RemoteAlbumAssetEntity
with
i0.TableInfo<$RemoteAlbumAssetEntityTable,
i1.RemoteAlbumAssetEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$RemoteAlbumAssetEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _assetIdMeta =
const i0.VerificationMeta('assetId');
@override
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
'asset_id', aliasedName, false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE'));
static const i0.VerificationMeta _albumIdMeta =
const i0.VerificationMeta('albumId');
@override
late final i0.GeneratedColumn<String> albumId = i0.GeneratedColumn<String>(
'album_id', aliasedName, false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_album_entity (id) ON DELETE CASCADE'));
@override
List<i0.GeneratedColumn> get $columns => [assetId, albumId];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'remote_album_asset_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.RemoteAlbumAssetEntityData> instance,
{bool isInserting = false}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('asset_id')) {
context.handle(_assetIdMeta,
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta));
} else if (isInserting) {
context.missing(_assetIdMeta);
}
if (data.containsKey('album_id')) {
context.handle(_albumIdMeta,
albumId.isAcceptableOrUnknown(data['album_id']!, _albumIdMeta));
} else if (isInserting) {
context.missing(_albumIdMeta);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {assetId, albumId};
@override
i1.RemoteAlbumAssetEntityData map(Map<String, dynamic> data,
{String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.RemoteAlbumAssetEntityData(
assetId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}asset_id'])!,
albumId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}album_id'])!,
);
}
@override
$RemoteAlbumAssetEntityTable createAlias(String alias) {
return $RemoteAlbumAssetEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class RemoteAlbumAssetEntityData extends i0.DataClass
implements i0.Insertable<i1.RemoteAlbumAssetEntityData> {
final String assetId;
final String albumId;
const RemoteAlbumAssetEntityData(
{required this.assetId, required this.albumId});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['asset_id'] = i0.Variable<String>(assetId);
map['album_id'] = i0.Variable<String>(albumId);
return map;
}
factory RemoteAlbumAssetEntityData.fromJson(Map<String, dynamic> json,
{i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return RemoteAlbumAssetEntityData(
assetId: serializer.fromJson<String>(json['assetId']),
albumId: serializer.fromJson<String>(json['albumId']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'assetId': serializer.toJson<String>(assetId),
'albumId': serializer.toJson<String>(albumId),
};
}
i1.RemoteAlbumAssetEntityData copyWith({String? assetId, String? albumId}) =>
i1.RemoteAlbumAssetEntityData(
assetId: assetId ?? this.assetId,
albumId: albumId ?? this.albumId,
);
RemoteAlbumAssetEntityData copyWithCompanion(
i1.RemoteAlbumAssetEntityCompanion data) {
return RemoteAlbumAssetEntityData(
assetId: data.assetId.present ? data.assetId.value : this.assetId,
albumId: data.albumId.present ? data.albumId.value : this.albumId,
);
}
@override
String toString() {
return (StringBuffer('RemoteAlbumAssetEntityData(')
..write('assetId: $assetId, ')
..write('albumId: $albumId')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(assetId, albumId);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.RemoteAlbumAssetEntityData &&
other.assetId == this.assetId &&
other.albumId == this.albumId);
}
class RemoteAlbumAssetEntityCompanion
extends i0.UpdateCompanion<i1.RemoteAlbumAssetEntityData> {
final i0.Value<String> assetId;
final i0.Value<String> albumId;
const RemoteAlbumAssetEntityCompanion({
this.assetId = const i0.Value.absent(),
this.albumId = const i0.Value.absent(),
});
RemoteAlbumAssetEntityCompanion.insert({
required String assetId,
required String albumId,
}) : assetId = i0.Value(assetId),
albumId = i0.Value(albumId);
static i0.Insertable<i1.RemoteAlbumAssetEntityData> custom({
i0.Expression<String>? assetId,
i0.Expression<String>? albumId,
}) {
return i0.RawValuesInsertable({
if (assetId != null) 'asset_id': assetId,
if (albumId != null) 'album_id': albumId,
});
}
i1.RemoteAlbumAssetEntityCompanion copyWith(
{i0.Value<String>? assetId, i0.Value<String>? albumId}) {
return i1.RemoteAlbumAssetEntityCompanion(
assetId: assetId ?? this.assetId,
albumId: albumId ?? this.albumId,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (assetId.present) {
map['asset_id'] = i0.Variable<String>(assetId.value);
}
if (albumId.present) {
map['album_id'] = i0.Variable<String>(albumId.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('RemoteAlbumAssetEntityCompanion(')
..write('assetId: $assetId, ')
..write('albumId: $albumId')
..write(')'))
.toString();
}
}

View File

@@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class RemoteAlbumUserEntity extends Table with DriftDefaultsMixin {
const RemoteAlbumUserEntity();
TextColumn get albumId =>
text().references(RemoteAlbumEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get userId =>
text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
IntColumn get role => intEnum<AlbumUserRole>()();
@override
Set<Column> get primaryKey => {albumId, userId};
}

View File

@@ -0,0 +1,618 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i1;
import 'package:immich_mobile/domain/models/album/album.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i4;
import 'package:drift/internal/modular.dart' as i5;
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
as i6;
typedef $$RemoteAlbumUserEntityTableCreateCompanionBuilder
= i1.RemoteAlbumUserEntityCompanion Function({
required String albumId,
required String userId,
required i2.AlbumUserRole role,
});
typedef $$RemoteAlbumUserEntityTableUpdateCompanionBuilder
= i1.RemoteAlbumUserEntityCompanion Function({
i0.Value<String> albumId,
i0.Value<String> userId,
i0.Value<i2.AlbumUserRole> role,
});
final class $$RemoteAlbumUserEntityTableReferences extends i0.BaseReferences<
i0.GeneratedDatabase,
i1.$RemoteAlbumUserEntityTable,
i1.RemoteAlbumUserEntityData> {
$$RemoteAlbumUserEntityTableReferences(
super.$_db, super.$_table, super.$_typedResult);
static i4.$RemoteAlbumEntityTable _albumIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumUserEntityTable>(
'remote_album_user_entity')
.albumId,
i5.ReadDatabaseContainer(db)
.resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity')
.id));
i4.$$RemoteAlbumEntityTableProcessedTableManager get albumId {
final $_column = $_itemColumn<String>('album_id')!;
final manager = i4
.$$RemoteAlbumEntityTableTableManager(
$_db,
i5.ReadDatabaseContainer($_db)
.resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity'))
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_albumIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
static i6.$UserEntityTable _userIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i6.$UserEntityTable>('user_entity')
.createAlias(i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumUserEntityTable>(
'remote_album_user_entity')
.userId,
i5.ReadDatabaseContainer(db)
.resultSet<i6.$UserEntityTable>('user_entity')
.id));
i6.$$UserEntityTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;
final manager = i6
.$$UserEntityTableTableManager(
$_db,
i5.ReadDatabaseContainer($_db)
.resultSet<i6.$UserEntityTable>('user_entity'))
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_userIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
}
class $$RemoteAlbumUserEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$RemoteAlbumUserEntityTable> {
$$RemoteAlbumUserEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnWithTypeConverterFilters<i2.AlbumUserRole, i2.AlbumUserRole, int>
get role => $composableBuilder(
column: $table.role,
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
i4.$$RemoteAlbumEntityTableFilterComposer get albumId {
final i4.$$RemoteAlbumEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.albumId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i4.$$RemoteAlbumEntityTableFilterComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i6.$$UserEntityTableFilterComposer get userId {
final i6.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.userId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i6.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i6.$$UserEntityTableFilterComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i6.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$RemoteAlbumUserEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$RemoteAlbumUserEntityTable> {
$$RemoteAlbumUserEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<int> get role => $composableBuilder(
column: $table.role, builder: (column) => i0.ColumnOrderings(column));
i4.$$RemoteAlbumEntityTableOrderingComposer get albumId {
final i4.$$RemoteAlbumEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.albumId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i4.$$RemoteAlbumEntityTableOrderingComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i4.$RemoteAlbumEntityTable>(
'remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i6.$$UserEntityTableOrderingComposer get userId {
final i6.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.userId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i6.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i6.$$UserEntityTableOrderingComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i6.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$RemoteAlbumUserEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$RemoteAlbumUserEntityTable> {
$$RemoteAlbumUserEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumnWithTypeConverter<i2.AlbumUserRole, int> get role =>
$composableBuilder(column: $table.role, builder: (column) => column);
i4.$$RemoteAlbumEntityTableAnnotationComposer get albumId {
final i4.$$RemoteAlbumEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.albumId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i4.$$RemoteAlbumEntityTableAnnotationComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i4.$RemoteAlbumEntityTable>(
'remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
i6.$$UserEntityTableAnnotationComposer get userId {
final i6.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.userId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i6.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i6.$$UserEntityTableAnnotationComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i6.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$RemoteAlbumUserEntityTableTableManager extends i0.RootTableManager<
i0.GeneratedDatabase,
i1.$RemoteAlbumUserEntityTable,
i1.RemoteAlbumUserEntityData,
i1.$$RemoteAlbumUserEntityTableFilterComposer,
i1.$$RemoteAlbumUserEntityTableOrderingComposer,
i1.$$RemoteAlbumUserEntityTableAnnotationComposer,
$$RemoteAlbumUserEntityTableCreateCompanionBuilder,
$$RemoteAlbumUserEntityTableUpdateCompanionBuilder,
(i1.RemoteAlbumUserEntityData, i1.$$RemoteAlbumUserEntityTableReferences),
i1.RemoteAlbumUserEntityData,
i0.PrefetchHooks Function({bool albumId, bool userId})> {
$$RemoteAlbumUserEntityTableTableManager(
i0.GeneratedDatabase db, i1.$RemoteAlbumUserEntityTable table)
: super(i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$RemoteAlbumUserEntityTableFilterComposer(
$db: db, $table: table),
createOrderingComposer: () =>
i1.$$RemoteAlbumUserEntityTableOrderingComposer(
$db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$RemoteAlbumUserEntityTableAnnotationComposer(
$db: db, $table: table),
updateCompanionCallback: ({
i0.Value<String> albumId = const i0.Value.absent(),
i0.Value<String> userId = const i0.Value.absent(),
i0.Value<i2.AlbumUserRole> role = const i0.Value.absent(),
}) =>
i1.RemoteAlbumUserEntityCompanion(
albumId: albumId,
userId: userId,
role: role,
),
createCompanionCallback: ({
required String albumId,
required String userId,
required i2.AlbumUserRole role,
}) =>
i1.RemoteAlbumUserEntityCompanion.insert(
albumId: albumId,
userId: userId,
role: role,
),
withReferenceMapper: (p0) => p0
.map((e) => (
e.readTable(table),
i1.$$RemoteAlbumUserEntityTableReferences(db, table, e)
))
.toList(),
prefetchHooksCallback: ({albumId = false, userId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins: <
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic>>(state) {
if (albumId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.albumId,
referencedTable: i1.$$RemoteAlbumUserEntityTableReferences
._albumIdTable(db),
referencedColumn: i1.$$RemoteAlbumUserEntityTableReferences
._albumIdTable(db)
.id,
) as T;
}
if (userId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.userId,
referencedTable: i1.$$RemoteAlbumUserEntityTableReferences
._userIdTable(db),
referencedColumn: i1.$$RemoteAlbumUserEntityTableReferences
._userIdTable(db)
.id,
) as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
));
}
typedef $$RemoteAlbumUserEntityTableProcessedTableManager
= i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$RemoteAlbumUserEntityTable,
i1.RemoteAlbumUserEntityData,
i1.$$RemoteAlbumUserEntityTableFilterComposer,
i1.$$RemoteAlbumUserEntityTableOrderingComposer,
i1.$$RemoteAlbumUserEntityTableAnnotationComposer,
$$RemoteAlbumUserEntityTableCreateCompanionBuilder,
$$RemoteAlbumUserEntityTableUpdateCompanionBuilder,
(
i1.RemoteAlbumUserEntityData,
i1.$$RemoteAlbumUserEntityTableReferences
),
i1.RemoteAlbumUserEntityData,
i0.PrefetchHooks Function({bool albumId, bool userId})>;
class $RemoteAlbumUserEntityTable extends i3.RemoteAlbumUserEntity
with
i0
.TableInfo<$RemoteAlbumUserEntityTable, i1.RemoteAlbumUserEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$RemoteAlbumUserEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _albumIdMeta =
const i0.VerificationMeta('albumId');
@override
late final i0.GeneratedColumn<String> albumId = i0.GeneratedColumn<String>(
'album_id', aliasedName, false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_album_entity (id) ON DELETE CASCADE'));
static const i0.VerificationMeta _userIdMeta =
const i0.VerificationMeta('userId');
@override
late final i0.GeneratedColumn<String> userId = i0.GeneratedColumn<String>(
'user_id', aliasedName, false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES user_entity (id) ON DELETE CASCADE'));
@override
late final i0.GeneratedColumnWithTypeConverter<i2.AlbumUserRole, int> role =
i0.GeneratedColumn<int>('role', aliasedName, false,
type: i0.DriftSqlType.int, requiredDuringInsert: true)
.withConverter<i2.AlbumUserRole>(
i1.$RemoteAlbumUserEntityTable.$converterrole);
@override
List<i0.GeneratedColumn> get $columns => [albumId, userId, role];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'remote_album_user_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.RemoteAlbumUserEntityData> instance,
{bool isInserting = false}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('album_id')) {
context.handle(_albumIdMeta,
albumId.isAcceptableOrUnknown(data['album_id']!, _albumIdMeta));
} else if (isInserting) {
context.missing(_albumIdMeta);
}
if (data.containsKey('user_id')) {
context.handle(_userIdMeta,
userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta));
} else if (isInserting) {
context.missing(_userIdMeta);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {albumId, userId};
@override
i1.RemoteAlbumUserEntityData map(Map<String, dynamic> data,
{String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.RemoteAlbumUserEntityData(
albumId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}album_id'])!,
userId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}user_id'])!,
role: i1.$RemoteAlbumUserEntityTable.$converterrole.fromSql(
attachedDatabase.typeMapping
.read(i0.DriftSqlType.int, data['${effectivePrefix}role'])!),
);
}
@override
$RemoteAlbumUserEntityTable createAlias(String alias) {
return $RemoteAlbumUserEntityTable(attachedDatabase, alias);
}
static i0.JsonTypeConverter2<i2.AlbumUserRole, int, int> $converterrole =
const i0.EnumIndexConverter<i2.AlbumUserRole>(i2.AlbumUserRole.values);
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class RemoteAlbumUserEntityData extends i0.DataClass
implements i0.Insertable<i1.RemoteAlbumUserEntityData> {
final String albumId;
final String userId;
final i2.AlbumUserRole role;
const RemoteAlbumUserEntityData(
{required this.albumId, required this.userId, required this.role});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['album_id'] = i0.Variable<String>(albumId);
map['user_id'] = i0.Variable<String>(userId);
{
map['role'] = i0.Variable<int>(
i1.$RemoteAlbumUserEntityTable.$converterrole.toSql(role));
}
return map;
}
factory RemoteAlbumUserEntityData.fromJson(Map<String, dynamic> json,
{i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return RemoteAlbumUserEntityData(
albumId: serializer.fromJson<String>(json['albumId']),
userId: serializer.fromJson<String>(json['userId']),
role: i1.$RemoteAlbumUserEntityTable.$converterrole
.fromJson(serializer.fromJson<int>(json['role'])),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'albumId': serializer.toJson<String>(albumId),
'userId': serializer.toJson<String>(userId),
'role': serializer.toJson<int>(
i1.$RemoteAlbumUserEntityTable.$converterrole.toJson(role)),
};
}
i1.RemoteAlbumUserEntityData copyWith(
{String? albumId, String? userId, i2.AlbumUserRole? role}) =>
i1.RemoteAlbumUserEntityData(
albumId: albumId ?? this.albumId,
userId: userId ?? this.userId,
role: role ?? this.role,
);
RemoteAlbumUserEntityData copyWithCompanion(
i1.RemoteAlbumUserEntityCompanion data) {
return RemoteAlbumUserEntityData(
albumId: data.albumId.present ? data.albumId.value : this.albumId,
userId: data.userId.present ? data.userId.value : this.userId,
role: data.role.present ? data.role.value : this.role,
);
}
@override
String toString() {
return (StringBuffer('RemoteAlbumUserEntityData(')
..write('albumId: $albumId, ')
..write('userId: $userId, ')
..write('role: $role')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(albumId, userId, role);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.RemoteAlbumUserEntityData &&
other.albumId == this.albumId &&
other.userId == this.userId &&
other.role == this.role);
}
class RemoteAlbumUserEntityCompanion
extends i0.UpdateCompanion<i1.RemoteAlbumUserEntityData> {
final i0.Value<String> albumId;
final i0.Value<String> userId;
final i0.Value<i2.AlbumUserRole> role;
const RemoteAlbumUserEntityCompanion({
this.albumId = const i0.Value.absent(),
this.userId = const i0.Value.absent(),
this.role = const i0.Value.absent(),
});
RemoteAlbumUserEntityCompanion.insert({
required String albumId,
required String userId,
required i2.AlbumUserRole role,
}) : albumId = i0.Value(albumId),
userId = i0.Value(userId),
role = i0.Value(role);
static i0.Insertable<i1.RemoteAlbumUserEntityData> custom({
i0.Expression<String>? albumId,
i0.Expression<String>? userId,
i0.Expression<int>? role,
}) {
return i0.RawValuesInsertable({
if (albumId != null) 'album_id': albumId,
if (userId != null) 'user_id': userId,
if (role != null) 'role': role,
});
}
i1.RemoteAlbumUserEntityCompanion copyWith(
{i0.Value<String>? albumId,
i0.Value<String>? userId,
i0.Value<i2.AlbumUserRole>? role}) {
return i1.RemoteAlbumUserEntityCompanion(
albumId: albumId ?? this.albumId,
userId: userId ?? this.userId,
role: role ?? this.role,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (albumId.present) {
map['album_id'] = i0.Variable<String>(albumId.value);
}
if (userId.present) {
map['user_id'] = i0.Variable<String>(userId.value);
}
if (role.present) {
map['role'] = i0.Variable<int>(
i1.$RemoteAlbumUserEntityTable.$converterrole.toSql(role.value));
}
return map;
}
@override
String toString() {
return (StringBuffer('RemoteAlbumUserEntityCompanion(')
..write('albumId: $albumId, ')
..write('userId: $userId, ')
..write('role: $role')
..write(')'))
.toString();
}
}

View File

@@ -1,5 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@@ -34,3 +35,21 @@ class RemoteAssetEntity extends Table
@override
Set<Column> get primaryKey => {id};
}
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
Asset toDto() => Asset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
height: height,
width: width,
thumbHash: thumbHash,
visibility: visibility,
localId: null,
);
}

View File

@@ -8,6 +8,9 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
@@ -40,6 +43,9 @@ class IsarDatabaseRepository implements IDatabaseRepository {
LocalAlbumAssetEntity,
RemoteAssetEntity,
RemoteExifEntity,
RemoteAlbumEntity,
RemoteAlbumAssetEntity,
RemoteAlbumUserEntity,
],
include: {
'package:immich_mobile/infrastructure/entities/merged_asset.drift',

View File

@@ -17,9 +17,15 @@ import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.d
as i7;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i9;
import 'package:drift/internal/modular.dart' as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i11;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i12;
import 'package:drift/internal/modular.dart' as i13;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -39,8 +45,14 @@ abstract class $Drift extends i0.GeneratedDatabase {
i7.$LocalAlbumAssetEntityTable(this);
late final i8.$RemoteExifEntityTable remoteExifEntity =
i8.$RemoteExifEntityTable(this);
i9.MergedAssetDrift get mergedAssetDrift => i10.ReadDatabaseContainer(this)
.accessor<i9.MergedAssetDrift>(i9.MergedAssetDrift.new);
late final i9.$RemoteAlbumEntityTable remoteAlbumEntity =
i9.$RemoteAlbumEntityTable(this);
late final i10.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity =
i10.$RemoteAlbumAssetEntityTable(this);
late final i11.$RemoteAlbumUserEntityTable remoteAlbumUserEntity =
i11.$RemoteAlbumUserEntityTable(this);
i12.MergedAssetDrift get mergedAssetDrift => i13.ReadDatabaseContainer(this)
.accessor<i12.MergedAssetDrift>(i12.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -56,7 +68,10 @@ abstract class $Drift extends i0.GeneratedDatabase {
partnerEntity,
localAlbumEntity,
localAlbumAssetEntity,
remoteExifEntity
remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity
];
@override
i0.StreamQueryUpdateRules get streamUpdateRules =>
@@ -114,6 +129,52 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('remote_exif_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.update),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('remote_album_asset_entity',
kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('remote_album_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('remote_album_asset_entity',
kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('remote_album_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('remote_album_user_entity',
kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('remote_album_user_entity',
kind: i0.UpdateKind.delete),
],
),
],
);
@override
@@ -140,4 +201,11 @@ class $DriftManager {
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i8.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i9.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i9.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i10.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i10.$$RemoteAlbumAssetEntityTableTableManager(
_db, _db.remoteAlbumAssetEntity);
i11.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i11
.$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity);
}

View File

@@ -1,6 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';

View File

@@ -0,0 +1,45 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
enum SortRemoteAlbumsBy { id }
class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftRemoteAlbumRepository(this._db) : super(_db);
Future<List<Album>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {}}) {
final query = _db.remoteAlbumEntity.select();
if (sortBy.isNotEmpty) {
final orderings = <OrderClauseGenerator<$RemoteAlbumEntityTable>>[];
for (final sort in sortBy) {
orderings.add(
switch (sort) {
SortRemoteAlbumsBy.id => (row) => OrderingTerm.asc(row.id),
},
);
}
query.orderBy(orderings);
}
return query.map((row) => row.toDto()).get();
}
}
extension on RemoteAlbumEntityData {
Album toDto() {
return Album(
id: id,
name: name,
ownerId: ownerId,
createdAt: createdAt,
updatedAt: updatedAt,
description: description,
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
);
}
}

View File

@@ -42,11 +42,16 @@ class SyncApiRepository {
SyncStreamDto(
types: [
SyncRequestType.usersV1,
SyncRequestType.partnersV1,
SyncRequestType.assetsV1,
SyncRequestType.partnerAssetsV1,
SyncRequestType.assetExifsV1,
SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1,
SyncRequestType.albumsV1,
SyncRequestType.albumUsersV1,
SyncRequestType.albumAssetsV1,
SyncRequestType.albumAssetExifsV1,
SyncRequestType.albumToAssetsV1,
],
).toJson(),
);
@@ -135,6 +140,25 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.partnerAssetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.partnerAssetExifBackfillV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumV1: SyncAlbumV1.fromJson,
SyncEntityType.albumDeleteV1: SyncAlbumDeleteV1.fromJson,
SyncEntityType.albumUserV1: SyncAlbumUserV1.fromJson,
SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson,
SyncEntityType.albumUserDeleteV1: SyncAlbumUserDeleteV1.fromJson,
SyncEntityType.albumAssetV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.albumAssetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumAssetExifBackfillV1: SyncAssetExifV1.fromJson,
SyncEntityType.albumToAssetV1: SyncAlbumToAssetV1.fromJson,
SyncEntityType.albumToAssetBackfillV1: SyncAlbumToAssetV1.fromJson,
SyncEntityType.albumToAssetDeleteV1: SyncAlbumToAssetDeleteV1.fromJson,
SyncEntityType.syncAckV1: _SyncAckV1.fromJson,
};
class _SyncAckV1 {
static _SyncAckV1? fromJson(dynamic _) => _SyncAckV1();
}

View File

@@ -1,13 +1,17 @@
import 'package:drift/drift.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/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole;
class SyncStreamRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftSyncStreamRepository');
@@ -17,16 +21,10 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final user in data) {
batch.delete(
_db.userEntity,
UserEntityCompanion(id: Value(user.userId)),
);
}
});
await _db.userEntity
.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId)));
} catch (error, stack) {
_logger.severe('Error while processing SyncUserDeleteV1', error, stack);
_logger.severe('Error: SyncUserDeleteV1', error, stack);
rethrow;
}
}
@@ -48,7 +46,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
});
} catch (error, stack) {
_logger.severe('Error while processing SyncUserV1', error, stack);
_logger.severe('Error: SyncUserV1', error, stack);
rethrow;
}
}
@@ -67,7 +65,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
});
} catch (e, s) {
_logger.severe('Error while processing SyncPartnerDeleteV1', e, s);
_logger.severe('Error: SyncPartnerDeleteV1', e, s);
rethrow;
}
}
@@ -90,67 +88,30 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
});
} catch (e, s) {
_logger.severe('Error while processing SyncPartnerV1', e, s);
_logger.severe('Error: SyncPartnerV1', e, s);
rethrow;
}
}
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data) async {
Future<void> deleteAssetsV1(
Iterable<SyncAssetDeleteV1> data, {
String debugLabel = 'user',
}) async {
try {
await _deleteAssetsV1(data);
await _db.remoteAssetEntity
.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId)));
} catch (e, s) {
_logger.severe('Error while processing deleteAssetsV1', e, s);
_logger.severe('Error: deleteAssetsV1 - $debugLabel', e, s);
rethrow;
}
}
Future<void> updateAssetsV1(Iterable<SyncAssetV1> data) async {
Future<void> updateAssetsV1(
Iterable<SyncAssetV1> data, {
String debugLabel = 'user',
}) async {
try {
await _updateAssetsV1(data);
} catch (e, s) {
_logger.severe('Error while processing updateAssetsV1', e, s);
rethrow;
}
}
Future<void> deletePartnerAssetsV1(Iterable<SyncAssetDeleteV1> data) async {
try {
await _deleteAssetsV1(data);
} catch (e, s) {
_logger.severe('Error while processing deletePartnerAssetsV1', e, s);
rethrow;
}
}
Future<void> updatePartnerAssetsV1(Iterable<SyncAssetV1> data) async {
try {
await _updateAssetsV1(data);
} catch (e, s) {
_logger.severe('Error while processing updatePartnerAssetsV1', e, s);
rethrow;
}
}
Future<void> updateAssetsExifV1(Iterable<SyncAssetExifV1> data) async {
try {
await _updateAssetExifV1(data);
} catch (e, s) {
_logger.severe('Error while processing updateAssetsExifV1', e, s);
rethrow;
}
}
Future<void> updatePartnerAssetsExifV1(Iterable<SyncAssetExifV1> data) async {
try {
await _updateAssetExifV1(data);
} catch (e, s) {
_logger.severe('Error while processing updatePartnerAssetsExifV1', e, s);
rethrow;
}
}
Future<void> _updateAssetsV1(Iterable<SyncAssetV1> data) =>
_db.batch((batch) {
await _db.batch((batch) {
for (final asset in data) {
final companion = RemoteAssetEntityCompanion(
name: Value(asset.originalFileName),
@@ -175,19 +136,18 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAssetsV1 - $debugLabel', e, s);
rethrow;
}
}
Future<void> _deleteAssetsV1(Iterable<SyncAssetDeleteV1> assets) =>
_db.batch((batch) {
for (final asset in assets) {
batch.delete(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(id: Value(asset.assetId)),
);
}
});
Future<void> _updateAssetExifV1(Iterable<SyncAssetExifV1> data) =>
_db.batch((batch) {
Future<void> updateAssetsExifV1(
Iterable<SyncAssetExifV1> data, {
String debugLabel = 'user',
}) async {
try {
await _db.batch((batch) {
for (final exif in data) {
final companion = RemoteExifEntityCompanion(
city: Value(exif.city),
@@ -219,6 +179,141 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAssetsExifV1 - $debugLabel', e, s);
rethrow;
}
}
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try {
await _db.remoteAlbumEntity
.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId)));
} catch (e, s) {
_logger.severe('Error: deleteAlbumsV1', e, s);
rethrow;
}
}
Future<void> updateAlbumsV1(Iterable<SyncAlbumV1> data) async {
try {
await _db.batch((batch) {
for (final album in data) {
final companion = RemoteAlbumEntityCompanion(
name: Value(album.name),
description: Value(album.description),
isActivityEnabled: Value(album.isActivityEnabled),
order: Value(album.order.toAlbumAssetOrder()),
thumbnailAssetId: Value(album.thumbnailAssetId),
ownerId: Value(album.ownerId),
createdAt: Value(album.createdAt),
updatedAt: Value(album.updatedAt),
);
batch.insert(
_db.remoteAlbumEntity,
companion.copyWith(id: Value(album.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAlbumsV1', e, s);
rethrow;
}
}
Future<void> deleteAlbumUsersV1(Iterable<SyncAlbumUserDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final album in data) {
batch.delete(
_db.remoteAlbumUserEntity,
RemoteAlbumUserEntityCompanion(
albumId: Value(album.albumId),
userId: Value(album.userId),
),
);
}
});
} catch (e, s) {
_logger.severe('Error: deleteAlbumUsersV1', e, s);
rethrow;
}
}
Future<void> updateAlbumUsersV1(
Iterable<SyncAlbumUserV1> data, {
String debugLabel = 'user',
}) async {
try {
await _db.batch((batch) {
for (final album in data) {
final companion = RemoteAlbumUserEntityCompanion(
role: Value(album.role.toAlbumUserRole()),
);
batch.insert(
_db.remoteAlbumUserEntity,
companion.copyWith(
albumId: Value(album.albumId),
userId: Value(album.userId),
),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAlbumUsersV1 - $debugLabel', e, s);
rethrow;
}
}
Future<void> deleteAlbumToAssetsV1(
Iterable<SyncAlbumToAssetDeleteV1> data,
) async {
try {
await _db.batch((batch) {
for (final album in data) {
batch.delete(
_db.remoteAlbumAssetEntity,
RemoteAlbumAssetEntityCompanion(
albumId: Value(album.albumId),
assetId: Value(album.assetId),
),
);
}
});
} catch (e, s) {
_logger.severe('Error: deleteAlbumToAssetsV1', e, s);
rethrow;
}
}
Future<void> updateAlbumToAssetsV1(
Iterable<SyncAlbumToAssetV1> data, {
String debugLabel = 'user',
}) async {
try {
await _db.batch((batch) {
for (final album in data) {
final companion = RemoteAlbumAssetEntityCompanion(
albumId: Value(album.albumId),
assetId: Value(album.assetId),
);
batch.insert(
_db.remoteAlbumAssetEntity,
companion,
onConflict: DoNothing(),
);
}
});
} catch (e, s) {
_logger.severe('Error: updateAlbumToAssetsV1 - $debugLabel', e, s);
rethrow;
}
}
}
extension on AssetTypeEnum {
@@ -231,6 +326,22 @@ extension on AssetTypeEnum {
};
}
extension on AssetOrder {
AlbumAssetOrder toAlbumAssetOrder() => switch (this) {
AssetOrder.asc => AlbumAssetOrder.asc,
AssetOrder.desc => AlbumAssetOrder.desc,
_ => throw Exception('Unknown AssetOrder value: $this'),
};
}
extension on api.AlbumUserRole {
AlbumUserRole toAlbumUserRole() => switch (this) {
api.AlbumUserRole.editor => AlbumUserRole.editor,
api.AlbumUserRole.viewer => AlbumUserRole.viewer,
_ => throw Exception('Unknown AlbumUserRole value: $this'),
};
}
extension on api.AssetVisibility {
AssetVisibility toAssetVisibility() => switch (this) {
api.AssetVisibility.timeline => AssetVisibility.timeline,

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:stream_transform/stream_transform.dart';
@@ -14,6 +15,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
const DriftTimelineRepository(super._db) : _db = _db;
Stream<List<String>> watchTimelineUserIds(String userId) {
final query = _db.partnerEntity.selectOnly()
..addColumns([_db.partnerEntity.sharedById])
..where(
_db.partnerEntity.inTimeline.equals(true) &
_db.partnerEntity.sharedWithId.equals(userId),
);
return query
.map((row) => row.read(_db.partnerEntity.sharedById)!)
.watch()
// Add current user ID to the list
.map((users) => users..add(userId));
}
List<Bucket> _generateBuckets(int count) {
final numBuckets = (count / kTimelineNoneSegmentSize).floor();
final buckets = List.generate(
@@ -139,6 +155,62 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
}
Stream<List<Bucket>> watchRemoteBucket(
String albumId, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
if (groupBy == GroupAssetsBy.none) {
return _db.remoteAlbumAssetEntity
.count(where: (row) => row.albumId.equals(albumId))
.map(_generateBuckets)
.watchSingle();
}
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..join([
innerJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId
.equalsExp(_db.remoteAssetEntity.id),
),
])
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
return query.map((row) {
final timeline = row.read(dateExp)!.dateFmt(groupBy);
final assetCount = row.read(assetCountExp)!;
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
}
Future<List<BaseAsset>> getRemoteBucketAssets(
String albumId, {
required int offset,
required int count,
}) {
final query = _db.remoteAssetEntity.select().join(
[
innerJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId
.equalsExp(_db.remoteAssetEntity.id),
),
],
)
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);
return query
.map((row) => row.readTable(_db.remoteAssetEntity).toDto())
.get();
}
}
extension on Expression<DateTime> {

View File

@@ -63,6 +63,9 @@ final _features = [
final db = ref.read(driftProvider);
await db.remoteAssetEntity.deleteAll();
await db.remoteExifEntity.deleteAll();
await db.remoteAlbumEntity.deleteAll();
await db.remoteAlbumUserEntity.deleteAll();
await db.remoteAlbumAssetEntity.deleteAll();
},
),
_Feature(

View File

@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class MainTimelinePage extends StatelessWidget {
@@ -17,9 +16,10 @@ class MainTimelinePage extends StatelessWidget {
overrides: [
timelineServiceProvider.overrideWith(
(ref) {
final timelineService = ref
.watch(timelineFactoryProvider)
.main(ref.watch(timelineUsersIdsProvider));
final timelineUsers =
ref.watch(timelineUsersProvider).valueOrNull ?? [];
final timelineService =
ref.watch(timelineFactoryProvider).main(timelineUsers);
ref.onDispose(() => unawaited(timelineService.dispose()));
return timelineService;
},

View File

@@ -40,7 +40,10 @@ class _Summary extends StatelessWidget {
} else if (snapshot.hasError) {
subtitle = const Icon(Icons.error_rounded);
} else {
subtitle = Text('${snapshot.data ?? 0}');
subtitle = Text(
'${snapshot.data ?? 0}',
style: ctx.textTheme.bodyLarge,
);
}
return ListTile(
leading: leading,
@@ -147,6 +150,10 @@ final _remoteStats = [
name: 'Exif Entities',
load: (db) => db.managers.remoteExifEntity.count(),
),
_Stat(
name: 'Remote Albums',
load: (db) => db.managers.remoteAlbumEntity.count(),
),
];
@RoutePage()
@@ -160,6 +167,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
body: Consumer(
builder: (ctx, ref, __) {
final db = ref.watch(driftProvider);
final albumsFuture = ref.watch(remoteAlbumRepository).getAll();
return CustomScrollView(
slivers: [
@@ -171,6 +179,49 @@ class RemoteMediaSummaryPage extends StatelessWidget {
},
itemCount: _remoteStats.length,
),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 15),
child: Text(
"Album summary",
style: ctx.textTheme.titleMedium,
),
),
],
),
),
FutureBuilder(
future: albumsFuture,
builder: (_, snap) {
final albums = snap.data ?? [];
if (albums.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
albums.sortBy((a) => a.name);
return SliverList.builder(
itemBuilder: (_, index) {
final album = albums[index];
final countFuture = db.managers.remoteAlbumAssetEntity
.filter((f) => f.albumId.id.equals(album.id))
.count();
return _Summary(
leading: const Icon(Icons.photo_album_rounded),
name: album.name,
countFuture: countFuture,
onTap: () => context.router.push(
RemoteTimelineRoute(albumId: album.id),
),
);
},
itemCount: albums.length,
);
},
),
],
);
},

View File

@@ -0,0 +1,32 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@RoutePage()
class RemoteTimelinePage extends StatelessWidget {
final String albumId;
const RemoteTimelinePage({super.key, required this.albumId});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith(
(ref) {
final timelineService = ref
.watch(timelineFactoryProvider)
.remoteAlbum(albumId: albumId);
ref.onDispose(() => unawaited(timelineService.dispose()));
return timelineService;
},
),
],
child: const Timeline(),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class ArchiveActionButton extends ConsumerWidget {
const ArchiveActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.archive_outlined,
label: "archive".t(context: context),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class BaseActionButton extends StatelessWidget {
const BaseActionButton({
super.key,
required this.label,
required this.iconData,
this.onPressed,
this.onLongPressed,
this.maxWidth = 90.0,
});
final String label;
final IconData iconData;
final double maxWidth;
final void Function()? onPressed;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context) {
final minWidth = context.isMobile ? context.width / 4.5 : 75.0;
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxWidth,
),
child: MaterialButton(
padding: const EdgeInsets.all(10),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
),
onPressed: onPressed,
onLongPress: onLongPressed,
minWidth: minWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(iconData, size: 24),
const SizedBox(height: 8),
Text(
label,
style:
const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400),
maxLines: 3,
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class DeletePermanentActionButton extends ConsumerWidget {
const DeletePermanentActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 110.0,
iconData: Icons.delete_forever,
label: "delete_dialog_title".t(context: context),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class DeleteLocalActionButton extends ConsumerWidget {
const DeleteLocalActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 95.0,
iconData: Icons.no_cell_outlined,
label: "control_bottom_app_bar_delete_from_local".t(context: context),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class DownloadActionButton extends ConsumerWidget {
const DownloadActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.download,
label: "download".t(context: context),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class EditDateTimeActionButton extends ConsumerWidget {
const EditDateTimeActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 95.0,
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".t(context: context),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class EditLocationActionButton extends ConsumerWidget {
const EditLocationActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.edit_location_alt_outlined,
label: "control_bottom_app_bar_edit_location".t(context: context),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class FavoriteActionButton extends ConsumerWidget {
const FavoriteActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.favorite_border_rounded,
label: "favorite".t(context: context),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class MoveToLockFolderActionButton extends ConsumerWidget {
const MoveToLockFolderActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 100.0,
iconData: Icons.lock_outline_rounded,
label: "move_to_locked_folder".t(context: context),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class RemoveFromAlbumActionButton extends ConsumerWidget {
const RemoveFromAlbumActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.remove_circle_outline,
label: "remove_from_album".t(context: context),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class RemoveFromLockFolderActionButton extends ConsumerWidget {
const RemoveFromLockFolderActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 100.0,
iconData: Icons.lock_open_rounded,
label: "remove_from_locked_folder".t(context: context),
);
}
}

View File

@@ -0,0 +1,19 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class ShareActionButton extends ConsumerWidget {
const ShareActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData:
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
label: 'share'.t(context: context),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class ShareLinkActionButton extends ConsumerWidget {
const ShareLinkActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.link_rounded,
label: "share_link".t(context: context),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class StackActionButton extends ConsumerWidget {
const StackActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.filter_none_rounded,
label: "stack".t(context: context),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class TrashActionButton extends ConsumerWidget {
const TrashActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 85.0,
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_trash_from_immich".t(context: context),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class UnarchiveActionButton extends ConsumerWidget {
const UnarchiveActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".t(context: context),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class UnFavoriteActionButton extends ConsumerWidget {
const UnFavoriteActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.favorite_rounded,
label: "unfavorite".t(context: context),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class UploadActionButton extends ConsumerWidget {
const UploadActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.backup_outlined,
label: "upload".t(context: context),
);
}
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
class BaseBottomSheet extends ConsumerStatefulWidget {
final List<Widget> actions;
final DraggableScrollableController? controller;
final List<Widget>? slivers;
final double initialChildSize;
final double minChildSize;
final double maxChildSize;
final bool expand;
final bool shouldCloseOnMinExtent;
final bool resizeOnScroll;
const BaseBottomSheet({
super.key,
required this.actions,
this.slivers,
this.controller,
this.initialChildSize = 0.35,
this.minChildSize = 0.15,
this.maxChildSize = 0.65,
this.expand = true,
this.shouldCloseOnMinExtent = true,
this.resizeOnScroll = true,
});
@override
ConsumerState<BaseBottomSheet> createState() =>
_BaseDraggableScrollableSheetState();
}
class _BaseDraggableScrollableSheetState
extends ConsumerState<BaseBottomSheet> {
late DraggableScrollableController _controller;
@override
void initState() {
super.initState();
_controller = widget.controller ?? DraggableScrollableController();
}
@override
Widget build(BuildContext context) {
ref.listen(timelineStateProvider, (previous, next) {
if (!widget.resizeOnScroll) {
return;
}
if (previous?.isInteracting != true && next.isInteracting) {
_controller.animateTo(
widget.minChildSize,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
}
});
return DraggableScrollableSheet(
controller: _controller,
initialChildSize: widget.initialChildSize,
minChildSize: widget.minChildSize,
maxChildSize: widget.maxChildSize,
snap: false,
expand: widget.expand,
shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent,
builder: (BuildContext context, ScrollController scrollController) {
return Card(
color: context.colorScheme.surfaceContainerHigh,
surfaceTintColor: context.colorScheme.surfaceContainerHigh,
borderOnForeground: false,
clipBehavior: Clip.antiAlias,
elevation: 6.0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
margin: const EdgeInsets.symmetric(horizontal: 0),
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
const SizedBox(height: 16),
const _DragHandle(),
const SizedBox(height: 16),
SizedBox(
height: 120,
child: ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
children: widget.actions,
),
),
],
),
),
...(widget.slivers ?? []),
],
),
);
},
);
}
}
class _DragHandle extends StatelessWidget {
const _DragHandle();
@override
Widget build(BuildContext context) {
return Container(
height: 6,
width: 32,
decoration: BoxDecoration(
color: context.themeData.dividerColor,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_buton.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class HomeBottomAppBar extends ConsumerWidget {
const HomeBottomAppBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(
serverInfoProvider.select((state) => state.serverFeatures.trash),
);
return BaseBottomSheet(
initialChildSize: 0.25,
minChildSize: 0.22,
actions: [
const ShareActionButton(),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(),
const ArchiveActionButton(),
const FavoriteActionButton(),
const DownloadActionButton(),
isTrashEnable
? const TrashActionButton()
: const DeletePermanentActionButton(),
const EditDateTimeActionButton(),
const EditLocationActionButton(),
const MoveToLockFolderActionButton(),
const StackActionButton(),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(),
const UploadActionButton(),
],
],
);
}
}

View File

@@ -13,7 +13,7 @@ import 'package:intl/intl.dart' hide TextDirection;
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
class Scrubber extends StatefulWidget {
class Scrubber extends ConsumerStatefulWidget {
/// The view that will be scrolled with the scroll thumb
final CustomScrollView child;
@@ -37,7 +37,7 @@ class Scrubber extends StatefulWidget {
}) : assert(child.scrollDirection == Axis.vertical);
@override
State createState() => ScrubberState();
ConsumerState createState() => ScrubberState();
}
List<_Segment> _buildSegments({
@@ -82,7 +82,8 @@ List<_Segment> _buildSegments({
return segments;
}
class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
class ScrubberState extends ConsumerState<Scrubber>
with TickerProviderStateMixin {
double _thumbTopOffset = 0.0;
bool _isDragging = false;
List<_Segment> _segments = [];
@@ -175,6 +176,13 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
return false;
}
if (notification is ScrollStartNotification ||
notification is ScrollUpdateNotification) {
ref.read(timelineStateProvider.notifier).setScrolling(true);
} else if (notification is ScrollEndNotification) {
ref.read(timelineStateProvider.notifier).setScrolling(false);
}
setState(() {
if (notification is ScrollUpdateNotification) {
_thumbTopOffset = _currentOffset;
@@ -191,7 +199,7 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
return false;
}
void _onDragStart(WidgetRef ref) {
void _onDragStart(DragStartDetails _) {
ref.read(timelineStateProvider.notifier).setScrubbing(true);
setState(() {
_isDragging = true;
@@ -293,10 +301,12 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
_scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent));
}
void _onDragEnd(WidgetRef ref) {
void _onDragEnd(DragEndDetails _) {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
_labelAnimationController.reverse();
_isDragging = false;
setState(() {
_isDragging = false;
});
_resetThumbTimer();
}
@@ -342,13 +352,10 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
top: _thumbTopOffset + widget.topPadding,
end: 0,
child: RepaintBoundary(
child: Consumer(
builder: (_, ref, child) => GestureDetector(
onVerticalDragStart: (_) => _onDragStart(ref),
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: (_) => _onDragEnd(ref),
child: child,
),
child: GestureDetector(
onVerticalDragStart: _onDragStart,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
child: _Scrubber(
thumbAnimation: _thumbAnimation,
labelAnimation: _labelAnimation,

View File

@@ -40,19 +40,28 @@ class TimelineArgs {
class TimelineState {
final bool isScrubbing;
final bool isScrolling;
const TimelineState({this.isScrubbing = false});
const TimelineState({
this.isScrubbing = false,
this.isScrolling = false,
});
bool get isInteracting => isScrubbing || isScrolling;
@override
bool operator ==(covariant TimelineState other) {
return isScrubbing == other.isScrubbing;
return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling;
}
@override
int get hashCode => isScrubbing.hashCode;
int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode;
TimelineState copyWith({bool? isScrubbing}) {
return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing);
TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) {
return TimelineState(
isScrubbing: isScrubbing ?? this.isScrubbing,
isScrolling: isScrolling ?? this.isScrolling,
);
}
}
@@ -63,8 +72,15 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
state = state.copyWith(isScrubbing: isScrubbing);
}
void setScrolling(bool isScrolling) {
state = state.copyWith(isScrolling: isScrolling);
}
@override
TimelineState build() => const TimelineState(isScrubbing: false);
TimelineState build() => const TimelineState(
isScrubbing: false,
isScrolling: false,
);
}
// This provider watches the buckets from the timeline service & args and serves the segments.

View File

@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
@@ -120,12 +121,14 @@ class _SliverTimelineState extends State<_SliverTimeline> {
],
),
),
if (isMultiSelectEnabled)
if (isMultiSelectEnabled) ...[
const Positioned(
top: 60,
left: 25,
child: _MultiSelectStatusButton(),
),
const HomeBottomAppBar(),
],
],
),
);

View File

@@ -1,7 +1,12 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
);
final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
(ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)),
);

View File

@@ -11,7 +11,6 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService(
@@ -33,7 +32,6 @@ final localSyncServiceProvider = Provider(
(ref) => LocalSyncService(
localAlbumRepository: ref.watch(localAlbumRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
storeService: ref.watch(storeServiceProvider),
),
);

View File

@@ -4,6 +4,7 @@ import 'package:immich_mobile/infrastructure/repositories/timeline.repository.da
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
@@ -25,3 +26,16 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
settingsService: ref.watch(settingsProvider),
),
);
final timelineUsersProvider = StreamProvider<List<String>>(
(ref) {
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
if (currentUserId == null) {
return Stream.value([]);
}
return ref
.watch(timelineRepositoryProvider)
.watchTimelineUserIds(currentUserId);
},
);

View File

@@ -19,6 +19,14 @@ class MultiSelectState {
});
bool get isEnabled => selectedAssets.isNotEmpty;
bool get hasRemote => selectedAssets.any(
(asset) =>
asset.storage == AssetState.remote ||
asset.storage == AssetState.merged,
);
bool get hasLocal => selectedAssets.any(
(asset) => asset.storage == AssetState.local,
);
MultiSelectState copyWith({
Set<BaseAsset>? selectedAssets,

View File

@@ -14,7 +14,7 @@ class AlbumMediaRepository {
const AlbumMediaRepository();
bool get useCustomFilter =>
Store.get(StoreKey.photoManagerCustomFilter, false);
Store.get(StoreKey.photoManagerCustomFilter, true);
FilterOptionGroup? _getAlbumFilter({
DateTimeCond? updateTimeCond,

View File

@@ -34,6 +34,12 @@ class AuthRepository extends DatabaseRepository {
db.users.clear(),
_drift.remoteAssetEntity.deleteAll(),
_drift.remoteExifEntity.deleteAll(),
_drift.userEntity.deleteAll(),
_drift.userMetadataEntity.deleteAll(),
_drift.partnerEntity.deleteAll(),
_drift.remoteAlbumEntity.deleteAll(),
_drift.remoteAlbumAssetEntity.deleteAll(),
_drift.remoteAlbumUserEntity.deleteAll(),
]);
});
}

View File

@@ -68,6 +68,7 @@ import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.da
import 'package:immich_mobile/presentation/pages/dev/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/auth_guard.dart';
@@ -365,6 +366,10 @@ class AppRouter extends RootStackRouter {
page: MainTimelineRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: RemoteTimelineRoute.page,
guards: [_authGuard, _duplicateGuard],
),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -1425,6 +1425,43 @@ class RemoteMediaSummaryRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [RemoteTimelinePage]
class RemoteTimelineRoute extends PageRouteInfo<RemoteTimelineRouteArgs> {
RemoteTimelineRoute({
Key? key,
required String albumId,
List<PageRouteInfo>? children,
}) : super(
RemoteTimelineRoute.name,
args: RemoteTimelineRouteArgs(key: key, albumId: albumId),
initialChildren: children,
);
static const String name = 'RemoteTimelineRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<RemoteTimelineRouteArgs>();
return RemoteTimelinePage(key: args.key, albumId: args.albumId);
},
);
}
class RemoteTimelineRouteArgs {
const RemoteTimelineRouteArgs({this.key, required this.albumId});
final Key? key;
final String albumId;
@override
String toString() {
return 'RemoteTimelineRouteArgs{key: $key, albumId: $albumId}';
}
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {

View File

@@ -88,7 +88,7 @@ enum AppSettingsEnum<T> {
photoManagerCustomFilter<bool>(
StoreKey.photoManagerCustomFilter,
null,
false,
true,
),
;

Some files were not shown because too many files have changed in this diff Show More