mirror of
https://github.com/immich-app/immich.git
synced 2026-06-17 12:22:25 -07:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ca2c5effc | |||
| febc162821 | |||
| a855852ae7 | |||
| 35fdaaeed8 | |||
| 5f2f472f98 | |||
| e459c3d5a3 | |||
| 800bfab956 | |||
| 84ba31bad4 | |||
| e225f874b1 | |||
| d227ba2d51 | |||
| 9cb94343d1 | |||
| aa126e377c | |||
| 74878628c8 | |||
| 4ead3e697d | |||
| fb798a8f29 | |||
| 07813135b5 | |||
| 92a75b0cd3 | |||
| 8132e8a38c | |||
| 43f2f56530 | |||
| e580bb5d0a | |||
| d3680871ef | |||
| b9b1cc2f65 | |||
| 7d198956a6 | |||
| a7b5f81701 | |||
| 5c38373808 | |||
| 1ce961fbb3 | |||
| 4bc411b7c7 | |||
| 11c1025271 | |||
| 8b5385f94b | |||
| d3438cf4a7 | |||
| 6c5c6a1035 | |||
| c928787b3e | |||
| fe9ca4f40a | |||
| a665cec920 | |||
| 568283a8eb |
@@ -15,7 +15,7 @@ jobs:
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@50e6a3413e5aa9c3ae4d8393c34745be44288b46 # v0.0.48
|
||||
uses: oasdiff/oasdiff-action/breaking@a8c7f0e5649d20d623edb5b38446d3ab3d82d43c # v0.0.53
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -50,14 +50,14 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: true
|
||||
|
||||
@@ -10,9 +10,13 @@ on:
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
- premajor
|
||||
- preminor
|
||||
- prepatch
|
||||
- prerelease
|
||||
- release
|
||||
mobileBump:
|
||||
description: 'Bump mobile build number'
|
||||
required: false
|
||||
@@ -55,7 +59,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
persist-credentials: true
|
||||
@@ -68,13 +72,13 @@ jobs:
|
||||
|
||||
# TODO move to mise
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Bump version
|
||||
env:
|
||||
SERVER_BUMP: ${{ inputs.serverBump }}
|
||||
MOBILE_BUMP: ${{ inputs.mobileBump }}
|
||||
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
|
||||
run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
|
||||
|
||||
- id: output
|
||||
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -125,7 +129,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
+48
-16
@@ -28,6 +28,10 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
root:
|
||||
- 'misc/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'mise.toml'
|
||||
i18n:
|
||||
- 'i18n/**'
|
||||
- 'mise.toml'
|
||||
@@ -62,6 +66,34 @@ jobs:
|
||||
- '.github/workflows/test.yml'
|
||||
force-events: 'workflow_dispatch'
|
||||
|
||||
root-unit-tests:
|
||||
name: Test the root workspace
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).root == true }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
server-unit-tests:
|
||||
name: Test & Lint Server
|
||||
needs: pre-job
|
||||
@@ -77,7 +109,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -108,7 +140,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -139,7 +171,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -183,7 +215,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -221,7 +253,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -249,7 +281,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -299,7 +331,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -331,7 +363,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -367,7 +399,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -444,7 +476,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -551,7 +583,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -589,7 +621,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -620,7 +652,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -649,7 +681,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -671,7 +703,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -729,7 +761,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
Vendored
+1
@@ -60,6 +60,7 @@
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"*.js": "${capture}.spec.js,${capture}.mock.js",
|
||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||
},
|
||||
"search.exclude": {
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('/admin/maintenance', () => {
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
@@ -190,7 +190,7 @@ describe('/admin/maintenance', () => {
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
|
||||
@@ -492,6 +492,20 @@ describe('/asset', () => {
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should set the negative rating', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ rating: -1 });
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({
|
||||
rating: -1,
|
||||
}),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should return tagged people', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
import {
|
||||
AssetMediaResponseDto,
|
||||
IntegrityReportResponseDto,
|
||||
LoginResponseDto,
|
||||
ManualJobName,
|
||||
QueueCommand,
|
||||
QueueName,
|
||||
} from '@immich/sdk';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { app, testAssetDir, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const assetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
||||
const asset1Filepath = `${testAssetDir}/albums/nature/el_torcal_rocks.jpg`;
|
||||
const asset2Filepath = `${testAssetDir}/albums/nature/wood_anemones.jpg`;
|
||||
|
||||
describe('/admin/integrity', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
|
||||
let user1: LoginResponseDto;
|
||||
let asset1: AssetMediaResponseDto;
|
||||
|
||||
let user2: LoginResponseDto;
|
||||
let asset2: AssetMediaResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
|
||||
user1 = await utils.userSetup(admin.accessToken, {
|
||||
email: '1@example.com',
|
||||
name: '1',
|
||||
password: '1',
|
||||
});
|
||||
|
||||
user2 = await utils.userSetup(admin.accessToken, {
|
||||
email: '2@example.com',
|
||||
name: '2',
|
||||
password: '2',
|
||||
});
|
||||
|
||||
for (const queue of Object.values(QueueName)) {
|
||||
if (queue === QueueName.IntegrityCheck) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await utils.queueCommand(admin.accessToken, queue, {
|
||||
command: QueueCommand.Pause,
|
||||
});
|
||||
}
|
||||
|
||||
asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename: 'asset.jpg',
|
||||
bytes: await readFile(assetFilepath),
|
||||
},
|
||||
});
|
||||
|
||||
asset1 = await utils.createAsset(user1.accessToken, {
|
||||
assetData: {
|
||||
filename: 'asset.jpg',
|
||||
bytes: await readFile(asset1Filepath),
|
||||
},
|
||||
});
|
||||
|
||||
asset2 = await utils.createAsset(user2.accessToken, {
|
||||
assetData: {
|
||||
filename: 'asset.jpg',
|
||||
bytes: await readFile(asset2Filepath),
|
||||
},
|
||||
});
|
||||
|
||||
await utils.mkFolder('/data/bak');
|
||||
await utils.copyFolder(`/data/upload/${admin.userId}`, `/data/bak/${admin.userId}`);
|
||||
|
||||
for (const queue of Object.values(QueueName)) {
|
||||
if (queue === QueueName.IntegrityCheck) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await utils.queueCommand(admin.accessToken, queue, {
|
||||
command: QueueCommand.Empty,
|
||||
});
|
||||
|
||||
await utils.queueCommand(admin.accessToken, queue, {
|
||||
command: QueueCommand.Resume,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await utils.deleteFolder(`/data/upload/${admin.userId}`);
|
||||
await utils.copyFolder(`/data/bak/${admin.userId}`, `/data/upload/${admin.userId}`);
|
||||
});
|
||||
|
||||
describe('POST /summary (& jobs)', async () => {
|
||||
it.sequential('reports no issues', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFilesDeleteAll,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
missing_file: 0,
|
||||
untracked_file: 0,
|
||||
checksum_mismatch: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.sequential('should detect an untracked file (job: check untracked files)', async () => {
|
||||
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
untracked_file: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect outdated untracked file reports (job: refresh untracked files)', async () => {
|
||||
// these should not be detected:
|
||||
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked2.png`);
|
||||
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked3.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFilesRefresh,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
untracked_file: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should delete untracked files (job: delete all untracked file reports)', async () => {
|
||||
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFilesDeleteAll,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
untracked_file: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect a missing file and not a checksum mismatch (job: check missing files)', async () => {
|
||||
await utils.deleteFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
missing_file: 1,
|
||||
checksum_mismatch: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect outdated missing file reports (job: refresh missing files)', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFilesRefresh,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
missing_file: 0,
|
||||
checksum_mismatch: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should delete assets with missing files (job: delete all missing file reports)', async () => {
|
||||
await utils.deleteFolder(`/data/upload/${user1.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus, body: listBody } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(listBody).toEqual(
|
||||
expect.objectContaining({
|
||||
missing_file: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFilesDeleteAll,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
missing_file: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(utils.getAssetInfo(user1.accessToken, asset1.id)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isTrashed: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect a checksum mismatch (job: check file checksums)', async () => {
|
||||
await utils.truncateFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
checksum_mismatch: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('should detect outdated checksum mismatch reports (job: refresh file checksums)', async () => {
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatchRefresh,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
checksum_mismatch: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential(
|
||||
'should delete assets with mismatched checksum (job: delete all checksum mismatch reports)',
|
||||
async () => {
|
||||
await utils.truncateFolder(`/data/upload/${user2.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus, body: listBody } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(listBody).toEqual(
|
||||
expect.objectContaining({
|
||||
checksum_mismatch: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatchDeleteAll,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/summary')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
checksum_mismatch: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(utils.getAssetInfo(user2.accessToken, asset2.id)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isTrashed: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('POST /report', async () => {
|
||||
it.sequential('reports untracked files', async () => {
|
||||
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/report?type=untracked_file')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
nextCursor: undefined,
|
||||
items: expect.arrayContaining([
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'untracked_file',
|
||||
path: `/data/upload/${admin.userId}/untracked1.png`,
|
||||
assetId: null,
|
||||
fileAssetId: null,
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it.sequential('reports missing files', async () => {
|
||||
await utils.deleteFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/report?type=missing_file')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
nextCursor: undefined,
|
||||
items: expect.arrayContaining([
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'missing_file',
|
||||
path: expect.any(String),
|
||||
assetId: asset.id,
|
||||
fileAssetId: null,
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it.sequential('reports checksum mismatched files', async () => {
|
||||
await utils.truncateFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get('/admin/integrity/report?type=checksum_mismatch')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
nextCursor: undefined,
|
||||
items: expect.arrayContaining([
|
||||
{
|
||||
id: expect.any(String),
|
||||
type: 'checksum_mismatch',
|
||||
path: expect.any(String),
|
||||
assetId: asset.id,
|
||||
fileAssetId: null,
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /report/:id', async () => {
|
||||
it.sequential('delete untracked files', async () => {
|
||||
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus, body: listBody } = await request(app)
|
||||
.get('/admin/integrity/report?type=untracked_file')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
|
||||
const report = (listBody as IntegrityReportResponseDto).items.find(
|
||||
(item) => item.path === `/data/upload/${admin.userId}/untracked1.png`,
|
||||
)!;
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/integrity/report/${report.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus2, body: listBody2 } = await request(app)
|
||||
.get('/admin/integrity/report?type=untracked_file')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(listStatus2).toBe(200);
|
||||
expect(listBody2).not.toBe(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: report.id,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('delete assets missing files', async () => {
|
||||
await utils.deleteFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus, body: listBody } = await request(app)
|
||||
.get('/admin/integrity/report?type=missing_file')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(listBody.items.length).toBe(1);
|
||||
|
||||
const report = (listBody as IntegrityReportResponseDto).items[0];
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/integrity/report/${report.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityMissingFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus2, body: listBody2 } = await request(app)
|
||||
.get('/admin/integrity/report?type=missing_file')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(listStatus2).toBe(200);
|
||||
expect(listBody2.items.length).toBe(0);
|
||||
});
|
||||
|
||||
it.sequential('delete assets with failing checksum', async () => {
|
||||
await utils.truncateFolder(`/data/upload/${admin.userId}`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus, body: listBody } = await request(app)
|
||||
.get('/admin/integrity/report?type=checksum_mismatch')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(listStatus).toBe(200);
|
||||
expect(listBody.items.length).toBe(1);
|
||||
|
||||
const report = (listBody as IntegrityReportResponseDto).items[0];
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/admin/integrity/report/${report.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityChecksumMismatch,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status: listStatus2, body: listBody2 } = await request(app)
|
||||
.get('/admin/integrity/report?type=checksum_mismatch')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(listStatus2).toBe(200);
|
||||
expect(listBody2.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /report/:type/csv', () => {
|
||||
it.sequential('exports untracked files as csv', async () => {
|
||||
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { status, headers, text } = await request(app)
|
||||
.get('/admin/integrity/report/untracked_file/csv')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(headers['content-type']).toContain('text/csv');
|
||||
expect(headers['content-disposition']).toContain('.csv');
|
||||
expect(text).toContain('id,type,assetId,fileAssetId,path');
|
||||
expect(text).toContain(`untracked_file`);
|
||||
expect(text).toContain(`/data/upload/${admin.userId}/untracked1.png`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /report/:id/file', () => {
|
||||
it.sequential('downloads untracked file', async () => {
|
||||
await utils.putTextFile('untracked-content', `/data/upload/${admin.userId}/untracked1.png`);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
const { body: listBody } = await request(app)
|
||||
.get('/admin/integrity/report?type=untracked_file')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
|
||||
const report = (listBody as IntegrityReportResponseDto).items.find(
|
||||
(item) => item.path === `/data/upload/${admin.userId}/untracked1.png`,
|
||||
)!;
|
||||
|
||||
const { status, headers, body } = await request(app)
|
||||
.get(`/admin/integrity/report/${report.id}/file`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.buffer(true)
|
||||
.send();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(headers['content-type']).toContain('application/octet-stream');
|
||||
expect(body.toString()).toBe('untracked-content');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { LoginResponseDto, ManualJobName, QueueName } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe.skip('Integrity', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test('run integrity jobs to update stats', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await utils.createJob(admin.accessToken, {
|
||||
name: ManualJobName.IntegrityUntrackedFiles,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
|
||||
|
||||
await page.goto('/admin/maintenance');
|
||||
|
||||
const count = page.getByText('Untracked Files').locator('..').locator('..').locator('div').nth(1);
|
||||
|
||||
const previousCount = Number.parseInt((await count.textContent()) ?? '');
|
||||
|
||||
await utils.mkFolder(`/data/upload/${admin.userId}`);
|
||||
await utils.putTextFile('untracked', `/data/upload/${admin.userId}/untracked1.png`);
|
||||
|
||||
const checkButton = page.getByText('Integrity Report').locator('..').getByRole('button', { name: 'Check All' });
|
||||
|
||||
await checkButton.click();
|
||||
await expect(checkButton).toBeEnabled();
|
||||
|
||||
await expect(count).toContainText((previousCount + 1).toString());
|
||||
});
|
||||
});
|
||||
+46
-3
@@ -192,6 +192,7 @@ export const utils = {
|
||||
'user',
|
||||
'system_metadata',
|
||||
'tag',
|
||||
'integrity_report',
|
||||
];
|
||||
|
||||
const truncateTables = tables.filter((table) => table !== 'system_metadata');
|
||||
@@ -559,10 +560,54 @@ export const utils = {
|
||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||
},
|
||||
|
||||
putFile(source: string, dest: string) {
|
||||
return executeCommand('docker', ['cp', source, `immich-e2e-server:${dest}`]).promise;
|
||||
},
|
||||
|
||||
async putTextFile(contents: string, dest: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||
const fn = join(dir, 'file');
|
||||
await pipeline(Readable.from(contents), createWriteStream(fn));
|
||||
return executeCommand('docker', ['cp', fn, `immich-e2e-server:${dest}`]).promise;
|
||||
},
|
||||
|
||||
async move(source: string, dest: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
|
||||
},
|
||||
|
||||
async copyFolder(source: string, dest: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'cp', '-r', source, dest]).promise;
|
||||
},
|
||||
|
||||
async deleteFile(path: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'rm', path]).promise;
|
||||
},
|
||||
|
||||
async deleteFolder(path: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'rm', '-r', path]).promise;
|
||||
},
|
||||
|
||||
async truncateFolder(path: string) {
|
||||
return executeCommand('docker', [
|
||||
'exec',
|
||||
'immich-e2e-server',
|
||||
'find',
|
||||
path,
|
||||
'-type',
|
||||
'f',
|
||||
'-exec',
|
||||
'truncate',
|
||||
'-s',
|
||||
'1',
|
||||
'{}',
|
||||
';',
|
||||
]).promise;
|
||||
},
|
||||
|
||||
async mkFolder(path: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mkdir', '-p', path]).promise;
|
||||
},
|
||||
|
||||
createBackup: async (accessToken: string) => {
|
||||
await utils.createJob(accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
@@ -579,10 +624,8 @@ export const utils = {
|
||||
|
||||
resetBackups: async (accessToken: string) => {
|
||||
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
|
||||
|
||||
const backupFiles = backups.map((b) => b.filename);
|
||||
await deleteDatabaseBackup(
|
||||
{ databaseBackupDeleteDto: { backups: backupFiles } },
|
||||
{ databaseBackupDeleteDto: { backups: backups.map((dto) => dto.filename) } },
|
||||
{ headers: asBearerAuth(accessToken) },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
|
||||
"cron_expression_presets": "Cron expression presets",
|
||||
"disable_login": "Disable login",
|
||||
"download_csv": "Download CSV",
|
||||
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
|
||||
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
|
||||
"export_config_as_json_description": "Download the current system config as a JSON file",
|
||||
@@ -191,6 +192,17 @@
|
||||
"maintenance_delete_backup": "Delete Backup",
|
||||
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
|
||||
"maintenance_delete_error": "Failed to delete backup.",
|
||||
"maintenance_integrity_check_all": "Check All",
|
||||
"maintenance_integrity_checksum_mismatch": "Checksum Mismatch",
|
||||
"maintenance_integrity_checksum_mismatch_job": "Check for checksum mismatches",
|
||||
"maintenance_integrity_checksum_mismatch_refresh_job": "Refresh checksum mismatch reports",
|
||||
"maintenance_integrity_missing_file": "Missing Files",
|
||||
"maintenance_integrity_missing_file_job": "Check for missing files",
|
||||
"maintenance_integrity_missing_file_refresh_job": "Refresh missing file reports",
|
||||
"maintenance_integrity_report": "Integrity Report",
|
||||
"maintenance_integrity_untracked_file": "Untracked Files",
|
||||
"maintenance_integrity_untracked_file_job": "Check for untracked files",
|
||||
"maintenance_integrity_untracked_file_refresh_job": "Refresh untracked file reports",
|
||||
"maintenance_restore_backup": "Restore Backup",
|
||||
"maintenance_restore_backup_description": "Immich will be wiped and restored from the chosen backup. A backup will be created before continuing.",
|
||||
"maintenance_restore_backup_different_version": "This backup was created with a different version of Immich!",
|
||||
@@ -915,6 +927,8 @@
|
||||
"deduplicate_all": "Deduplicate All",
|
||||
"default_locale": "Default Locale",
|
||||
"default_locale_description": "Format dates and numbers based on your browser locale",
|
||||
"default_quality_subtitle": "Quality used when tapping share. Long press the share button to choose each time.",
|
||||
"default_share_quality": "Default share quality",
|
||||
"delete": "Delete",
|
||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||
"delete_action_prompt": "{count} deleted",
|
||||
@@ -1224,6 +1238,7 @@
|
||||
"failed": "Failed",
|
||||
"failed_count": "Failed: {count}",
|
||||
"failed_to_authenticate": "Failed to authenticate",
|
||||
"failed_to_delete_file": "Failed to delete file",
|
||||
"failed_to_load_assets": "Failed to load assets",
|
||||
"failed_to_load_folder": "Failed to load folder",
|
||||
"favorite": "Favorite",
|
||||
@@ -1354,6 +1369,7 @@
|
||||
"individual_share": "Individual share",
|
||||
"individual_shares": "Individual shares",
|
||||
"info": "Info",
|
||||
"integrity_checks": "Integrity Checks",
|
||||
"interval": {
|
||||
"day_at_onepm": "Every day at 1pm",
|
||||
"hours": "Every {hours, plural, one {hour} other {{hours, number} hours}}",
|
||||
@@ -1426,6 +1442,7 @@
|
||||
"linked_oauth_account": "Linked OAuth account",
|
||||
"list": "List",
|
||||
"live": "Live",
|
||||
"load_more": "Load More",
|
||||
"loading": "Loading",
|
||||
"loading_search_results_failed": "Loading search results failed",
|
||||
"local": "Local",
|
||||
@@ -2084,6 +2101,7 @@
|
||||
"select_person": "Select person",
|
||||
"select_person_to_tag": "Select a person to tag",
|
||||
"select_photos": "Select photos",
|
||||
"select_quality": "Select quality",
|
||||
"select_trash_all": "Select trash all",
|
||||
"select_user_for_sharing_page_err_album": "Failed to create album",
|
||||
"selected": "Selected",
|
||||
@@ -2147,6 +2165,8 @@
|
||||
"share_assets_selected": "{count} selected",
|
||||
"share_dialog_preparing": "Preparing...",
|
||||
"share_link": "Share Link",
|
||||
"share_original": "Use original (large)",
|
||||
"share_preview": "Use thumbnail (small)",
|
||||
"shared": "Shared",
|
||||
"shared_album_activities_input_disable": "Comment is disabled",
|
||||
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
||||
@@ -2248,6 +2268,7 @@
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
"smart_album": "Smart album",
|
||||
"some_assets_already_have_a_location_warning": "Some of the selected assets already have a location",
|
||||
"sort_albums_by": "Sort albums by...",
|
||||
"sort_created": "Date created",
|
||||
"sort_items": "Number of items",
|
||||
|
||||
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4e
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-core-2_2.34.4+21428_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-opencl-2_2.34.4+21428_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/intel-opencl-icd_26.18.38308.1-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/libigdgmm12_22.10.0_amd64.deb && \
|
||||
dpkg -i *.deb && \
|
||||
rm *.deb && \
|
||||
apt-get remove wget -yqq && \
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#! /usr/bin/env node
|
||||
const { readFileSync, writeFileSync } = require('node:fs');
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const asVersion = (item) => {
|
||||
const { label, url } = item;
|
||||
const [major, minor, patch] = label.substring(1).split('.').map(Number);
|
||||
const [version] = label.substring(1).split('-');
|
||||
const [major, minor, patch] = version.split('.').map(Number);
|
||||
return { major, minor, patch, label, url };
|
||||
};
|
||||
|
||||
@@ -31,7 +32,7 @@ for (const item of versions) {
|
||||
) {
|
||||
versions = versions.filter((item) => item.label !== version.label);
|
||||
console.log(
|
||||
`Removed ${version.label} (replaced with ${lastVersion.label})`
|
||||
`Removed ${version.label} (replaced with ${lastVersion.label})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -41,5 +42,5 @@ for (const item of versions) {
|
||||
|
||||
writeFileSync(
|
||||
filename,
|
||||
JSON.stringify([newVersion, ...versions], null, 2) + '\n'
|
||||
JSON.stringify([newVersion, ...versions], null, 2) + '\n',
|
||||
);
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
#
|
||||
# Pump one or both of the server/mobile versions in appropriate files
|
||||
#
|
||||
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false>
|
||||
# usage: './scripts/pump-version.sh -s <minor|patch|premajor|preminor|prepatch|prerelease> <-m> <true|false>
|
||||
#
|
||||
# examples:
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -s premajor # 1.0.0+50 => 2.0.0-rc.0+50
|
||||
# ./scripts/pump-version.sh -s prerelease # 2.0.0-rc.0+50 => 2.0.0-rc.1+50
|
||||
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
|
||||
#
|
||||
|
||||
SERVER_PUMP="false"
|
||||
@@ -25,31 +27,15 @@ while getopts 's:m:' flag; do
|
||||
esac
|
||||
done
|
||||
|
||||
CURRENT_SERVER=$(jq -r '.version' server/package.json)
|
||||
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
|
||||
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
|
||||
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
|
||||
|
||||
if [[ $SERVER_PUMP == "major" ]]; then
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "minor" ]]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "patch" ]]; then
|
||||
PATCH=$((PATCH + 1))
|
||||
elif [[ $SERVER_PUMP == "false" ]]; then
|
||||
echo 'Skipping Server Pump'
|
||||
else
|
||||
echo 'Expected <major|minor|patch|false> for the server argument'
|
||||
CURRENT_SERVER=$(jq -r '.version' package.json)
|
||||
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
|
||||
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEXT_SERVER=$MAJOR.$MINOR.$PATCH
|
||||
|
||||
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
|
||||
NEXT_MOBILE=$CURRENT_MOBILE
|
||||
|
||||
if [[ $MOBILE_PUMP == "true" ]]; then
|
||||
set $((NEXT_MOBILE++))
|
||||
elif [[ $MOBILE_PUMP == "false" ]]; then
|
||||
@@ -59,15 +45,17 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/sdk
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
|
||||
|
||||
# copy version to open-api spec
|
||||
mise run //:open-api
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { pump } from './pump.js';
|
||||
|
||||
const [versionRaw, type] = process.argv.slice(2);
|
||||
const { message, exitCode } = pump(versionRaw, type);
|
||||
|
||||
console.log(message);
|
||||
process.exit(exitCode);
|
||||
@@ -0,0 +1,105 @@
|
||||
import semver, { SemVer } from 'semver';
|
||||
|
||||
const printUsage = () => {
|
||||
return {
|
||||
message:
|
||||
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
|
||||
exitCode: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const isPrerelease = (version) => version.prerelease.length > 0;
|
||||
|
||||
/**
|
||||
* @param {SemVer} version
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
|
||||
|
||||
/** @param {string} version */
|
||||
const normalize = (version) => {
|
||||
if (version.startsWith('v')) {
|
||||
version = version.slice(1);
|
||||
}
|
||||
|
||||
return version;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} versionRaw
|
||||
* @param {string} type
|
||||
*/
|
||||
export const pump = (versionRaw, type) => {
|
||||
if (!versionRaw) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
versionRaw = normalize(versionRaw);
|
||||
|
||||
const version = semver.parse(versionRaw);
|
||||
if (!version) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
let newVersionRaw;
|
||||
let valid = true;
|
||||
|
||||
switch (type) {
|
||||
case 'patch':
|
||||
case 'prepatch':
|
||||
case 'minor':
|
||||
case 'preminor':
|
||||
case 'premajor': {
|
||||
newVersionRaw = inc(version, type);
|
||||
// can only use while not in a prerelease
|
||||
valid = !isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'prerelease': {
|
||||
newVersionRaw = inc(version, type);
|
||||
// can only use while in a prerelease
|
||||
valid = isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'release': {
|
||||
// drop prerelease part
|
||||
newVersionRaw = `${version.major}.${version.minor}.${version.patch}`;
|
||||
// can only use to promote a prerelease to a release (no version change)
|
||||
valid = isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
return printUsage();
|
||||
}
|
||||
}
|
||||
|
||||
if (!newVersionRaw) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
newVersionRaw = normalize(newVersionRaw);
|
||||
|
||||
const newVersion = semver.parse(newVersionRaw);
|
||||
if (!newVersion) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
const invalidUpgrade =
|
||||
isPrerelease(version) &&
|
||||
!isPrerelease(newVersion) &&
|
||||
(version.major !== newVersion.major ||
|
||||
version.minor !== newVersion.minor ||
|
||||
version.patch !== newVersion.patch);
|
||||
|
||||
if (!valid || invalidUpgrade) {
|
||||
return {
|
||||
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
|
||||
exitCode: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: newVersionRaw, exitCode: 0 };
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pump } from './pump';
|
||||
|
||||
describe(pump.name, () => {
|
||||
describe('usage', () => {
|
||||
it.each([
|
||||
[],
|
||||
['2.7.5'],
|
||||
['2.7.5', 'invalid'],
|
||||
['invalid', 'patch'],
|
||||
['2.7.5', 'major'],
|
||||
])('should not accept $0, $1 as inputs', (version, type) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Usage: '),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitions', () => {
|
||||
const valid = [
|
||||
{
|
||||
name: 'patch',
|
||||
items: [['patch', '2.7.5', '2.7.6']],
|
||||
},
|
||||
{
|
||||
name: 'prepatch',
|
||||
items: [
|
||||
['prepatch', '2.7.5', '2.7.6-rc.0'],
|
||||
['prerelease', '2.7.6-rc.0', '2.7.6-rc.1'],
|
||||
['release', '2.7.6-rc.1', '2.7.6'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'minor',
|
||||
items: [['minor', '2.7.5', '2.8.0']],
|
||||
},
|
||||
{
|
||||
name: 'preminor',
|
||||
items: [
|
||||
['preminor', '2.7.5', '2.8.0-rc.0'],
|
||||
['prerelease', '2.8.0-rc.0', '2.8.0-rc.1'],
|
||||
['release', '2.8.0-rc.1', '2.8.0'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'premajor',
|
||||
items: [
|
||||
['premajor', '2.7.5', '3.0.0-rc.0'],
|
||||
['prerelease', '3.0.0-rc.0', '3.0.0-rc.1'],
|
||||
['release', '3.0.0-rc.1', '3.0.0'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const group of valid) {
|
||||
describe(group.name, () => {
|
||||
it.each(group.items)(
|
||||
'should allow a $0 from $1 to $2',
|
||||
(type, version, next) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: next,
|
||||
exitCode: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('invalid', () => {
|
||||
it.each([
|
||||
['patch', 'v3.0.0-rc.0'],
|
||||
['prepatch', 'v3.0.0-rc.0'],
|
||||
['minor', 'v3.0.0-rc.0'],
|
||||
['preminor', 'v3.0.0-rc.0'],
|
||||
['premajor', 'v3.0.0-rc.0'],
|
||||
['prerelease', 'v3.0.0'],
|
||||
['release', 'v3.0.0'],
|
||||
])('should not allow a $0 on $1', (type, version) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Invalid pump'),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+3603
File diff suppressed because it is too large
Load Diff
+3660
File diff suppressed because it is too large
Load Diff
+3717
File diff suppressed because it is too large
Load Diff
@@ -34,14 +34,14 @@ void main() {
|
||||
server = await FakeImmichServer.start();
|
||||
await ApiService().resolveAndSetEndpoint(server.endpoint);
|
||||
await drift.delete(drift.userEntity).go();
|
||||
await Store.delete(StoreKey.syncMigrationStatus);
|
||||
await Store.delete(StoreKey.legacySyncMigrationStatus);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await workerManagerPatch.dispose();
|
||||
await server.close();
|
||||
await Store.delete(StoreKey.serverEndpoint);
|
||||
await Store.delete(StoreKey.syncMigrationStatus);
|
||||
await Store.delete(StoreKey.legacyServerEndpoint);
|
||||
await Store.delete(StoreKey.legacySyncMigrationStatus);
|
||||
});
|
||||
|
||||
void sendUser(SyncStream stream, String id, String name) {
|
||||
@@ -119,7 +119,9 @@ void main() {
|
||||
final releaseTxn = Completer<void>();
|
||||
final txnHeld = Completer<void>();
|
||||
final txn = drift.transaction(() async {
|
||||
await drift.into(drift.userEntity).insert(
|
||||
await drift
|
||||
.into(drift.userEntity)
|
||||
.insert(
|
||||
UserEntityCompanion.insert(
|
||||
id: 'holder',
|
||||
name: 'holder',
|
||||
|
||||
@@ -13,6 +13,8 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
enum ActionSource { timeline, viewer }
|
||||
|
||||
enum ShareAssetType { original, preview }
|
||||
|
||||
enum CleanupStep { selectDate, scan, delete }
|
||||
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:immich_mobile/domain/models/value_codec.dart';
|
||||
|
||||
const int kCurrentVersion = 29;
|
||||
|
||||
enum AppMetadataKey<T> {
|
||||
version<int>(kCurrentVersion),
|
||||
syncMigrationStatus<List<String>>([], codec: ListCodec(PrimitiveCodec.string)),
|
||||
manageLocalMediaAndroid<bool>(false);
|
||||
|
||||
const AppMetadataKey(this.defaultValue, {ValueCodec<T>? codec}) : _codecOverride = codec;
|
||||
|
||||
final T defaultValue;
|
||||
|
||||
final ValueCodec<T>? _codecOverride;
|
||||
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
class AdvancedConfig {
|
||||
final bool troubleshooting;
|
||||
final bool enableHapticFeedback;
|
||||
final bool readonlyModeEnabled;
|
||||
|
||||
const AdvancedConfig({
|
||||
this.troubleshooting = false,
|
||||
this.enableHapticFeedback = true,
|
||||
this.readonlyModeEnabled = false,
|
||||
});
|
||||
|
||||
AdvancedConfig copyWith({bool? troubleshooting, bool? enableHapticFeedback, bool? readonlyModeEnabled}) =>
|
||||
AdvancedConfig(
|
||||
troubleshooting: troubleshooting ?? this.troubleshooting,
|
||||
enableHapticFeedback: enableHapticFeedback ?? this.enableHapticFeedback,
|
||||
readonlyModeEnabled: readonlyModeEnabled ?? this.readonlyModeEnabled,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is AdvancedConfig &&
|
||||
other.troubleshooting == troubleshooting &&
|
||||
other.enableHapticFeedback == enableHapticFeedback &&
|
||||
other.readonlyModeEnabled == readonlyModeEnabled);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(troubleshooting, enableHapticFeedback, readonlyModeEnabled);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AdvancedConfig(troubleshooting: $troubleshooting, enableHapticFeedback: $enableHapticFeedback, readonlyModeEnabled: $readonlyModeEnabled)';
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/config/advanced_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/album_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/backup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/image_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/share_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
|
||||
@@ -30,6 +32,8 @@ class AppConfig {
|
||||
final AlbumConfig album;
|
||||
final BackupConfig backup;
|
||||
final NetworkConfig network;
|
||||
final ShareConfig share;
|
||||
final AdvancedConfig advanced;
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
@@ -43,6 +47,8 @@ class AppConfig {
|
||||
this.album = const .new(),
|
||||
this.backup = const .new(),
|
||||
this.network = const .new(),
|
||||
this.share = const .new(),
|
||||
this.advanced = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
@@ -57,6 +63,8 @@ class AppConfig {
|
||||
AlbumConfig? album,
|
||||
BackupConfig? backup,
|
||||
NetworkConfig? network,
|
||||
ShareConfig? share,
|
||||
AdvancedConfig? advanced,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
theme: theme ?? this.theme,
|
||||
@@ -69,6 +77,8 @@ class AppConfig {
|
||||
album: album ?? this.album,
|
||||
backup: backup ?? this.backup,
|
||||
network: network ?? this.network,
|
||||
share: share ?? this.share,
|
||||
advanced: advanced ?? this.advanced,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -85,17 +95,32 @@ class AppConfig {
|
||||
other.slideshow == slideshow &&
|
||||
other.album == album &&
|
||||
other.backup == backup &&
|
||||
other.network == network);
|
||||
other.network == network &&
|
||||
other.share == share &&
|
||||
other.advanced == advanced);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
|
||||
int get hashCode => Object.hash(
|
||||
logLevel,
|
||||
theme,
|
||||
cleanup,
|
||||
map,
|
||||
timeline,
|
||||
image,
|
||||
viewer,
|
||||
slideshow,
|
||||
album,
|
||||
backup,
|
||||
network,
|
||||
share,
|
||||
advanced,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network)';
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, advanced: $advanced)';
|
||||
|
||||
T read<T extends Object>(SettingsKey<T> key) =>
|
||||
T read<T>(SettingsKey<T> key) =>
|
||||
(switch (key) {
|
||||
.logLevel => logLevel,
|
||||
.themePrimaryColor => theme.primaryColor,
|
||||
@@ -135,18 +160,22 @@ class AppConfig {
|
||||
.cleanupKeepAlbumIds => cleanup.keepAlbumIds,
|
||||
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
|
||||
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
|
||||
.shareFileType => share.fileType,
|
||||
.slideshowTransition => slideshow.transition,
|
||||
.slideshowRepeat => slideshow.repeat,
|
||||
.slideshowDuration => slideshow.duration,
|
||||
.slideshowLook => slideshow.look,
|
||||
.slideshowDirection => slideshow.direction,
|
||||
.advancedTroubleshooting => advanced.troubleshooting,
|
||||
.advancedEnableHapticFeedback => advanced.enableHapticFeedback,
|
||||
.advancedReadonlyModeEnabled => advanced.readonlyModeEnabled,
|
||||
})
|
||||
as T;
|
||||
|
||||
factory AppConfig.fromEntries(Map<SettingsKey<Object>, Object> overrides) =>
|
||||
factory AppConfig.fromEntries(Map<SettingsKey, Object?> overrides) =>
|
||||
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
|
||||
|
||||
AppConfig write<T extends Object>(SettingsKey<T> key, T value) {
|
||||
AppConfig write<T, U extends T>(SettingsKey<T> key, U value) {
|
||||
return switch (key) {
|
||||
.logLevel => copyWith(logLevel: value as LogLevel),
|
||||
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
|
||||
@@ -160,8 +189,10 @@ class AppConfig {
|
||||
.viewerAutoPlayVideo => copyWith(viewer: viewer.copyWith(autoPlayVideo: value as bool)),
|
||||
.viewerTapToNavigate => copyWith(viewer: viewer.copyWith(tapToNavigate: value as bool)),
|
||||
.networkAutoEndpointSwitching => copyWith(network: network.copyWith(autoEndpointSwitching: value as bool)),
|
||||
.networkPreferredWifiName => copyWith(network: network.copyWith(preferredWifiName: (value as String))),
|
||||
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: (value as String))),
|
||||
.networkPreferredWifiName => copyWith(
|
||||
network: network.copyWith(preferredWifiName: .fromNullable((value as String?))),
|
||||
),
|
||||
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: .fromNullable((value as String?)))),
|
||||
.networkExternalEndpointList => copyWith(network: network.copyWith(externalEndpointList: value as List<String>)),
|
||||
.networkCustomHeaders => copyWith(network: network.copyWith(customHeaders: value as Map<String, String>)),
|
||||
.albumSortMode => copyWith(album: album.copyWith(sortMode: value as AlbumSortMode)),
|
||||
@@ -186,11 +217,15 @@ class AppConfig {
|
||||
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
|
||||
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
|
||||
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
|
||||
.shareFileType => copyWith(share: share.copyWith(fileType: value as ShareAssetType)),
|
||||
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
|
||||
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
|
||||
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
|
||||
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
|
||||
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
|
||||
.advancedTroubleshooting => copyWith(advanced: advanced.copyWith(troubleshooting: value as bool)),
|
||||
.advancedEnableHapticFeedback => copyWith(advanced: advanced.copyWith(enableHapticFeedback: value as bool)),
|
||||
.advancedReadonlyModeEnabled => copyWith(advanced: advanced.copyWith(readonlyModeEnabled: value as bool)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class NetworkConfig {
|
||||
final bool autoEndpointSwitching;
|
||||
final String preferredWifiName;
|
||||
final String localEndpoint;
|
||||
final String? preferredWifiName;
|
||||
final String? localEndpoint;
|
||||
final List<String> externalEndpointList;
|
||||
final Map<String, String> customHeaders;
|
||||
|
||||
const NetworkConfig({
|
||||
this.autoEndpointSwitching = false,
|
||||
this.preferredWifiName = '',
|
||||
this.localEndpoint = '',
|
||||
this.preferredWifiName,
|
||||
this.localEndpoint,
|
||||
this.externalEndpointList = const [],
|
||||
this.customHeaders = const {},
|
||||
});
|
||||
|
||||
NetworkConfig copyWith({
|
||||
bool? autoEndpointSwitching,
|
||||
String? preferredWifiName,
|
||||
String? localEndpoint,
|
||||
Option<String>? preferredWifiName,
|
||||
Option<String>? localEndpoint,
|
||||
List<String>? externalEndpointList,
|
||||
Map<String, String>? customHeaders,
|
||||
}) => NetworkConfig(
|
||||
autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching,
|
||||
preferredWifiName: preferredWifiName ?? this.preferredWifiName,
|
||||
localEndpoint: localEndpoint ?? this.localEndpoint,
|
||||
preferredWifiName: preferredWifiName.patch(this.preferredWifiName),
|
||||
localEndpoint: localEndpoint.patch(this.localEndpoint),
|
||||
externalEndpointList: externalEndpointList ?? this.externalEndpointList,
|
||||
customHeaders: customHeaders ?? this.customHeaders,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
|
||||
class ShareConfig {
|
||||
final ShareAssetType fileType;
|
||||
|
||||
const ShareConfig({this.fileType = ShareAssetType.original});
|
||||
|
||||
ShareConfig copyWith({ShareAssetType? fileType}) => ShareConfig(fileType: fileType ?? this.fileType);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is ShareConfig && other.fileType == fileType);
|
||||
|
||||
@override
|
||||
int get hashCode => fileType.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'ShareConfig(fileType: $fileType)';
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:immich_mobile/domain/models/value_codec.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
enum SessionKey<T> {
|
||||
serverUrl<String?>(),
|
||||
accessToken<String?>(),
|
||||
serverEndpoint<String?>();
|
||||
|
||||
ValueCodec<T> get _codec => ValueCodec.forType(T);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw);
|
||||
}
|
||||
|
||||
const defaultSession = Session();
|
||||
|
||||
class Session {
|
||||
final String? serverUrl;
|
||||
final String? accessToken;
|
||||
final String? serverEndpoint;
|
||||
|
||||
const Session({this.serverUrl, this.accessToken, this.serverEndpoint});
|
||||
|
||||
Session copyWith({Option<String>? serverUrl, Option<String>? accessToken, Option<String>? serverEndpoint}) => .new(
|
||||
serverUrl: serverUrl.patch(this.serverUrl),
|
||||
accessToken: accessToken.patch(this.accessToken),
|
||||
serverEndpoint: serverEndpoint.patch(this.serverEndpoint),
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is Session &&
|
||||
other.serverUrl == serverUrl &&
|
||||
other.accessToken == accessToken &&
|
||||
other.serverEndpoint == serverEndpoint);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(serverUrl, accessToken, serverEndpoint);
|
||||
|
||||
@override
|
||||
String toString() => 'Session(serverUrl: $serverUrl, accessToken: $accessToken, serverEndpoint: $serverEndpoint)';
|
||||
|
||||
T read<T>(SessionKey<T> key) =>
|
||||
(switch (key) {
|
||||
.serverUrl => serverUrl,
|
||||
.accessToken => accessToken,
|
||||
.serverEndpoint => serverEndpoint,
|
||||
})
|
||||
as T;
|
||||
|
||||
factory Session.fromEntries(Map<SessionKey, Object?> overrides) =>
|
||||
overrides.entries.fold(const Session(), (session, entry) => session.write(entry.key, entry.value));
|
||||
|
||||
Session write<T, U extends T>(SessionKey<T> key, U value) {
|
||||
return switch (key) {
|
||||
.serverUrl => copyWith(serverUrl: .fromNullable(value as String?)),
|
||||
.accessToken => copyWith(accessToken: .fromNullable(value as String?)),
|
||||
.serverEndpoint => copyWith(serverEndpoint: .fromNullable(value as String?)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
|
||||
enum Setting<T> {
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false);
|
||||
|
||||
const Setting(this.storeKey, this.defaultValue);
|
||||
|
||||
final StoreKey<T> storeKey;
|
||||
final T defaultValue;
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/models/value_codec.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
enum SettingsKey<T extends Object> {
|
||||
enum SettingsKey<T> {
|
||||
// Theme
|
||||
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
themePrimaryColor<ImmichColorPreset>(codec: EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
|
||||
themeDynamic<bool>(),
|
||||
themeColorfulInterface<bool>(),
|
||||
|
||||
@@ -26,13 +25,13 @@ enum SettingsKey<T extends Object> {
|
||||
|
||||
// Network
|
||||
networkAutoEndpointSwitching<bool>(),
|
||||
networkPreferredWifiName<String>(),
|
||||
networkLocalEndpoint<String>(),
|
||||
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
|
||||
networkExternalEndpointList<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)),
|
||||
networkPreferredWifiName<String?>(),
|
||||
networkLocalEndpoint<String?>(),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
|
||||
albumSortMode<AlbumSortMode>(codec: EnumCodec(AlbumSortMode.values)),
|
||||
albumIsReverse<bool>(),
|
||||
albumIsGrid<bool>(),
|
||||
|
||||
@@ -46,172 +45,48 @@ enum SettingsKey<T extends Object> {
|
||||
|
||||
// Timeline
|
||||
timelineTilesPerRow<int>(),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
|
||||
timelineStorageIndicator<bool>(),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
|
||||
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
|
||||
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
mapRelativeDate<int>(),
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
|
||||
// Cleanup
|
||||
cleanupKeepFavorites<bool>(),
|
||||
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
|
||||
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupKeepMediaType<AssetKeepType>(codec: EnumCodec(AssetKeepType.values)),
|
||||
cleanupKeepAlbumIds<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(),
|
||||
cleanupDefaultsInitialized<bool>(),
|
||||
|
||||
// Share
|
||||
shareFileType<ShareAssetType>(codec: EnumCodec(ShareAssetType.values)),
|
||||
|
||||
// Slideshow
|
||||
slideshowTransition<bool>(),
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
slideshowLook<SlideshowLook>(codec: EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values)),
|
||||
|
||||
final _SettingsCodec<T>? _codecOverride;
|
||||
// Advanced
|
||||
advancedTroubleshooting<bool>(),
|
||||
advancedEnableHapticFeedback<bool>(),
|
||||
advancedReadonlyModeEnabled<bool>();
|
||||
|
||||
const SettingsKey({_SettingsCodec<T>? codec}) : _codecOverride = codec;
|
||||
final ValueCodec<T>? _codecOverride;
|
||||
|
||||
_SettingsCodec<T> get _codec => _codecOverride ?? _SettingsCodec.forType(T);
|
||||
const SettingsKey({ValueCodec<T>? codec}) : _codecOverride = codec;
|
||||
|
||||
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw);
|
||||
}
|
||||
|
||||
sealed class _SettingsCodec<T extends Object> {
|
||||
const _SettingsCodec();
|
||||
|
||||
String encode(T value);
|
||||
T decode(String raw);
|
||||
|
||||
static const Map<Type, _SettingsCodec<Object>> _primitives = {
|
||||
int: _PrimitiveCodec.integer,
|
||||
double: _PrimitiveCodec.real,
|
||||
bool: _PrimitiveCodec.boolean,
|
||||
String: _PrimitiveCodec.string,
|
||||
DateTime: _DateTimeCodec(),
|
||||
};
|
||||
|
||||
static _SettingsCodec<T> forType<T extends Object>(Type runtimeType) {
|
||||
final codec = _primitives[runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the SettingsKey.');
|
||||
}
|
||||
return codec as _SettingsCodec<T>;
|
||||
}
|
||||
}
|
||||
|
||||
final class _EnumCodec<T extends Enum> extends _SettingsCodec<T> {
|
||||
final List<T> values;
|
||||
|
||||
const _EnumCodec(this.values);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T decode(String raw) => values.firstWhere((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class _DateTimeCodec extends _SettingsCodec<DateTime> {
|
||||
const _DateTimeCodec();
|
||||
|
||||
@override
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
|
||||
final _SettingsCodec<K> _keyCodec;
|
||||
final _SettingsCodec<V> _valueCodec;
|
||||
|
||||
const _MapCodec(this._keyCodec, this._valueCodec);
|
||||
|
||||
@override
|
||||
String encode(Map<K, V> value) {
|
||||
final entries = <String, String>{};
|
||||
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
|
||||
return jsonEncode(entries);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return {};
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
return {};
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _ListCodec<T extends Object> extends _SettingsCodec<List<T>> {
|
||||
final _SettingsCodec<T> _elementCodec;
|
||||
|
||||
const _ListCodec(this._elementCodec);
|
||||
|
||||
@override
|
||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
||||
|
||||
@override
|
||||
List<T> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return [];
|
||||
}
|
||||
final result = <T>[];
|
||||
for (final item in decoded) {
|
||||
if (item is! String) {
|
||||
return [];
|
||||
}
|
||||
final element = _elementCodec.decode(item);
|
||||
result.add(element);
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _PrimitiveCodec<T extends Object> extends _SettingsCodec<T> {
|
||||
final T Function(String) _parse;
|
||||
|
||||
const _PrimitiveCodec._(this._parse);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = _PrimitiveCodec<int>._(int.parse);
|
||||
static const real = _PrimitiveCodec<double>._(double.parse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
|
||||
static const string = _PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String _identity(String s) => s;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
|
||||
/// Key for each possible value in the `Store`.
|
||||
/// Defines the data type for each value
|
||||
enum StoreKey<T> {
|
||||
version<int>._(0),
|
||||
currentUser<UserDto>._(2),
|
||||
deviceId<String>._(4),
|
||||
serverUrl<String>._(10),
|
||||
accessToken<String>._(11),
|
||||
serverEndpoint<String>._(12),
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
enableHapticFeedback<bool>._(126),
|
||||
|
||||
manageLocalMediaAndroid<bool>._(137),
|
||||
// Read-only Mode settings
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
|
||||
syncMigrationStatus<String>._(1013),
|
||||
|
||||
// Legacy keys that have been migrated to the new metadata store
|
||||
legacyVersion<int>._(0),
|
||||
legacyManageLocalMediaAndroid<bool>._(137),
|
||||
legacySyncMigrationStatus<String>._(1013),
|
||||
legacyAdvancedTroubleshooting<bool>._(114),
|
||||
legacyEnableHapticFeedback<bool>._(126),
|
||||
legacyReadonlyModeEnabled<bool>._(138),
|
||||
legacyServerUrl<String>._(10),
|
||||
legacyAccessToken<String>._(11),
|
||||
legacyServerEndpoint<String>._(12),
|
||||
legacyBackupRequireCharging<bool>._(7),
|
||||
legacyBackupTriggerDelay<int>._(8),
|
||||
legacySyncAlbums<bool>._(131),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
|
||||
sealed class ValueCodec<T> {
|
||||
const ValueCodec();
|
||||
|
||||
String encode(T value);
|
||||
T decode(String raw);
|
||||
|
||||
static final Map<Type, ValueCodec<Object>> _primitives = {
|
||||
..._register<int>(PrimitiveCodec.integer),
|
||||
..._register<double>(PrimitiveCodec.real),
|
||||
..._register<bool>(PrimitiveCodec.boolean),
|
||||
..._register<String>(PrimitiveCodec.string),
|
||||
..._register<DateTime>(const DateTimeCodec()),
|
||||
};
|
||||
|
||||
static Map<Type, ValueCodec<Object>> _register<T>(ValueCodec<Object> codec) => {
|
||||
T: codec,
|
||||
// Reifies the nullable type T so it can be used as a key in the _primitives map
|
||||
_typeOf<T?>(): codec,
|
||||
};
|
||||
|
||||
static Type _typeOf<T>() => T;
|
||||
|
||||
static ValueCodec<T> forType<T>(Type runtimeType) {
|
||||
final codec = _primitives[runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the key.');
|
||||
}
|
||||
return codec as ValueCodec<T>;
|
||||
}
|
||||
}
|
||||
|
||||
final class EnumCodec<T extends Enum> extends ValueCodec<T> {
|
||||
final List<T> values;
|
||||
|
||||
const EnumCodec(this.values);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T decode(String raw) => values.firstWhere((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class DateTimeCodec extends ValueCodec<DateTime> {
|
||||
const DateTimeCodec();
|
||||
|
||||
@override
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class MapCodec<K extends Object, V extends Object> extends ValueCodec<Map<K, V>> {
|
||||
final ValueCodec<K> _keyCodec;
|
||||
final ValueCodec<V> _valueCodec;
|
||||
|
||||
const MapCodec(this._keyCodec, this._valueCodec);
|
||||
|
||||
@override
|
||||
String encode(Map<K, V> value) {
|
||||
final entries = <String, String>{};
|
||||
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
|
||||
return jsonEncode(entries);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return {};
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
continue;
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ListCodec<T extends Object> extends ValueCodec<List<T>> {
|
||||
final ValueCodec<T> _elementCodec;
|
||||
|
||||
const ListCodec(this._elementCodec);
|
||||
|
||||
@override
|
||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
||||
|
||||
@override
|
||||
List<T> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return const [];
|
||||
}
|
||||
final result = <T>[];
|
||||
for (final item in decoded) {
|
||||
if (item is! String) {
|
||||
return const [];
|
||||
}
|
||||
final element = _elementCodec.decode(item);
|
||||
result.add(element);
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class PrimitiveCodec<T extends Object> extends ValueCodec<T> {
|
||||
final T Function(String) _parse;
|
||||
|
||||
const PrimitiveCodec._(this._parse);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = PrimitiveCodec<int>._(int.parse);
|
||||
static const real = PrimitiveCodec<double>._(double.parse);
|
||||
static const boolean = PrimitiveCodec<bool>._(bool.parse);
|
||||
static const string = PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String _identity(String s) => s;
|
||||
}
|
||||
@@ -5,9 +5,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.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/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
@@ -28,6 +27,7 @@ class LocalSyncService {
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final AppMetadataRepository _appMetadataRepository;
|
||||
final Completer<void>? _cancellation;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
@@ -38,6 +38,7 @@ class LocalSyncService {
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._assetMediaRepository,
|
||||
required this._permissionRepository,
|
||||
required this._appMetadataRepository,
|
||||
this._cancellation,
|
||||
}) {
|
||||
_cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
|
||||
@@ -48,7 +49,7 @@ class LocalSyncService {
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
if (CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid)) {
|
||||
final hasPermission = await _permissionRepository.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _syncTrashedAssets();
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -138,7 +137,7 @@ class RemoteAlbumService {
|
||||
Future<RemoteAlbum> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
Option<String?> description = const Option.none(),
|
||||
String? description,
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
|
||||
// Singleton instance of SettingsService, to use in places
|
||||
// where reactivity is not required
|
||||
// ignore: non_constant_identifier_names
|
||||
final AppSetting = SettingsService(storeService: StoreService.I);
|
||||
|
||||
class SettingsService {
|
||||
final StoreService _storeService;
|
||||
|
||||
const SettingsService({required this._storeService});
|
||||
|
||||
T get<T>(Setting<T> setting) => _storeService.get(setting.storeKey, setting.defaultValue);
|
||||
|
||||
Future<void> set<T>(Setting<T> setting, T value) => _storeService.put(setting.storeKey, value);
|
||||
|
||||
Stream<T> watch<T>(Setting<T> setting) => _storeService.watch(setting.storeKey).map((v) => v ?? setting.defaultValue);
|
||||
}
|
||||
@@ -2,13 +2,12 @@ import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.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/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -18,7 +17,7 @@ final syncLinkedAlbumServiceProvider = Provider(
|
||||
ref.watch(localAlbumRepository),
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(storeServiceProvider),
|
||||
ref.watch(authUserRepositoryProvider),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
),
|
||||
);
|
||||
@@ -27,14 +26,14 @@ class SyncLinkedAlbumService {
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
final DriftRemoteAlbumRepository _remoteAlbumRepository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final StoreService _storeService;
|
||||
final DriftAuthUserRepository _authUserRepository;
|
||||
final Completer<void>? _cancellation;
|
||||
|
||||
SyncLinkedAlbumService(
|
||||
this._localAlbumRepository,
|
||||
this._remoteAlbumRepository,
|
||||
this._albumApiRepository,
|
||||
this._storeService, {
|
||||
this._authUserRepository, {
|
||||
this._cancellation,
|
||||
});
|
||||
|
||||
@@ -123,11 +122,12 @@ class SyncLinkedAlbumService {
|
||||
/// Creates a new remote album and links it to the local album
|
||||
Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async {
|
||||
dPrint(() => "Creating new remote album for local album: ${localAlbum.name}");
|
||||
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(
|
||||
localAlbum.name,
|
||||
_storeService.get(StoreKey.currentUser),
|
||||
assetIds: [],
|
||||
);
|
||||
final currentUser = await _authUserRepository.get();
|
||||
if (currentUser == null) {
|
||||
_log.warning("No user logged in, skipping remote album creation for local album: ${localAlbum.name}");
|
||||
return;
|
||||
}
|
||||
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, currentUser, assetIds: []);
|
||||
await _remoteAlbumRepository.create(newRemoteAlbum, []);
|
||||
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
|
||||
@@ -38,6 +36,7 @@ class SyncStreamService {
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final SyncMigrationRepository _syncMigrationRepository;
|
||||
final ApiService _api;
|
||||
final AppMetadataRepository _appMetadataRepository;
|
||||
final Completer<void>? _cancellation;
|
||||
|
||||
SyncStreamService({
|
||||
@@ -49,10 +48,12 @@ class SyncStreamService {
|
||||
required this._permissionRepository,
|
||||
required this._syncMigrationRepository,
|
||||
required this._api,
|
||||
required this._appMetadataRepository,
|
||||
this._cancellation,
|
||||
});
|
||||
|
||||
bool get isCancelled => _cancellation?.isCompleted ?? false;
|
||||
bool _manageLocalMediaAndroid = false;
|
||||
|
||||
Future<bool> sync() async {
|
||||
_logger.info("Remote sync request for user");
|
||||
@@ -64,16 +65,17 @@ class SyncStreamService {
|
||||
|
||||
final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
|
||||
|
||||
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
|
||||
final migrations = (jsonDecode(value) as List).cast<String>();
|
||||
final migrations = (await _appMetadataRepository.get(.syncMigrationStatus)).toList();
|
||||
int previousLength = migrations.length;
|
||||
await _runPreSyncTasks(migrations, serverSemVer);
|
||||
|
||||
if (migrations.length != previousLength) {
|
||||
_logger.info("Updated pre-sync migration status: $migrations");
|
||||
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
|
||||
await _appMetadataRepository.set(.syncMigrationStatus, migrations);
|
||||
}
|
||||
|
||||
_manageLocalMediaAndroid = CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid);
|
||||
|
||||
// Start the sync stream and handle events
|
||||
bool shouldReset = false;
|
||||
await _syncApiRepository.streamChanges(
|
||||
@@ -96,7 +98,7 @@ class SyncStreamService {
|
||||
|
||||
if (migrations.length != previousLength) {
|
||||
_logger.info("Updated pre-sync migration status: $migrations");
|
||||
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
|
||||
await _appMetadataRepository.set(.syncMigrationStatus, migrations);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -106,10 +108,10 @@ class SyncStreamService {
|
||||
if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) {
|
||||
_logger.info("Running pre-sync task: v20260128_ResetExifV1");
|
||||
await _syncApiRepository.deleteSyncAck([
|
||||
SyncEntityType.assetExifV1,
|
||||
SyncEntityType.partnerAssetExifV1,
|
||||
SyncEntityType.albumAssetExifCreateV1,
|
||||
SyncEntityType.albumAssetExifUpdateV1,
|
||||
.assetExifV1,
|
||||
.partnerAssetExifV1,
|
||||
.albumAssetExifCreateV1,
|
||||
.albumAssetExifUpdateV1,
|
||||
]);
|
||||
migrations.add(SyncMigrationTask.v20260128_ResetExifV1.name);
|
||||
}
|
||||
@@ -117,12 +119,7 @@ class SyncStreamService {
|
||||
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
|
||||
semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
|
||||
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
|
||||
await _syncApiRepository.deleteSyncAck([
|
||||
SyncEntityType.assetV1,
|
||||
SyncEntityType.partnerAssetV1,
|
||||
SyncEntityType.albumAssetCreateV1,
|
||||
SyncEntityType.albumAssetUpdateV1,
|
||||
]);
|
||||
await _syncApiRepository.deleteSyncAck([.assetV1, .partnerAssetV1, .albumAssetCreateV1, .albumAssetUpdateV1]);
|
||||
|
||||
migrations.add(SyncMigrationTask.v20260128_ResetAssetV1.name);
|
||||
|
||||
@@ -134,7 +131,7 @@ class SyncStreamService {
|
||||
if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) &&
|
||||
semVer > const SemVer(major: 2, minor: 7, patch: 5)) {
|
||||
_logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2");
|
||||
await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]);
|
||||
await _syncApiRepository.deleteSyncAck([.assetV1, .assetV2]);
|
||||
migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name);
|
||||
}
|
||||
}
|
||||
@@ -197,20 +194,20 @@ class SyncStreamService {
|
||||
case SyncEntityType.assetV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
if (_manageLocalMediaAndroid) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
return;
|
||||
case SyncEntityType.assetV2:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV2>();
|
||||
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
if (_manageLocalMediaAndroid) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
return;
|
||||
case SyncEntityType.assetDeleteV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
if (_manageLocalMediaAndroid) {
|
||||
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
|
||||
}
|
||||
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class UserService {
|
||||
final Logger _log = Logger("UserService");
|
||||
final UserApiRepository _userApiRepository;
|
||||
final StoreService _storeService;
|
||||
final DriftAuthUserRepository _authUserRepository;
|
||||
|
||||
UserService({required this._userApiRepository, required this._storeService});
|
||||
UserService({required this._userApiRepository, required this._authUserRepository});
|
||||
|
||||
UserDto getMyUser() {
|
||||
return _storeService.get(StoreKey.currentUser);
|
||||
}
|
||||
|
||||
UserDto? tryGetMyUser() {
|
||||
return _storeService.tryGet(StoreKey.currentUser);
|
||||
Future<UserDto?> tryGetMyUser() {
|
||||
return _authUserRepository.get();
|
||||
}
|
||||
|
||||
Stream<UserDto?> watchMyUser() {
|
||||
return _storeService.watch(StoreKey.currentUser);
|
||||
return _authUserRepository.watch();
|
||||
}
|
||||
|
||||
Future<UserDto?> refreshMyUser() async {
|
||||
@@ -31,15 +26,17 @@ class UserService {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
await _storeService.put(StoreKey.currentUser, user);
|
||||
await _authUserRepository.upsert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<String?> createProfileImage(String name, Uint8List image) async {
|
||||
try {
|
||||
final path = await _userApiRepository.createProfileImage(name: name, data: image);
|
||||
final updatedUser = getMyUser();
|
||||
await _storeService.put(StoreKey.currentUser, updatedUser);
|
||||
final updatedUser = await tryGetMyUser();
|
||||
if (updatedUser != null) {
|
||||
await _authUserRepository.upsert(updatedUser);
|
||||
}
|
||||
return path;
|
||||
} catch (e) {
|
||||
_log.warning("Failed to upload profile image", e);
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
|
||||
final user = Store.tryGet(StoreKey.currentUser);
|
||||
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) async {
|
||||
final user = await ref.read(authUserRepositoryProvider).get();
|
||||
if (user == null) {
|
||||
Logger("SyncLinkedAlbum").warning("No user logged in, skipping linked album sync");
|
||||
return Future.value();
|
||||
return;
|
||||
}
|
||||
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diacritic/diacritic.dart' as diacritic;
|
||||
|
||||
extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
|
||||
}
|
||||
|
||||
String? get nullIfEmpty => isEmpty ? null : this;
|
||||
|
||||
String removeDiacritics() => diacritic.removeDiacritics(this);
|
||||
}
|
||||
|
||||
extension DurationExtension on String {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
class AppMetadataEntity extends Table with DriftDefaultsMixin {
|
||||
const AppMetadataEntity();
|
||||
|
||||
TextColumn get key => text()();
|
||||
|
||||
TextColumn get value => text().nullable()();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {key};
|
||||
|
||||
@override
|
||||
String get tableName => "app_metadata";
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart'
|
||||
as i1;
|
||||
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.dart'
|
||||
as i2;
|
||||
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
|
||||
|
||||
typedef $$AppMetadataEntityTableCreateCompanionBuilder =
|
||||
i1.AppMetadataEntityCompanion Function({
|
||||
required String key,
|
||||
i0.Value<String?> value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
typedef $$AppMetadataEntityTableUpdateCompanionBuilder =
|
||||
i1.AppMetadataEntityCompanion Function({
|
||||
i0.Value<String> key,
|
||||
i0.Value<String?> value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
|
||||
class $$AppMetadataEntityTableFilterComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$AppMetadataEntityTable> {
|
||||
$$AppMetadataEntityTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnFilters<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get value => $composableBuilder(
|
||||
column: $table.value,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$AppMetadataEntityTableOrderingComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$AppMetadataEntityTable> {
|
||||
$$AppMetadataEntityTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnOrderings<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get value => $composableBuilder(
|
||||
column: $table.value,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$AppMetadataEntityTableAnnotationComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$AppMetadataEntityTable> {
|
||||
$$AppMetadataEntityTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.GeneratedColumn<String> get key =>
|
||||
$composableBuilder(column: $table.key, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<String> get value =>
|
||||
$composableBuilder(column: $table.value, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<DateTime> get updatedAt =>
|
||||
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$AppMetadataEntityTableTableManager
|
||||
extends
|
||||
i0.RootTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$AppMetadataEntityTable,
|
||||
i1.AppMetadataEntityData,
|
||||
i1.$$AppMetadataEntityTableFilterComposer,
|
||||
i1.$$AppMetadataEntityTableOrderingComposer,
|
||||
i1.$$AppMetadataEntityTableAnnotationComposer,
|
||||
$$AppMetadataEntityTableCreateCompanionBuilder,
|
||||
$$AppMetadataEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.AppMetadataEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$AppMetadataEntityTable,
|
||||
i1.AppMetadataEntityData
|
||||
>,
|
||||
),
|
||||
i1.AppMetadataEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
> {
|
||||
$$AppMetadataEntityTableTableManager(
|
||||
i0.GeneratedDatabase db,
|
||||
i1.$AppMetadataEntityTable table,
|
||||
) : super(
|
||||
i0.TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
i1.$$AppMetadataEntityTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () => i1
|
||||
.$$AppMetadataEntityTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
i1.$$AppMetadataEntityTableAnnotationComposer(
|
||||
$db: db,
|
||||
$table: table,
|
||||
),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
i0.Value<String> key = const i0.Value.absent(),
|
||||
i0.Value<String?> value = const i0.Value.absent(),
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.AppMetadataEntityCompanion(
|
||||
key: key,
|
||||
value: value,
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String key,
|
||||
i0.Value<String?> value = const i0.Value.absent(),
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.AppMetadataEntityCompanion.insert(
|
||||
key: key,
|
||||
value: value,
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$AppMetadataEntityTableProcessedTableManager =
|
||||
i0.ProcessedTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$AppMetadataEntityTable,
|
||||
i1.AppMetadataEntityData,
|
||||
i1.$$AppMetadataEntityTableFilterComposer,
|
||||
i1.$$AppMetadataEntityTableOrderingComposer,
|
||||
i1.$$AppMetadataEntityTableAnnotationComposer,
|
||||
$$AppMetadataEntityTableCreateCompanionBuilder,
|
||||
$$AppMetadataEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.AppMetadataEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$AppMetadataEntityTable,
|
||||
i1.AppMetadataEntityData
|
||||
>,
|
||||
),
|
||||
i1.AppMetadataEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $AppMetadataEntityTable extends i2.AppMetadataEntity
|
||||
with i0.TableInfo<$AppMetadataEntityTable, i1.AppMetadataEntityData> {
|
||||
@override
|
||||
final i0.GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$AppMetadataEntityTable(this.attachedDatabase, [this._alias]);
|
||||
static const i0.VerificationMeta _keyMeta = const i0.VerificationMeta('key');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> key = i0.GeneratedColumn<String>(
|
||||
'key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _valueMeta = const i0.VerificationMeta(
|
||||
'value',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
|
||||
'value',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
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: i3.currentDateAndTime,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [key, value, updatedAt];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'app_metadata';
|
||||
@override
|
||||
i0.VerificationContext validateIntegrity(
|
||||
i0.Insertable<i1.AppMetadataEntityData> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = i0.VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('key')) {
|
||||
context.handle(
|
||||
_keyMeta,
|
||||
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_keyMeta);
|
||||
}
|
||||
if (data.containsKey('value')) {
|
||||
context.handle(
|
||||
_valueMeta,
|
||||
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('updated_at')) {
|
||||
context.handle(
|
||||
_updatedAtMeta,
|
||||
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<i0.GeneratedColumn> get $primaryKey => {key};
|
||||
@override
|
||||
i1.AppMetadataEntityData map(
|
||||
Map<String, dynamic> data, {
|
||||
String? tablePrefix,
|
||||
}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return i1.AppMetadataEntityData(
|
||||
key: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}key'],
|
||||
)!,
|
||||
value: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}value'],
|
||||
),
|
||||
updatedAt: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}updated_at'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$AppMetadataEntityTable createAlias(String alias) {
|
||||
return $AppMetadataEntityTable(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
}
|
||||
|
||||
class AppMetadataEntityData extends i0.DataClass
|
||||
implements i0.Insertable<i1.AppMetadataEntityData> {
|
||||
final String key;
|
||||
final String? value;
|
||||
final DateTime updatedAt;
|
||||
const AppMetadataEntityData({
|
||||
required this.key,
|
||||
this.value,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
map['key'] = i0.Variable<String>(key);
|
||||
if (!nullToAbsent || value != null) {
|
||||
map['value'] = i0.Variable<String>(value);
|
||||
}
|
||||
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
factory AppMetadataEntityData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
i0.ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return AppMetadataEntityData(
|
||||
key: serializer.fromJson<String>(json['key']),
|
||||
value: serializer.fromJson<String?>(json['value']),
|
||||
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'key': serializer.toJson<String>(key),
|
||||
'value': serializer.toJson<String?>(value),
|
||||
'updatedAt': serializer.toJson<DateTime>(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
i1.AppMetadataEntityData copyWith({
|
||||
String? key,
|
||||
i0.Value<String?> value = const i0.Value.absent(),
|
||||
DateTime? updatedAt,
|
||||
}) => i1.AppMetadataEntityData(
|
||||
key: key ?? this.key,
|
||||
value: value.present ? value.value : this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
AppMetadataEntityData copyWithCompanion(i1.AppMetadataEntityCompanion data) {
|
||||
return AppMetadataEntityData(
|
||||
key: data.key.present ? data.key.value : this.key,
|
||||
value: data.value.present ? data.value.value : this.value,
|
||||
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('AppMetadataEntityData(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, value, updatedAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is i1.AppMetadataEntityData &&
|
||||
other.key == this.key &&
|
||||
other.value == this.value &&
|
||||
other.updatedAt == this.updatedAt);
|
||||
}
|
||||
|
||||
class AppMetadataEntityCompanion
|
||||
extends i0.UpdateCompanion<i1.AppMetadataEntityData> {
|
||||
final i0.Value<String> key;
|
||||
final i0.Value<String?> value;
|
||||
final i0.Value<DateTime> updatedAt;
|
||||
const AppMetadataEntityCompanion({
|
||||
this.key = const i0.Value.absent(),
|
||||
this.value = const i0.Value.absent(),
|
||||
this.updatedAt = const i0.Value.absent(),
|
||||
});
|
||||
AppMetadataEntityCompanion.insert({
|
||||
required String key,
|
||||
this.value = const i0.Value.absent(),
|
||||
this.updatedAt = const i0.Value.absent(),
|
||||
}) : key = i0.Value(key);
|
||||
static i0.Insertable<i1.AppMetadataEntityData> custom({
|
||||
i0.Expression<String>? key,
|
||||
i0.Expression<String>? value,
|
||||
i0.Expression<DateTime>? updatedAt,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (key != null) 'key': key,
|
||||
if (value != null) 'value': value,
|
||||
if (updatedAt != null) 'updated_at': updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
i1.AppMetadataEntityCompanion copyWith({
|
||||
i0.Value<String>? key,
|
||||
i0.Value<String?>? value,
|
||||
i0.Value<DateTime>? updatedAt,
|
||||
}) {
|
||||
return i1.AppMetadataEntityCompanion(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
if (key.present) {
|
||||
map['key'] = i0.Variable<String>(key.value);
|
||||
}
|
||||
if (value.present) {
|
||||
map['value'] = i0.Variable<String>(value.value);
|
||||
}
|
||||
if (updatedAt.present) {
|
||||
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('AppMetadataEntityCompanion(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
class SessionEntity extends Table with DriftDefaultsMixin {
|
||||
const SessionEntity();
|
||||
|
||||
TextColumn get key => text()();
|
||||
|
||||
TextColumn get value => text().nullable()();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {key};
|
||||
|
||||
@override
|
||||
String get tableName => "session";
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'
|
||||
as i1;
|
||||
import 'package:immich_mobile/infrastructure/entities/session.entity.dart'
|
||||
as i2;
|
||||
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
|
||||
|
||||
typedef $$SessionEntityTableCreateCompanionBuilder =
|
||||
i1.SessionEntityCompanion Function({
|
||||
required String key,
|
||||
i0.Value<String?> value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
typedef $$SessionEntityTableUpdateCompanionBuilder =
|
||||
i1.SessionEntityCompanion Function({
|
||||
i0.Value<String> key,
|
||||
i0.Value<String?> value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
|
||||
class $$SessionEntityTableFilterComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$SessionEntityTable> {
|
||||
$$SessionEntityTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnFilters<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get value => $composableBuilder(
|
||||
column: $table.value,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$SessionEntityTableOrderingComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$SessionEntityTable> {
|
||||
$$SessionEntityTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnOrderings<String> get key => $composableBuilder(
|
||||
column: $table.key,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get value => $composableBuilder(
|
||||
column: $table.value,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
|
||||
column: $table.updatedAt,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$SessionEntityTableAnnotationComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$SessionEntityTable> {
|
||||
$$SessionEntityTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.GeneratedColumn<String> get key =>
|
||||
$composableBuilder(column: $table.key, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<String> get value =>
|
||||
$composableBuilder(column: $table.value, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<DateTime> get updatedAt =>
|
||||
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$SessionEntityTableTableManager
|
||||
extends
|
||||
i0.RootTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$SessionEntityTable,
|
||||
i1.SessionEntityData,
|
||||
i1.$$SessionEntityTableFilterComposer,
|
||||
i1.$$SessionEntityTableOrderingComposer,
|
||||
i1.$$SessionEntityTableAnnotationComposer,
|
||||
$$SessionEntityTableCreateCompanionBuilder,
|
||||
$$SessionEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.SessionEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$SessionEntityTable,
|
||||
i1.SessionEntityData
|
||||
>,
|
||||
),
|
||||
i1.SessionEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
> {
|
||||
$$SessionEntityTableTableManager(
|
||||
i0.GeneratedDatabase db,
|
||||
i1.$SessionEntityTable table,
|
||||
) : super(
|
||||
i0.TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
i1.$$SessionEntityTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
i1.$$SessionEntityTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
i1.$$SessionEntityTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
i0.Value<String> key = const i0.Value.absent(),
|
||||
i0.Value<String?> value = const i0.Value.absent(),
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.SessionEntityCompanion(
|
||||
key: key,
|
||||
value: value,
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String key,
|
||||
i0.Value<String?> value = const i0.Value.absent(),
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.SessionEntityCompanion.insert(
|
||||
key: key,
|
||||
value: value,
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$SessionEntityTableProcessedTableManager =
|
||||
i0.ProcessedTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$SessionEntityTable,
|
||||
i1.SessionEntityData,
|
||||
i1.$$SessionEntityTableFilterComposer,
|
||||
i1.$$SessionEntityTableOrderingComposer,
|
||||
i1.$$SessionEntityTableAnnotationComposer,
|
||||
$$SessionEntityTableCreateCompanionBuilder,
|
||||
$$SessionEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.SessionEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$SessionEntityTable,
|
||||
i1.SessionEntityData
|
||||
>,
|
||||
),
|
||||
i1.SessionEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $SessionEntityTable extends i2.SessionEntity
|
||||
with i0.TableInfo<$SessionEntityTable, i1.SessionEntityData> {
|
||||
@override
|
||||
final i0.GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$SessionEntityTable(this.attachedDatabase, [this._alias]);
|
||||
static const i0.VerificationMeta _keyMeta = const i0.VerificationMeta('key');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> key = i0.GeneratedColumn<String>(
|
||||
'key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _valueMeta = const i0.VerificationMeta(
|
||||
'value',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
|
||||
'value',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
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: i3.currentDateAndTime,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [key, value, updatedAt];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'session';
|
||||
@override
|
||||
i0.VerificationContext validateIntegrity(
|
||||
i0.Insertable<i1.SessionEntityData> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = i0.VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('key')) {
|
||||
context.handle(
|
||||
_keyMeta,
|
||||
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_keyMeta);
|
||||
}
|
||||
if (data.containsKey('value')) {
|
||||
context.handle(
|
||||
_valueMeta,
|
||||
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('updated_at')) {
|
||||
context.handle(
|
||||
_updatedAtMeta,
|
||||
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<i0.GeneratedColumn> get $primaryKey => {key};
|
||||
@override
|
||||
i1.SessionEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return i1.SessionEntityData(
|
||||
key: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}key'],
|
||||
)!,
|
||||
value: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}value'],
|
||||
),
|
||||
updatedAt: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}updated_at'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$SessionEntityTable createAlias(String alias) {
|
||||
return $SessionEntityTable(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
}
|
||||
|
||||
class SessionEntityData extends i0.DataClass
|
||||
implements i0.Insertable<i1.SessionEntityData> {
|
||||
final String key;
|
||||
final String? value;
|
||||
final DateTime updatedAt;
|
||||
const SessionEntityData({
|
||||
required this.key,
|
||||
this.value,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
map['key'] = i0.Variable<String>(key);
|
||||
if (!nullToAbsent || value != null) {
|
||||
map['value'] = i0.Variable<String>(value);
|
||||
}
|
||||
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
factory SessionEntityData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
i0.ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return SessionEntityData(
|
||||
key: serializer.fromJson<String>(json['key']),
|
||||
value: serializer.fromJson<String?>(json['value']),
|
||||
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'key': serializer.toJson<String>(key),
|
||||
'value': serializer.toJson<String?>(value),
|
||||
'updatedAt': serializer.toJson<DateTime>(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
i1.SessionEntityData copyWith({
|
||||
String? key,
|
||||
i0.Value<String?> value = const i0.Value.absent(),
|
||||
DateTime? updatedAt,
|
||||
}) => i1.SessionEntityData(
|
||||
key: key ?? this.key,
|
||||
value: value.present ? value.value : this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
SessionEntityData copyWithCompanion(i1.SessionEntityCompanion data) {
|
||||
return SessionEntityData(
|
||||
key: data.key.present ? data.key.value : this.key,
|
||||
value: data.value.present ? data.value.value : this.value,
|
||||
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('SessionEntityData(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, value, updatedAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is i1.SessionEntityData &&
|
||||
other.key == this.key &&
|
||||
other.value == this.value &&
|
||||
other.updatedAt == this.updatedAt);
|
||||
}
|
||||
|
||||
class SessionEntityCompanion extends i0.UpdateCompanion<i1.SessionEntityData> {
|
||||
final i0.Value<String> key;
|
||||
final i0.Value<String?> value;
|
||||
final i0.Value<DateTime> updatedAt;
|
||||
const SessionEntityCompanion({
|
||||
this.key = const i0.Value.absent(),
|
||||
this.value = const i0.Value.absent(),
|
||||
this.updatedAt = const i0.Value.absent(),
|
||||
});
|
||||
SessionEntityCompanion.insert({
|
||||
required String key,
|
||||
this.value = const i0.Value.absent(),
|
||||
this.updatedAt = const i0.Value.absent(),
|
||||
}) : key = i0.Value(key);
|
||||
static i0.Insertable<i1.SessionEntityData> custom({
|
||||
i0.Expression<String>? key,
|
||||
i0.Expression<String>? value,
|
||||
i0.Expression<DateTime>? updatedAt,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (key != null) 'key': key,
|
||||
if (value != null) 'value': value,
|
||||
if (updatedAt != null) 'updated_at': updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
i1.SessionEntityCompanion copyWith({
|
||||
i0.Value<String>? key,
|
||||
i0.Value<String?>? value,
|
||||
i0.Value<DateTime>? updatedAt,
|
||||
}) {
|
||||
return i1.SessionEntityCompanion(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
if (key.present) {
|
||||
map['key'] = i0.Variable<String>(key.value);
|
||||
}
|
||||
if (value.present) {
|
||||
map['value'] = i0.Variable<String>(value.value);
|
||||
}
|
||||
if (updatedAt.present) {
|
||||
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('SessionEntityCompanion(')
|
||||
..write('key: $key, ')
|
||||
..write('value: $value, ')
|
||||
..write('updatedAt: $updatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ class SettingsEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get key => text()();
|
||||
|
||||
TextColumn get value => text()();
|
||||
TextColumn get value => text().nullable()();
|
||||
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
|
||||
+20
-21
@@ -10,13 +10,13 @@ import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
|
||||
typedef $$SettingsEntityTableCreateCompanionBuilder =
|
||||
i1.SettingsEntityCompanion Function({
|
||||
required String key,
|
||||
required String value,
|
||||
i0.Value<String?> value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
typedef $$SettingsEntityTableUpdateCompanionBuilder =
|
||||
i1.SettingsEntityCompanion Function({
|
||||
i0.Value<String> key,
|
||||
i0.Value<String> value,
|
||||
i0.Value<String?> value,
|
||||
i0.Value<DateTime> updatedAt,
|
||||
});
|
||||
|
||||
@@ -127,7 +127,7 @@ class $$SettingsEntityTableTableManager
|
||||
updateCompanionCallback:
|
||||
({
|
||||
i0.Value<String> key = const i0.Value.absent(),
|
||||
i0.Value<String> value = const i0.Value.absent(),
|
||||
i0.Value<String?> value = const i0.Value.absent(),
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.SettingsEntityCompanion(
|
||||
key: key,
|
||||
@@ -137,7 +137,7 @@ class $$SettingsEntityTableTableManager
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String key,
|
||||
required String value,
|
||||
i0.Value<String?> value = const i0.Value.absent(),
|
||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||
}) => i1.SettingsEntityCompanion.insert(
|
||||
key: key,
|
||||
@@ -196,9 +196,9 @@ class $SettingsEntityTable extends i2.SettingsEntity
|
||||
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
|
||||
'value',
|
||||
aliasedName,
|
||||
false,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
|
||||
'updatedAt',
|
||||
@@ -240,8 +240,6 @@ class $SettingsEntityTable extends i2.SettingsEntity
|
||||
_valueMeta,
|
||||
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_valueMeta);
|
||||
}
|
||||
if (data.containsKey('updated_at')) {
|
||||
context.handle(
|
||||
@@ -265,7 +263,7 @@ class $SettingsEntityTable extends i2.SettingsEntity
|
||||
value: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}value'],
|
||||
)!,
|
||||
),
|
||||
updatedAt: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}updated_at'],
|
||||
@@ -287,18 +285,20 @@ class $SettingsEntityTable extends i2.SettingsEntity
|
||||
class SettingsEntityData extends i0.DataClass
|
||||
implements i0.Insertable<i1.SettingsEntityData> {
|
||||
final String key;
|
||||
final String value;
|
||||
final String? value;
|
||||
final DateTime updatedAt;
|
||||
const SettingsEntityData({
|
||||
required this.key,
|
||||
required this.value,
|
||||
this.value,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
map['key'] = i0.Variable<String>(key);
|
||||
map['value'] = i0.Variable<String>(value);
|
||||
if (!nullToAbsent || value != null) {
|
||||
map['value'] = i0.Variable<String>(value);
|
||||
}
|
||||
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
|
||||
return map;
|
||||
}
|
||||
@@ -310,7 +310,7 @@ class SettingsEntityData extends i0.DataClass
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return SettingsEntityData(
|
||||
key: serializer.fromJson<String>(json['key']),
|
||||
value: serializer.fromJson<String>(json['value']),
|
||||
value: serializer.fromJson<String?>(json['value']),
|
||||
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
@@ -319,18 +319,18 @@ class SettingsEntityData extends i0.DataClass
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'key': serializer.toJson<String>(key),
|
||||
'value': serializer.toJson<String>(value),
|
||||
'value': serializer.toJson<String?>(value),
|
||||
'updatedAt': serializer.toJson<DateTime>(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
i1.SettingsEntityData copyWith({
|
||||
String? key,
|
||||
String? value,
|
||||
i0.Value<String?> value = const i0.Value.absent(),
|
||||
DateTime? updatedAt,
|
||||
}) => i1.SettingsEntityData(
|
||||
key: key ?? this.key,
|
||||
value: value ?? this.value,
|
||||
value: value.present ? value.value : this.value,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
SettingsEntityData copyWithCompanion(i1.SettingsEntityCompanion data) {
|
||||
@@ -365,7 +365,7 @@ class SettingsEntityData extends i0.DataClass
|
||||
class SettingsEntityCompanion
|
||||
extends i0.UpdateCompanion<i1.SettingsEntityData> {
|
||||
final i0.Value<String> key;
|
||||
final i0.Value<String> value;
|
||||
final i0.Value<String?> value;
|
||||
final i0.Value<DateTime> updatedAt;
|
||||
const SettingsEntityCompanion({
|
||||
this.key = const i0.Value.absent(),
|
||||
@@ -374,10 +374,9 @@ class SettingsEntityCompanion
|
||||
});
|
||||
SettingsEntityCompanion.insert({
|
||||
required String key,
|
||||
required String value,
|
||||
this.value = const i0.Value.absent(),
|
||||
this.updatedAt = const i0.Value.absent(),
|
||||
}) : key = i0.Value(key),
|
||||
value = i0.Value(value);
|
||||
}) : key = i0.Value(key);
|
||||
static i0.Insertable<i1.SettingsEntityData> custom({
|
||||
i0.Expression<String>? key,
|
||||
i0.Expression<String>? value,
|
||||
@@ -392,7 +391,7 @@ class SettingsEntityCompanion
|
||||
|
||||
i1.SettingsEntityCompanion copyWith({
|
||||
i0.Value<String>? key,
|
||||
i0.Value<String>? value,
|
||||
i0.Value<String?>? value,
|
||||
i0.Value<DateTime>? updatedAt,
|
||||
}) {
|
||||
return i1.SettingsEntityCompanion(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class AppMetadataRepository {
|
||||
final Drift _db;
|
||||
|
||||
const AppMetadataRepository(this._db);
|
||||
|
||||
Future<T> get<T>(AppMetadataKey<T> key) async {
|
||||
final row = await (_db.select(_db.appMetadataEntity)..where((row) => row.key.equals(key.name))).getSingleOrNull();
|
||||
final value = row?.value;
|
||||
return value == null ? key.defaultValue : key.decode(value);
|
||||
}
|
||||
|
||||
Future<void> set<T, U extends T>(AppMetadataKey<T> key, U value) async {
|
||||
await _db
|
||||
.into(_db.appMetadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
AppMetadataEntityCompanion.insert(
|
||||
key: key.name,
|
||||
value: Value(key.encode(value)),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
abstract class CachedKeyValueRepository<K extends Enum, S> {
|
||||
CachedKeyValueRepository(this._snapshot);
|
||||
|
||||
S _snapshot;
|
||||
S get snapshot => _snapshot;
|
||||
@protected
|
||||
set snapshot(S value) => _snapshot = value;
|
||||
|
||||
List<K> get keys;
|
||||
|
||||
Object decodeValue(K key, String raw);
|
||||
|
||||
S buildSnapshot(Map<K, Object?> overrides);
|
||||
|
||||
Selectable<({String key, String? value})> selectable();
|
||||
|
||||
Future<void> refresh() async => _snapshot = _build(await selectable().get());
|
||||
|
||||
@protected
|
||||
Stream<S> watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows));
|
||||
|
||||
S _build(List<({String key, String? value})> rows) => buildSnapshot(
|
||||
rows.fold({}, (overrides, row) {
|
||||
final key = keys.firstWhereOrNull((key) => key.name == row.key);
|
||||
if (key == null) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
Object? decodedValue;
|
||||
if (row.value != null) {
|
||||
decodedValue = decodeValue(key, row.value!);
|
||||
}
|
||||
|
||||
return {...overrides, key: decodedValue};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:drift/src/runtime/executor/stream_queries.dart' show StreamQueryStore;
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.dart';
|
||||
@@ -25,6 +26,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/session.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
@@ -67,6 +69,8 @@ import 'package:sqlite_async/sqlite_async.dart';
|
||||
AssetEditEntity,
|
||||
SettingsEntity,
|
||||
AssetOcrEntity,
|
||||
SessionEntity,
|
||||
AppMetadataEntity,
|
||||
],
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
@@ -120,7 +124,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 29;
|
||||
int get schemaVersion => 32;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -308,6 +312,20 @@ class Drift extends $Drift {
|
||||
await m.createTable(v29.assetOcrEntity);
|
||||
await m.createIndex(v29.idxAssetOcrAssetId);
|
||||
},
|
||||
from29To30: (m, v30) async {
|
||||
await m.alterTable(TableMigration(v30.settings));
|
||||
},
|
||||
from30To31: (m, v31) async {
|
||||
await m.createTable(v31.session);
|
||||
},
|
||||
from31To32: (m, v32) async {
|
||||
await m.createTable(v32.appMetadata);
|
||||
await customStatement(
|
||||
"INSERT INTO app_metadata (key, value) "
|
||||
"SELECT 'version', CAST(int_value AS TEXT) FROM store_entity "
|
||||
"WHERE id = 0 AND int_value IS NOT NULL",
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+19
-4
@@ -47,9 +47,13 @@ import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart
|
||||
as i22;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
|
||||
as i23;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'
|
||||
as i24;
|
||||
import 'package:drift/internal/modular.dart' as i25;
|
||||
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart'
|
||||
as i25;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
as i26;
|
||||
import 'package:drift/internal/modular.dart' as i27;
|
||||
|
||||
abstract class $Drift extends i0.GeneratedDatabase {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
@@ -99,9 +103,14 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
late final i23.$AssetOcrEntityTable assetOcrEntity = i23.$AssetOcrEntityTable(
|
||||
this,
|
||||
);
|
||||
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
|
||||
late final i24.$SessionEntityTable sessionEntity = i24.$SessionEntityTable(
|
||||
this,
|
||||
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
|
||||
);
|
||||
late final i25.$AppMetadataEntityTable appMetadataEntity = i25
|
||||
.$AppMetadataEntityTable(this);
|
||||
i26.MergedAssetDrift get mergedAssetDrift => i27.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i26.MergedAssetDrift>(i26.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@@ -140,6 +149,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
assetEditEntity,
|
||||
settingsEntity,
|
||||
assetOcrEntity,
|
||||
sessionEntity,
|
||||
appMetadataEntity,
|
||||
i10.idxPartnerSharedWithId,
|
||||
i11.idxLatLng,
|
||||
i11.idxRemoteExifCity,
|
||||
@@ -414,4 +425,8 @@ class $DriftManager {
|
||||
i22.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
|
||||
i23.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
|
||||
i23.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
|
||||
i24.$$SessionEntityTableTableManager get sessionEntity =>
|
||||
i24.$$SessionEntityTableTableManager(_db, _db.sessionEntity);
|
||||
i25.$$AppMetadataEntityTableTableManager get appMetadataEntity =>
|
||||
i25.$$AppMetadataEntityTableTableManager(_db, _db.appMetadataEntity);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,20 +16,21 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
|
||||
final query =
|
||||
_db.select(_db.assetFaceEntity).join([
|
||||
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
|
||||
])..where(
|
||||
_db.assetFaceEntity.assetId.equals(assetId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull() &
|
||||
_db.personEntity.isHidden.equals(false),
|
||||
);
|
||||
// An asset can have multiple face records for the same person (e.g., metadata
|
||||
// imports alongside ML detections). Use a subquery instead of a join so each
|
||||
// person is returned once, regardless of how many of their faces are on the asset
|
||||
final faceQuery = _db.assetFaceEntity.selectOnly()
|
||||
..addColumns([_db.assetFaceEntity.personId])
|
||||
..where(
|
||||
_db.assetFaceEntity.assetId.equals(assetId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull(),
|
||||
);
|
||||
|
||||
return query.map((row) {
|
||||
final person = row.readTable(_db.personEntity);
|
||||
return person.toDto();
|
||||
}).get();
|
||||
final query = _db.select(_db.personEntity)
|
||||
..where((row) => row.id.isInQuery(faceQuery) & row.isHidden.equals(false));
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAllPeople({int minFaces = 3}) async {
|
||||
|
||||
@@ -194,12 +194,15 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateDateTime(List<String> ids, DateTime dateTime) {
|
||||
Future<void> updateDateTime(List<String> ids, DateTime dateTime, {String? timeZone}) {
|
||||
return _db.batch((batch) async {
|
||||
for (final id in ids) {
|
||||
batch.update(
|
||||
_db.remoteExifEntity,
|
||||
RemoteExifEntityCompanion(dateTimeOriginal: Value(dateTime)),
|
||||
RemoteExifEntityCompanion(
|
||||
dateTimeOriginal: Value(dateTime),
|
||||
timeZone: timeZone == null ? const Value.absent() : Value(timeZone),
|
||||
),
|
||||
where: (e) => e.assetId.equals(id),
|
||||
);
|
||||
batch.update(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/session.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class SessionRepository extends CachedKeyValueRepository<SessionKey, Session> {
|
||||
final Drift _db;
|
||||
|
||||
SessionRepository._(this._db) : super(const .new());
|
||||
|
||||
static SessionRepository? _instance;
|
||||
|
||||
static SessionRepository get instance {
|
||||
final instance = _instance;
|
||||
if (instance == null) {
|
||||
throw StateError('SessionRepository not initialized. Call ensureInitialized() first');
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
static Future<SessionRepository> ensureInitialized(Drift db) async {
|
||||
if (_instance == null) {
|
||||
final instance = SessionRepository._(db);
|
||||
await instance.refresh();
|
||||
_instance = instance;
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
@override
|
||||
List<SessionKey> get keys => SessionKey.values;
|
||||
|
||||
@override
|
||||
Object decodeValue(SessionKey key, String raw) => key.decode(raw);
|
||||
|
||||
@override
|
||||
Session buildSnapshot(Map<SessionKey, Object?> overrides) => Session.fromEntries(overrides);
|
||||
|
||||
@override
|
||||
@protected
|
||||
Selectable<({String key, String? value})> selectable() =>
|
||||
_db.select(_db.sessionEntity).map((row) => (key: row.key, value: row.value));
|
||||
|
||||
Session get session => snapshot;
|
||||
|
||||
Future<void> clear(Iterable<SessionKey> keys) async {
|
||||
if (keys.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final names = keys.map((key) => key.name).toList();
|
||||
await (_db.delete(_db.sessionEntity)..where((row) => row.key.isIn(names))).go();
|
||||
|
||||
var session = snapshot;
|
||||
for (final key in keys) {
|
||||
session = session.write(key, defaultSession.read(key));
|
||||
}
|
||||
snapshot = session;
|
||||
}
|
||||
|
||||
Future<void> write<T, U extends T>(SessionKey<T> key, U value) async {
|
||||
if (value == snapshot.read(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String? resolvedValue;
|
||||
if (value != null) {
|
||||
resolvedValue = key.encode(value);
|
||||
}
|
||||
|
||||
await _db
|
||||
.into(_db.sessionEntity)
|
||||
.insertOnConflictUpdate(
|
||||
SessionEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
|
||||
);
|
||||
snapshot = snapshot.write(key, value);
|
||||
}
|
||||
|
||||
Stream<Session> watch() => watchSnapshot();
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class SettingsRepository extends DriftDatabaseRepository {
|
||||
class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig> {
|
||||
final Drift _db;
|
||||
|
||||
SettingsRepository._(this._db) : super(_db);
|
||||
SettingsRepository._(this._db) : super(const .new());
|
||||
|
||||
static SettingsRepository? _instance;
|
||||
|
||||
@@ -20,9 +21,6 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
return instance;
|
||||
}
|
||||
|
||||
AppConfig _appConfig = const .new();
|
||||
AppConfig get appConfig => _appConfig;
|
||||
|
||||
static Future<SettingsRepository> ensureInitialized(Drift db) async {
|
||||
if (_instance == null) {
|
||||
final instance = SettingsRepository._(db);
|
||||
@@ -32,7 +30,21 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
|
||||
@override
|
||||
List<SettingsKey> get keys => SettingsKey.values;
|
||||
|
||||
@override
|
||||
Object decodeValue(SettingsKey key, String raw) => key.decode(raw);
|
||||
|
||||
@override
|
||||
AppConfig buildSnapshot(Map<SettingsKey, Object?> overrides) => AppConfig.fromEntries(overrides);
|
||||
|
||||
@override
|
||||
@protected
|
||||
Selectable<({String key, String? value})> selectable() =>
|
||||
_db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value));
|
||||
|
||||
AppConfig get appConfig => snapshot;
|
||||
|
||||
Future<void> clear(Iterable<SettingsKey> keys) async {
|
||||
if (keys.isEmpty) {
|
||||
@@ -42,13 +54,15 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
final names = keys.map((key) => key.name).toList();
|
||||
await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go();
|
||||
|
||||
var config = snapshot;
|
||||
for (final key in keys) {
|
||||
_appConfig = _appConfig.write(key, defaultConfig.read(key));
|
||||
config = config.write(key, defaultConfig.read(key));
|
||||
}
|
||||
snapshot = config;
|
||||
}
|
||||
|
||||
Future<void> write<T extends Object, U extends T>(SettingsKey<T> key, U value) async {
|
||||
if (value == _appConfig.read(key)) {
|
||||
Future<void> write<T, U extends T>(SettingsKey<T> key, U value) async {
|
||||
if (value == snapshot.read(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,29 +70,18 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
return clear([key]);
|
||||
}
|
||||
|
||||
String? resolvedValue;
|
||||
if (value != null) {
|
||||
resolvedValue = key.encode(value);
|
||||
}
|
||||
|
||||
await _db
|
||||
.into(_db.settingsEntity)
|
||||
.insertOnConflictUpdate(
|
||||
SettingsEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
SettingsEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
|
||||
);
|
||||
_appConfig = _appConfig.write(key, value);
|
||||
snapshot = snapshot.write(key, value);
|
||||
}
|
||||
|
||||
Stream<AppConfig> watchConfig() => _db.select(_db.settingsEntity).watch().map((rows) {
|
||||
_applyOverrides(rows);
|
||||
return _appConfig;
|
||||
});
|
||||
|
||||
void _applyOverrides(List<SettingsEntityData> rows) {
|
||||
_appConfig = AppConfig.fromEntries(
|
||||
rows.fold({}, (overrides, row) {
|
||||
final metadataKey = SettingsKey.values.firstWhereOrNull((key) => key.name == row.key);
|
||||
if (metadataKey == null) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
return {...overrides, metadataKey: metadataKey.decode(row.value)};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Stream<AppConfig> watch() => watchSnapshot();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
|
||||
class DriftStoreRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
@@ -63,8 +61,6 @@ class DriftStoreRepository extends DriftDatabaseRepository {
|
||||
const (String) => entity.stringValue,
|
||||
const (bool) => entity.intValue == 1,
|
||||
const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
|
||||
const (UserDto) =>
|
||||
entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!),
|
||||
_ => null,
|
||||
}
|
||||
as T?;
|
||||
@@ -75,7 +71,6 @@ class DriftStoreRepository extends DriftDatabaseRepository {
|
||||
const (String) => (null, value as String),
|
||||
const (bool) => ((value as bool) ? 1 : 0, null),
|
||||
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
|
||||
const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id),
|
||||
_ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"),
|
||||
};
|
||||
return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue));
|
||||
|
||||
@@ -17,16 +17,15 @@ class DriftAuthUserRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftAuthUserRepository(super.db) : _db = db;
|
||||
|
||||
Future<UserDto?> get(String id) async {
|
||||
final user = await _db.managers.authUserEntity.filter((user) => user.id.equals(id)).getSingleOrNull();
|
||||
Selectable<UserDto?> get _authUserQuery => (_db.authUserEntity.select()..limit(1)).asyncMap(_toDto);
|
||||
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
Future<UserDto?> get() => _authUserQuery.getSingleOrNull();
|
||||
|
||||
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(id));
|
||||
Stream<UserDto?> watch() => _authUserQuery.watchSingleOrNull();
|
||||
|
||||
Future<UserDto> _toDto(AuthUserEntityData user) async {
|
||||
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(user.id));
|
||||
final metadata = await query.map((row) => row.toDto()).get();
|
||||
|
||||
return user.toDto(metadata);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
@@ -85,7 +83,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
final backupSyncManager = ref.read(backgroundSyncProvider);
|
||||
|
||||
Future<void> startBackup() async {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,14 +8,15 @@ import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
@@ -306,9 +307,10 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
}
|
||||
|
||||
void resumeSession() async {
|
||||
final serverUrl = Store.tryGet(StoreKey.serverUrl);
|
||||
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
|
||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||
final session = ref.read(sessionProvider);
|
||||
final serverUrl = session.serverUrl;
|
||||
final endpoint = session.serverEndpoint;
|
||||
final accessToken = session.accessToken;
|
||||
|
||||
if (accessToken != null && serverUrl != null && endpoint != null) {
|
||||
final infoProvider = ref.read(serverInfoProvider.notifier);
|
||||
@@ -316,6 +318,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
final backupProvider = ref.read(driftBackupProvider.notifier);
|
||||
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
||||
final authUserRepository = ref.read(authUserRepositoryProvider);
|
||||
|
||||
unawaited(
|
||||
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
||||
@@ -335,9 +338,9 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
backgroundManager.hashAssets().then((_) {
|
||||
_resumeBackup(backupProvider);
|
||||
_resumeBackup(backupProvider, authUserRepository);
|
||||
}),
|
||||
_resumeBackup(backupProvider),
|
||||
_resumeBackup(backupProvider, authUserRepository),
|
||||
// TODO: Bring back when the soft freeze issue is addressed
|
||||
// backgroundManager.syncCloudIds(),
|
||||
]);
|
||||
@@ -373,11 +376,11 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
|
||||
Future<void> _resumeBackup(DriftBackupNotifier notifier, DriftAuthUserRepository authUserRepository) async {
|
||||
final isEnableBackup = SettingsRepository.instance.appConfig.backup.enabled;
|
||||
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
final currentUser = await authUserRepository.get();
|
||||
if (currentUser != null) {
|
||||
unawaited(notifier.startForegroundBackup(currentUser.id));
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ class FolderContent extends HookConsumerWidget {
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
"${asset.exifInfo.fileSize != null ? formatBytes(asset.exifInfo.fileSize ?? 0) : ""} • ${DateFormat.yMMMd().format(asset.createdAt)}",
|
||||
"${asset.exifInfo.fileSize != null ? "${formatBytes(asset.exifInfo.fileSize ?? 0)} • " : ""}${DateFormat.yMMMd().format(asset.createdAt.toLocal())}",
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftCreateAlbumPage extends ConsumerStatefulWidget {
|
||||
@@ -47,7 +48,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canCreateAlbum => albumTitleController.text.isNotEmpty;
|
||||
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty;
|
||||
|
||||
String _getEffectiveTitle() {
|
||||
return albumTitleController.text.isNotEmpty
|
||||
@@ -169,25 +170,23 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||
onBackgroundTapped();
|
||||
|
||||
final title = _getEffectiveTitle().trim();
|
||||
if (title.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('create_album_title_required'.t()), backgroundColor: context.colorScheme.error),
|
||||
);
|
||||
|
||||
try {
|
||||
final album = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.createAlbumWithAssets(
|
||||
title: title,
|
||||
description: albumDescriptionController.text.trim(),
|
||||
assets: selectedAssets,
|
||||
);
|
||||
|
||||
if (album != null && context.mounted) {
|
||||
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final album = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.createAlbumWithAssets(
|
||||
title: title,
|
||||
description: albumDescriptionController.text.trim(),
|
||||
assets: selectedAssets,
|
||||
);
|
||||
|
||||
if (album != null && context.mounted) {
|
||||
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
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/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -64,7 +65,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
data: (people) {
|
||||
if (_search != null) {
|
||||
people = people.where((person) {
|
||||
return person.name.toLowerCase().contains(_search!.toLowerCase());
|
||||
return person.name.toLowerCase().removeDiacritics().contains(
|
||||
_search!.toLowerCase().removeDiacritics(),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
return GridView.builder(
|
||||
|
||||
@@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dar
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
|
||||
|
||||
@@ -248,13 +247,10 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
||||
try {
|
||||
final newTitle = titleController.text.trim();
|
||||
final newDescription = descriptionController.text.trim();
|
||||
final description = newDescription.isEmpty
|
||||
? const Option<String?>.some(null)
|
||||
: Option<String?>.some(newDescription);
|
||||
|
||||
await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.updateAlbum(widget.album.id, name: newTitle, description: description);
|
||||
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(
|
||||
|
||||
@@ -42,6 +42,7 @@ class BaseActionButton extends ConsumerWidget {
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPressed,
|
||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||
);
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
@@ -22,7 +21,7 @@ class OpenInBrowserActionButton extends ConsumerWidget {
|
||||
});
|
||||
|
||||
void _onTap() async {
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
|
||||
final serverEndpoint = SessionRepository.instance.session.serverEndpoint!.replaceFirst('/api', '');
|
||||
|
||||
String originPath = '';
|
||||
switch (origin) {
|
||||
|
||||
@@ -6,10 +6,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -48,6 +50,34 @@ class _SharePreparingDialog extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareFileTypeDialog extends StatelessWidget {
|
||||
const _ShareFileTypeDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.select_quality),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.high_quality_rounded),
|
||||
title: Text(context.t.share_original),
|
||||
onTap: () => context.pop(ShareAssetType.original),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_size_select_large_rounded),
|
||||
title: Text(context.t.share_preview),
|
||||
onTap: () => context.pop(ShareAssetType.preview),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
@@ -60,6 +90,35 @@ class ShareActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileType = ref.read(appConfigProvider).share.fileType;
|
||||
await _share(context, ref, fileType);
|
||||
}
|
||||
|
||||
void _onLongPress(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileType = await showDialog<ShareAssetType>(
|
||||
context: context,
|
||||
builder: (_) => const _ShareFileTypeDialog(),
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
if (fileType == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(settingsProvider).write(SettingsKey.shareFileType, fileType);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _share(context, ref, fileType);
|
||||
}
|
||||
|
||||
Future<void> _share(BuildContext context, WidgetRef ref, ShareAssetType fileType) async {
|
||||
final cancelCompleter = Completer<void>();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
final preparingDialog = _SharePreparingDialog(progress: progress);
|
||||
@@ -71,6 +130,7 @@ class ShareActionButton extends ConsumerWidget {
|
||||
.shareAssets(
|
||||
source,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: (value) => progress.value = value,
|
||||
)
|
||||
@@ -84,7 +144,7 @@ class ShareActionButton extends ConsumerWidget {
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
msg: context.t.scaffold_body_error_occurred,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
@@ -110,10 +170,11 @@ class ShareActionButton extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||
label: 'share'.t(context: context),
|
||||
label: context.t.share,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
onLongPressed: () => _onLongPress(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
switch (event) {
|
||||
case ViewerShowDetailsEvent():
|
||||
_showDetails();
|
||||
case TimelineReloadEvent():
|
||||
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
if (asset != _asset) {
|
||||
setState(() => _asset = asset);
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_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/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -100,6 +101,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
OcrToggleButton(asset: asset),
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
|
||||
@@ -157,6 +157,55 @@ class _OcrBoxes extends StatelessWidget {
|
||||
final cx = viewportWidth / 2 + position.dx;
|
||||
final cy = viewportHeight / 2 + position.dy;
|
||||
|
||||
final quads = <List<Offset>>[];
|
||||
final boxes = <Widget>[];
|
||||
|
||||
for (final entry in ocrData.asMap().entries) {
|
||||
final index = entry.key;
|
||||
final ocr = entry.value;
|
||||
|
||||
// Map normalized image coords (0–1) to viewport space
|
||||
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
||||
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
|
||||
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
|
||||
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
|
||||
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
|
||||
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
|
||||
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
|
||||
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
|
||||
|
||||
// Bounding rectangle for hit testing and Positioned placement
|
||||
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
|
||||
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
|
||||
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
||||
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
||||
|
||||
quads.add([Offset(x1, y1), Offset(x2, y2), Offset(x3, y3), Offset(x4, y4)]);
|
||||
|
||||
boxes.add(
|
||||
_OcrBoxItem(
|
||||
key: ValueKey(index),
|
||||
ocr: ocr,
|
||||
index: index,
|
||||
isSelected: selectedBoxIndex == index,
|
||||
points: [
|
||||
Offset(x1 - minX, y1 - minY),
|
||||
Offset(x2 - minX, y2 - minY),
|
||||
Offset(x3 - minX, y3 - minY),
|
||||
Offset(x4 - minX, y4 - minY),
|
||||
],
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
angle: math.atan2(y2 - y1, x2 - x1),
|
||||
labelDx: (minX + maxX) / 2 - minX,
|
||||
labelDy: (minY + maxY) / 2 - minY,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => onSelectionChanged(null),
|
||||
@@ -165,47 +214,13 @@ class _OcrBoxes extends StatelessWidget {
|
||||
children: [
|
||||
// Fills the viewport so taps outside boxes deselect
|
||||
SizedBox(width: viewportWidth, height: viewportHeight),
|
||||
...ocrData.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ocr = entry.value;
|
||||
|
||||
// Map normalized image coords (0–1) to viewport space
|
||||
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
||||
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
|
||||
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
|
||||
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
|
||||
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
|
||||
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
|
||||
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
|
||||
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
|
||||
|
||||
// Bounding rectangle for hit testing and Positioned placement
|
||||
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
|
||||
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
|
||||
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
||||
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return _OcrBoxItem(
|
||||
key: ValueKey(index),
|
||||
ocr: ocr,
|
||||
index: index,
|
||||
isSelected: selectedBoxIndex == index,
|
||||
points: [
|
||||
Offset(x1 - minX, y1 - minY),
|
||||
Offset(x2 - minX, y2 - minY),
|
||||
Offset(x3 - minX, y3 - minY),
|
||||
Offset(x4 - minX, y4 - minY),
|
||||
],
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
angle: math.atan2(y2 - y1, x2 - x1),
|
||||
labelDx: (minX + maxX) / 2 - minX,
|
||||
labelDy: (minY + maxY) / 2 - minY,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
);
|
||||
}),
|
||||
// Dark scrim with the text boxes punched out
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(painter: _OcrScrimPainter(quads: quads)),
|
||||
),
|
||||
),
|
||||
...boxes,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -307,6 +322,35 @@ class _OcrBoxItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrScrimPainter extends CustomPainter {
|
||||
final List<List<Offset>> quads;
|
||||
|
||||
const _OcrScrimPainter({required this.quads});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// Fill the whole viewport, then subtract each text quad using the even-odd
|
||||
// rule so the original image shows through the boxes.
|
||||
final path = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size);
|
||||
|
||||
for (final quad in quads) {
|
||||
path
|
||||
..moveTo(quad[0].dx, quad[0].dy)
|
||||
..lineTo(quad[1].dx, quad[1].dy)
|
||||
..lineTo(quad[2].dx, quad[2].dy)
|
||||
..lineTo(quad[3].dx, quad[3].dy)
|
||||
..close();
|
||||
}
|
||||
|
||||
canvas.drawPath(path, Paint()..color = Colors.black54);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_OcrScrimPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
class _OcrBoxPainter extends CustomPainter {
|
||||
final List<Offset> points;
|
||||
final bool isSelected;
|
||||
@@ -322,7 +366,7 @@ class _OcrBoxPainter extends CustomPainter {
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
final fillPaint = Paint()
|
||||
..color = (isSelected ? colorScheme.primary : colorScheme.secondary).withValues(alpha: 0.1)
|
||||
..color = isSelected ? colorScheme.primary.withValues(alpha: 0.45) : Colors.transparent
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final path = Path()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
|
||||
|
||||
class OcrToggleButton extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
const OcrToggleButton({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: !hasOcr
|
||||
? const SizedBox.shrink()
|
||||
: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 32, bottom: 8),
|
||||
child: Material(
|
||||
color: showingOcr ? context.primaryColor : Colors.black.withValues(alpha: 0.4),
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: ref.read(assetViewerProvider.notifier).toggleOcr,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(10.0),
|
||||
child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
@@ -13,6 +11,7 @@ import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.pro
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -143,7 +142,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final serverEndpoint = ref.read(sessionProvider).serverEndpoint!;
|
||||
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
|
||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
@@ -33,7 +33,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
||||
final advancedTroubleshooting = ref.watch(appConfigProvider.select((c) => c.advanced.troubleshooting));
|
||||
|
||||
final actionContext = ActionButtonContext(
|
||||
asset: asset,
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -36,7 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
|
||||
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
|
||||
@@ -48,15 +46,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
|
||||
|
||||
final actions = <Widget>[
|
||||
if (hasOcr)
|
||||
IconButton(
|
||||
icon: Icon(showingOcr ? Icons.text_fields : Icons.text_fields_outlined),
|
||||
onPressed: ref.read(assetViewerProvider.notifier).toggleOcr,
|
||||
color: showingOcr ? context.primaryColor : null,
|
||||
),
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
@@ -14,21 +16,68 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class ArchiveBottomSheet extends ConsumerWidget {
|
||||
class ArchiveBottomSheet extends ConsumerStatefulWidget {
|
||||
const ArchiveBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ArchiveBottomSheet> createState() => _ArchiveBottomSheetState();
|
||||
}
|
||||
|
||||
class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
late final DraggableScrollableController sheetController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
sheetController = DraggableScrollableController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
sheetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.count == 0
|
||||
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
|
||||
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onKeyboardExpand() {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.4,
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
@@ -48,6 +97,10 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
||||
],
|
||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||
@@ -24,7 +23,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
@@ -56,7 +55,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
||||
final advancedTroubleshooting = ref.watch(appConfigProvider.select((c) => c.advanced.troubleshooting));
|
||||
final tagsEnabled = ref.watch(
|
||||
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ class MapBottomSheet extends StatelessWidget {
|
||||
resizeOnScroll: false,
|
||||
actions: [],
|
||||
backgroundColor: context.themeData.colorScheme.surface,
|
||||
slivers: [const SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
|
||||
slivers: [const SliverFillRemaining(hasScrollBody: true, child: _ScopedMapTimeline())],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,6 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
}
|
||||
|
||||
void switchTheme(ThemeMode mode) {
|
||||
// TODO: Remove this line when map theme provider is removed
|
||||
// Until then, keep both in sync as MapThemeOverride uses map state provider
|
||||
// ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index);
|
||||
ref.read(mapStateNotifierProvider.notifier).switchTheme(mode);
|
||||
state = state.copyWith(themeMode: mode);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
|
||||
style: TextStyle(color: Colors.grey[400], fontSize: 13.0, fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
df.format(fileCreatedDate),
|
||||
df.format(fileCreatedDate.toLocal()),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15.0, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
@@ -41,7 +41,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
|
||||
minWidth: 0,
|
||||
onPressed: () async {
|
||||
await context.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate));
|
||||
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate.toLocal()));
|
||||
},
|
||||
shape: const CircleBorder(),
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
|
||||
|
||||
class PartnerUserAvatar extends StatelessWidget {
|
||||
class PartnerUserAvatar extends ConsumerWidget {
|
||||
const PartnerUserAvatar({super.key, required this.userId, required this.name});
|
||||
|
||||
final String userId;
|
||||
final String name;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = "${Store.get(StoreKey.serverEndpoint)}/users/$userId/profile-image";
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final url = "${ref.read(sessionProvider).serverEndpoint}/users/$userId/profile-image";
|
||||
final nameFirstLetter = name.isNotEmpty ? name[0] : "";
|
||||
return CircleAvatar(
|
||||
radius: 16,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
@@ -13,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
@@ -140,7 +139,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
final isEnableBackup = _ref.read(appConfigProvider).backup.enabled;
|
||||
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
final currentUser = _ref.read(currentUserProvider);
|
||||
if (currentUser != null) {
|
||||
await _safeRun(
|
||||
_ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
final appSettingsServiceProvider = Provider((_) => const AppSettingsService());
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/session.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
@@ -125,10 +127,10 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
Future<bool> saveAuthInfo({required String accessToken}) async {
|
||||
await Store.put(StoreKey.accessToken, accessToken);
|
||||
await _ref.read(sessionRepository).write(SessionKey.accessToken, accessToken);
|
||||
await _apiService.updateHeaders();
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final serverEndpoint = _ref.read(sessionProvider).serverEndpoint!;
|
||||
final headerMap = _ref.read(appConfigProvider).network.customHeaders;
|
||||
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
|
||||
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
|
||||
@@ -136,7 +138,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||
String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
|
||||
|
||||
UserDto? user = _userService.tryGetMyUser();
|
||||
UserDto? user = await _userService.tryGetMyUser();
|
||||
|
||||
try {
|
||||
final serverUser = await _userService.refreshMyUser().timeout(_timeoutDuration);
|
||||
@@ -193,9 +195,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
return _ref.read(appConfigProvider).network.localEndpoint;
|
||||
}
|
||||
|
||||
/// Returns the current server endpoint (with /api) URL from the store
|
||||
/// Returns the current server endpoint (with /api) URL from the session
|
||||
String? getServerEndpoint() {
|
||||
return Store.tryGet(StoreKey.serverEndpoint);
|
||||
return _ref.read(sessionProvider).serverEndpoint;
|
||||
}
|
||||
|
||||
Future<String?> setOpenApiServiceEndpoint() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
|
||||
final hapticFeedbackProvider = StateNotifierProvider<HapticNotifier, void>((ref) {
|
||||
return HapticNotifier(ref);
|
||||
@@ -14,31 +13,31 @@ class HapticNotifier extends StateNotifier<void> {
|
||||
HapticNotifier(this._ref) : super(null);
|
||||
|
||||
selectionClick() {
|
||||
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
}
|
||||
|
||||
lightImpact() {
|
||||
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
mediumImpact() {
|
||||
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
}
|
||||
|
||||
heavyImpact() {
|
||||
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
}
|
||||
|
||||
vibrate() {
|
||||
if (_ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
if (_ref.read(appConfigProvider).advanced.enableHapticFeedback) {
|
||||
HapticFeedback.vibrate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +353,10 @@ class ActionNotifier extends Notifier<void> {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
ref.invalidate(assetExifProvider);
|
||||
}
|
||||
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to edit date and time for assets', error, stack);
|
||||
@@ -514,19 +518,21 @@ class ActionNotifier extends Notifier<void> {
|
||||
Future<ActionResult> shareAssets(
|
||||
ActionSource source,
|
||||
BuildContext context, {
|
||||
ShareAssetType fileType = ShareAssetType.original,
|
||||
Completer<void>? cancelCompleter,
|
||||
void Function(double progress)? onAssetDownloadProgress,
|
||||
}) async {
|
||||
final ids = _getAssets(source).toList(growable: false);
|
||||
|
||||
try {
|
||||
await _service.shareAssets(
|
||||
final count = await _service.shareAssets(
|
||||
ids,
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
onAssetDownloadProgress: onAssetDownloadProgress,
|
||||
);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
return ActionResult(count: count, success: count > 0 || ids.isEmpty);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to share assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
|
||||
final appMetadataRepositoryProvider = Provider<AppMetadataRepository>(
|
||||
(ref) => AppMetadataRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
@@ -1,22 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
class ReadOnlyModeNotifier extends Notifier<bool> {
|
||||
late AppSettingsService _appSettingService;
|
||||
|
||||
@override
|
||||
bool build() {
|
||||
_appSettingService = ref.read(appSettingsServiceProvider);
|
||||
final readonlyMode = _appSettingService.getSetting(AppSettingsEnum.readonlyModeEnabled);
|
||||
return readonlyMode;
|
||||
return ref.read(appConfigProvider).advanced.readonlyModeEnabled;
|
||||
}
|
||||
|
||||
void setMode(bool value) {
|
||||
final isLoggedIn = ref.read(authProvider).isAuthenticated;
|
||||
_appSettingService.setSetting(AppSettingsEnum.readonlyModeEnabled, value);
|
||||
unawaited(ref.read(settingsProvider).write(.advancedReadonlyModeEnabled, value));
|
||||
state = value;
|
||||
|
||||
if (value && isLoggedIn) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
@@ -154,7 +153,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
Future<RemoteAlbum?> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
Option<String?> description = const Option.none(),
|
||||
String? description,
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/session.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
|
||||
|
||||
final sessionRepository = Provider.autoDispose<SessionRepository>((_) => SessionRepository.instance);
|
||||
|
||||
final sessionProvider = Provider.autoDispose<Session>((ref) {
|
||||
final repo = ref.watch(sessionRepository);
|
||||
final subscription = repo.watch().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.session;
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
|
||||
class SettingsNotifier extends Notifier<SettingsService> {
|
||||
@override
|
||||
SettingsService build() => SettingsService(storeService: ref.read(storeServiceProvider));
|
||||
|
||||
T get<T>(Setting<T> setting) => state.get(setting);
|
||||
|
||||
Future<void> set<T>(Setting<T> setting, T value) async {
|
||||
await state.set(setting, value);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Stream<T> watch<T>(Setting<T> setting) => state.watch(setting);
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, SettingsService>(SettingsNotifier.new);
|
||||
@@ -6,7 +6,7 @@ final settingsProvider = Provider.autoDispose<SettingsRepository>((_) => Setting
|
||||
|
||||
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
|
||||
final repo = ref.watch(settingsProvider);
|
||||
final subscription = repo.watchConfig().listen((event) => ref.state = event);
|
||||
final subscription = repo.watch().listen((event) => ref.state = event);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return repo.appConfig;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user