mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 17:23:10 +03:00
Compare commits
59 Commits
prevent-so
...
feat/dynam
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd8ef9e1bf | ||
|
|
02de19df6d | ||
|
|
2152f20b6c | ||
|
|
a6c76e78d6 | ||
|
|
644a3bf090 | ||
|
|
42dd3315f8 | ||
|
|
3a694219bf | ||
|
|
d9fd52ea18 | ||
|
|
2a281e7906 | ||
|
|
5f987a95f5 | ||
|
|
edf577d7f7 | ||
|
|
5e482dabc6 | ||
|
|
76c73549ae | ||
|
|
271a42ac7f | ||
|
|
4462952564 | ||
|
|
38d4d1a573 | ||
|
|
d310c6f3cd | ||
|
|
c086a65fa8 | ||
|
|
7134dd29ca | ||
|
|
3e08953a43 | ||
|
|
58c3c7e26b | ||
|
|
237ddcb648 | ||
|
|
fbaeffd65c | ||
|
|
d64c339b4f | ||
|
|
69880ee165 | ||
|
|
15e00f82f0 | ||
|
|
ce82e27f4b | ||
|
|
eeee5147cc | ||
|
|
af22f9b014 | ||
|
|
1086f22166 | ||
|
|
e94eb5012f | ||
|
|
4dcc049465 | ||
|
|
d784d431d0 | ||
|
|
1200bfad13 | ||
|
|
f11bfb9581 | ||
|
|
074fdb2b96 | ||
|
|
f1f203719d | ||
|
|
f73ca9d9c0 | ||
|
|
ad3f4fb434 | ||
|
|
8001dedcbf | ||
|
|
07a39226c5 | ||
|
|
88e7e21683 | ||
|
|
2cefbf8ca3 | ||
|
|
4a6c50cd81 | ||
|
|
e0535e20e6 | ||
|
|
62580455af | ||
|
|
0e7e67efe1 | ||
|
|
2c54b506b3 | ||
|
|
8969b8bdb2 | ||
|
|
5186092faa | ||
|
|
4c9142308f | ||
|
|
bea5d4fd37 | ||
|
|
74c24bfa88 | ||
|
|
95834c68d9 | ||
|
|
09024c3558 | ||
|
|
137cb043ef | ||
|
|
edf21bae41 | ||
|
|
c958f9856d | ||
|
|
70ab8bc657 |
@@ -29,6 +29,12 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
// https://github.com/devcontainers/features/issues/1466
|
||||
"moby": false
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000, 9231, 9230, 2283],
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- ../plugins:/build/corePlugin
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
immich-machine-learning:
|
||||
|
||||
22
.github/workflows/build-mobile.yml
vendored
22
.github/workflows/build-mobile.yml
vendored
@@ -96,7 +96,7 @@ jobs:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
@@ -188,13 +188,13 @@ jobs:
|
||||
needs: pre-job
|
||||
permissions:
|
||||
contents: read
|
||||
# Run on main branch or workflow_dispatch
|
||||
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true && github.ref == 'refs/heads/main' }}
|
||||
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
|
||||
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
@@ -303,12 +303,20 @@ jobs:
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
if [[ "$ENVIRONMENT" == "development" ]]; then
|
||||
bundle exec fastlane gha_testflight_dev
|
||||
# Only upload to TestFlight on main branch
|
||||
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||
if [[ "$ENVIRONMENT" == "development" ]]; then
|
||||
bundle exec fastlane gha_testflight_dev
|
||||
else
|
||||
bundle exec fastlane gha_release_prod
|
||||
fi
|
||||
else
|
||||
bundle exec fastlane gha_release_prod
|
||||
# Build only, no TestFlight upload for non-main branches
|
||||
bundle exec fastlane gha_build_only
|
||||
fi
|
||||
|
||||
- name: Clean up keychain
|
||||
|
||||
2
.github/workflows/cache-cleanup.yml
vendored
2
.github/workflows/cache-cleanup.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
4
.github/workflows/cli.yml
vendored
4
.github/workflows/cli.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -50,14 +50,14 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
|
||||
# ℹ️ 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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
2
.github/workflows/docs-build.yml
vendored
2
.github/workflows/docs-build.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/docs-deploy.yml
vendored
2
.github/workflows/docs-deploy.yml
vendored
@@ -125,7 +125,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/docs-destroy.yml
vendored
2
.github/workflows/docs-destroy.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
8
.github/workflows/prepare-release.yml
vendored
8
.github/workflows/prepare-release.yml
vendored
@@ -55,14 +55,14 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
draft: true
|
||||
tag_name: ${{ env.IMMICH_VERSION }}
|
||||
|
||||
170
.github/workflows/release-pr.yml
vendored
Normal file
170
.github/workflows/release-pr.yml
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
name: Manage release PR
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Determine release type
|
||||
id: bump-type
|
||||
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Bump versions
|
||||
env:
|
||||
TYPE: ${{ steps.bump-type.outputs.bump }}
|
||||
run: |
|
||||
if [ "$TYPE" == "none" ]; then
|
||||
exit 1 # TODO: Is there a cleaner way to abort the workflow?
|
||||
fi
|
||||
misc/release/pump-version.sh -s $TYPE -m true
|
||||
|
||||
- name: Manage Outline release document
|
||||
id: outline
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
|
||||
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
|
||||
const baseUrl = 'https://outline.immich.cloud';
|
||||
|
||||
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ parentDocumentId })
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||
}
|
||||
|
||||
const listData = await listResponse.json();
|
||||
const allDocuments = listData.data || [];
|
||||
|
||||
const document = allDocuments.find(doc => doc.title === 'next');
|
||||
|
||||
let documentId;
|
||||
let documentUrl;
|
||||
let documentText;
|
||||
|
||||
if (!document) {
|
||||
// Create new document
|
||||
console.log('No existing document found. Creating new one...');
|
||||
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
|
||||
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'next',
|
||||
text: notesTmpl,
|
||||
collectionId: collectionId,
|
||||
parentDocumentId: parentDocumentId,
|
||||
publish: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(`Failed to create document: ${createResponse.statusText}`);
|
||||
}
|
||||
|
||||
const createData = await createResponse.json();
|
||||
documentId = createData.data.id;
|
||||
const urlId = createData.data.urlId;
|
||||
documentUrl = `${baseUrl}/doc/next-${urlId}`;
|
||||
documentText = createData.data.text || '';
|
||||
console.log(`Created new document: ${documentUrl}`);
|
||||
} else {
|
||||
documentId = document.id;
|
||||
const docPath = document.url;
|
||||
documentUrl = `${baseUrl}${docPath}`;
|
||||
documentText = document.text || '';
|
||||
console.log(`Found existing document: ${documentUrl}`);
|
||||
}
|
||||
|
||||
// Generate GitHub release notes
|
||||
console.log('Generating GitHub release notes...');
|
||||
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `${process.env.NEXT_VERSION}`,
|
||||
});
|
||||
|
||||
// Combine the content
|
||||
const changelog = `
|
||||
# ${process.env.NEXT_VERSION}
|
||||
|
||||
${documentText}
|
||||
|
||||
${releaseNotesResponse.data.body}
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
|
||||
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
|
||||
|
||||
core.setOutput('document_url', documentUrl);
|
||||
|
||||
- name: Create PR
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
|
||||
labels: 'changelog:skip'
|
||||
branch: 'release/next'
|
||||
draft: true
|
||||
147
.github/workflows/release.yml
vendored
Normal file
147
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
name: release.yml
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
paths:
|
||||
- CHANGELOG.md
|
||||
|
||||
jobs:
|
||||
# Maybe double check PR source branch?
|
||||
|
||||
merge_translations:
|
||||
uses: ./.github/workflows/merge-translations.yml
|
||||
permissions:
|
||||
pull-requests: write
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||
|
||||
build_mobile:
|
||||
uses: ./.github/workflows/build-mobile.yml
|
||||
needs: merge_translations
|
||||
permissions:
|
||||
contents: read
|
||||
secrets:
|
||||
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
# iOS secrets
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
with:
|
||||
ref: main
|
||||
environment: production
|
||||
|
||||
prepare_release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_mobile
|
||||
permissions:
|
||||
actions: read # To download the app artifact
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
|
||||
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
|
||||
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
|
||||
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.result }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
body_path: ${{ steps.changelog.outputs.path }}
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
docker/prometheus.yml
|
||||
*.apk
|
||||
|
||||
- name: Rename Outline document
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
continue-on-error: true
|
||||
env:
|
||||
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||
VERSION: ${{ steps.changelog.outputs.version }}
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||
const version = process.env.VERSION;
|
||||
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
|
||||
const baseUrl = 'https://outline.immich.cloud';
|
||||
|
||||
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ parentDocumentId })
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||
}
|
||||
|
||||
const listData = await listResponse.json();
|
||||
const allDocuments = listData.data || [];
|
||||
const document = allDocuments.find(doc => doc.title === 'next');
|
||||
|
||||
if (document) {
|
||||
console.log(`Found document 'next', renaming to '${version}'...`);
|
||||
|
||||
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: document.id,
|
||||
title: version
|
||||
})
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No document titled "next" found to rename');
|
||||
}
|
||||
2
.github/workflows/sdk.yml
vendored
2
.github/workflows/sdk.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -55,7 +55,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
42
.github/workflows/test.yml
vendored
42
.github/workflows/test.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -333,7 +333,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -379,7 +379,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -418,7 +418,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -473,7 +473,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -500,8 +500,16 @@ jobs:
|
||||
run: docker compose build
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (web)
|
||||
env:
|
||||
CI: true
|
||||
run: npx playwright test
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive test results
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-web-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
success-check-e2e:
|
||||
name: End-to-End Tests Success
|
||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||
@@ -526,7 +534,7 @@ jobs:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -558,12 +566,12 @@ jobs:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||
# with:
|
||||
@@ -602,7 +610,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -631,7 +639,7 @@ jobs:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -653,7 +661,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -715,7 +723,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
3
Makefile
3
Makefile
@@ -17,6 +17,9 @@ dev-docs:
|
||||
e2e:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||
|
||||
e2e-dev:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
e2e-update:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -118,16 +118,16 @@ Read more about translations [here](https://docs.immich.app/developer/translatio
|
||||
|
||||
## Star history
|
||||
|
||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
||||
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||
<a href="https://github.com/immich-app/immich/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||
</a>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/node": "^22.19.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -41,6 +41,7 @@ services:
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- ../plugins:/build/corePlugin
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
18
docs/docs/administration/maintenance-mode.md
Normal file
18
docs/docs/administration/maintenance-mode.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Maintenance Mode
|
||||
|
||||
Maintenance mode is used to perform administrative tasks such as restoring backups to Immich.
|
||||
|
||||
You can enter maintenance mode by either:
|
||||
|
||||
- Selecting "enable maintenance mode" in system settings in administration.
|
||||
- Running the enable maintenance mode [administration command](./server-commands.md).
|
||||
|
||||
## Logging in during maintenance
|
||||
|
||||
Maintenance mode uses a separate login system which is handled automatically behind the scenes in most cases. Enabling maintenance mode in settings will automatically log you into maintenance mode when the server comes back up.
|
||||
|
||||
If you find that you've been logged out, you can:
|
||||
|
||||
- Open the logs for the Immich server and look for _"🚧 Immich is in maintenance mode, you can log in using the following URL:"_
|
||||
- Run the enable maintenance mode [administration command](./server-commands.md) again, this will give you a new URL to login with.
|
||||
- Run the disable maintenance mode [administration command](./server-commands.md) then re-enter through system settings.
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------ | ------------------------------------------------------------- |
|
||||
| `help` | Display help |
|
||||
| `reset-admin-password` | Reset the password for the admin user |
|
||||
| `disable-password-login` | Disable password login |
|
||||
| `enable-password-login` | Enable password login |
|
||||
| `enable-oauth-login` | Enable OAuth login |
|
||||
| `disable-oauth-login` | Disable OAuth login |
|
||||
| `list-users` | List Immich users |
|
||||
| `version` | Print Immich version |
|
||||
| `change-media-location` | Change database file paths to align with a new media location |
|
||||
| Command | Description |
|
||||
| -------------------------- | ------------------------------------------------------------- |
|
||||
| `help` | Display help |
|
||||
| `reset-admin-password` | Reset the password for the admin user |
|
||||
| `disable-password-login` | Disable password login |
|
||||
| `enable-password-login` | Enable password login |
|
||||
| `disable-maintenance-mode` | Disable maintenance mode |
|
||||
| `enable-maintenance-mode` | Enable maintenance mode |
|
||||
| `enable-oauth-login` | Enable OAuth login |
|
||||
| `disable-oauth-login` | Disable OAuth login |
|
||||
| `list-users` | List Immich users |
|
||||
| `version` | Print Immich version |
|
||||
| `change-media-location` | Change database file paths to align with a new media location |
|
||||
|
||||
## How to run a command
|
||||
|
||||
@@ -47,6 +49,23 @@ immich-admin enable-password-login
|
||||
Password login has been enabled.
|
||||
```
|
||||
|
||||
Disable Maintenance Mode
|
||||
|
||||
```
|
||||
immich-admin disable-maintenace-mode
|
||||
Maintenance mode has been disabled.
|
||||
```
|
||||
|
||||
Enable Maintenance Mode
|
||||
|
||||
```
|
||||
immich-admin enable-maintenance-mode
|
||||
Maintenance mode has been enabled.
|
||||
|
||||
Log in using the following URL:
|
||||
https://my.immich.app/maintenance?token=<token>
|
||||
```
|
||||
|
||||
Enable OAuth login
|
||||
|
||||
```
|
||||
|
||||
@@ -12,3 +12,13 @@ pnpm run migrations:generate <migration-name>
|
||||
3. Move the migration file to folder `./server/src/schema/migrations` in your code editor.
|
||||
|
||||
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.
|
||||
|
||||
## Reverting a Migration
|
||||
|
||||
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
|
||||
|
||||
```bash
|
||||
pnpm run migrations:revert
|
||||
```
|
||||
|
||||
This command rolls back the latest migration and brings the database schema back to its previous state.
|
||||
|
||||
@@ -256,7 +256,7 @@ The Dev Container supports multiple ways to run tests:
|
||||
|
||||
```bash
|
||||
# Run tests for specific components
|
||||
make test-server # Server unit tests
|
||||
make test-server # Server unit tests
|
||||
make test-web # Web unit tests
|
||||
make test-e2e # End-to-end tests
|
||||
make test-cli # CLI tests
|
||||
@@ -268,12 +268,13 @@ make test-all # Runs tests for all components
|
||||
make test-medium-dev # End-to-end tests
|
||||
```
|
||||
|
||||
#### Using NPM Directly
|
||||
#### Using PNPM Directly
|
||||
|
||||
```bash
|
||||
# Server tests
|
||||
cd /workspaces/immich/server
|
||||
pnpm test # Run all tests
|
||||
pnpm test # Run all tests
|
||||
pnpm run test:medium # Medium tests (integration tests)
|
||||
pnpm run test:watch # Watch mode
|
||||
pnpm run test:cov # Coverage report
|
||||
|
||||
@@ -293,21 +294,21 @@ pnpm run test:web # Run web UI tests
|
||||
```bash
|
||||
# Linting
|
||||
make lint-server # Lint server code
|
||||
make lint-web # Lint web code
|
||||
make lint-all # Lint all components
|
||||
make lint-web # Lint web code
|
||||
make lint-all # Lint all components
|
||||
|
||||
# Formatting
|
||||
make format-server # Format server code
|
||||
make format-web # Format web code
|
||||
make format-all # Format all code
|
||||
make format-web # Format web code
|
||||
make format-all # Format all code
|
||||
|
||||
# Type checking
|
||||
make check-server # Type check server
|
||||
make check-web # Type check web
|
||||
make check-all # Check all components
|
||||
make check-web # Type check web
|
||||
make check-all # Check all components
|
||||
|
||||
# Complete hygiene check
|
||||
make hygiene-all # Runs lint, format, check, SQL sync, and audit
|
||||
make hygiene-all # Run lint, format, check, SQL sync, and audit
|
||||
```
|
||||
|
||||
### Additional Make Commands
|
||||
@@ -315,21 +316,21 @@ make hygiene-all # Runs lint, format, check, SQL sync, and audit
|
||||
```bash
|
||||
# Build commands
|
||||
make build-server # Build server
|
||||
make build-web # Build web app
|
||||
make build-all # Build everything
|
||||
make build-web # Build web app
|
||||
make build-all # Build everything
|
||||
|
||||
# API generation
|
||||
make open-api # Generate OpenAPI specs
|
||||
make open-api # Generate OpenAPI specs
|
||||
make open-api-typescript # Generate TypeScript SDK
|
||||
make open-api-dart # Generate Dart SDK
|
||||
make open-api-dart # Generate Dart SDK
|
||||
|
||||
# Database
|
||||
make sql # Sync database schema
|
||||
make sql # Sync database schema
|
||||
|
||||
# Dependencies
|
||||
make install-server # Install server dependencies
|
||||
make install-web # Install web dependencies
|
||||
make install-all # Install all dependencies
|
||||
make install-server # Install server dependencies
|
||||
make install-web # Install web dependencies
|
||||
make install-all # Install all dependencies
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
@@ -5,7 +5,7 @@ sidebar_position: 2
|
||||
# Setup
|
||||
|
||||
:::note
|
||||
If there's a feature you're planning to work on, just give us a heads up in [Discord](https://discord.com/channels/979116623879368755/1071165397228855327) so we can:
|
||||
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:
|
||||
|
||||
1. Let you know if it's something we would accept into Immich
|
||||
2. Provide any guidance on how something like that would ideally be implemented
|
||||
|
||||
1
e2e/.gitignore
vendored
1
e2e/.gitignore
vendored
@@ -4,3 +4,4 @@ node_modules/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/dist
|
||||
.env
|
||||
|
||||
105
e2e/docker-compose.dev.yml
Normal file
105
e2e/docker-compose.dev.yml
Normal file
@@ -0,0 +1,105 @@
|
||||
name: immich-e2e
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich-e2e-server
|
||||
command: ['immich-dev']
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile.dev
|
||||
target: dev
|
||||
environment:
|
||||
- DB_HOSTNAME=database
|
||||
- DB_USERNAME=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_DATABASE_NAME=immich
|
||||
- IMMICH_MACHINE_LEARNING_ENABLED=false
|
||||
- IMMICH_TELEMETRY_INCLUDE=all
|
||||
- IMMICH_ENV=testing
|
||||
- IMMICH_PORT=2285
|
||||
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
||||
volumes:
|
||||
- ./test-assets:/test-assets
|
||||
- ..:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}/photos:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
- ../plugins:/build/corePlugin
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
immich-web:
|
||||
container_name: immich-e2e-web
|
||||
image: immich-web-dev:latest
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile.dev
|
||||
target: dev
|
||||
command: ['immich-web']
|
||||
ports:
|
||||
- 2285:3000
|
||||
environment:
|
||||
- IMMICH_SERVER_URL=http://immich-server:2285/
|
||||
volumes:
|
||||
- ..:/usr/src/app
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
|
||||
|
||||
database:
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: immich
|
||||
ports:
|
||||
- 5435:5432
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
model-cache:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
pnpm-store:
|
||||
server-node_modules:
|
||||
web-node_modules:
|
||||
github-node_modules:
|
||||
cli-node_modules:
|
||||
docs-node_modules:
|
||||
e2e-node_modules:
|
||||
sdk-node_modules:
|
||||
app-node_modules:
|
||||
sveltekit:
|
||||
coverage:
|
||||
@@ -20,16 +20,18 @@
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@immich/cli": "file:../cli",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/oidc-provider": "^9.0.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { defineConfig, devices, PlaywrightTestConfig } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import { cpus } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
dotenv.config({ path: resolve(import.meta.dirname, '.env') });
|
||||
|
||||
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
||||
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
||||
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './src/web/specs',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 4 : 0,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:2285',
|
||||
baseURL: playwriteBaseUrl,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
launchOptions: {
|
||||
slowMo: playwriteSlowMo,
|
||||
},
|
||||
},
|
||||
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
|
||||
workers: process.env.CI ? 4 : Math.round(cpus().length * 0.75),
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
workers: 1,
|
||||
},
|
||||
{
|
||||
name: 'parallel tests',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
},
|
||||
|
||||
// {
|
||||
@@ -59,4 +86,8 @@ export default defineConfig({
|
||||
stderr: 'pipe',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (playwrightDisableWebserver) {
|
||||
delete config.webServer;
|
||||
}
|
||||
export default defineConfig(config);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk';
|
||||
import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich/sdk';
|
||||
import { cpSync, rmSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
@@ -17,28 +17,28 @@ describe('/jobs', () => {
|
||||
|
||||
describe('PUT /jobs', () => {
|
||||
afterEach(async () => {
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.FaceDetection, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.SmartSearch, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.SmartSearch, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.DuplicateDetection, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -59,8 +59,8 @@ describe('/jobs', () => {
|
||||
it('should queue metadata extraction for missing assets', async () => {
|
||||
const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Pause,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Pause,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -77,20 +77,20 @@ describe('/jobs', () => {
|
||||
expect(asset.exifInfo?.make).toBeNull();
|
||||
}
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Empty,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Empty,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Start,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Start,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -124,8 +124,8 @@ describe('/jobs', () => {
|
||||
|
||||
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Start,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||
command: QueueCommand.Start,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -144,8 +144,8 @@ describe('/jobs', () => {
|
||||
it('should queue thumbnail extraction for assets missing thumbs', async () => {
|
||||
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Pause,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Pause,
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -153,32 +153,32 @@ describe('/jobs', () => {
|
||||
assetData: { bytes: await readFile(path), filename: basename(path) },
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
||||
expect(assetBefore.thumbhash).toBeNull();
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Empty,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Empty,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Start,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Start,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
||||
expect(assetAfter.thumbhash).not.toBeNull();
|
||||
@@ -193,26 +193,26 @@ describe('/jobs', () => {
|
||||
assetData: { bytes: await readFile(path), filename: basename(path) },
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
||||
|
||||
cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Resume,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
// This runs the missing thumbnail job
|
||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
||||
command: JobCommand.Start,
|
||||
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||
command: QueueCommand.Start,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||
|
||||
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
||||
|
||||
|
||||
172
e2e/src/api/specs/maintenance.e2e-spec.ts
Normal file
172
e2e/src/api/specs/maintenance.e2e-spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/maintenance', () => {
|
||||
let cookie: string | undefined;
|
||||
let admin: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
});
|
||||
|
||||
// => outside of maintenance mode
|
||||
|
||||
describe('GET ~/server/config', async () => {
|
||||
it('should indicate we are out of maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
expect(body.maintenanceMode).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /login', async () => {
|
||||
it('should not work out of maintenance mode', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
|
||||
});
|
||||
});
|
||||
|
||||
// => enter maintenance mode
|
||||
|
||||
describe.sequential('POST /', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||
action: 'end',
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should only work for admins', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
|
||||
.send({ action: 'end' });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
});
|
||||
|
||||
it('should be a no-op if try to exit maintenance mode', async () => {
|
||||
const { status } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ action: 'end' });
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
|
||||
it('should enter maintenance mode', async () => {
|
||||
const { status, headers } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
action: 'start',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
expect(cookie).toEqual(
|
||||
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// => in maintenance mode
|
||||
|
||||
describe.sequential('in maintenance mode', () => {
|
||||
describe('GET ~/server/config', async () => {
|
||||
it('should indicate we are in maintenance mode', async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
expect(body.maintenanceMode).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /login', async () => {
|
||||
it('should fail without cookie or token in body', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
|
||||
});
|
||||
|
||||
it('should succeed with cookie', async () => {
|
||||
const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
username: 'Immich Admin',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed with token', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/admin/maintenance/login')
|
||||
.send({
|
||||
token: cookie!.split('=')[1].trim(),
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
username: 'Immich Admin',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', async () => {
|
||||
it('should be a no-op if try to enter maintenance mode', async () => {
|
||||
const { status } = await request(app)
|
||||
.post('/admin/maintenance')
|
||||
.set('cookie', cookie!)
|
||||
.send({ action: 'start' });
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// => exit maintenance mode
|
||||
|
||||
describe.sequential('POST /', () => {
|
||||
it('should exit maintenance mode', async () => {
|
||||
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||
action: 'end',
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { body } = await request(app).get('/server/config');
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 5e2,
|
||||
timeout: 1e4,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,7 @@ describe('/server', () => {
|
||||
externalDomain: '',
|
||||
publicUsers: true,
|
||||
isOnboarded: false,
|
||||
maintenanceMode: false,
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
JobName,
|
||||
LoginResponseDto,
|
||||
QueueName,
|
||||
createStack,
|
||||
deleteUserAdmin,
|
||||
getMyUser,
|
||||
@@ -328,7 +328,7 @@ describe('/admin/users', () => {
|
||||
{ headers: asBearerAuth(user.accessToken) },
|
||||
);
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask);
|
||||
await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/admin/users/${user.userId}`)
|
||||
|
||||
37
e2e/src/generators/timeline.ts
Normal file
37
e2e/src/generators/timeline.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export { generateTimelineData } from './timeline/model-objects';
|
||||
|
||||
export { createDefaultTimelineConfig, validateTimelineConfig } from './timeline/timeline-config';
|
||||
|
||||
export type {
|
||||
MockAlbum,
|
||||
MonthSpec,
|
||||
SerializedTimelineData,
|
||||
MockTimelineAsset as TimelineAssetConfig,
|
||||
TimelineConfig,
|
||||
MockTimelineData as TimelineData,
|
||||
} from './timeline/timeline-config';
|
||||
|
||||
export {
|
||||
getAlbum,
|
||||
getAsset,
|
||||
getTimeBucket,
|
||||
getTimeBuckets,
|
||||
toAssetResponseDto,
|
||||
toColumnarFormat,
|
||||
} from './timeline/rest-response';
|
||||
|
||||
export type { Changes } from './timeline/rest-response';
|
||||
|
||||
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
|
||||
|
||||
export {
|
||||
SeededRandom,
|
||||
getMockAsset,
|
||||
parseTimeBucketKey,
|
||||
selectRandom,
|
||||
selectRandomDays,
|
||||
selectRandomMultiple,
|
||||
} from './timeline/utils';
|
||||
|
||||
export { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './timeline/distribution-patterns';
|
||||
export type { DayPattern, MonthDistribution } from './timeline/distribution-patterns';
|
||||
183
e2e/src/generators/timeline/distribution-patterns.ts
Normal file
183
e2e/src/generators/timeline/distribution-patterns.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { generateConsecutiveDays, generateDayAssets } from 'src/generators/timeline/model-objects';
|
||||
import { SeededRandom, selectRandomDays } from 'src/generators/timeline/utils';
|
||||
import type { MockTimelineAsset } from './timeline-config';
|
||||
import { GENERATION_CONSTANTS } from './timeline-config';
|
||||
|
||||
type AssetDistributionStrategy = (rng: SeededRandom) => number;
|
||||
|
||||
type DayDistributionStrategy = (
|
||||
year: number,
|
||||
month: number,
|
||||
daysInMonth: number,
|
||||
totalAssets: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
) => MockTimelineAsset[];
|
||||
|
||||
/**
|
||||
* Strategies for determining total asset count per month
|
||||
*/
|
||||
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
|
||||
empty: null, // Special case - handled separately
|
||||
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
|
||||
medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
|
||||
dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
|
||||
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
|
||||
};
|
||||
|
||||
/**
|
||||
* Strategies for distributing assets across days within a month
|
||||
*/
|
||||
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
|
||||
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// All assets on one day in the middle of the month
|
||||
const day = Math.floor(daysInMonth / 2);
|
||||
return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
|
||||
},
|
||||
|
||||
'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// 3-5 consecutive days with evenly distributed assets
|
||||
const numDays = Math.min(5, Math.floor(totalAssets / 15));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
|
||||
},
|
||||
|
||||
'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Multiple consecutive days with 1-3 assets each (side-by-side layout)
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
let assetIndex = 0;
|
||||
|
||||
for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
|
||||
const dayAssets = Math.min(3, rng.nextInt(1, 4));
|
||||
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
|
||||
assetIndex += actualAssets;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Alternate between large (15-25) and small (1-3) days
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
let day = 1;
|
||||
let isLarge = true;
|
||||
let assetIndex = 0;
|
||||
|
||||
while (assetIndex < totalAssets && day <= daysInMonth) {
|
||||
const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
|
||||
|
||||
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
|
||||
assetIndex += actualAssets;
|
||||
|
||||
day += isLarge ? 1 : 1; // Could add gaps here
|
||||
isLarge = !isLarge;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Spread assets across random days with gaps
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
|
||||
const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
|
||||
let assetIndex = 0;
|
||||
|
||||
for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
|
||||
const dayAssets =
|
||||
Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
|
||||
// Create a new RNG for this day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
|
||||
assetIndex += dayAssets;
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in first week
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const firstWeekAssets = Math.floor(totalAssets * 0.7);
|
||||
const remainingAssets = totalAssets - firstWeekAssets;
|
||||
|
||||
// First 7 days
|
||||
assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
|
||||
|
||||
// Remaining scattered
|
||||
if (remainingAssets > 0) {
|
||||
const midDay = Math.floor(daysInMonth / 2);
|
||||
// Create a new RNG for the remaining assets
|
||||
const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
|
||||
'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in last week
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const lastWeekAssets = Math.floor(totalAssets * 0.7);
|
||||
const remainingAssets = totalAssets - lastWeekAssets;
|
||||
|
||||
// Remaining at start
|
||||
if (remainingAssets > 0) {
|
||||
// Create a new RNG for the start assets
|
||||
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
|
||||
}
|
||||
|
||||
// Last 7 days
|
||||
const startDay = daysInMonth - 6;
|
||||
assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
|
||||
return assets;
|
||||
},
|
||||
|
||||
'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// Most assets in middle of month
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const midAssets = Math.floor(totalAssets * 0.7);
|
||||
const sideAssets = Math.floor((totalAssets - midAssets) / 2);
|
||||
|
||||
// Start
|
||||
if (sideAssets > 0) {
|
||||
// Create a new RNG for the start assets
|
||||
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
|
||||
}
|
||||
|
||||
// Middle
|
||||
const midStart = Math.floor(daysInMonth / 2) - 3;
|
||||
assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
|
||||
|
||||
// End
|
||||
const endAssets = totalAssets - midAssets - sideAssets;
|
||||
if (endAssets > 0) {
|
||||
// Create a new RNG for the end assets
|
||||
const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||
assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
|
||||
}
|
||||
return assets;
|
||||
},
|
||||
};
|
||||
export type MonthDistribution =
|
||||
| 'empty' // 0 assets
|
||||
| 'sparse' // 3-8 assets
|
||||
| 'medium' // 15-30 assets
|
||||
| 'dense' // 50-80 assets
|
||||
| 'very-dense'; // 80-150 assets
|
||||
|
||||
export type DayPattern =
|
||||
| 'single-day' // All images in one day
|
||||
| 'consecutive-large' // Multiple days with 15-25 images each
|
||||
| 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
|
||||
| 'alternating' // Alternating large/small days
|
||||
| 'sparse-scattered' // Few images scattered across month
|
||||
| 'start-heavy' // Most images at start of month
|
||||
| 'end-heavy' // Most images at end of month
|
||||
| 'mid-heavy'; // Most images in middle of month
|
||||
111
e2e/src/generators/timeline/images.ts
Normal file
111
e2e/src/generators/timeline/images.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import sharp from 'sharp';
|
||||
import { SeededRandom } from 'src/generators/timeline/utils';
|
||||
|
||||
export const randomThumbnail = async (seed: string, ratio: number) => {
|
||||
const height = 235;
|
||||
const width = Math.round(height * ratio);
|
||||
return randomImageFromString(seed, { width, height });
|
||||
};
|
||||
|
||||
export const randomPreview = async (seed: string, ratio: number) => {
|
||||
const height = 500;
|
||||
const width = Math.round(height * ratio);
|
||||
return randomImageFromString(seed, { width, height });
|
||||
};
|
||||
|
||||
export const randomImageFromString = async (
|
||||
seed: string = '',
|
||||
{ width = 100, height = 100 }: { width: number; height: number },
|
||||
) => {
|
||||
// Convert string to number for seeding
|
||||
let seedNumber = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
|
||||
seedNumber = seedNumber & seedNumber; // Convert to 32bit integer
|
||||
}
|
||||
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
|
||||
};
|
||||
|
||||
export const randomImage = async (rng: SeededRandom, { width, height }: { width: number; height: number }) => {
|
||||
const r1 = rng.nextInt(0, 256);
|
||||
const g1 = rng.nextInt(0, 256);
|
||||
const b1 = rng.nextInt(0, 256);
|
||||
const r2 = rng.nextInt(0, 256);
|
||||
const g2 = rng.nextInt(0, 256);
|
||||
const b2 = rng.nextInt(0, 256);
|
||||
const patternType = rng.nextInt(0, 5);
|
||||
|
||||
let svgPattern = '';
|
||||
|
||||
switch (patternType) {
|
||||
case 0: {
|
||||
// Solid color
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="rgb(${r1},${g1},${b1})"/>
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 1: {
|
||||
// Horizontal stripes
|
||||
const stripeHeight = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from(
|
||||
{ length: height / stripeHeight },
|
||||
(_, i) =>
|
||||
`<rect x="0" y="${i * stripeHeight}" width="${width}" height="${stripeHeight}"
|
||||
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 2: {
|
||||
// Vertical stripes
|
||||
const stripeWidth = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from(
|
||||
{ length: width / stripeWidth },
|
||||
(_, i) =>
|
||||
`<rect x="${i * stripeWidth}" y="0" width="${stripeWidth}" height="${height}"
|
||||
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 3: {
|
||||
// Checkerboard
|
||||
const squareSize = 10;
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
${Array.from({ length: height / squareSize }, (_, row) =>
|
||||
Array.from({ length: width / squareSize }, (_, col) => {
|
||||
const isEven = (row + col) % 2 === 0;
|
||||
return `<rect x="${col * squareSize}" y="${row * squareSize}"
|
||||
width="${squareSize}" height="${squareSize}"
|
||||
fill="rgb(${isEven ? r1 : r2},${isEven ? g1 : g2},${isEven ? b1 : b2})"/>`;
|
||||
}).join(''),
|
||||
).join('')}
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 4: {
|
||||
// Diagonal stripes
|
||||
svgPattern = `<svg width="${width}" height="${height}">
|
||||
<defs>
|
||||
<pattern id="diagonal" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<rect x="0" y="0" width="10" height="20" fill="rgb(${r1},${g1},${b1})"/>
|
||||
<rect x="10" y="0" width="10" height="20" fill="rgb(${r2},${g2},${b2})"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#diagonal)" transform="rotate(45 50 50)"/>
|
||||
</svg>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const svgBuffer = Buffer.from(svgPattern);
|
||||
const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
|
||||
return jpegData;
|
||||
};
|
||||
265
e2e/src/generators/timeline/model-objects.ts
Normal file
265
e2e/src/generators/timeline/model-objects.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Generator functions for timeline model objects
|
||||
*/
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { SeededRandom } from 'src/generators/timeline/utils';
|
||||
import type { DayPattern, MonthDistribution } from './distribution-patterns';
|
||||
import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
|
||||
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
|
||||
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
|
||||
|
||||
/**
|
||||
* Generate a random aspect ratio based on weighted probabilities
|
||||
*/
|
||||
export function generateAspectRatio(rng: SeededRandom): string {
|
||||
const random = rng.next();
|
||||
let cumulative = 0;
|
||||
|
||||
for (const [ratio, weight] of Object.entries(ASPECT_RATIO_WEIGHTS)) {
|
||||
cumulative += weight;
|
||||
if (random < cumulative) {
|
||||
return ratio;
|
||||
}
|
||||
}
|
||||
return '16:9'; // Default fallback
|
||||
}
|
||||
|
||||
export function generateThumbhash(rng: SeededRandom): string {
|
||||
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function generateDuration(rng: SeededRandom): string {
|
||||
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
return faker.string.uuid();
|
||||
}
|
||||
|
||||
export function generateAsset(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset {
|
||||
const from = DateTime.fromObject({ year, month, day }).setZone('UTC');
|
||||
const to = from.endOf('day');
|
||||
const date = faker.date.between({ from: from.toJSDate(), to: to.toJSDate() });
|
||||
const isVideo = rng.next() < GENERATION_CONSTANTS.VIDEO_PROBABILITY;
|
||||
|
||||
const assetId = generateUUID();
|
||||
const hasGPS = rng.next() < GENERATION_CONSTANTS.GPS_PERCENTAGE;
|
||||
|
||||
const ratio = generateAspectRatio(rng);
|
||||
|
||||
const asset: MockTimelineAsset = {
|
||||
id: assetId,
|
||||
ownerId,
|
||||
ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
|
||||
thumbhash: generateThumbhash(rng),
|
||||
localDateTime: date.toISOString(),
|
||||
fileCreatedAt: date.toISOString(),
|
||||
isFavorite: rng.next() < GENERATION_CONSTANTS.FAVORITE_PROBABILITY,
|
||||
isTrashed: false,
|
||||
isVideo,
|
||||
isImage: !isVideo,
|
||||
duration: isVideo ? generateDuration(rng) : null,
|
||||
projectionType: null,
|
||||
livePhotoVideoId: null,
|
||||
city: hasGPS ? faker.location.city() : null,
|
||||
country: hasGPS ? faker.location.country() : null,
|
||||
people: null,
|
||||
latitude: hasGPS ? faker.location.latitude() : null,
|
||||
longitude: hasGPS ? faker.location.longitude() : null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
stack: null,
|
||||
fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
|
||||
checksum: faker.string.alphanumeric({ length: 5 }),
|
||||
};
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate assets for a specific day
|
||||
*/
|
||||
export function generateDayAssets(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
assetCount: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset[] {
|
||||
return Array.from({ length: assetCount }, () => generateAsset(year, month, day, ownerId, rng));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute assets evenly across consecutive days
|
||||
*
|
||||
* @returns Array of generated timeline assets
|
||||
*/
|
||||
export function generateConsecutiveDays(
|
||||
year: number,
|
||||
month: number,
|
||||
startDay: number,
|
||||
numDays: number,
|
||||
totalAssets: number,
|
||||
ownerId: string,
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset[] {
|
||||
const assets: MockTimelineAsset[] = [];
|
||||
const assetsPerDay = Math.floor(totalAssets / numDays);
|
||||
|
||||
for (let i = 0; i < numDays; i++) {
|
||||
const dayAssets =
|
||||
i === numDays - 1
|
||||
? totalAssets - assetsPerDay * (numDays - 1) // Remainder on last day
|
||||
: assetsPerDay;
|
||||
// Create a new RNG with a different seed for each day
|
||||
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000) + i * 100);
|
||||
assets.push(...generateDayAssets(year, month, startDay + i, dayAssets, ownerId, dayRng));
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate assets for a month with specified distribution pattern
|
||||
*/
|
||||
export function generateMonthAssets(
|
||||
year: number,
|
||||
month: number,
|
||||
ownerId: string,
|
||||
distribution: MonthDistribution = 'medium',
|
||||
pattern: DayPattern = 'consecutive-large',
|
||||
rng: SeededRandom,
|
||||
): MockTimelineAsset[] {
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
if (distribution === 'empty') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const distributionStrategy = ASSET_DISTRIBUTION[distribution];
|
||||
if (!distributionStrategy) {
|
||||
console.warn(`Unknown distribution: ${distribution}, defaulting to medium`);
|
||||
return [];
|
||||
}
|
||||
const totalAssets = distributionStrategy(rng);
|
||||
|
||||
const dayStrategy = DAY_DISTRIBUTION[pattern];
|
||||
if (!dayStrategy) {
|
||||
console.warn(`Unknown pattern: ${pattern}, defaulting to consecutive-large`);
|
||||
// Fallback to consecutive-large pattern
|
||||
const numDays = Math.min(5, Math.floor(totalAssets / 15));
|
||||
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||
const assets = generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
|
||||
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||
return assets;
|
||||
}
|
||||
|
||||
const assets = dayStrategy(year, month, daysInMonth, totalAssets, ownerId, rng);
|
||||
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main generator function for timeline data
|
||||
*/
|
||||
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
|
||||
validateTimelineConfig(config);
|
||||
|
||||
const buckets = new Map<string, MockTimelineAsset[]>();
|
||||
const monthStats: Record<string, { count: number; distribution: MonthDistribution; pattern: DayPattern }> = {};
|
||||
|
||||
const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
|
||||
faker.seed(globalRng.nextInt(0, 1_000_000));
|
||||
for (const monthConfig of config.months) {
|
||||
const { year, month, distribution, pattern } = monthConfig;
|
||||
|
||||
const monthSeed = globalRng.nextInt(0, 1_000_000);
|
||||
const monthRng = new SeededRandom(monthSeed);
|
||||
|
||||
const monthAssets = generateMonthAssets(
|
||||
year,
|
||||
month,
|
||||
config.ownerId || generateUUID(),
|
||||
distribution,
|
||||
pattern,
|
||||
monthRng,
|
||||
);
|
||||
|
||||
if (monthAssets.length > 0) {
|
||||
const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
|
||||
monthStats[monthKey] = {
|
||||
count: monthAssets.length,
|
||||
distribution,
|
||||
pattern,
|
||||
};
|
||||
|
||||
// Create bucket key (YYYY-MM-01)
|
||||
const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
|
||||
buckets.set(bucketKey, monthAssets);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a mock album from random assets
|
||||
const allAssets = [...buckets.values()].flat();
|
||||
|
||||
// Select 10-30 random assets for the album (or all assets if less than 10)
|
||||
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
|
||||
const selectedAssetConfigs: MockTimelineAsset[] = [];
|
||||
const usedIndices = new Set<number>();
|
||||
|
||||
while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
|
||||
const randomIndex = globalRng.nextInt(0, allAssets.length);
|
||||
if (!usedIndices.has(randomIndex)) {
|
||||
usedIndices.add(randomIndex);
|
||||
selectedAssetConfigs.push(allAssets[randomIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort selected assets by date (newest first)
|
||||
selectedAssetConfigs.sort(
|
||||
(a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
|
||||
);
|
||||
|
||||
const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const album = {
|
||||
id: generateUUID(),
|
||||
albumName: 'Test Album',
|
||||
description: 'A mock album for testing',
|
||||
assetIds: selectedAssets,
|
||||
thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Write to file if configured
|
||||
if (config.writeToFile) {
|
||||
const outputPath = config.outputPath || '/tmp/timeline-data.json';
|
||||
|
||||
// Convert Map to object for serialization
|
||||
const serializedData: SerializedTimelineData = {
|
||||
buckets: Object.fromEntries(buckets),
|
||||
album,
|
||||
};
|
||||
|
||||
try {
|
||||
writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
|
||||
console.log(`Timeline data written to ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to write timeline data to ${outputPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { buckets, album };
|
||||
}
|
||||
436
e2e/src/generators/timeline/rest-response.ts
Normal file
436
e2e/src/generators/timeline/rest-response.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* REST API output functions for converting timeline data to API response formats
|
||||
*/
|
||||
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
UserAvatarColor,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type ExifResponseDto,
|
||||
type TimeBucketAssetResponseDto,
|
||||
type TimeBucketsResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { signupDto } from 'src/fixtures';
|
||||
import { parseTimeBucketKey } from 'src/generators/timeline/utils';
|
||||
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
|
||||
|
||||
/**
|
||||
* Convert timeline/asset models to columnar format (parallel arrays)
|
||||
*/
|
||||
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
|
||||
const result: TimeBucketAssetResponseDto = {
|
||||
id: [],
|
||||
ownerId: [],
|
||||
ratio: [],
|
||||
thumbhash: [],
|
||||
fileCreatedAt: [],
|
||||
localOffsetHours: [],
|
||||
isFavorite: [],
|
||||
isTrashed: [],
|
||||
isImage: [],
|
||||
duration: [],
|
||||
projectionType: [],
|
||||
livePhotoVideoId: [],
|
||||
city: [],
|
||||
country: [],
|
||||
visibility: [],
|
||||
};
|
||||
|
||||
for (const asset of assets) {
|
||||
result.id.push(asset.id);
|
||||
result.ownerId.push(asset.ownerId);
|
||||
result.ratio.push(asset.ratio);
|
||||
result.thumbhash.push(asset.thumbhash);
|
||||
result.fileCreatedAt.push(asset.fileCreatedAt);
|
||||
result.localOffsetHours.push(0); // Assuming UTC for mocks
|
||||
result.isFavorite.push(asset.isFavorite);
|
||||
result.isTrashed.push(asset.isTrashed);
|
||||
result.isImage.push(asset.isImage);
|
||||
result.duration.push(asset.duration);
|
||||
result.projectionType.push(asset.projectionType);
|
||||
result.livePhotoVideoId.push(asset.livePhotoVideoId);
|
||||
result.city.push(asset.city);
|
||||
result.country.push(asset.country);
|
||||
result.visibility.push(asset.visibility);
|
||||
}
|
||||
|
||||
if (assets.some((a) => a.latitude !== null || a.longitude !== null)) {
|
||||
result.latitude = assets.map((a) => a.latitude);
|
||||
result.longitude = assets.map((a) => a.longitude);
|
||||
}
|
||||
|
||||
result.stack = assets.map(() => null);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single bucket from timeline data (mimics getTimeBucket API)
|
||||
* Automatically handles both ISO timestamp and simple month formats
|
||||
* Returns data in columnar format matching the actual API
|
||||
* When albumId is provided, only returns assets from that album
|
||||
*/
|
||||
export function getTimeBucket(
|
||||
timelineData: MockTimelineData,
|
||||
timeBucket: string,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): TimeBucketAssetResponseDto {
|
||||
const bucketKey = parseTimeBucketKey(timeBucket);
|
||||
let assets = timelineData.buckets.get(bucketKey);
|
||||
|
||||
if (!assets) {
|
||||
return toColumnarFormat([]);
|
||||
}
|
||||
|
||||
// Create sets for quick lookups
|
||||
const deletedAssetIds = new Set(changes.assetDeletions);
|
||||
const archivedAssetIds = new Set(changes.assetArchivals);
|
||||
const favoritedAssetIds = new Set(changes.assetFavorites);
|
||||
|
||||
// Filter assets based on trashed/archived status
|
||||
assets = assets.filter((asset) =>
|
||||
shouldIncludeAsset(asset, isTrashed, isArchived, isFavorite, deletedAssetIds, archivedAssetIds, favoritedAssetIds),
|
||||
);
|
||||
|
||||
// Filter to only include assets from the specified album
|
||||
if (albumId) {
|
||||
const album = timelineData.album;
|
||||
if (!album || album.id !== albumId) {
|
||||
return toColumnarFormat([]);
|
||||
}
|
||||
|
||||
// Create a Set for faster lookup
|
||||
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
|
||||
assets = assets.filter((asset) => albumAssetIds.has(asset.id));
|
||||
}
|
||||
|
||||
// Override properties for assets in changes arrays
|
||||
const assetsWithOverrides = assets.map((asset) => {
|
||||
if (deletedAssetIds.has(asset.id) || archivedAssetIds.has(asset.id) || favoritedAssetIds.has(asset.id)) {
|
||||
return {
|
||||
...asset,
|
||||
isFavorite: favoritedAssetIds.has(asset.id) ? true : asset.isFavorite,
|
||||
isTrashed: deletedAssetIds.has(asset.id) ? true : asset.isTrashed,
|
||||
visibility: archivedAssetIds.has(asset.id) ? AssetVisibility.Archive : asset.visibility,
|
||||
};
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
|
||||
return toColumnarFormat(assetsWithOverrides);
|
||||
}
|
||||
|
||||
export type Changes = {
|
||||
// ids of assets that are newly added to the album
|
||||
albumAdditions: string[];
|
||||
// ids of assets that are newly deleted
|
||||
assetDeletions: string[];
|
||||
// ids of assets that are newly archived
|
||||
assetArchivals: string[];
|
||||
// ids of assets that are newly favorited
|
||||
assetFavorites: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to determine if an asset should be included based on filter criteria
|
||||
* @param asset - The asset to check
|
||||
* @param isTrashed - Filter for trashed status (undefined means no filter)
|
||||
* @param isArchived - Filter for archived status (undefined means no filter)
|
||||
* @param isFavorite - Filter for favorite status (undefined means no filter)
|
||||
* @param deletedAssetIds - Set of IDs for assets that have been deleted
|
||||
* @param archivedAssetIds - Set of IDs for assets that have been archived
|
||||
* @param favoritedAssetIds - Set of IDs for assets that have been favorited
|
||||
* @returns true if the asset matches all filter criteria
|
||||
*/
|
||||
function shouldIncludeAsset(
|
||||
asset: MockTimelineAsset,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
deletedAssetIds: Set<string>,
|
||||
archivedAssetIds: Set<string>,
|
||||
favoritedAssetIds: Set<string>,
|
||||
): boolean {
|
||||
// Determine actual status (property or in changes)
|
||||
const actuallyTrashed = asset.isTrashed || deletedAssetIds.has(asset.id);
|
||||
const actuallyArchived = asset.visibility === 'archive' || archivedAssetIds.has(asset.id);
|
||||
const actuallyFavorited = asset.isFavorite || favoritedAssetIds.has(asset.id);
|
||||
|
||||
// Apply filters
|
||||
if (isTrashed !== undefined && actuallyTrashed !== isTrashed) {
|
||||
return false;
|
||||
}
|
||||
if (isArchived !== undefined && actuallyArchived !== isArchived) {
|
||||
return false;
|
||||
}
|
||||
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Get summary for all buckets (mimics getTimeBuckets API)
|
||||
* When albumId is provided, only includes buckets that contain assets from that album
|
||||
*/
|
||||
export function getTimeBuckets(
|
||||
timelineData: MockTimelineData,
|
||||
isTrashed: boolean | undefined,
|
||||
isArchived: boolean | undefined,
|
||||
isFavorite: boolean | undefined,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): TimeBucketsResponseDto[] {
|
||||
const summary: TimeBucketsResponseDto[] = [];
|
||||
|
||||
// Create sets for quick lookups
|
||||
const deletedAssetIds = new Set(changes.assetDeletions);
|
||||
const archivedAssetIds = new Set(changes.assetArchivals);
|
||||
const favoritedAssetIds = new Set(changes.assetFavorites);
|
||||
|
||||
// If no albumId is specified, return summary for all assets
|
||||
if (albumId) {
|
||||
// Filter to only include buckets with assets from the specified album
|
||||
const album = timelineData.album;
|
||||
if (!album || album.id !== albumId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create a Set for faster lookup
|
||||
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
|
||||
for (const removed of changes.assetDeletions) {
|
||||
albumAssetIds.delete(removed);
|
||||
}
|
||||
for (const [bucketKey, assets] of timelineData.buckets) {
|
||||
// Count how many assets in this bucket are in the album and match trashed/archived filters
|
||||
const albumAssetsInBucket = assets.filter((asset) => {
|
||||
// Must be in the album
|
||||
if (!albumAssetIds.has(asset.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return shouldIncludeAsset(
|
||||
asset,
|
||||
isTrashed,
|
||||
isArchived,
|
||||
isFavorite,
|
||||
deletedAssetIds,
|
||||
archivedAssetIds,
|
||||
favoritedAssetIds,
|
||||
);
|
||||
});
|
||||
|
||||
if (albumAssetsInBucket.length > 0) {
|
||||
summary.push({
|
||||
timeBucket: bucketKey,
|
||||
count: albumAssetsInBucket.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const [bucketKey, assets] of timelineData.buckets) {
|
||||
// Filter assets based on trashed/archived status
|
||||
const filteredAssets = assets.filter((asset) =>
|
||||
shouldIncludeAsset(
|
||||
asset,
|
||||
isTrashed,
|
||||
isArchived,
|
||||
isFavorite,
|
||||
deletedAssetIds,
|
||||
archivedAssetIds,
|
||||
favoritedAssetIds,
|
||||
),
|
||||
);
|
||||
|
||||
if (filteredAssets.length > 0) {
|
||||
summary.push({
|
||||
timeBucket: bucketKey,
|
||||
count: filteredAssets.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort summary by date (newest first) using luxon
|
||||
summary.sort((a, b) => {
|
||||
const dateA = DateTime.fromISO(a.timeBucket);
|
||||
const dateB = DateTime.fromISO(b.timeBucket);
|
||||
return dateB.diff(dateA).milliseconds;
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
const createDefaultOwner = (ownerId: string) => {
|
||||
const defaultOwner: UserResponseDto = {
|
||||
id: ownerId,
|
||||
email: signupDto.admin.email,
|
||||
name: signupDto.admin.name,
|
||||
profileImagePath: '',
|
||||
profileChangedAt: new Date().toISOString(),
|
||||
avatarColor: UserAvatarColor.Blue,
|
||||
};
|
||||
return defaultOwner;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Default owner if not provided
|
||||
const defaultOwner = createDefaultOwner(asset.ownerId);
|
||||
|
||||
const exifInfo: ExifResponseDto = {
|
||||
make: null,
|
||||
model: null,
|
||||
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
|
||||
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
|
||||
fileSizeInByte: asset.fileSizeInByte,
|
||||
orientation: '1',
|
||||
dateTimeOriginal: asset.fileCreatedAt,
|
||||
modifyDate: asset.fileCreatedAt,
|
||||
timeZone: asset.latitude === null ? null : 'UTC',
|
||||
lensModel: null,
|
||||
fNumber: null,
|
||||
focalLength: null,
|
||||
iso: null,
|
||||
exposureTime: null,
|
||||
latitude: asset.latitude,
|
||||
longitude: asset.longitude,
|
||||
city: asset.city,
|
||||
country: asset.country,
|
||||
state: null,
|
||||
description: null,
|
||||
};
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
deviceAssetId: `device-${asset.id}`,
|
||||
ownerId: asset.ownerId,
|
||||
owner: owner || defaultOwner,
|
||||
libraryId: `library-${asset.ownerId}`,
|
||||
deviceId: `device-${asset.ownerId}`,
|
||||
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
|
||||
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
|
||||
originalMimeType: asset.isVideo ? 'video/mp4' : 'image/jpeg',
|
||||
thumbhash: asset.thumbhash,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileCreatedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
updatedAt: now,
|
||||
createdAt: asset.fileCreatedAt,
|
||||
isFavorite: asset.isFavorite,
|
||||
isArchived: false,
|
||||
isTrashed: asset.isTrashed,
|
||||
visibility: asset.visibility,
|
||||
duration: asset.duration || '0:00:00.00000',
|
||||
exifInfo,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
tags: [],
|
||||
people: [],
|
||||
unassignedFaces: [],
|
||||
stack: asset.stack,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
duplicateId: null,
|
||||
resized: true,
|
||||
checksum: asset.checksum,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single asset by ID from timeline data
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function getAsset(
|
||||
timelineData: MockTimelineData,
|
||||
assetId: string,
|
||||
owner?: UserResponseDto,
|
||||
): AssetResponseDto | undefined {
|
||||
// Search through all buckets for the asset
|
||||
const buckets = [...timelineData.buckets.values()];
|
||||
for (const assets of buckets) {
|
||||
const asset = assets.find((a) => a.id === assetId);
|
||||
if (asset) {
|
||||
return toAssetResponseDto(asset, owner);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mock album from timeline data
|
||||
* This matches the response from GET /api/albums/:id
|
||||
*/
|
||||
export function getAlbum(
|
||||
timelineData: MockTimelineData,
|
||||
ownerId: string,
|
||||
albumId: string | undefined,
|
||||
changes: Changes,
|
||||
): AlbumResponseDto | undefined {
|
||||
if (!timelineData.album) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If albumId is provided and doesn't match, return undefined
|
||||
if (albumId && albumId !== timelineData.album.id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const album = timelineData.album;
|
||||
const albumOwner = createDefaultOwner(ownerId);
|
||||
|
||||
// Get the actual asset objects from the timeline data
|
||||
const albumAssets: AssetResponseDto[] = [];
|
||||
const allAssets = [...timelineData.buckets.values()].flat();
|
||||
|
||||
for (const assetId of album.assetIds) {
|
||||
const assetConfig = allAssets.find((a) => a.id === assetId);
|
||||
if (assetConfig) {
|
||||
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
|
||||
}
|
||||
}
|
||||
for (const assetId of changes.albumAdditions ?? []) {
|
||||
const assetConfig = allAssets.find((a) => a.id === assetId);
|
||||
if (assetConfig) {
|
||||
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
|
||||
}
|
||||
}
|
||||
|
||||
albumAssets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||
|
||||
// For a basic mock album, we don't include any albumUsers (shared users)
|
||||
// The owner is represented by the owner field, not in albumUsers
|
||||
const response: AlbumResponseDto = {
|
||||
id: album.id,
|
||||
albumName: album.albumName,
|
||||
description: album.description,
|
||||
albumThumbnailAssetId: album.thumbnailAssetId,
|
||||
createdAt: album.createdAt,
|
||||
updatedAt: album.updatedAt,
|
||||
ownerId: albumOwner.id,
|
||||
owner: albumOwner,
|
||||
albumUsers: [], // Empty array for non-shared album
|
||||
shared: false,
|
||||
hasSharedLink: false,
|
||||
isActivityEnabled: true,
|
||||
assetCount: albumAssets.length,
|
||||
assets: albumAssets,
|
||||
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
|
||||
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
||||
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
200
e2e/src/generators/timeline/timeline-config.ts
Normal file
200
e2e/src/generators/timeline/timeline-config.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { AssetVisibility } from '@immich/sdk';
|
||||
import { DayPattern, MonthDistribution } from 'src/generators/timeline/distribution-patterns';
|
||||
|
||||
// Constants for generation parameters
|
||||
export const GENERATION_CONSTANTS = {
|
||||
VIDEO_PROBABILITY: 0.15, // 15% of assets are videos
|
||||
GPS_PERCENTAGE: 0.7, // 70% of assets have GPS data
|
||||
FAVORITE_PROBABILITY: 0.1, // 10% of assets are favorited
|
||||
MIN_VIDEO_DURATION_SECONDS: 5,
|
||||
MAX_VIDEO_DURATION_SECONDS: 300,
|
||||
DEFAULT_SEED: 12_345,
|
||||
DEFAULT_OWNER_ID: 'user-1',
|
||||
MAX_SELECT_ATTEMPTS: 10,
|
||||
SPARSE_DAY_COVERAGE: 0.4, // 40% of days have photos in sparse pattern
|
||||
} as const;
|
||||
|
||||
// Aspect ratio distribution weights (must sum to 1)
|
||||
export const ASPECT_RATIO_WEIGHTS = {
|
||||
'4:3': 0.35, // 35% 4:3 landscape
|
||||
'3:2': 0.25, // 25% 3:2 landscape
|
||||
'16:9': 0.2, // 20% 16:9 landscape
|
||||
'2:3': 0.1, // 10% 2:3 portrait
|
||||
'1:1': 0.09, // 9% 1:1 square
|
||||
'3:1': 0.01, // 1% 3:1 panorama
|
||||
} as const;
|
||||
|
||||
export type AspectRatio = {
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Mock configuration for asset generation - will be transformed to API response formats
|
||||
export type MockTimelineAsset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
ratio: number;
|
||||
thumbhash: string | null;
|
||||
localDateTime: string;
|
||||
fileCreatedAt: string;
|
||||
isFavorite: boolean;
|
||||
isTrashed: boolean;
|
||||
isVideo: boolean;
|
||||
isImage: boolean;
|
||||
duration: string | null;
|
||||
projectionType: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
people: string[] | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
visibility: AssetVisibility;
|
||||
stack: null;
|
||||
checksum: string;
|
||||
fileSizeInByte: number;
|
||||
};
|
||||
|
||||
export type MonthSpec = {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
distribution: MonthDistribution;
|
||||
pattern: DayPattern;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for timeline data generation
|
||||
*/
|
||||
export type TimelineConfig = {
|
||||
ownerId?: string;
|
||||
months: MonthSpec[];
|
||||
seed?: number;
|
||||
writeToFile?: boolean;
|
||||
outputPath?: string;
|
||||
};
|
||||
|
||||
export type MockAlbum = {
|
||||
id: string;
|
||||
albumName: string;
|
||||
description: string;
|
||||
assetIds: string[]; // IDs of assets in the album
|
||||
thumbnailAssetId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MockTimelineData = {
|
||||
buckets: Map<string, MockTimelineAsset[]>;
|
||||
album: MockAlbum; // Mock album created from random assets
|
||||
};
|
||||
|
||||
export type SerializedTimelineData = {
|
||||
buckets: Record<string, MockTimelineAsset[]>;
|
||||
album: MockAlbum;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a TimelineConfig object to ensure all values are within expected ranges
|
||||
*/
|
||||
export function validateTimelineConfig(config: TimelineConfig): void {
|
||||
if (!config.months || config.months.length === 0) {
|
||||
throw new Error('TimelineConfig must contain at least one month');
|
||||
}
|
||||
|
||||
const seenMonths = new Set<string>();
|
||||
|
||||
for (const month of config.months) {
|
||||
if (month.month < 1 || month.month > 12) {
|
||||
throw new Error(`Invalid month: ${month.month}. Must be between 1 and 12`);
|
||||
}
|
||||
|
||||
if (month.year < 1900 || month.year > 2100) {
|
||||
throw new Error(`Invalid year: ${month.year}. Must be between 1900 and 2100`);
|
||||
}
|
||||
|
||||
const monthKey = `${month.year}-${month.month}`;
|
||||
if (seenMonths.has(monthKey)) {
|
||||
throw new Error(`Duplicate month found: ${monthKey}`);
|
||||
}
|
||||
seenMonths.add(monthKey);
|
||||
|
||||
// Validate distribution if provided
|
||||
if (month.distribution && !['empty', 'sparse', 'medium', 'dense', 'very-dense'].includes(month.distribution)) {
|
||||
throw new Error(
|
||||
`Invalid distribution: ${month.distribution}. Must be one of: empty, sparse, medium, dense, very-dense`,
|
||||
);
|
||||
}
|
||||
|
||||
const validPatterns = [
|
||||
'single-day',
|
||||
'consecutive-large',
|
||||
'consecutive-small',
|
||||
'alternating',
|
||||
'sparse-scattered',
|
||||
'start-heavy',
|
||||
'end-heavy',
|
||||
'mid-heavy',
|
||||
];
|
||||
if (month.pattern && !validPatterns.includes(month.pattern)) {
|
||||
throw new Error(`Invalid pattern: ${month.pattern}. Must be one of: ${validPatterns.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate seed if provided
|
||||
if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
|
||||
throw new Error('Seed must be a non-negative integer');
|
||||
}
|
||||
|
||||
// Validate ownerId if provided
|
||||
if (config.ownerId !== undefined && config.ownerId.trim() === '') {
|
||||
throw new Error('Owner ID cannot be an empty string');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default timeline configuration
|
||||
*/
|
||||
export function createDefaultTimelineConfig(): TimelineConfig {
|
||||
const months: MonthSpec[] = [
|
||||
// 2024 - Mix of patterns
|
||||
{ year: 2024, month: 12, distribution: 'very-dense', pattern: 'alternating' },
|
||||
{ year: 2024, month: 11, distribution: 'dense', pattern: 'consecutive-large' },
|
||||
{ year: 2024, month: 10, distribution: 'medium', pattern: 'mid-heavy' },
|
||||
{ year: 2024, month: 9, distribution: 'sparse', pattern: 'consecutive-small' },
|
||||
{ year: 2024, month: 8, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2024, month: 7, distribution: 'dense', pattern: 'start-heavy' },
|
||||
{ year: 2024, month: 6, distribution: 'medium', pattern: 'sparse-scattered' },
|
||||
{ year: 2024, month: 5, distribution: 'sparse', pattern: 'single-day' },
|
||||
{ year: 2024, month: 4, distribution: 'very-dense', pattern: 'consecutive-large' },
|
||||
{ year: 2024, month: 3, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2024, month: 2, distribution: 'medium', pattern: 'end-heavy' },
|
||||
{ year: 2024, month: 1, distribution: 'dense', pattern: 'alternating' },
|
||||
|
||||
// 2023 - Testing year boundaries and more patterns
|
||||
{ year: 2023, month: 12, distribution: 'very-dense', pattern: 'end-heavy' },
|
||||
{ year: 2023, month: 11, distribution: 'sparse', pattern: 'consecutive-small' },
|
||||
{ year: 2023, month: 10, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2023, month: 9, distribution: 'medium', pattern: 'alternating' },
|
||||
{ year: 2023, month: 8, distribution: 'dense', pattern: 'mid-heavy' },
|
||||
{ year: 2023, month: 7, distribution: 'sparse', pattern: 'sparse-scattered' },
|
||||
{ year: 2023, month: 6, distribution: 'medium', pattern: 'consecutive-large' },
|
||||
{ year: 2023, month: 5, distribution: 'empty', pattern: 'single-day' },
|
||||
{ year: 2023, month: 4, distribution: 'sparse', pattern: 'single-day' },
|
||||
{ year: 2023, month: 3, distribution: 'dense', pattern: 'start-heavy' },
|
||||
{ year: 2023, month: 2, distribution: 'medium', pattern: 'alternating' },
|
||||
{ year: 2023, month: 1, distribution: 'very-dense', pattern: 'consecutive-large' },
|
||||
];
|
||||
|
||||
for (let year = 2022; year >= 2000; year--) {
|
||||
for (let month = 12; month >= 1; month--) {
|
||||
months.push({ year, month, distribution: 'medium', pattern: 'sparse-scattered' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
months,
|
||||
seed: 42,
|
||||
};
|
||||
}
|
||||
186
e2e/src/generators/timeline/utils.ts
Normal file
186
e2e/src/generators/timeline/utils.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/generators/timeline/timeline-config';
|
||||
|
||||
/**
|
||||
* Linear Congruential Generator for deterministic pseudo-random numbers
|
||||
*/
|
||||
export class SeededRandom {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next random number in range [0, 1)
|
||||
*/
|
||||
next(): number {
|
||||
// LCG parameters from Numerical Recipes
|
||||
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
|
||||
return this.seed / 2_147_483_647;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random integer in range [min, max)
|
||||
*/
|
||||
nextInt(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random boolean with given probability
|
||||
*/
|
||||
nextBoolean(probability = 0.5): boolean {
|
||||
return this.next() < probability;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select random days using seed variation to avoid collisions.
|
||||
*
|
||||
* @param daysInMonth - Total number of days in the month
|
||||
* @param numDays - Number of days to select
|
||||
* @param rng - Random number generator instance
|
||||
* @returns Array of selected day numbers, sorted in descending order
|
||||
*/
|
||||
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
|
||||
const selectedDays = new Set<number>();
|
||||
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
|
||||
let attempts = 0;
|
||||
|
||||
while (selectedDays.size < numDays && attempts < maxAttempts) {
|
||||
const day = rng.nextInt(1, daysInMonth + 1);
|
||||
selectedDays.add(day);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Fallback: if we couldn't select enough random days, fill with sequential days
|
||||
if (selectedDays.size < numDays) {
|
||||
for (let day = 1; day <= daysInMonth && selectedDays.size < numDays; day++) {
|
||||
selectedDays.add(day);
|
||||
}
|
||||
}
|
||||
|
||||
return [...selectedDays].sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select item from array using seeded random
|
||||
*/
|
||||
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
}
|
||||
const index = rng.nextInt(0, arr.length);
|
||||
return arr[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select multiple random items from array using seeded random without duplicates
|
||||
*/
|
||||
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
}
|
||||
if (count < 0) {
|
||||
throw new Error('Count must be non-negative');
|
||||
}
|
||||
if (count > arr.length) {
|
||||
throw new Error('Count cannot exceed array length');
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
const selectedIndices = new Set<number>();
|
||||
|
||||
while (result.length < count) {
|
||||
const index = rng.nextInt(0, arr.length);
|
||||
if (!selectedIndices.has(index)) {
|
||||
selectedIndices.add(index);
|
||||
result.push(arr[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timeBucket parameter to extract year-month key
|
||||
* Handles both formats:
|
||||
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
|
||||
* - Simple format: "2024-12-01" -> "2024-12-01"
|
||||
*/
|
||||
export function parseTimeBucketKey(timeBucket: string): string {
|
||||
if (!timeBucket) {
|
||||
throw new Error('timeBucket parameter cannot be empty');
|
||||
}
|
||||
|
||||
const dt = DateTime.fromISO(timeBucket, { zone: 'utc' });
|
||||
|
||||
if (!dt.isValid) {
|
||||
// Fallback to regex if not a valid ISO string
|
||||
const match = timeBucket.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return match ? match[1] : timeBucket;
|
||||
}
|
||||
|
||||
// Format as YYYY-MM-01 (first day of month)
|
||||
return `${dt.year}-${String(dt.month).padStart(2, '0')}-01`;
|
||||
}
|
||||
|
||||
export function getMockAsset(
|
||||
asset: MockTimelineAsset,
|
||||
sortedDescendingAssets: MockTimelineAsset[],
|
||||
direction: 'next' | 'previous',
|
||||
unit: 'day' | 'month' | 'year' = 'day',
|
||||
): MockTimelineAsset | null {
|
||||
const currentDateTime = DateTime.fromISO(asset.localDateTime, { zone: 'utc' });
|
||||
|
||||
const currentIndex = sortedDescendingAssets.findIndex((a) => a.id === asset.id);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const step = direction === 'next' ? 1 : -1;
|
||||
const startIndex = currentIndex + step;
|
||||
|
||||
if (direction === 'next' && currentIndex >= sortedDescendingAssets.length - 1) {
|
||||
return null;
|
||||
}
|
||||
if (direction === 'previous' && currentIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
|
||||
if (unit === 'day') {
|
||||
return !date1.startOf('day').equals(date2.startOf('day'));
|
||||
} else if (unit === 'month') {
|
||||
return date1.year !== date2.year || date1.month !== date2.month;
|
||||
} else {
|
||||
return date1.year !== date2.year;
|
||||
}
|
||||
};
|
||||
|
||||
if (direction === 'next') {
|
||||
// Search forward in array (backwards in time)
|
||||
for (let i = startIndex; i < sortedDescendingAssets.length; i++) {
|
||||
const nextAsset = sortedDescendingAssets[i];
|
||||
const nextDate = DateTime.fromISO(nextAsset.localDateTime, { zone: 'utc' });
|
||||
|
||||
if (isInDifferentPeriod(nextDate, currentDateTime)) {
|
||||
return nextAsset;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Search backward in array (forwards in time)
|
||||
for (let i = startIndex; i >= 0; i--) {
|
||||
const prevAsset = sortedDescendingAssets[i];
|
||||
const prevDate = DateTime.fromISO(prevAsset.localDateTime, { zone: 'utc' });
|
||||
|
||||
if (isInDifferentPeriod(prevDate, currentDateTime)) {
|
||||
return prevAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
285
e2e/src/mock-network/base-network.ts
Normal file
285
e2e/src/mock-network/base-network.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { playwrightHost } from 'playwright.config';
|
||||
|
||||
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_is_authenticated',
|
||||
value: 'true',
|
||||
domain: playwrightHost,
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
await context.route('**/api/users/me', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
id: adminUserId,
|
||||
email: 'admin@immich.cloud',
|
||||
name: 'Immich Admin',
|
||||
profileImagePath: '',
|
||||
avatarColor: 'orange',
|
||||
profileChangedAt: '2025-01-22T21:31:23.996Z',
|
||||
storageLabel: 'admin',
|
||||
shouldChangePassword: true,
|
||||
isAdmin: true,
|
||||
createdAt: '2025-01-22T21:31:23.996Z',
|
||||
deletedAt: null,
|
||||
updatedAt: '2025-11-14T00:00:00.369Z',
|
||||
oauthId: '',
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 20_849_000_159,
|
||||
status: 'active',
|
||||
license: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/users/me/preferences', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
albums: {
|
||||
defaultAssetOrder: 'desc',
|
||||
},
|
||||
folders: {
|
||||
enabled: false,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
memories: {
|
||||
enabled: true,
|
||||
duration: 5,
|
||||
},
|
||||
people: {
|
||||
enabled: true,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
sharedLinks: {
|
||||
enabled: true,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
ratings: {
|
||||
enabled: false,
|
||||
},
|
||||
tags: {
|
||||
enabled: false,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
emailNotifications: {
|
||||
enabled: true,
|
||||
albumInvite: true,
|
||||
albumUpdate: true,
|
||||
},
|
||||
download: {
|
||||
archiveSize: 4_294_967_296,
|
||||
includeEmbeddedVideos: false,
|
||||
},
|
||||
purchase: {
|
||||
showSupportBadge: true,
|
||||
hideBuyButtonUntil: '2100-02-12T00:00:00.000Z',
|
||||
},
|
||||
cast: {
|
||||
gCastEnabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/server/about', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
version: 'v2.2.3',
|
||||
versionUrl: 'https://github.com/immich-app/immich/releases/tag/v2.2.3',
|
||||
licensed: false,
|
||||
build: '1234567890',
|
||||
buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
|
||||
buildImage: 'e2e',
|
||||
buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
|
||||
repository: 'immich-app/immich',
|
||||
repositoryUrl: 'https://github.com/immich-app/immich',
|
||||
sourceRef: 'e2e',
|
||||
sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
|
||||
sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
|
||||
nodejs: 'v22.18.0',
|
||||
exiftool: '13.41',
|
||||
ffmpeg: '7.1.1-6',
|
||||
libvips: '8.17.2',
|
||||
imagemagick: '7.1.2-2',
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/features', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
smartSearch: false,
|
||||
facialRecognition: false,
|
||||
duplicateDetection: false,
|
||||
map: true,
|
||||
reverseGeocoding: true,
|
||||
importFaces: false,
|
||||
sidecar: true,
|
||||
search: true,
|
||||
trash: true,
|
||||
oauth: false,
|
||||
oauthAutoLaunch: false,
|
||||
ocr: false,
|
||||
passwordLogin: true,
|
||||
configFile: false,
|
||||
email: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/config', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
loginPageMessage: '',
|
||||
trashDays: 30,
|
||||
userDeleteDelay: 7,
|
||||
oauthButtonText: 'Login with OAuth',
|
||||
isInitialized: true,
|
||||
isOnboarded: true,
|
||||
externalDomain: '',
|
||||
publicUsers: true,
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
maintenanceMode: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/media-types', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
video: [
|
||||
'.3gp',
|
||||
'.3gpp',
|
||||
'.avi',
|
||||
'.flv',
|
||||
'.insv',
|
||||
'.m2t',
|
||||
'.m2ts',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp4',
|
||||
'.mpe',
|
||||
'.mpeg',
|
||||
'.mpg',
|
||||
'.mts',
|
||||
'.vob',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
],
|
||||
image: [
|
||||
'.3fr',
|
||||
'.ari',
|
||||
'.arw',
|
||||
'.cap',
|
||||
'.cin',
|
||||
'.cr2',
|
||||
'.cr3',
|
||||
'.crw',
|
||||
'.dcr',
|
||||
'.dng',
|
||||
'.erf',
|
||||
'.fff',
|
||||
'.iiq',
|
||||
'.k25',
|
||||
'.kdc',
|
||||
'.mrw',
|
||||
'.nef',
|
||||
'.nrw',
|
||||
'.orf',
|
||||
'.ori',
|
||||
'.pef',
|
||||
'.psd',
|
||||
'.raf',
|
||||
'.raw',
|
||||
'.rw2',
|
||||
'.rwl',
|
||||
'.sr2',
|
||||
'.srf',
|
||||
'.srw',
|
||||
'.x3f',
|
||||
'.avif',
|
||||
'.gif',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.hif',
|
||||
'.insp',
|
||||
'.jp2',
|
||||
'.jpe',
|
||||
'.jxl',
|
||||
'.svg',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
],
|
||||
sidecar: ['.xmp'],
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/notifications*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
});
|
||||
await context.route('**/api/albums*', async (route, request) => {
|
||||
if (request.url().endsWith('albums?shared=true') || request.url().endsWith('albums')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
await context.route('**/api/memories*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [],
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/storage', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
diskSize: '100.0 GiB',
|
||||
diskUse: '74.4 GiB',
|
||||
diskAvailable: '25.6 GiB',
|
||||
diskSizeRaw: 107_374_182_400,
|
||||
diskUseRaw: 79_891_660_800,
|
||||
diskAvailableRaw: 27_482_521_600,
|
||||
diskUsagePercentage: 74.4,
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/version-history', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [
|
||||
{
|
||||
id: 'd1fbeadc-cb4f-4db3-8d19-8c6a921d5d8e',
|
||||
createdAt: '2025-11-15T20:14:01.935Z',
|
||||
version: '2.2.3',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
};
|
||||
139
e2e/src/mock-network/timeline-network.ts
Normal file
139
e2e/src/mock-network/timeline-network.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { BrowserContext, Page, Request, Route } from '@playwright/test';
|
||||
import { basename } from 'node:path';
|
||||
import {
|
||||
Changes,
|
||||
getAlbum,
|
||||
getAsset,
|
||||
getTimeBucket,
|
||||
getTimeBuckets,
|
||||
randomPreview,
|
||||
randomThumbnail,
|
||||
TimelineData,
|
||||
} from 'src/generators/timeline';
|
||||
import { sleep } from 'src/web/specs/timeline/utils';
|
||||
|
||||
export class TimelineTestContext {
|
||||
slowBucket = false;
|
||||
adminId = '';
|
||||
}
|
||||
|
||||
export const setupTimelineMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
timelineRestData: TimelineData,
|
||||
changes: Changes,
|
||||
testContext: TimelineTestContext,
|
||||
) => {
|
||||
await context.route('**/api/timeline**', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
if (pathname === '/api/timeline/buckets') {
|
||||
const albumId = url.searchParams.get('albumId') || undefined;
|
||||
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
|
||||
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
|
||||
const isArchived = url.searchParams.get('visibility')
|
||||
? url.searchParams.get('visibility') === 'archive'
|
||||
: undefined;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
|
||||
});
|
||||
} else if (pathname === '/api/timeline/bucket') {
|
||||
const timeBucket = url.searchParams.get('timeBucket');
|
||||
if (!timeBucket) {
|
||||
return route.continue();
|
||||
}
|
||||
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
|
||||
const isArchived = url.searchParams.get('visibility')
|
||||
? url.searchParams.get('visibility') === 'archive'
|
||||
: undefined;
|
||||
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
|
||||
const albumId = url.searchParams.get('albumId') || undefined;
|
||||
const assets = getTimeBucket(timelineRestData, timeBucket, isTrashed, isArchived, isFavorite, albumId, changes);
|
||||
if (testContext.slowBucket) {
|
||||
await sleep(5000);
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: assets,
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/**', async (route, request) => {
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match) {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
const assetId = basename(pathname);
|
||||
const asset = getAsset(timelineRestData, assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: asset,
|
||||
});
|
||||
}
|
||||
if (match.groups?.size === 'preview') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
||||
body: await randomPreview(
|
||||
match.groups?.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (match.groups?.size === 'thumbnail') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail(
|
||||
match.groups?.assetId,
|
||||
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await context.route('**/api/albums/**', async (route, request) => {
|
||||
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match) {
|
||||
return route.continue();
|
||||
}
|
||||
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: album,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const pageRoutePromise = async (
|
||||
page: Page,
|
||||
route: string,
|
||||
callback: (route: Route, request: Request) => Promise<void>,
|
||||
) => {
|
||||
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
|
||||
const deleteRequest = new Promise((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
await page.route(route, async (route, request) => {
|
||||
await callback(route, request);
|
||||
const requestJson = request.postDataJSON();
|
||||
resolveRequest?.(requestJson);
|
||||
});
|
||||
return deleteRequest;
|
||||
};
|
||||
@@ -7,6 +7,12 @@ export const errorDto = {
|
||||
message: 'Authentication required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
unauthorizedWithMessage: (message: string) => ({
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
forbidden: {
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AllJobStatusResponseDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetResponseDto,
|
||||
@@ -7,11 +6,13 @@ import {
|
||||
CheckExistingAssetsDto,
|
||||
CreateAlbumDto,
|
||||
CreateLibraryDto,
|
||||
JobCommandDto,
|
||||
JobName,
|
||||
MaintenanceAction,
|
||||
MetadataSearchDto,
|
||||
Permission,
|
||||
PersonCreateDto,
|
||||
QueueCommandDto,
|
||||
QueueName,
|
||||
QueuesResponseDto,
|
||||
SharedLinkCreateDto,
|
||||
UpdateLibraryDto,
|
||||
UserAdminCreateDto,
|
||||
@@ -27,15 +28,16 @@ import {
|
||||
createStack,
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
getAllJobsStatus,
|
||||
getAssetInfo,
|
||||
getConfig,
|
||||
getConfigDefaults,
|
||||
getQueuesLegacy,
|
||||
login,
|
||||
runQueueCommandLegacy,
|
||||
scanLibrary,
|
||||
searchAssets,
|
||||
sendJobCommand,
|
||||
setBaseUrl,
|
||||
setMaintenanceMode,
|
||||
signUpAdmin,
|
||||
tagAssets,
|
||||
updateAdminOnboarding,
|
||||
@@ -52,7 +54,7 @@ import { exec, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path, { dirname } from 'node:path';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import pg from 'pg';
|
||||
@@ -60,6 +62,8 @@ import { io, type Socket } from 'socket.io-client';
|
||||
import { loginDto, signupDto } from 'src/fixtures';
|
||||
import { makeRandomImage } from 'src/generators';
|
||||
import request from 'supertest';
|
||||
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
|
||||
|
||||
export type { Emitter } from '@socket.io/component-emitter';
|
||||
|
||||
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
|
||||
@@ -68,12 +72,12 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
|
||||
type AdminSetupOptions = { onboarding?: boolean };
|
||||
type FileData = { bytes?: Buffer; filename: string };
|
||||
|
||||
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
|
||||
export const baseUrl = 'http://127.0.0.1:2285';
|
||||
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
|
||||
export const baseUrl = playwriteBaseUrl;
|
||||
export const shareUrl = `${baseUrl}/share`;
|
||||
export const app = `${baseUrl}/api`;
|
||||
// TODO move test assets into e2e/assets
|
||||
export const testAssetDir = path.resolve('./test-assets');
|
||||
export const testAssetDir = resolve(import.meta.dirname, '../test-assets');
|
||||
export const testAssetDirInternal = '/test-assets';
|
||||
export const tempDir = tmpdir();
|
||||
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
||||
@@ -477,10 +481,10 @@ export const utils = {
|
||||
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) =>
|
||||
sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
|
||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_access_token',
|
||||
@@ -514,6 +518,42 @@ export const utils = {
|
||||
},
|
||||
]),
|
||||
|
||||
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_maintenance_token',
|
||||
value: token,
|
||||
domain,
|
||||
path: '/',
|
||||
expires: 2_058_028_213,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]),
|
||||
|
||||
enterMaintenance: async (accessToken: string) => {
|
||||
let setCookie: string[] | undefined;
|
||||
|
||||
await setMaintenanceMode(
|
||||
{
|
||||
setMaintenanceModeDto: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: asBearerAuth(accessToken),
|
||||
fetch: (...args: Parameters<typeof fetch>) =>
|
||||
fetch(...args).then((response) => {
|
||||
setCookie = response.headers.getSetCookie();
|
||||
return response;
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return setCookie;
|
||||
},
|
||||
|
||||
resetTempFolder: () => {
|
||||
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
|
||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||
@@ -524,13 +564,13 @@ export const utils = {
|
||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||
},
|
||||
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => {
|
||||
const queues = await getAllJobsStatus({ headers: asBearerAuth(accessToken) });
|
||||
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => {
|
||||
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
|
||||
const jobCounts = queues[queue].jobCounts;
|
||||
return !jobCounts.active && !jobCounts.waiting;
|
||||
},
|
||||
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof AllJobStatusResponseDto, ms?: number) => {
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);
|
||||
|
||||
51
e2e/src/web/specs/maintenance.e2e-spec.ts
Normal file
51
e2e/src/web/specs/maintenance.e2e-spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Maintenance', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test('enter and exit maintenance mode', async ({ context, page }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
|
||||
await page.goto('/admin/system-settings?isOpen=maintenance');
|
||||
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
|
||||
|
||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||
const setCookie = await utils.enterMaintenance(admin.accessToken);
|
||||
const cookie = setCookie
|
||||
?.map((cookie) => cookie.split(';')[0].split('='))
|
||||
?.find(([name]) => name === 'immich_maintenance_token');
|
||||
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
await expect(async () => {
|
||||
await page.goto('/');
|
||||
await page.waitForURL('**/maintenance?**', {
|
||||
timeout: 1000,
|
||||
});
|
||||
}).toPass({ timeout: 10_000 });
|
||||
|
||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
|
||||
|
||||
await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
|
||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||
await page.waitForURL('**/auth/login');
|
||||
});
|
||||
});
|
||||
775
e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts
Normal file
775
e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts
Normal file
@@ -0,0 +1,775 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
getAsset,
|
||||
getMockAsset,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
selectRandomMultiple,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||
import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||
import { utils } from 'src/utils';
|
||||
import {
|
||||
assetViewerUtils,
|
||||
cancelAllPollers,
|
||||
padYearMonth,
|
||||
pageUtils,
|
||||
poll,
|
||||
thumbnailUtils,
|
||||
timelineUtils,
|
||||
} from 'src/web/specs/timeline/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('Timeline', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const yearMonths: string[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
utils.initSdk();
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cancelAllPollers();
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/photos', () => {
|
||||
test('Open /photos', async ({ page }) => {
|
||||
await page.goto(`/photos`);
|
||||
await page.waitForSelector('#asset-grid');
|
||||
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||
});
|
||||
test('Deep link to last photo', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||
await thumbnailUtils.expectInViewport(page, lastAsset.id);
|
||||
});
|
||||
const rng = new SeededRandom(529);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
test('Deep link to random asset ' + i, async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
});
|
||||
}
|
||||
test('Open /photos, open asset-viewer, browser back', async ({ page }) => {
|
||||
const rng = new SeededRandom(22);
|
||||
const asset = selectRandom(assets, rng);
|
||||
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||
const scrollTopBefore = await timelineUtils.getScrollTop(page);
|
||||
await thumbnailUtils.clickAssetId(page, asset.id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.goBack();
|
||||
await timelineUtils.locator(page).waitFor();
|
||||
const scrollTopAfter = await timelineUtils.getScrollTop(page);
|
||||
expect(scrollTopAfter).toBe(scrollTopBefore);
|
||||
});
|
||||
test('Open /photos, open asset-viewer, next photo, browser back, back', async ({ page }) => {
|
||||
const rng = new SeededRandom(49);
|
||||
const asset = selectRandom(assets, rng);
|
||||
const assetIndex = assets.indexOf(asset);
|
||||
const nextAsset = assets[assetIndex + 1];
|
||||
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||
const scrollTopBefore = await timelineUtils.getScrollTop(page);
|
||||
await thumbnailUtils.clickAssetId(page, asset.id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, nextAsset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${nextAsset.id}`);
|
||||
await page.goBack();
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.goBack();
|
||||
await page.waitForURL('**/photos?at=*');
|
||||
const scrollTopAfter = await timelineUtils.getScrollTop(page);
|
||||
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(5);
|
||||
});
|
||||
test('Open /photos, open asset-viewer, next photo 15x, backwardsArrow', async ({ page }) => {
|
||||
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
|
||||
await thumbnailUtils.clickAssetId(page, assets[0].id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[i]);
|
||||
}
|
||||
await page.getByLabel('Go back').click();
|
||||
await page.waitForURL('**/photos?at=*');
|
||||
await thumbnailUtils.expectInViewport(page, assets[15].id);
|
||||
await thumbnailUtils.expectBottomIsTimelineBottom(page, assets[15]!.id);
|
||||
});
|
||||
test('Open /photos, open asset-viewer, previous photo 15x, backwardsArrow', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
await thumbnailUtils.clickAssetId(page, lastAsset.id);
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets.at(-1 - i)!);
|
||||
}
|
||||
await page.getByLabel('Go back').click();
|
||||
await page.waitForURL('**/photos?at=*');
|
||||
await thumbnailUtils.expectInViewport(page, assets.at(-1 - 15)!.id);
|
||||
await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(-1 - 15)!.id);
|
||||
});
|
||||
});
|
||||
test.describe('keyboard', () => {
|
||||
/**
|
||||
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
|
||||
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
|
||||
* as necessary downwards), then the asset should always be at the lowest row of the grid.
|
||||
*/
|
||||
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
|
||||
const rightKey = 'ArrowRight';
|
||||
const leftKey = 'ArrowLeft';
|
||||
for (let i = 1; i < 15; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 15; i <= 20; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
expect(await thumbnailUtils.expectBottomIsTimelineBottom(page, assets.at(i)!.id));
|
||||
}
|
||||
// now test previous asset
|
||||
for (let i = 19; i >= 15; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 14; i > 0; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
expect(await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(i)!.id));
|
||||
}
|
||||
});
|
||||
test('Next/previous asset - Tab/Shift+Tab', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
|
||||
const rightKey = 'Tab';
|
||||
const leftKey = 'Shift+Tab';
|
||||
for (let i = 1; i < 15; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 15; i <= 20; i++) {
|
||||
await page.keyboard.press(rightKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
// now test previous asset
|
||||
for (let i = 19; i >= 15; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
for (let i = 14; i > 0; i--) {
|
||||
await page.keyboard.press(leftKey);
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||
}
|
||||
});
|
||||
test('Next/previous day - d, Shift+D', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
let asset = assets[0];
|
||||
await timelineUtils.locator(page).hover();
|
||||
await page.keyboard.press('d');
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('d');
|
||||
const next = getMockAsset(asset, assets, 'next', 'day')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||
asset = next;
|
||||
}
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Shift+D');
|
||||
const previous = getMockAsset(asset, assets, 'previous', 'day')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||
asset = previous;
|
||||
}
|
||||
});
|
||||
test('Next/previous month - m, Shift+M', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
let asset = assets[0];
|
||||
await timelineUtils.locator(page).hover();
|
||||
await page.keyboard.press('m');
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('m');
|
||||
const next = getMockAsset(asset, assets, 'next', 'month')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||
asset = next;
|
||||
}
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Shift+M');
|
||||
const previous = getMockAsset(asset, assets, 'previous', 'month')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||
asset = previous;
|
||||
}
|
||||
});
|
||||
test('Next/previous year - y, Shift+Y', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
let asset = assets[0];
|
||||
await timelineUtils.locator(page).hover();
|
||||
await page.keyboard.press('y');
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('y');
|
||||
const next = getMockAsset(asset, assets, 'next', 'year')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||
asset = next;
|
||||
}
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Shift+Y');
|
||||
const previous = getMockAsset(asset, assets, 'previous', 'year')!;
|
||||
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||
asset = previous;
|
||||
}
|
||||
});
|
||||
test('Navigate to time - g', async ({ page }) => {
|
||||
const rng = new SeededRandom(4782);
|
||||
await pageUtils.openPhotosPage(page);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
test.describe('selection', () => {
|
||||
test('Select day, unselect day', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
|
||||
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
|
||||
});
|
||||
test('Select asset, click asset to select', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
|
||||
await thumbnailUtils.selectButton(page, assets[1].id).click();
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
|
||||
// no need to hover, once selection is active
|
||||
await thumbnailUtils.clickAssetId(page, assets[2].id);
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(2);
|
||||
});
|
||||
test('Select asset, click unselect asset', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
|
||||
await thumbnailUtils.selectButton(page, assets[1].id).click();
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
|
||||
await thumbnailUtils.clickAssetId(page, assets[1].id);
|
||||
// the hover uses a checked button too, so just move mouse away
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
|
||||
});
|
||||
test('Select asset, shift-hover candidates, shift-click end', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const asset = assets[0];
|
||||
await thumbnailUtils.withAssetId(page, asset.id).hover();
|
||||
await thumbnailUtils.selectButton(page, asset.id).click();
|
||||
await page.keyboard.down('Shift');
|
||||
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
|
||||
await expect(
|
||||
thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
|
||||
).toHaveCount(3);
|
||||
await thumbnailUtils.selectButton(page, assets[2].id).click();
|
||||
await page.keyboard.up('Shift');
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(3);
|
||||
});
|
||||
test('Add multiple to selection - Select day, shift-click end', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).hover();
|
||||
await thumbnailUtils.selectButton(page, assets[0].id).click();
|
||||
await thumbnailUtils.clickAssetId(page, assets[2].id);
|
||||
await page.keyboard.down('Shift');
|
||||
await thumbnailUtils.clickAssetId(page, assets[4].id);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
|
||||
});
|
||||
});
|
||||
test.describe('scroll', () => {
|
||||
test('Open /photos, random click scrubber 20x', async ({ page }) => {
|
||||
test.slow();
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const rng = new SeededRandom(6637);
|
||||
const selectedMonths = selectRandomMultiple(yearMonths, 20, rng);
|
||||
for (const month of selectedMonths) {
|
||||
await page.locator(`[data-segment-year-month="${month}"]`).click({ force: true });
|
||||
const visibleMockAssetsYearMonths = await poll(page, async () => {
|
||||
const assetIds = await thumbnailUtils.getAllInViewport(
|
||||
page,
|
||||
(assetId: string) => getYearMonth(assets, assetId) === month,
|
||||
);
|
||||
const visibleMockAssetsYearMonths: string[] = [];
|
||||
for (const assetId of assetIds!) {
|
||||
const yearMonth = getYearMonth(assets, assetId);
|
||||
visibleMockAssetsYearMonths.push(yearMonth);
|
||||
if (yearMonth === month) {
|
||||
return [yearMonth];
|
||||
}
|
||||
}
|
||||
});
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(visibleMockAssetsYearMonths).toContain(month);
|
||||
}
|
||||
});
|
||||
test('Deep link to last photo, scroll up', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
|
||||
await timelineUtils.locator(page).hover();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await page.mouse.wheel(0, -100);
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
|
||||
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
|
||||
});
|
||||
test('Deep link to first bucket, scroll down', async ({ page }) => {
|
||||
const lastAsset = assets.at(0)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
await timelineUtils.locator(page).hover();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await page.mouse.wheel(0, 100);
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await thumbnailUtils.expectInViewport(page, 'b7983a13-4b4e-4950-a731-f2962d9a1555');
|
||||
});
|
||||
test('Deep link to last photo, drag scrubber to scroll up', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||
const lastMonth = yearMonths.at(-1);
|
||||
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
|
||||
const lastScrubSegment = page.locator(`[data-segment-year-month="${lastMonth}"]`);
|
||||
const sourcebox = (await lastScrubSegment.boundingBox())!;
|
||||
const targetBox = (await firstScrubSegment.boundingBox())!;
|
||||
await firstScrubSegment.hover();
|
||||
const currentY = sourcebox.y;
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, targetBox.y, { steps: 100 });
|
||||
await page.mouse.up();
|
||||
await thumbnailUtils.expectInViewport(page, assets[0].id);
|
||||
});
|
||||
test('Deep link to first bucket, drag scrubber to scroll down', async ({ page }) => {
|
||||
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
|
||||
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
|
||||
const sourcebox = (await firstScrubSegment.boundingBox())!;
|
||||
await firstScrubSegment.hover();
|
||||
const currentY = sourcebox.y;
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
|
||||
await page.mouse.down();
|
||||
const height = page.viewportSize()?.height;
|
||||
expect(height).toBeDefined();
|
||||
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, height! - 10, {
|
||||
steps: 100,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await thumbnailUtils.expectInViewport(page, assets.at(-1)!.id);
|
||||
});
|
||||
test('Buckets cancel on scroll', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
testContext.slowBucket = true;
|
||||
const failedUris: string[] = [];
|
||||
page.on('requestfailed', (request) => {
|
||||
failedUris.push(request.url());
|
||||
});
|
||||
const offscreenSegment = page.locator(`[data-segment-year-month="${yearMonths[12]}"]`);
|
||||
await offscreenSegment.click({ force: true });
|
||||
const lastSegment = page.locator(`[data-segment-year-month="${yearMonths.at(-1)!}"]`);
|
||||
await lastSegment.click({ force: true });
|
||||
const uris = await poll(page, async () => (failedUris.length > 0 ? failedUris : null));
|
||||
expect(uris).toEqual(expect.arrayContaining([expect.stringContaining(padYearMonth(yearMonths[12]!))]));
|
||||
});
|
||||
});
|
||||
test.describe('/albums', () => {
|
||||
test('Open album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, album.assetIds[0]);
|
||||
});
|
||||
test('Deep link to last photo', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
const lastAsset = album.assetIds.at(-1);
|
||||
await pageUtils.deepLinkAlbumPage(page, album.id, lastAsset!);
|
||||
await thumbnailUtils.expectInViewport(page, album.assetIds.at(-1)!);
|
||||
await thumbnailUtils.expectBottomIsTimelineBottom(page, album.assetIds.at(-1)!);
|
||||
});
|
||||
test('Add photos to album pre-selects existing', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await page.getByLabel('Add photos').click();
|
||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
|
||||
});
|
||||
test('Add photos to album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await page.locator('nav button[aria-label="Add photos"]').click();
|
||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
|
||||
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
|
||||
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||
});
|
||||
changes.albumAdditions.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByText('Done').click();
|
||||
await expect(put).resolves.toEqual({
|
||||
ids: [
|
||||
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
|
||||
'040fd762-dbbc-486d-a51a-2d84115e6229',
|
||||
'86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
|
||||
],
|
||||
});
|
||||
const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
|
||||
await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
|
||||
await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
|
||||
await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
|
||||
});
|
||||
});
|
||||
test.describe('/trash', () => {
|
||||
test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const assetToTrash = assets[0];
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions.push(...requestJson.ids);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||
});
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Delete').click();
|
||||
await expect(deleteRequest).resolves.toEqual({
|
||||
force: false,
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await page.getByText('Trash', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: { count: requestJson.ids.length },
|
||||
});
|
||||
});
|
||||
await page.getByText('Restore', { exact: true }).click();
|
||||
await expect(restoreRequest).resolves.toEqual({
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
});
|
||||
test('open album, trash photo, open /trash, restore', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
const assetToTrash = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions.push(...requestJson.ids);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||
});
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Delete').click();
|
||||
await expect(deleteRequest).resolves.toEqual({
|
||||
force: false,
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.getByText('Trash', { exact: true }).click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: { count: requestJson.ids.length },
|
||||
});
|
||||
});
|
||||
await page.getByText('Restore', { exact: true }).click();
|
||||
await expect(restoreRequest).resolves.toEqual({
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
});
|
||||
});
|
||||
test.describe('/archive', () => {
|
||||
test('open /photos, archive photo, open /archive, unarchive', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const assetToArchive = assets[0];
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
test('open album, archive photo, open album, unarchive', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
const assetToArchive = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
await page.getByLabel('Menu').click();
|
||||
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'archive') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals.push(...requestJson.ids);
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByRole('menuitem').getByText('Archive').click();
|
||||
await expect(archive).resolves.toEqual({
|
||||
visibility: 'archive',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon');
|
||||
// await thumbnail.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.visibility !== 'timeline') {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Unarchive').click();
|
||||
await expect(unarchiveRequest).resolves.toEqual({
|
||||
visibility: 'timeline',
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
|
||||
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
});
|
||||
});
|
||||
test.describe('/favorite', () => {
|
||||
test('open /photos, favorite photo, open /favorites, remove favorite, open /photos', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
const assetToFavorite = assets[0];
|
||||
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
// ensure thumbnail still exists and has favorite icon
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await page.getByRole('link').getByText('Favorites').click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
|
||||
await page.getByText('Photos', { exact: true }).click();
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
});
|
||||
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
|
||||
const album = timelineRestData.album;
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
const assetToFavorite = getAsset(timelineRestData, album.assetIds[0])!;
|
||||
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
const isFavorite = requestJson.isFavorite;
|
||||
if (isFavorite) {
|
||||
changes.assetFavorites.push(...requestJson.ids);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Favorite').click();
|
||||
await expect(favorite).resolves.toEqual({
|
||||
isFavorite: true,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
// ensure thumbnail still exists and has favorite icon
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Favorites').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
|
||||
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
|
||||
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||
const requestJson = request.postDataJSON();
|
||||
if (requestJson.isFavorite === undefined) {
|
||||
return await route.continue();
|
||||
}
|
||||
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
});
|
||||
});
|
||||
await page.getByLabel('Remove from favorites').click();
|
||||
await expect(unFavoriteRequest).resolves.toEqual({
|
||||
isFavorite: false,
|
||||
ids: [assetToFavorite.id],
|
||||
});
|
||||
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(0);
|
||||
await pageUtils.openAlbumPage(page, album.id);
|
||||
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getYearMonth = (assets: TimelineAssetConfig[], assetId: string) => {
|
||||
const mockAsset = assets.find((mockAsset) => mockAsset.id === assetId)!;
|
||||
const dateTime = DateTime.fromISO(mockAsset.fileCreatedAt!);
|
||||
return dateTime.year + '-' + dateTime.month;
|
||||
};
|
||||
234
e2e/src/web/specs/timeline/utils.ts
Normal file
234
e2e/src/web/specs/timeline/utils.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { BrowserContext, expect, Page } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import { TimelineAssetConfig } from 'src/generators/timeline';
|
||||
|
||||
export const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
export const padYearMonth = (yearMonth: string) => {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
return `${year}-${month.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export async function throttlePage(context: BrowserContext, page: Page) {
|
||||
const session = await context.newCDPSession(page);
|
||||
await session.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
downloadThroughput: (1.5 * 1024 * 1024) / 8,
|
||||
uploadThroughput: (750 * 1024) / 8,
|
||||
latency: 40,
|
||||
connectionType: 'cellular3g',
|
||||
});
|
||||
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
|
||||
}
|
||||
|
||||
let activePollsAbortController = new AbortController();
|
||||
|
||||
export const cancelAllPollers = () => {
|
||||
activePollsAbortController.abort();
|
||||
activePollsAbortController = new AbortController();
|
||||
};
|
||||
|
||||
export const poll = async <T>(
|
||||
page: Page,
|
||||
query: () => Promise<T>,
|
||||
callback?: (result: Awaited<T> | undefined) => boolean,
|
||||
) => {
|
||||
let result;
|
||||
const timeout = Date.now() + 10_000;
|
||||
const signal = activePollsAbortController.signal;
|
||||
|
||||
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
|
||||
while (!terminate(result) && Date.now() < timeout) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
result = await query();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await page.waitForTimeout(50);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!result) {
|
||||
// rerun to trigger error if any
|
||||
result = await query();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const thumbnailUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container]');
|
||||
},
|
||||
withAssetId(page: Page, assetId: string) {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||
},
|
||||
selectButton(page: Page, assetId: string) {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
|
||||
},
|
||||
selectedAsset(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
|
||||
},
|
||||
async clickAssetId(page: Page, assetId: string) {
|
||||
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||
},
|
||||
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
|
||||
const assetIds: string[] = [];
|
||||
for (const thumb of await this.locator(page).all()) {
|
||||
const box = await thumb.boundingBox();
|
||||
if (box) {
|
||||
const assetId = await thumb.evaluate((e) => e.dataset.asset);
|
||||
if (collector?.(assetId!)) {
|
||||
return [assetId!];
|
||||
}
|
||||
assetIds.push(assetId!);
|
||||
}
|
||||
}
|
||||
return assetIds;
|
||||
},
|
||||
async getFirstInViewport(page: Page) {
|
||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, () => true));
|
||||
},
|
||||
async getAllInViewport(page: Page, collector: (assetId: string) => boolean) {
|
||||
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
|
||||
},
|
||||
async expectThumbnailIsFavorite(page: Page, assetId: string) {
|
||||
await expect(
|
||||
thumbnailUtils
|
||||
.withAssetId(page, assetId)
|
||||
.locator(
|
||||
'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]',
|
||||
),
|
||||
).toHaveCount(1);
|
||||
},
|
||||
async expectThumbnailIsArchive(page: Page, assetId: string) {
|
||||
await expect(
|
||||
thumbnailUtils
|
||||
.withAssetId(page, assetId)
|
||||
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'),
|
||||
).toHaveCount(1);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||
const first = await thumbnailUtils.getFirstInViewport(page);
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(first).toBeTruthy();
|
||||
},
|
||||
async expectInViewport(page: Page, assetId: string) {
|
||||
const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox());
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(box).toBeTruthy();
|
||||
},
|
||||
async expectBottomIsTimelineBottom(page: Page, assetId: string) {
|
||||
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
|
||||
const gridBox = await timelineUtils.locator(page).boundingBox();
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0);
|
||||
},
|
||||
async expectTopIsTimelineTop(page: Page, assetId: string) {
|
||||
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
|
||||
const gridBox = await timelineUtils.locator(page).boundingBox();
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
expect(box!.y).toBeCloseTo(gridBox!.y, 0);
|
||||
},
|
||||
};
|
||||
export const timelineUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#asset-grid');
|
||||
},
|
||||
async waitForTimelineLoad(page: Page) {
|
||||
await expect(timelineUtils.locator(page)).toBeInViewport();
|
||||
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
||||
},
|
||||
async getScrollTop(page: Page) {
|
||||
const queryTop = () =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return document.querySelector('#asset-grid').scrollTop;
|
||||
});
|
||||
await expect.poll(queryTop).toBeGreaterThan(0);
|
||||
return await queryTop();
|
||||
},
|
||||
};
|
||||
|
||||
export const assetViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||
await page
|
||||
.locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)
|
||||
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
|
||||
.waitFor();
|
||||
},
|
||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||
const activeElement = () =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return document.activeElement?.dataset?.asset;
|
||||
});
|
||||
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);
|
||||
},
|
||||
};
|
||||
export const pageUtils = {
|
||||
async deepLinkPhotosPage(page: Page, assetId: string) {
|
||||
await page.goto(`/photos?at=${assetId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openPhotosPage(page: Page) {
|
||||
await page.goto(`/photos`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async openAlbumPage(page: Page, albumId: string) {
|
||||
await page.goto(`/albums/${albumId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
|
||||
await page.goto(`/albums/${albumId}?at=${assetId}`);
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
},
|
||||
async goToAsset(page: Page, assetDate: string) {
|
||||
await timelineUtils.locator(page).hover();
|
||||
const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa');
|
||||
await page.keyboard.press('g');
|
||||
await page.locator('#datetime').pressSequentially(stringDate);
|
||||
await page.getByText('Confirm').click();
|
||||
},
|
||||
async selectDay(page: Page, day: string) {
|
||||
await page.getByTitle(day).hover();
|
||||
await page.locator('[data-group] .w-8').click();
|
||||
},
|
||||
async pauseTestDebug() {
|
||||
console.log('NOTE: pausing test indefinately for debug');
|
||||
await new Promise(() => void 0);
|
||||
},
|
||||
};
|
||||
@@ -58,8 +58,12 @@ test.describe('User Administration', () => {
|
||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
expect(updated.isAdmin).toBe(true);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
return userAdmin.isAdmin;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('revoke admin access', async ({ context, page }) => {
|
||||
@@ -83,7 +87,11 @@ test.describe('User Administration', () => {
|
||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
expect(updated.isAdmin).toBe(false);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||
return userAdmin.isAdmin;
|
||||
})
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
34
i18n/en.json
34
i18n/en.json
@@ -17,7 +17,6 @@
|
||||
"add_birthday": "Add a birthday",
|
||||
"add_endpoint": "Add endpoint",
|
||||
"add_exclusion_pattern": "Add exclusion pattern",
|
||||
"add_import_path": "Add import path",
|
||||
"add_location": "Add location",
|
||||
"add_more_users": "Add more users",
|
||||
"add_partner": "Add partner",
|
||||
@@ -32,6 +31,7 @@
|
||||
"add_to_album_toggle": "Toggle selection for {album}",
|
||||
"add_to_albums": "Add to albums",
|
||||
"add_to_albums_count": "Add to albums ({count})",
|
||||
"add_to_bottom_bar": "Add to",
|
||||
"add_to_shared_album": "Add to shared album",
|
||||
"add_upload_to_stack": "Add upload to stack",
|
||||
"add_url": "Add URL",
|
||||
@@ -112,13 +112,17 @@
|
||||
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
||||
"library_created": "Created library: {library}",
|
||||
"library_deleted": "Library deleted",
|
||||
"library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
|
||||
"library_details": "Library details",
|
||||
"library_folder_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
|
||||
"library_remove_exclusion_pattern_prompt": "Are you sure you want to remove this exclusion pattern?",
|
||||
"library_remove_folder_prompt": "Are you sure you want to remove this import folder?",
|
||||
"library_scanning": "Periodic Scanning",
|
||||
"library_scanning_description": "Configure periodic library scanning",
|
||||
"library_scanning_enable_description": "Enable periodic library scanning",
|
||||
"library_settings": "External Library",
|
||||
"library_settings_description": "Manage external library settings",
|
||||
"library_tasks_description": "Scan external libraries for new and/or changed assets",
|
||||
"library_updated": "Updated library",
|
||||
"library_watching_enable_description": "Watch external libraries for file changes",
|
||||
"library_watching_settings": "Library watching [EXPERIMENTAL]",
|
||||
"library_watching_settings_description": "Automatically watch for changed files",
|
||||
@@ -173,6 +177,10 @@
|
||||
"machine_learning_smart_search_enabled": "Enable smart search",
|
||||
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
|
||||
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
|
||||
"maintenance_settings": "Maintenance",
|
||||
"maintenance_settings_description": "Put Immich into maintenance mode.",
|
||||
"maintenance_start": "Start maintenance mode",
|
||||
"maintenance_start_error": "Failed to start maintenance mode.",
|
||||
"manage_concurrency": "Manage Concurrency",
|
||||
"manage_log_settings": "Manage log settings",
|
||||
"map_dark_style": "Dark style",
|
||||
@@ -430,6 +438,7 @@
|
||||
"age_months": "Age {months, plural, one {# month} other {# months}}",
|
||||
"age_year_months": "Age 1 year, {months, plural, one {# month} other {# months}}",
|
||||
"age_years": "{years, plural, other {Age #}}",
|
||||
"album": "Album",
|
||||
"album_added": "Album added",
|
||||
"album_added_notification_setting_description": "Receive an email notification when you are added to a shared album",
|
||||
"album_cover_updated": "Album cover updated",
|
||||
@@ -895,8 +904,6 @@
|
||||
"edit_description_prompt": "Please select a new description:",
|
||||
"edit_exclusion_pattern": "Edit exclusion pattern",
|
||||
"edit_faces": "Edit faces",
|
||||
"edit_import_path": "Edit import path",
|
||||
"edit_import_paths": "Edit Import Paths",
|
||||
"edit_key": "Edit key",
|
||||
"edit_link": "Edit link",
|
||||
"edit_location": "Edit location",
|
||||
@@ -968,8 +975,8 @@
|
||||
"failed_to_stack_assets": "Failed to stack assets",
|
||||
"failed_to_unstack_assets": "Failed to un-stack assets",
|
||||
"failed_to_update_notification_status": "Failed to update notification status",
|
||||
"import_path_already_exists": "This import path already exists.",
|
||||
"incorrect_email_or_password": "Incorrect email or password",
|
||||
"library_folder_already_exists": "This import path already exists.",
|
||||
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
|
||||
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
|
||||
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
|
||||
@@ -978,7 +985,6 @@
|
||||
"unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
|
||||
"unable_to_add_comment": "Unable to add comment",
|
||||
"unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",
|
||||
"unable_to_add_import_path": "Unable to add import path",
|
||||
"unable_to_add_partners": "Unable to add partners",
|
||||
"unable_to_add_remove_archive": "Unable to {archived, select, true {remove asset from} other {add asset to}} archive",
|
||||
"unable_to_add_remove_favorites": "Unable to {favorite, select, true {add asset to} other {remove asset from}} favorites",
|
||||
@@ -1001,12 +1007,10 @@
|
||||
"unable_to_delete_asset": "Unable to delete asset",
|
||||
"unable_to_delete_assets": "Error deleting assets",
|
||||
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
|
||||
"unable_to_delete_import_path": "Unable to delete import path",
|
||||
"unable_to_delete_shared_link": "Unable to delete shared link",
|
||||
"unable_to_delete_user": "Unable to delete user",
|
||||
"unable_to_download_files": "Unable to download files",
|
||||
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
|
||||
"unable_to_edit_import_path": "Unable to edit import path",
|
||||
"unable_to_empty_trash": "Unable to empty trash",
|
||||
"unable_to_enter_fullscreen": "Unable to enter fullscreen",
|
||||
"unable_to_exit_fullscreen": "Unable to exit fullscreen",
|
||||
@@ -1057,6 +1061,7 @@
|
||||
"unable_to_update_user": "Unable to update user",
|
||||
"unable_to_upload_file": "Unable to upload file"
|
||||
},
|
||||
"exclusion_pattern": "Exclusion pattern",
|
||||
"exif": "Exif",
|
||||
"exif_bottom_sheet_description": "Add Description...",
|
||||
"exif_bottom_sheet_description_error": "Error updating description",
|
||||
@@ -1116,6 +1121,7 @@
|
||||
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
|
||||
"forgot_pin_code_question": "Forgot your PIN?",
|
||||
"forward": "Forward",
|
||||
"full_path": "Full path: {path}",
|
||||
"gcast_enabled": "Google Cast",
|
||||
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
|
||||
"general": "General",
|
||||
@@ -1245,6 +1251,8 @@
|
||||
"let_others_respond": "Let others respond",
|
||||
"level": "Level",
|
||||
"library": "Library",
|
||||
"library_add_folder": "Add folder",
|
||||
"library_edit_folder": "Edit folder",
|
||||
"library_options": "Library options",
|
||||
"library_page_device_albums": "Albums on Device",
|
||||
"library_page_new_album": "New album",
|
||||
@@ -1316,6 +1324,11 @@
|
||||
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
||||
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
||||
"main_menu": "Main menu",
|
||||
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
|
||||
"maintenance_end": "End maintenance mode",
|
||||
"maintenance_end_error": "Failed to end maintenance mode.",
|
||||
"maintenance_logged_in_as": "Currently logged in as {user}",
|
||||
"maintenance_title": "Temporarily Unavailable",
|
||||
"make": "Make",
|
||||
"manage_geolocation": "Manage location",
|
||||
"manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
|
||||
@@ -1385,6 +1398,7 @@
|
||||
"more": "More",
|
||||
"move": "Move",
|
||||
"move_off_locked_folder": "Move out of locked folder",
|
||||
"move_to": "Move to",
|
||||
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||
"move_to_locked_folder": "Move to locked folder",
|
||||
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
||||
@@ -1437,6 +1451,7 @@
|
||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||
"no_local_assets_found": "No local assets found with this checksum",
|
||||
"no_location_set": "No location set",
|
||||
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
|
||||
"no_name": "No Name",
|
||||
"no_notifications": "No notifications",
|
||||
@@ -1827,6 +1842,8 @@
|
||||
"server_offline": "Server Offline",
|
||||
"server_online": "Server Online",
|
||||
"server_privacy": "Server Privacy",
|
||||
"server_restarting_description": "This page will refresh momentarily.",
|
||||
"server_restarting_title": "Server is restarting",
|
||||
"server_stats": "Server Stats",
|
||||
"server_update_available": "Server update is available",
|
||||
"server_version": "Server Version",
|
||||
@@ -2181,6 +2198,7 @@
|
||||
"welcome": "Welcome",
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
"workflow": "Workflow",
|
||||
"wrong_pin_code": "Wrong PIN code",
|
||||
"year": "Year",
|
||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||
|
||||
@@ -6,7 +6,7 @@ from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
from rapidocr.ch_ppocr_det.utils import DBPostProcess
|
||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||
from rapidocr.utils import DownloadFile, DownloadFileInput
|
||||
from rapidocr.utils.download_file import DownloadFile, DownloadFileInput
|
||||
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
|
||||
from rapidocr.utils.typings import ModelType as RapidModelType
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from PIL import Image
|
||||
from rapidocr.ch_ppocr_rec import TextRecInput
|
||||
from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer
|
||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||
from rapidocr.utils import DownloadFile, DownloadFileInput
|
||||
from rapidocr.utils.download_file import DownloadFile, DownloadFileInput
|
||||
from rapidocr.utils.typings import EngineType, LangRec, OCRVersion, TaskType
|
||||
from rapidocr.utils.typings import ModelType as RapidModelType
|
||||
from rapidocr.utils.vis_res import VisRes
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
#
|
||||
# Pump one or both of the server/mobile versions in appropriate files
|
||||
#
|
||||
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m>
|
||||
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-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 # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -m # 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 -m true # 1.0.0+50 => 1.0.0+51
|
||||
#
|
||||
|
||||
SERVER_PUMP="false"
|
||||
|
||||
@@ -112,7 +112,7 @@ end
|
||||
workspace: "Runner.xcworkspace",
|
||||
configuration: configuration,
|
||||
export_method: "app-store",
|
||||
xcargs: "CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||
export_options: {
|
||||
provisioningProfiles: {
|
||||
"#{app_identifier}" => "#{app_identifier} AppStore",
|
||||
@@ -195,7 +195,7 @@ end
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
skip_package_ipa: false,
|
||||
xcargs: "-allowProvisioningUpdates",
|
||||
xcargs: "-skipMacroValidation -allowProvisioningUpdates",
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
signingStyle: "automatic",
|
||||
@@ -210,4 +210,37 @@ end
|
||||
)
|
||||
end
|
||||
|
||||
desc "iOS Build Only (no TestFlight upload)"
|
||||
lane :gha_build_only do
|
||||
# Use the same build process as production, just skip the upload
|
||||
# This ensures PR builds validate the same way as production builds
|
||||
|
||||
# Install provisioning profiles (use development profiles for PR builds)
|
||||
install_provisioning_profile(path: "profile_dev.mobileprovision")
|
||||
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
|
||||
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
|
||||
|
||||
# Configure code signing for dev bundle IDs
|
||||
configure_code_signing(bundle_id_suffix: "development")
|
||||
|
||||
# Build the app (same as gha_testflight_dev but without upload)
|
||||
build_app(
|
||||
scheme: "Runner",
|
||||
workspace: "Runner.xcworkspace",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
skip_package_ipa: true,
|
||||
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||
export_options: {
|
||||
provisioningProfiles: {
|
||||
"#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore",
|
||||
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore",
|
||||
"#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore"
|
||||
},
|
||||
signingStyle: "manual",
|
||||
signingCertificate: CODE_SIGN_IDENTITY
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -3,8 +3,6 @@ class ExifInfo {
|
||||
final int? fileSize;
|
||||
final String? description;
|
||||
final bool isFlipped;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final String? orientation;
|
||||
final String? timeZone;
|
||||
final DateTime? dateTimeOriginal;
|
||||
@@ -46,8 +44,6 @@ class ExifInfo {
|
||||
this.fileSize,
|
||||
this.description,
|
||||
this.orientation,
|
||||
this.width,
|
||||
this.height,
|
||||
this.timeZone,
|
||||
this.dateTimeOriginal,
|
||||
this.isFlipped = false,
|
||||
@@ -72,8 +68,6 @@ class ExifInfo {
|
||||
return other.fileSize == fileSize &&
|
||||
other.description == description &&
|
||||
other.isFlipped == isFlipped &&
|
||||
other.width == width &&
|
||||
other.height == height &&
|
||||
other.orientation == orientation &&
|
||||
other.timeZone == timeZone &&
|
||||
other.dateTimeOriginal == dateTimeOriginal &&
|
||||
@@ -98,8 +92,6 @@ class ExifInfo {
|
||||
description.hashCode ^
|
||||
orientation.hashCode ^
|
||||
isFlipped.hashCode ^
|
||||
width.hashCode ^
|
||||
height.hashCode ^
|
||||
timeZone.hashCode ^
|
||||
dateTimeOriginal.hashCode ^
|
||||
latitude.hashCode ^
|
||||
@@ -123,8 +115,6 @@ class ExifInfo {
|
||||
fileSize: ${fileSize ?? 'NA'},
|
||||
description: ${description ?? 'NA'},
|
||||
orientation: ${orientation ?? 'NA'},
|
||||
width: ${width ?? 'NA'},
|
||||
height: ${height ?? 'NA'},
|
||||
isFlipped: $isFlipped,
|
||||
timeZone: ${timeZone ?? 'NA'},
|
||||
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
||||
|
||||
@@ -65,8 +65,8 @@ class AssetService {
|
||||
if (asset.hasRemote) {
|
||||
final exif = await getExif(asset);
|
||||
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
|
||||
width = exif?.width ?? asset.width?.toDouble();
|
||||
height = exif?.height ?? asset.height?.toDouble();
|
||||
width = asset.width?.toDouble();
|
||||
height = asset.height?.toDouble();
|
||||
} else if (asset is LocalAsset) {
|
||||
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
|
||||
width = asset.width?.toDouble();
|
||||
|
||||
@@ -177,6 +177,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
await runZonedGuarded(_handleCleanup, (error, stack) {
|
||||
dPrint(() => "Error during background worker cleanup: $error, $stack");
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleCleanup() async {
|
||||
// If ref is null, it means the service was never initialized properly
|
||||
if (_isCleanedUp || _ref == null) {
|
||||
return;
|
||||
@@ -186,11 +192,16 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
_isCleanedUp = true;
|
||||
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
||||
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
||||
|
||||
await _drift.close();
|
||||
await _driftLogger.close();
|
||||
|
||||
_ref?.dispose();
|
||||
_ref = null;
|
||||
|
||||
_cancellationToken.cancel();
|
||||
_logger.info("Cleaning up background worker");
|
||||
|
||||
final cleanupFutures = [
|
||||
nativeSyncApi?.cancelHashing(),
|
||||
workerManagerPatch.dispose().catchError((_) async {
|
||||
@@ -199,8 +210,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}),
|
||||
LogService.I.dispose(),
|
||||
Store.dispose(),
|
||||
_drift.close(),
|
||||
_driftLogger.close(),
|
||||
|
||||
backgroundSyncManager?.cancel(),
|
||||
];
|
||||
|
||||
|
||||
@@ -165,8 +165,6 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
|
||||
f: fNumber?.toDouble(),
|
||||
mm: focalLength?.toDouble(),
|
||||
lens: lens,
|
||||
width: width?.toDouble(),
|
||||
height: height?.toDouble(),
|
||||
isFlipped: ExifDtoConverter.isOrientationFlipped(orientation),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,8 +219,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
country: Value(exif.country),
|
||||
dateTimeOriginal: Value(exif.dateTimeOriginal),
|
||||
description: Value(exif.description),
|
||||
height: Value(exif.exifImageHeight),
|
||||
width: Value(exif.exifImageWidth),
|
||||
exposureTime: Value(exif.exposureTime),
|
||||
fNumber: Value(exif.fNumber),
|
||||
fileSize: Value(exif.fileSizeInByte),
|
||||
@@ -244,6 +242,16 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await _db.batch((batch) {
|
||||
for (final exif in data) {
|
||||
batch.update(
|
||||
_db.remoteAssetEntity,
|
||||
RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)),
|
||||
where: (row) => row.id.equals(exif.assetId),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: updateAssetsExifV1 - $debugLabel', error, stack);
|
||||
rethrow;
|
||||
|
||||
@@ -161,8 +161,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
|
||||
value: exif.fileSize != null ? '${(exif.fileSize! / 1024 / 1024).toStringAsFixed(2)} MB' : null,
|
||||
),
|
||||
_PropertyItem(label: 'Description', value: exif.description),
|
||||
_PropertyItem(label: 'EXIF Width', value: exif.width?.toString()),
|
||||
_PropertyItem(label: 'EXIF Height', value: exif.height?.toString()),
|
||||
_PropertyItem(label: 'Date Taken', value: exif.dateTimeOriginal?.toString()),
|
||||
_PropertyItem(label: 'Time Zone', value: exif.timeZone),
|
||||
_PropertyItem(label: 'Camera Make', value: exif.make),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
|
||||
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
||||
|
||||
class AddActionButton extends ConsumerWidget {
|
||||
const AddActionButton({super.key});
|
||||
|
||||
Future<void> _showAddOptions(BuildContext context, WidgetRef ref) async {
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return;
|
||||
|
||||
final user = ref.read(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
final hasRemote = asset is RemoteAsset;
|
||||
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
|
||||
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
|
||||
final menuItemHeight = 30.0;
|
||||
|
||||
final List<PopupMenuEntry<AddToMenuItem>> items = [
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
textStyle: context.textTheme.labelMedium,
|
||||
height: 40,
|
||||
child: Text("add_to_bottom_bar".tr()),
|
||||
),
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.album,
|
||||
child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())),
|
||||
if (isOwner) ...[
|
||||
if (showArchive)
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.archive,
|
||||
child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())),
|
||||
),
|
||||
if (showUnarchive)
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.unarchive,
|
||||
child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())),
|
||||
),
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.lockedFolder,
|
||||
child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
final AddToMenuItem? selected = await showMenu<AddToMenuItem>(
|
||||
context: context,
|
||||
color: context.themeData.scaffoldBackgroundColor,
|
||||
position: _menuPosition(context),
|
||||
items: items,
|
||||
);
|
||||
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (selected) {
|
||||
case AddToMenuItem.album:
|
||||
_openAlbumSelector(context, ref);
|
||||
break;
|
||||
case AddToMenuItem.archive:
|
||||
await performArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.unarchive:
|
||||
await performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.lockedFolder:
|
||||
await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
RelativeRect _menuPosition(BuildContext context) {
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject is! RenderBox) {
|
||||
return RelativeRect.fill;
|
||||
}
|
||||
|
||||
final size = renderObject.size;
|
||||
final position = renderObject.localToGlobal(Offset.zero);
|
||||
|
||||
return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy);
|
||||
}
|
||||
|
||||
void _openAlbumSelector(BuildContext context, WidgetRef ref) {
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
if (currentAsset == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
final List<Widget> slivers = [
|
||||
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)),
|
||||
];
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) {
|
||||
return BaseBottomSheet(
|
||||
actions: const [],
|
||||
slivers: slivers,
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
backgroundColor: context.isDarkTheme ? Colors.black : Colors.white,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async {
|
||||
final latest = ref.read(currentAssetNotifier);
|
||||
|
||||
if (latest == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
final addedCount = await ref.read(remoteAlbumProvider.notifier).addAssets(album.id, [latest.remoteId!]);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (addedCount == 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).maybePop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Builder(
|
||||
builder: (buttonContext) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.add,
|
||||
label: "add_to_bottom_bar".tr(),
|
||||
onPressed: () => _showAddOptions(buttonContext, ref),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,33 +10,36 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
// used to allow performing archive action from different sources (without duplicating code)
|
||||
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).archive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ArchiveActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const ArchiveActionButton({super.key, required this.source});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).archive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
await performArchiveAction(context, ref, source: source);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -10,36 +10,39 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
// Reusable helper: move to locked folder from any source (e.g called from menu)
|
||||
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'move_to_lock_folder_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MoveToLockFolderActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const MoveToLockFolderActionButton({super.key, required this.source});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'move_to_lock_folder_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
await performMoveToLockFolderAction(context, ref, source: source);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// dart
|
||||
// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart`
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -7,30 +9,39 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
|
||||
// used to allow performing unarchive action from different sources (without duplicating code)
|
||||
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).unArchive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UnArchiveActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const UnArchiveActionButton({super.key, required this.source});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).unArchive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||
await performUnArchiveAction(context, ref, source: source);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -3,13 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_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/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
@@ -34,7 +33,6 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0;
|
||||
@@ -44,11 +42,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||
if (asset.hasRemote) const AddActionButton(),
|
||||
|
||||
if (isOwner) ...[
|
||||
if (asset.hasRemote && isOwner && isArchived)
|
||||
const UnArchiveActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ArchiveActionButton(source: ActionSource.viewer),
|
||||
asset.isLocalOnly
|
||||
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@@ -16,8 +15,8 @@ import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
@@ -97,8 +96,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
}
|
||||
|
||||
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
|
||||
final height = asset.height ?? exifInfo?.height;
|
||||
final width = asset.width ?? exifInfo?.width;
|
||||
final height = asset.height;
|
||||
final width = asset.width;
|
||||
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null;
|
||||
final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
|
||||
|
||||
@@ -181,7 +180,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (albums.isNotEmpty)
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: 'appears_in'.t(context: context).toUpperCase(),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
@@ -233,7 +232,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
future: assetMediaRepository.getOriginalFilename(asset.id),
|
||||
builder: (context, snapshot) {
|
||||
final displayName = snapshot.data ?? asset.name;
|
||||
return _SheetTile(
|
||||
return SheetTile(
|
||||
title: displayName,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(
|
||||
@@ -250,7 +249,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
);
|
||||
} else {
|
||||
// For remote assets, use the name directly
|
||||
return _SheetTile(
|
||||
return SheetTile(
|
||||
title: asset.name,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(
|
||||
@@ -269,7 +268,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
return SliverList.list(
|
||||
children: [
|
||||
// Asset Date and Time
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset),
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
@@ -279,7 +278,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
const SheetPeopleDetails(),
|
||||
const SheetLocationDetails(),
|
||||
// Details header
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: 'exif_bottom_sheet_details'.t(context: context),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
@@ -290,7 +289,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
buildFileInfoTile(),
|
||||
// Camera info
|
||||
if (cameraTitle != null)
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: cameraTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
@@ -301,7 +300,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
),
|
||||
// Lens info
|
||||
if (lensTitle != null)
|
||||
_SheetTile(
|
||||
SheetTile(
|
||||
title: lensTitle,
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
|
||||
@@ -319,77 +318,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetTile extends ConsumerWidget {
|
||||
final String title;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final String? subtitle;
|
||||
final TextStyle? titleStyle;
|
||||
final TextStyle? subtitleStyle;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SheetTile({
|
||||
required this.title,
|
||||
this.titleStyle,
|
||||
this.leading,
|
||||
this.subtitle,
|
||||
this.subtitleStyle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
void copyTitle(BuildContext context, WidgetRef ref) {
|
||||
Clipboard.setData(ClipboardData(text: title));
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'copied_to_clipboard'.t(context: context),
|
||||
toastType: ToastType.info,
|
||||
);
|
||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final Widget titleWidget;
|
||||
if (leading == null) {
|
||||
titleWidget = LimitedBox(
|
||||
maxWidth: double.infinity,
|
||||
child: Text(title, style: titleStyle),
|
||||
);
|
||||
} else {
|
||||
titleWidget = Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
child: Text(title, style: titleStyle),
|
||||
);
|
||||
}
|
||||
|
||||
final Widget? subtitleWidget;
|
||||
if (leading == null && subtitle != null) {
|
||||
subtitleWidget = Text(subtitle!, style: subtitleStyle);
|
||||
} else if (leading != null && subtitle != null) {
|
||||
subtitleWidget = Padding(
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
child: Text(subtitle!, style: subtitleStyle),
|
||||
);
|
||||
} else {
|
||||
subtitleWidget = null;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||
subtitle: subtitleWidget,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
||||
final ExifInfo exif;
|
||||
final bool isEditable;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
@@ -16,8 +19,6 @@ class SheetLocationDetails extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
BaseAsset? asset;
|
||||
ExifInfo? exifInfo;
|
||||
MapLibreMapController? _mapController;
|
||||
|
||||
String? _getLocationName(ExifInfo? exifInfo) {
|
||||
@@ -39,14 +40,11 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
}
|
||||
|
||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
||||
asset = ref.read(currentAssetNotifier);
|
||||
setState(() {
|
||||
exifInfo = current.valueOrNull;
|
||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||
if (exifInfo != null && hasCoordinates) {
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exifInfo!.latitude!, exifInfo!.longitude!)));
|
||||
}
|
||||
});
|
||||
final currentExif = current.valueOrNull;
|
||||
|
||||
if (currentExif != null && currentExif.hasCoordinates) {
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -55,45 +53,71 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
|
||||
}
|
||||
|
||||
void editLocation() async {
|
||||
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||
|
||||
// Guard no lat/lng
|
||||
if (!hasCoordinates || (asset != null && asset is LocalAsset && asset!.hasRemote)) {
|
||||
// Guard local assets
|
||||
if (asset != null && asset is LocalAsset && asset.hasRemote) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final remoteId = asset is LocalAsset ? (asset as LocalAsset).remoteId : (asset as RemoteAsset).id;
|
||||
final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id;
|
||||
final locationName = _getLocationName(exifInfo);
|
||||
final coordinates = "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}";
|
||||
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.0, horizontal: context.isMobile ? 16.0 : 56.0),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
"exif_bottom_sheet_location".t(context: context),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
SheetTile(
|
||||
title: 'exif_bottom_sheet_location'.t(context: context),
|
||||
titleStyle: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
|
||||
onTap: editLocation,
|
||||
),
|
||||
if (hasCoordinates)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: context.isMobile ? 16.0 : 56.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
|
||||
const SizedBox(height: 16),
|
||||
if (locationName != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(locationName, style: context.textTheme.labelLarge),
|
||||
),
|
||||
Text(
|
||||
coordinates,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(150),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
|
||||
const SizedBox(height: 15),
|
||||
if (locationName != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(locationName, style: context.textTheme.labelLarge),
|
||||
if (!hasCoordinates)
|
||||
SheetTile(
|
||||
title: "add_a_location".t(context: context),
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
leading: const Icon(Icons.location_off),
|
||||
onTap: editLocation,
|
||||
),
|
||||
Text(
|
||||
coordinates,
|
||||
style: context.textTheme.labelMedium?.copyWith(color: context.textTheme.labelMedium?.color?.withAlpha(150)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class SheetTile extends ConsumerWidget {
|
||||
final String title;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final String? subtitle;
|
||||
final TextStyle? titleStyle;
|
||||
final TextStyle? subtitleStyle;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SheetTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.titleStyle,
|
||||
this.leading,
|
||||
this.subtitle,
|
||||
this.subtitleStyle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
void copyTitle(BuildContext context, WidgetRef ref) {
|
||||
Clipboard.setData(ClipboardData(text: title));
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'copied_to_clipboard'.t(context: context),
|
||||
toastType: ToastType.info,
|
||||
);
|
||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final Widget titleWidget;
|
||||
if (leading == null) {
|
||||
titleWidget = LimitedBox(
|
||||
maxWidth: double.infinity,
|
||||
child: Text(title, style: titleStyle),
|
||||
);
|
||||
} else {
|
||||
titleWidget = Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
child: Text(title, style: titleStyle),
|
||||
);
|
||||
}
|
||||
|
||||
final Widget? subtitleWidget;
|
||||
if (leading == null && subtitle != null) {
|
||||
subtitleWidget = Text(subtitle!, style: subtitleStyle);
|
||||
} else if (leading != null && subtitle != null) {
|
||||
subtitleWidget = Padding(
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
child: Text(subtitle!, style: subtitleStyle),
|
||||
);
|
||||
} else {
|
||||
subtitleWidget = null;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||
subtitle: subtitleWidget,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,45 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double dimension;
|
||||
class TimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double height;
|
||||
final List<double> widths;
|
||||
final double spacing;
|
||||
final TextDirection textDirection;
|
||||
|
||||
const FixedTimelineRow({
|
||||
const TimelineRow({
|
||||
super.key,
|
||||
required this.dimension,
|
||||
required this.height,
|
||||
required this.widths,
|
||||
required this.spacing,
|
||||
required this.textDirection,
|
||||
required super.children,
|
||||
});
|
||||
|
||||
factory TimelineRow.fixed({
|
||||
required double dimension,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
required List<Widget> children,
|
||||
}) => TimelineRow(
|
||||
height: dimension,
|
||||
widths: List.filled(children.length, dimension),
|
||||
spacing: spacing,
|
||||
textDirection: textDirection,
|
||||
children: children,
|
||||
);
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
|
||||
return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
|
||||
renderObject.dimension = dimension;
|
||||
renderObject.height = height;
|
||||
renderObject.widths = widths;
|
||||
renderObject.spacing = spacing;
|
||||
renderObject.textDirection = textDirection;
|
||||
}
|
||||
@@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('height', height));
|
||||
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
@@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
||||
RenderFixedRow({
|
||||
List<RenderBox>? children,
|
||||
required double dimension,
|
||||
required double height,
|
||||
required List<double> widths,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
}) : _dimension = dimension,
|
||||
}) : _height = height,
|
||||
_widths = widths,
|
||||
_spacing = spacing,
|
||||
_textDirection = textDirection {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
double get dimension => _dimension;
|
||||
double _dimension;
|
||||
double get height => _height;
|
||||
double _height;
|
||||
|
||||
set dimension(double value) {
|
||||
if (_dimension == value) return;
|
||||
_dimension = value;
|
||||
set height(double value) {
|
||||
if (_height == value) return;
|
||||
_height = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
List<double> get widths => _widths;
|
||||
List<double> _widths;
|
||||
|
||||
set widths(List<double> value) {
|
||||
if (listEquals(_widths, value)) return;
|
||||
_widths = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox
|
||||
}
|
||||
}
|
||||
|
||||
double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1);
|
||||
double get intrinsicWidth => widths.sum + (spacing * (childCount - 1));
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
|
||||
@@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox
|
||||
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) => dimension;
|
||||
double computeMinIntrinsicHeight(double width) => height;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) => dimension;
|
||||
double computeMaxIntrinsicHeight(double width) => height;
|
||||
|
||||
@override
|
||||
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
||||
@@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('height', height));
|
||||
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
@@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox
|
||||
return;
|
||||
}
|
||||
// Use the entire width of the parent for the row.
|
||||
size = Size(constraints.maxWidth, dimension);
|
||||
// Each tile is forced to be dimension x dimension.
|
||||
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
|
||||
size = Size(constraints.maxWidth, height);
|
||||
|
||||
final flipMainAxis = textDirection == TextDirection.rtl;
|
||||
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
|
||||
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
|
||||
int childIndex = 0;
|
||||
double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0;
|
||||
// Layout each child horizontally.
|
||||
while (child != null) {
|
||||
while (child != null && childIndex < widths.length) {
|
||||
final width = widths[childIndex];
|
||||
final childConstraints = BoxConstraints.tight(Size(width, height));
|
||||
child.layout(childConstraints, parentUsesSize: false);
|
||||
final childParentData = child.parentData! as _RowParentData;
|
||||
childParentData.offset = offset;
|
||||
offset += Offset(dx, 0);
|
||||
childParentData.offset = Offset(currentX, 0);
|
||||
child = childParentData.nextSibling;
|
||||
childIndex++;
|
||||
|
||||
if (child != null && childIndex < widths.length) {
|
||||
final nextWidth = widths[childIndex];
|
||||
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@@ -14,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
@@ -21,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
class FixedSegment extends Segment {
|
||||
final double tileHeight;
|
||||
@@ -78,6 +81,7 @@ class FixedSegment extends Segment {
|
||||
assetCount: numberOfAssets,
|
||||
tileHeight: tileHeight,
|
||||
spacing: spacing,
|
||||
columnCount: columnCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,24 +91,34 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
final int assetCount;
|
||||
final double tileHeight;
|
||||
final double spacing;
|
||||
final int columnCount;
|
||||
|
||||
const _FixedSegmentRow({
|
||||
required this.assetIndex,
|
||||
required this.assetCount,
|
||||
required this.tileHeight,
|
||||
required this.spacing,
|
||||
required this.columnCount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final isDynamicLayout = ref.watch(
|
||||
appSettingsServiceProvider.select((s) => s.getSetting(AppSettingsEnum.dynamicLayout)),
|
||||
);
|
||||
|
||||
if (isScrubbing) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
|
||||
return _buildAssetRow(
|
||||
context,
|
||||
timelineService.getAssets(assetIndex, assetCount),
|
||||
timelineService,
|
||||
isDynamicLayout,
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
@@ -113,7 +127,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService);
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -122,23 +136,58 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
||||
return FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
Widget _buildAssetRow(
|
||||
BuildContext context,
|
||||
List<BaseAsset> assets,
|
||||
TimelineService timelineService,
|
||||
bool isDynamicLayout,
|
||||
) {
|
||||
final children = [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
final widths = List.filled(assets.length, tileHeight);
|
||||
|
||||
if (isDynamicLayout) {
|
||||
final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
|
||||
final meanAspectRatio = aspectRatios.sum / assets.length;
|
||||
|
||||
// 1: mean width
|
||||
// 0.5: width < mean - threshold
|
||||
// 1.5: width > mean + threshold
|
||||
final arConfiguration = aspectRatios.map((e) {
|
||||
if (e - meanAspectRatio > 0.3) return 1.5;
|
||||
if (e - meanAspectRatio < -0.3) return 0.5;
|
||||
return 1.0;
|
||||
});
|
||||
|
||||
// Normalize to get width distribution
|
||||
final sum = arConfiguration.sum;
|
||||
|
||||
int index = 0;
|
||||
for (final ratio in arConfiguration) {
|
||||
// Distribute the available width proportionally based on aspect ratio configuration
|
||||
widths[index++] = ((ratio * assets.length) / sum) * tileHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return TimelineDragRegion(
|
||||
child: TimelineRow(
|
||||
height: tileHeight,
|
||||
widths: widths,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
|
||||
Size size = kTimelineFixedTileExtent,
|
||||
double spacing = kTimelineSpacing,
|
||||
}) => RepaintBoundary(
|
||||
child: FixedTimelineRow(
|
||||
child: TimelineRow.fixed(
|
||||
dimension: size.height,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
|
||||
@@ -264,9 +264,8 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
|
||||
Future<ActionResult?> deleteLocal(ActionSource source, BuildContext context) async {
|
||||
// Always perform the operation if there is only one merged asset
|
||||
final assets = _getAssets(source);
|
||||
bool? backedUpOnly = assets.length == 1 && assets.first.storage == AssetState.merged
|
||||
bool? backedUpOnly = assets.every((asset) => asset.storage == AssetState.merged)
|
||||
? true
|
||||
: await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -302,6 +301,13 @@ class ActionNotifier extends Notifier<void> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// This must be called since editing location
|
||||
// does not update the currentAsset which means
|
||||
// the exif provider will not be refreshed automatically
|
||||
if (source == ActionSource.viewer) {
|
||||
ref.invalidate(currentAssetExifProvider);
|
||||
}
|
||||
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to edit location for assets', error, stack);
|
||||
|
||||
@@ -29,7 +29,7 @@ import 'package:isar/isar.dart';
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 17;
|
||||
const int targetVersion = 18;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
@@ -63,7 +63,8 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
await Store.populateCache();
|
||||
}
|
||||
|
||||
await handleBetaMigration(version, await _isNewInstallation(db, drift), SyncStreamRepository(drift));
|
||||
final syncStreamRepository = SyncStreamRepository(drift);
|
||||
await handleBetaMigration(version, await _isNewInstallation(db, drift), syncStreamRepository);
|
||||
|
||||
if (version < 17 && Store.isBetaTimelineEnabled) {
|
||||
final delay = Store.get(StoreKey.backupTriggerDelay, AppSettingsEnum.backupTriggerDelay.defaultValue);
|
||||
@@ -72,6 +73,11 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 18 && Store.isBetaTimelineEnabled) {
|
||||
await syncStreamRepository.reset();
|
||||
await Store.put(StoreKey.shouldResetSync, true);
|
||||
}
|
||||
|
||||
if (targetVersion >= 12) {
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@@ -17,19 +16,36 @@ Future<LatLng?> showLocationPicker({required BuildContext context, LatLng? initi
|
||||
);
|
||||
}
|
||||
|
||||
enum _LocationPickerMode { map, manual }
|
||||
|
||||
class _LocationPicker extends HookWidget {
|
||||
final LatLng? initialLatLng;
|
||||
|
||||
const _LocationPicker({this.initialLatLng});
|
||||
|
||||
bool _validateLat(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -90 && l < 90;
|
||||
}
|
||||
|
||||
bool _validateLong(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -180 && l < 180;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latitude = useState(initialLatLng?.latitude ?? 0.0);
|
||||
final longitude = useState(initialLatLng?.longitude ?? 0.0);
|
||||
final latlng = LatLng(latitude.value, longitude.value);
|
||||
final pickerMode = useState(_LocationPickerMode.map);
|
||||
final latitiudeFocusNode = useFocusNode();
|
||||
final longitudeFocusNode = useFocusNode();
|
||||
final latitudeController = useTextEditingController(text: latitude.value.toStringAsFixed(4));
|
||||
final longitudeController = useTextEditingController(text: longitude.value.toStringAsFixed(4));
|
||||
|
||||
useEffect(() {
|
||||
latitudeController.text = latitude.value.toStringAsFixed(4);
|
||||
longitudeController.text = longitude.value.toStringAsFixed(4);
|
||||
return null;
|
||||
}, [latitude.value, longitude.value]);
|
||||
|
||||
Future<void> onMapTap() async {
|
||||
final newLatLng = await context.pushRoute<LatLng?>(MapLocationPickerRoute(initialLatLng: latlng));
|
||||
@@ -39,23 +55,55 @@ class _LocationPicker extends HookWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void onLatitudeUpdated(double value) {
|
||||
latitude.value = value;
|
||||
longitudeFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void onLongitudeEditingCompleted(double value) {
|
||||
longitude.value = value;
|
||||
longitudeFocusNode.unfocus();
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
contentPadding: const EdgeInsets.all(30),
|
||||
alignment: Alignment.center,
|
||||
content: SingleChildScrollView(
|
||||
child: pickerMode.value == _LocationPickerMode.map
|
||||
? _MapPicker(
|
||||
key: ValueKey(latlng),
|
||||
latlng: latlng,
|
||||
onModeSwitch: () => pickerMode.value = _LocationPickerMode.manual,
|
||||
onMapTap: onMapTap,
|
||||
)
|
||||
: _ManualPicker(
|
||||
latlng: latlng,
|
||||
onModeSwitch: () => pickerMode.value = _LocationPickerMode.map,
|
||||
onLatUpdated: (value) => latitude.value = value,
|
||||
onLonUpdated: (value) => longitude.value = value,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("edit_location_dialog_title", style: context.textTheme.titleMedium).tr(),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: TextButton.icon(
|
||||
icon: const Text("location_picker_choose_on_map").tr(),
|
||||
label: const Icon(Icons.map_outlined, size: 16),
|
||||
onPressed: onMapTap,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ManualPickerInput(
|
||||
controller: latitudeController,
|
||||
decorationText: "latitude",
|
||||
hintText: "location_picker_latitude_hint",
|
||||
errorText: "location_picker_latitude_error",
|
||||
focusNode: latitiudeFocusNode,
|
||||
validator: _validateLat,
|
||||
onUpdated: onLatitudeUpdated,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_ManualPickerInput(
|
||||
controller: longitudeController,
|
||||
decorationText: "longitude",
|
||||
hintText: "location_picker_longitude_hint",
|
||||
errorText: "location_picker_longitude_error",
|
||||
focusNode: longitudeFocusNode,
|
||||
validator: _validateLong,
|
||||
onUpdated: onLongitudeEditingCompleted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -81,7 +129,7 @@ class _LocationPicker extends HookWidget {
|
||||
}
|
||||
|
||||
class _ManualPickerInput extends HookWidget {
|
||||
final String initialValue;
|
||||
final TextEditingController controller;
|
||||
final String decorationText;
|
||||
final String hintText;
|
||||
final String errorText;
|
||||
@@ -90,7 +138,7 @@ class _ManualPickerInput extends HookWidget {
|
||||
final Function(double value) onUpdated;
|
||||
|
||||
const _ManualPickerInput({
|
||||
required this.initialValue,
|
||||
required this.controller,
|
||||
required this.decorationText,
|
||||
required this.hintText,
|
||||
required this.errorText,
|
||||
@@ -101,7 +149,6 @@ class _ManualPickerInput extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isValid = useState(true);
|
||||
final controller = useTextEditingController(text: initialValue);
|
||||
|
||||
void onEditingComplete() {
|
||||
isValid.value = validator(controller.text);
|
||||
@@ -131,109 +178,3 @@ class _ManualPickerInput extends HookWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ManualPicker extends HookWidget {
|
||||
final LatLng latlng;
|
||||
final Function() onModeSwitch;
|
||||
final Function(double) onLatUpdated;
|
||||
final Function(double) onLonUpdated;
|
||||
|
||||
const _ManualPicker({
|
||||
required this.latlng,
|
||||
required this.onModeSwitch,
|
||||
required this.onLatUpdated,
|
||||
required this.onLonUpdated,
|
||||
});
|
||||
|
||||
bool _validateLat(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -90 && l < 90;
|
||||
}
|
||||
|
||||
bool _validateLong(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -180 && l < 180;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latitiudeFocusNode = useFocusNode();
|
||||
final longitudeFocusNode = useFocusNode();
|
||||
|
||||
void onLatitudeUpdated(double value) {
|
||||
onLatUpdated(value);
|
||||
longitudeFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void onLongitudeEditingCompleted(double value) {
|
||||
onLonUpdated(value);
|
||||
longitudeFocusNode.unfocus();
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("edit_location_dialog_title", textAlign: TextAlign.center).tr(),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
icon: const Text("location_picker_choose_on_map").tr(),
|
||||
label: const Icon(Icons.map_outlined, size: 16),
|
||||
onPressed: onModeSwitch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ManualPickerInput(
|
||||
initialValue: latlng.latitude.toStringAsFixed(4),
|
||||
decorationText: "latitude",
|
||||
hintText: "location_picker_latitude_hint",
|
||||
errorText: "location_picker_latitude_error",
|
||||
focusNode: latitiudeFocusNode,
|
||||
validator: _validateLat,
|
||||
onUpdated: onLatitudeUpdated,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_ManualPickerInput(
|
||||
initialValue: latlng.longitude.toStringAsFixed(4),
|
||||
decorationText: "longitude",
|
||||
hintText: "location_picker_longitude_hint",
|
||||
errorText: "location_picker_longitude_error",
|
||||
focusNode: longitudeFocusNode,
|
||||
validator: _validateLong,
|
||||
onUpdated: onLongitudeEditingCompleted,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapPicker extends StatelessWidget {
|
||||
final LatLng latlng;
|
||||
final Function() onModeSwitch;
|
||||
final Function() onMapTap;
|
||||
|
||||
const _MapPicker({required this.latlng, required this.onModeSwitch, required this.onMapTap, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("edit_location_dialog_title", textAlign: TextAlign.center).tr(),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
icon: Text("${latlng.latitude.toStringAsFixed(4)}, ${latlng.longitude.toStringAsFixed(4)}"),
|
||||
label: const Icon(Icons.edit_outlined, size: 16),
|
||||
onPressed: onModeSwitch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MapThumbnail(
|
||||
centre: latlng,
|
||||
height: 200,
|
||||
width: 200,
|
||||
zoom: 8,
|
||||
showMarkerPin: true,
|
||||
onTap: (_, __) => onMapTap(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
41
mobile/openapi/README.md
generated
41
mobile/openapi/README.md
generated
@@ -149,8 +149,8 @@ Class | Method | HTTP request | Description
|
||||
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | Retrieve faces for asset
|
||||
*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | Re-assign a face to another person
|
||||
*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | Create a manual job
|
||||
*JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | Retrieve queue counts and status
|
||||
*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | Run jobs
|
||||
*JobsApi* | [**getQueuesLegacy**](doc//JobsApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
|
||||
*JobsApi* | [**runQueueCommandLegacy**](doc//JobsApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
|
||||
*LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | Create a library
|
||||
*LibrariesApi* | [**deleteLibrary**](doc//LibrariesApi.md#deletelibrary) | **DELETE** /libraries/{id} | Delete a library
|
||||
*LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | Retrieve libraries
|
||||
@@ -159,6 +159,8 @@ Class | Method | HTTP request | Description
|
||||
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library
|
||||
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
|
||||
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
|
||||
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
|
||||
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
|
||||
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
|
||||
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates
|
||||
*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory
|
||||
@@ -194,6 +196,8 @@ Class | Method | HTTP request | Description
|
||||
*PeopleApi* | [**reassignFaces**](doc//PeopleApi.md#reassignfaces) | **PUT** /people/{id}/reassign | Reassign faces
|
||||
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
|
||||
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
||||
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
||||
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
|
||||
*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city
|
||||
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data
|
||||
*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions
|
||||
@@ -295,6 +299,11 @@ Class | Method | HTTP request | Description
|
||||
*UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | Update user preferences
|
||||
*ViewsApi* | [**getAssetsByOriginalPath**](doc//ViewsApi.md#getassetsbyoriginalpath) | **GET** /view/folder | Retrieve assets by original path
|
||||
*ViewsApi* | [**getUniqueOriginalPaths**](doc//ViewsApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | Retrieve unique paths
|
||||
*WorkflowsApi* | [**createWorkflow**](doc//WorkflowsApi.md#createworkflow) | **POST** /workflows | Create a workflow
|
||||
*WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow
|
||||
*WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow
|
||||
*WorkflowsApi* | [**getWorkflows**](doc//WorkflowsApi.md#getworkflows) | **GET** /workflows | List all workflows
|
||||
*WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow
|
||||
|
||||
|
||||
## Documentation For Models
|
||||
@@ -318,7 +327,6 @@ Class | Method | HTTP request | Description
|
||||
- [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md)
|
||||
- [AlbumsResponse](doc//AlbumsResponse.md)
|
||||
- [AlbumsUpdate](doc//AlbumsUpdate.md)
|
||||
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
|
||||
- [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md)
|
||||
- [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md)
|
||||
- [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md)
|
||||
@@ -387,13 +395,8 @@ Class | Method | HTTP request | Description
|
||||
- [FoldersResponse](doc//FoldersResponse.md)
|
||||
- [FoldersUpdate](doc//FoldersUpdate.md)
|
||||
- [ImageFormat](doc//ImageFormat.md)
|
||||
- [JobCommand](doc//JobCommand.md)
|
||||
- [JobCommandDto](doc//JobCommandDto.md)
|
||||
- [JobCountsDto](doc//JobCountsDto.md)
|
||||
- [JobCreateDto](doc//JobCreateDto.md)
|
||||
- [JobName](doc//JobName.md)
|
||||
- [JobSettingsDto](doc//JobSettingsDto.md)
|
||||
- [JobStatusDto](doc//JobStatusDto.md)
|
||||
- [LibraryResponseDto](doc//LibraryResponseDto.md)
|
||||
- [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md)
|
||||
- [LicenseKeyDto](doc//LicenseKeyDto.md)
|
||||
@@ -403,6 +406,9 @@ Class | Method | HTTP request | Description
|
||||
- [LoginResponseDto](doc//LoginResponseDto.md)
|
||||
- [LogoutResponseDto](doc//LogoutResponseDto.md)
|
||||
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
|
||||
- [MaintenanceAction](doc//MaintenanceAction.md)
|
||||
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
|
||||
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
|
||||
- [ManualJobName](doc//ManualJobName.md)
|
||||
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
|
||||
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
|
||||
@@ -450,9 +456,20 @@ Class | Method | HTTP request | Description
|
||||
- [PinCodeResetDto](doc//PinCodeResetDto.md)
|
||||
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
|
||||
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
|
||||
- [PluginContext](doc//PluginContext.md)
|
||||
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
|
||||
- [PluginResponseDto](doc//PluginResponseDto.md)
|
||||
- [PluginTriggerType](doc//PluginTriggerType.md)
|
||||
- [PurchaseResponse](doc//PurchaseResponse.md)
|
||||
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
||||
- [QueueCommand](doc//QueueCommand.md)
|
||||
- [QueueCommandDto](doc//QueueCommandDto.md)
|
||||
- [QueueName](doc//QueueName.md)
|
||||
- [QueueResponseDto](doc//QueueResponseDto.md)
|
||||
- [QueueStatisticsDto](doc//QueueStatisticsDto.md)
|
||||
- [QueueStatusDto](doc//QueueStatusDto.md)
|
||||
- [QueuesResponseDto](doc//QueuesResponseDto.md)
|
||||
- [RandomSearchDto](doc//RandomSearchDto.md)
|
||||
- [RatingsResponse](doc//RatingsResponse.md)
|
||||
- [RatingsUpdate](doc//RatingsUpdate.md)
|
||||
@@ -484,6 +501,7 @@ Class | Method | HTTP request | Description
|
||||
- [SessionResponseDto](doc//SessionResponseDto.md)
|
||||
- [SessionUnlockDto](doc//SessionUnlockDto.md)
|
||||
- [SessionUpdateDto](doc//SessionUpdateDto.md)
|
||||
- [SetMaintenanceModeDto](doc//SetMaintenanceModeDto.md)
|
||||
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
|
||||
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
|
||||
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
|
||||
@@ -603,6 +621,13 @@ Class | Method | HTTP request | Description
|
||||
- [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md)
|
||||
- [VideoCodec](doc//VideoCodec.md)
|
||||
- [VideoContainer](doc//VideoContainer.md)
|
||||
- [WorkflowActionItemDto](doc//WorkflowActionItemDto.md)
|
||||
- [WorkflowActionResponseDto](doc//WorkflowActionResponseDto.md)
|
||||
- [WorkflowCreateDto](doc//WorkflowCreateDto.md)
|
||||
- [WorkflowFilterItemDto](doc//WorkflowFilterItemDto.md)
|
||||
- [WorkflowFilterResponseDto](doc//WorkflowFilterResponseDto.md)
|
||||
- [WorkflowResponseDto](doc//WorkflowResponseDto.md)
|
||||
- [WorkflowUpdateDto](doc//WorkflowUpdateDto.md)
|
||||
|
||||
|
||||
## Documentation For Authorization
|
||||
|
||||
31
mobile/openapi/lib/api.dart
generated
31
mobile/openapi/lib/api.dart
generated
@@ -42,12 +42,14 @@ part 'api/duplicates_api.dart';
|
||||
part 'api/faces_api.dart';
|
||||
part 'api/jobs_api.dart';
|
||||
part 'api/libraries_api.dart';
|
||||
part 'api/maintenance_admin_api.dart';
|
||||
part 'api/map_api.dart';
|
||||
part 'api/memories_api.dart';
|
||||
part 'api/notifications_api.dart';
|
||||
part 'api/notifications_admin_api.dart';
|
||||
part 'api/partners_api.dart';
|
||||
part 'api/people_api.dart';
|
||||
part 'api/plugins_api.dart';
|
||||
part 'api/search_api.dart';
|
||||
part 'api/server_api.dart';
|
||||
part 'api/sessions_api.dart';
|
||||
@@ -62,6 +64,7 @@ part 'api/trash_api.dart';
|
||||
part 'api/users_api.dart';
|
||||
part 'api/users_admin_api.dart';
|
||||
part 'api/views_api.dart';
|
||||
part 'api/workflows_api.dart';
|
||||
|
||||
part 'model/api_key_create_dto.dart';
|
||||
part 'model/api_key_create_response_dto.dart';
|
||||
@@ -82,7 +85,6 @@ part 'model/albums_add_assets_dto.dart';
|
||||
part 'model/albums_add_assets_response_dto.dart';
|
||||
part 'model/albums_response.dart';
|
||||
part 'model/albums_update.dart';
|
||||
part 'model/all_job_status_response_dto.dart';
|
||||
part 'model/asset_bulk_delete_dto.dart';
|
||||
part 'model/asset_bulk_update_dto.dart';
|
||||
part 'model/asset_bulk_upload_check_dto.dart';
|
||||
@@ -151,13 +153,8 @@ part 'model/facial_recognition_config.dart';
|
||||
part 'model/folders_response.dart';
|
||||
part 'model/folders_update.dart';
|
||||
part 'model/image_format.dart';
|
||||
part 'model/job_command.dart';
|
||||
part 'model/job_command_dto.dart';
|
||||
part 'model/job_counts_dto.dart';
|
||||
part 'model/job_create_dto.dart';
|
||||
part 'model/job_name.dart';
|
||||
part 'model/job_settings_dto.dart';
|
||||
part 'model/job_status_dto.dart';
|
||||
part 'model/library_response_dto.dart';
|
||||
part 'model/library_stats_response_dto.dart';
|
||||
part 'model/license_key_dto.dart';
|
||||
@@ -167,6 +164,9 @@ part 'model/login_credential_dto.dart';
|
||||
part 'model/login_response_dto.dart';
|
||||
part 'model/logout_response_dto.dart';
|
||||
part 'model/machine_learning_availability_checks_dto.dart';
|
||||
part 'model/maintenance_action.dart';
|
||||
part 'model/maintenance_auth_dto.dart';
|
||||
part 'model/maintenance_login_dto.dart';
|
||||
part 'model/manual_job_name.dart';
|
||||
part 'model/map_marker_response_dto.dart';
|
||||
part 'model/map_reverse_geocode_response_dto.dart';
|
||||
@@ -214,9 +214,20 @@ part 'model/pin_code_change_dto.dart';
|
||||
part 'model/pin_code_reset_dto.dart';
|
||||
part 'model/pin_code_setup_dto.dart';
|
||||
part 'model/places_response_dto.dart';
|
||||
part 'model/plugin_action_response_dto.dart';
|
||||
part 'model/plugin_context.dart';
|
||||
part 'model/plugin_filter_response_dto.dart';
|
||||
part 'model/plugin_response_dto.dart';
|
||||
part 'model/plugin_trigger_type.dart';
|
||||
part 'model/purchase_response.dart';
|
||||
part 'model/purchase_update.dart';
|
||||
part 'model/queue_command.dart';
|
||||
part 'model/queue_command_dto.dart';
|
||||
part 'model/queue_name.dart';
|
||||
part 'model/queue_response_dto.dart';
|
||||
part 'model/queue_statistics_dto.dart';
|
||||
part 'model/queue_status_dto.dart';
|
||||
part 'model/queues_response_dto.dart';
|
||||
part 'model/random_search_dto.dart';
|
||||
part 'model/ratings_response.dart';
|
||||
part 'model/ratings_update.dart';
|
||||
@@ -248,6 +259,7 @@ part 'model/session_create_response_dto.dart';
|
||||
part 'model/session_response_dto.dart';
|
||||
part 'model/session_unlock_dto.dart';
|
||||
part 'model/session_update_dto.dart';
|
||||
part 'model/set_maintenance_mode_dto.dart';
|
||||
part 'model/shared_link_create_dto.dart';
|
||||
part 'model/shared_link_edit_dto.dart';
|
||||
part 'model/shared_link_response_dto.dart';
|
||||
@@ -367,6 +379,13 @@ part 'model/validate_library_response_dto.dart';
|
||||
part 'model/version_check_state_response_dto.dart';
|
||||
part 'model/video_codec.dart';
|
||||
part 'model/video_container.dart';
|
||||
part 'model/workflow_action_item_dto.dart';
|
||||
part 'model/workflow_action_response_dto.dart';
|
||||
part 'model/workflow_create_dto.dart';
|
||||
part 'model/workflow_filter_item_dto.dart';
|
||||
part 'model/workflow_filter_response_dto.dart';
|
||||
part 'model/workflow_response_dto.dart';
|
||||
part 'model/workflow_update_dto.dart';
|
||||
|
||||
|
||||
/// An [ApiClient] instance that uses the default values obtained from
|
||||
|
||||
12
mobile/openapi/lib/api/assets_api.dart
generated
12
mobile/openapi/lib/api/assets_api.dart
generated
@@ -352,7 +352,7 @@ class AssetsApi {
|
||||
|
||||
/// Retrieve assets by device ID
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Get all asset of a device that are in the database, ID only.
|
||||
/// Get all asset of a device that are in the database, ID only.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -387,7 +387,7 @@ class AssetsApi {
|
||||
|
||||
/// Retrieve assets by device ID
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Get all asset of a device that are in the database, ID only.
|
||||
/// Get all asset of a device that are in the database, ID only.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
@@ -740,7 +740,7 @@ class AssetsApi {
|
||||
|
||||
/// Get random assets
|
||||
///
|
||||
/// This property was deprecated in v1.116.0. Retrieve a specified number of random assets for the authenticated user.
|
||||
/// Retrieve a specified number of random assets for the authenticated user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -778,7 +778,7 @@ class AssetsApi {
|
||||
|
||||
/// Get random assets
|
||||
///
|
||||
/// This property was deprecated in v1.116.0. Retrieve a specified number of random assets for the authenticated user.
|
||||
/// Retrieve a specified number of random assets for the authenticated user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
@@ -875,7 +875,7 @@ class AssetsApi {
|
||||
|
||||
/// Replace asset
|
||||
///
|
||||
/// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id.
|
||||
/// Replace the asset with new file, without changing its id.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -969,7 +969,7 @@ class AssetsApi {
|
||||
|
||||
/// Replace asset
|
||||
///
|
||||
/// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id.
|
||||
/// Replace the asset with new file, without changing its id.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
24
mobile/openapi/lib/api/deprecated_api.dart
generated
24
mobile/openapi/lib/api/deprecated_api.dart
generated
@@ -18,7 +18,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Create a partner
|
||||
///
|
||||
/// This property was deprecated in v1.141.0. Create a new partner to share assets with.
|
||||
/// Create a new partner to share assets with.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -53,7 +53,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Create a partner
|
||||
///
|
||||
/// This property was deprecated in v1.141.0. Create a new partner to share assets with.
|
||||
/// Create a new partner to share assets with.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
@@ -75,7 +75,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Retrieve assets by device ID
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Get all asset of a device that are in the database, ID only.
|
||||
/// Get all asset of a device that are in the database, ID only.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -110,7 +110,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Retrieve assets by device ID
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Get all asset of a device that are in the database, ID only.
|
||||
/// Get all asset of a device that are in the database, ID only.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
@@ -135,7 +135,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Get delta sync for user
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Retrieve changed assets since the last sync for the authenticated user.
|
||||
/// Retrieve changed assets since the last sync for the authenticated user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -169,7 +169,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Get delta sync for user
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Retrieve changed assets since the last sync for the authenticated user.
|
||||
/// Retrieve changed assets since the last sync for the authenticated user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
@@ -191,7 +191,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Get full sync for user
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Retrieve all assets for a full synchronization for the authenticated user.
|
||||
/// Retrieve all assets for a full synchronization for the authenticated user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -225,7 +225,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Get full sync for user
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Retrieve all assets for a full synchronization for the authenticated user.
|
||||
/// Retrieve all assets for a full synchronization for the authenticated user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
@@ -250,7 +250,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Get random assets
|
||||
///
|
||||
/// This property was deprecated in v1.116.0. Retrieve a specified number of random assets for the authenticated user.
|
||||
/// Retrieve a specified number of random assets for the authenticated user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -288,7 +288,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Get random assets
|
||||
///
|
||||
/// This property was deprecated in v1.116.0. Retrieve a specified number of random assets for the authenticated user.
|
||||
/// Retrieve a specified number of random assets for the authenticated user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
@@ -313,7 +313,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Replace asset
|
||||
///
|
||||
/// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id.
|
||||
/// Replace the asset with new file, without changing its id.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -407,7 +407,7 @@ class DeprecatedApi {
|
||||
|
||||
/// Replace asset
|
||||
///
|
||||
/// This property was deprecated in v1.142.0. Replace the asset with new file, without changing its id.
|
||||
/// Replace the asset with new file, without changing its id.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
30
mobile/openapi/lib/api/jobs_api.dart
generated
30
mobile/openapi/lib/api/jobs_api.dart
generated
@@ -69,7 +69,7 @@ class JobsApi {
|
||||
/// Retrieve the counts of the current queue, as well as the current status.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getAllJobsStatusWithHttpInfo() async {
|
||||
Future<Response> getQueuesLegacyWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/jobs';
|
||||
|
||||
@@ -97,8 +97,8 @@ class JobsApi {
|
||||
/// Retrieve queue counts and status
|
||||
///
|
||||
/// Retrieve the counts of the current queue, as well as the current status.
|
||||
Future<AllJobStatusResponseDto?> getAllJobsStatus() async {
|
||||
final response = await getAllJobsStatusWithHttpInfo();
|
||||
Future<QueuesResponseDto?> getQueuesLegacy() async {
|
||||
final response = await getQueuesLegacyWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -106,7 +106,7 @@ class JobsApi {
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AllJobStatusResponseDto',) as AllJobStatusResponseDto;
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseDto',) as QueuesResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
@@ -120,16 +120,16 @@ class JobsApi {
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [JobName] id (required):
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [JobCommandDto] jobCommandDto (required):
|
||||
Future<Response> sendJobCommandWithHttpInfo(JobName id, JobCommandDto jobCommandDto,) async {
|
||||
/// * [QueueCommandDto] queueCommandDto (required):
|
||||
Future<Response> runQueueCommandLegacyWithHttpInfo(QueueName name, QueueCommandDto queueCommandDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/jobs/{id}'
|
||||
.replaceAll('{id}', id.toString());
|
||||
final apiPath = r'/jobs/{name}'
|
||||
.replaceAll('{name}', name.toString());
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = jobCommandDto;
|
||||
Object? postBody = queueCommandDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
@@ -155,11 +155,11 @@ class JobsApi {
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [JobName] id (required):
|
||||
/// * [QueueName] name (required):
|
||||
///
|
||||
/// * [JobCommandDto] jobCommandDto (required):
|
||||
Future<JobStatusDto?> sendJobCommand(JobName id, JobCommandDto jobCommandDto,) async {
|
||||
final response = await sendJobCommandWithHttpInfo(id, jobCommandDto,);
|
||||
/// * [QueueCommandDto] queueCommandDto (required):
|
||||
Future<QueueResponseDto?> runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async {
|
||||
final response = await runQueueCommandLegacyWithHttpInfo(name, queueCommandDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -167,7 +167,7 @@ class JobsApi {
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'JobStatusDto',) as JobStatusDto;
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
|
||||
122
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
Normal file
122
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
Normal file
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class MaintenanceAdminApi {
|
||||
MaintenanceAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Log into maintenance mode
|
||||
///
|
||||
/// Login with maintenance token or cookie to receive current information and perform further actions.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [MaintenanceLoginDto] maintenanceLoginDto (required):
|
||||
Future<Response> maintenanceLoginWithHttpInfo(MaintenanceLoginDto maintenanceLoginDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/maintenance/login';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = maintenanceLoginDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Log into maintenance mode
|
||||
///
|
||||
/// Login with maintenance token or cookie to receive current information and perform further actions.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [MaintenanceLoginDto] maintenanceLoginDto (required):
|
||||
Future<MaintenanceAuthDto?> maintenanceLogin(MaintenanceLoginDto maintenanceLoginDto,) async {
|
||||
final response = await maintenanceLoginWithHttpInfo(maintenanceLoginDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceAuthDto',) as MaintenanceAuthDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Set maintenance mode
|
||||
///
|
||||
/// Put Immich into or take it out of maintenance mode
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [SetMaintenanceModeDto] setMaintenanceModeDto (required):
|
||||
Future<Response> setMaintenanceModeWithHttpInfo(SetMaintenanceModeDto setMaintenanceModeDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/maintenance';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = setMaintenanceModeDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set maintenance mode
|
||||
///
|
||||
/// Put Immich into or take it out of maintenance mode
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [SetMaintenanceModeDto] setMaintenanceModeDto (required):
|
||||
Future<void> setMaintenanceMode(SetMaintenanceModeDto setMaintenanceModeDto,) async {
|
||||
final response = await setMaintenanceModeWithHttpInfo(setMaintenanceModeDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
4
mobile/openapi/lib/api/partners_api.dart
generated
4
mobile/openapi/lib/api/partners_api.dart
generated
@@ -74,7 +74,7 @@ class PartnersApi {
|
||||
|
||||
/// Create a partner
|
||||
///
|
||||
/// This property was deprecated in v1.141.0. Create a new partner to share assets with.
|
||||
/// Create a new partner to share assets with.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -109,7 +109,7 @@ class PartnersApi {
|
||||
|
||||
/// Create a partner
|
||||
///
|
||||
/// This property was deprecated in v1.141.0. Create a new partner to share assets with.
|
||||
/// Create a new partner to share assets with.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
126
mobile/openapi/lib/api/plugins_api.dart
generated
Normal file
126
mobile/openapi/lib/api/plugins_api.dart
generated
Normal file
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class PluginsApi {
|
||||
PluginsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Retrieve a plugin
|
||||
///
|
||||
/// Retrieve information about a specific plugin by its ID.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<Response> getPluginWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/plugins/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve a plugin
|
||||
///
|
||||
/// Retrieve information about a specific plugin by its ID.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<PluginResponseDto?> getPlugin(String id,) async {
|
||||
final response = await getPluginWithHttpInfo(id,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PluginResponseDto',) as PluginResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all plugins
|
||||
///
|
||||
/// Retrieve a list of plugins available to the authenticated user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getPluginsWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/plugins';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// List all plugins
|
||||
///
|
||||
/// Retrieve a list of plugins available to the authenticated user.
|
||||
Future<List<PluginResponseDto>?> getPlugins() async {
|
||||
final response = await getPluginsWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<PluginResponseDto>') as List)
|
||||
.cast<PluginResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2
mobile/openapi/lib/api/search_api.dart
generated
2
mobile/openapi/lib/api/search_api.dart
generated
@@ -131,7 +131,6 @@ class SearchApi {
|
||||
/// * [String] country:
|
||||
///
|
||||
/// * [bool] includeNull:
|
||||
/// This property was added in v111.0.0
|
||||
///
|
||||
/// * [String] lensModel:
|
||||
///
|
||||
@@ -196,7 +195,6 @@ class SearchApi {
|
||||
/// * [String] country:
|
||||
///
|
||||
/// * [bool] includeNull:
|
||||
/// This property was added in v111.0.0
|
||||
///
|
||||
/// * [String] lensModel:
|
||||
///
|
||||
|
||||
8
mobile/openapi/lib/api/sync_api.dart
generated
8
mobile/openapi/lib/api/sync_api.dart
generated
@@ -66,7 +66,7 @@ class SyncApi {
|
||||
|
||||
/// Get delta sync for user
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Retrieve changed assets since the last sync for the authenticated user.
|
||||
/// Retrieve changed assets since the last sync for the authenticated user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -100,7 +100,7 @@ class SyncApi {
|
||||
|
||||
/// Get delta sync for user
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Retrieve changed assets since the last sync for the authenticated user.
|
||||
/// Retrieve changed assets since the last sync for the authenticated user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
@@ -122,7 +122,7 @@ class SyncApi {
|
||||
|
||||
/// Get full sync for user
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Retrieve all assets for a full synchronization for the authenticated user.
|
||||
/// Retrieve all assets for a full synchronization for the authenticated user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -156,7 +156,7 @@ class SyncApi {
|
||||
|
||||
/// Get full sync for user
|
||||
///
|
||||
/// This property was deprecated in v2.0.0. Retrieve all assets for a full synchronization for the authenticated user.
|
||||
/// Retrieve all assets for a full synchronization for the authenticated user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
292
mobile/openapi/lib/api/workflows_api.dart
generated
Normal file
292
mobile/openapi/lib/api/workflows_api.dart
generated
Normal file
@@ -0,0 +1,292 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class WorkflowsApi {
|
||||
WorkflowsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Create a workflow
|
||||
///
|
||||
/// Create a new workflow, the workflow can also be created with empty filters and actions.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [WorkflowCreateDto] workflowCreateDto (required):
|
||||
Future<Response> createWorkflowWithHttpInfo(WorkflowCreateDto workflowCreateDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = workflowCreateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a workflow
|
||||
///
|
||||
/// Create a new workflow, the workflow can also be created with empty filters and actions.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [WorkflowCreateDto] workflowCreateDto (required):
|
||||
Future<WorkflowResponseDto?> createWorkflow(WorkflowCreateDto workflowCreateDto,) async {
|
||||
final response = await createWorkflowWithHttpInfo(workflowCreateDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Delete a workflow
|
||||
///
|
||||
/// Delete a workflow by its ID.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<Response> deleteWorkflowWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete a workflow
|
||||
///
|
||||
/// Delete a workflow by its ID.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<void> deleteWorkflow(String id,) async {
|
||||
final response = await deleteWorkflowWithHttpInfo(id,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a workflow
|
||||
///
|
||||
/// Retrieve information about a specific workflow by its ID.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<Response> getWorkflowWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve a workflow
|
||||
///
|
||||
/// Retrieve information about a specific workflow by its ID.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<WorkflowResponseDto?> getWorkflow(String id,) async {
|
||||
final response = await getWorkflowWithHttpInfo(id,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all workflows
|
||||
///
|
||||
/// Retrieve a list of workflows available to the authenticated user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getWorkflowsWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// List all workflows
|
||||
///
|
||||
/// Retrieve a list of workflows available to the authenticated user.
|
||||
Future<List<WorkflowResponseDto>?> getWorkflows() async {
|
||||
final response = await getWorkflowsWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<WorkflowResponseDto>') as List)
|
||||
.cast<WorkflowResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update a workflow
|
||||
///
|
||||
/// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [WorkflowUpdateDto] workflowUpdateDto (required):
|
||||
Future<Response> updateWorkflowWithHttpInfo(String id, WorkflowUpdateDto workflowUpdateDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = workflowUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a workflow
|
||||
///
|
||||
/// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [WorkflowUpdateDto] workflowUpdateDto (required):
|
||||
Future<WorkflowResponseDto?> updateWorkflow(String id, WorkflowUpdateDto workflowUpdateDto,) async {
|
||||
final response = await updateWorkflowWithHttpInfo(id, workflowUpdateDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
56
mobile/openapi/lib/api_client.dart
generated
56
mobile/openapi/lib/api_client.dart
generated
@@ -220,8 +220,6 @@ class ApiClient {
|
||||
return AlbumsResponse.fromJson(value);
|
||||
case 'AlbumsUpdate':
|
||||
return AlbumsUpdate.fromJson(value);
|
||||
case 'AllJobStatusResponseDto':
|
||||
return AllJobStatusResponseDto.fromJson(value);
|
||||
case 'AssetBulkDeleteDto':
|
||||
return AssetBulkDeleteDto.fromJson(value);
|
||||
case 'AssetBulkUpdateDto':
|
||||
@@ -358,20 +356,10 @@ class ApiClient {
|
||||
return FoldersUpdate.fromJson(value);
|
||||
case 'ImageFormat':
|
||||
return ImageFormatTypeTransformer().decode(value);
|
||||
case 'JobCommand':
|
||||
return JobCommandTypeTransformer().decode(value);
|
||||
case 'JobCommandDto':
|
||||
return JobCommandDto.fromJson(value);
|
||||
case 'JobCountsDto':
|
||||
return JobCountsDto.fromJson(value);
|
||||
case 'JobCreateDto':
|
||||
return JobCreateDto.fromJson(value);
|
||||
case 'JobName':
|
||||
return JobNameTypeTransformer().decode(value);
|
||||
case 'JobSettingsDto':
|
||||
return JobSettingsDto.fromJson(value);
|
||||
case 'JobStatusDto':
|
||||
return JobStatusDto.fromJson(value);
|
||||
case 'LibraryResponseDto':
|
||||
return LibraryResponseDto.fromJson(value);
|
||||
case 'LibraryStatsResponseDto':
|
||||
@@ -390,6 +378,12 @@ class ApiClient {
|
||||
return LogoutResponseDto.fromJson(value);
|
||||
case 'MachineLearningAvailabilityChecksDto':
|
||||
return MachineLearningAvailabilityChecksDto.fromJson(value);
|
||||
case 'MaintenanceAction':
|
||||
return MaintenanceActionTypeTransformer().decode(value);
|
||||
case 'MaintenanceAuthDto':
|
||||
return MaintenanceAuthDto.fromJson(value);
|
||||
case 'MaintenanceLoginDto':
|
||||
return MaintenanceLoginDto.fromJson(value);
|
||||
case 'ManualJobName':
|
||||
return ManualJobNameTypeTransformer().decode(value);
|
||||
case 'MapMarkerResponseDto':
|
||||
@@ -484,12 +478,34 @@ class ApiClient {
|
||||
return PinCodeSetupDto.fromJson(value);
|
||||
case 'PlacesResponseDto':
|
||||
return PlacesResponseDto.fromJson(value);
|
||||
case 'PluginActionResponseDto':
|
||||
return PluginActionResponseDto.fromJson(value);
|
||||
case 'PluginContext':
|
||||
return PluginContextTypeTransformer().decode(value);
|
||||
case 'PluginFilterResponseDto':
|
||||
return PluginFilterResponseDto.fromJson(value);
|
||||
case 'PluginResponseDto':
|
||||
return PluginResponseDto.fromJson(value);
|
||||
case 'PluginTriggerType':
|
||||
return PluginTriggerTypeTypeTransformer().decode(value);
|
||||
case 'PurchaseResponse':
|
||||
return PurchaseResponse.fromJson(value);
|
||||
case 'PurchaseUpdate':
|
||||
return PurchaseUpdate.fromJson(value);
|
||||
case 'QueueCommand':
|
||||
return QueueCommandTypeTransformer().decode(value);
|
||||
case 'QueueCommandDto':
|
||||
return QueueCommandDto.fromJson(value);
|
||||
case 'QueueName':
|
||||
return QueueNameTypeTransformer().decode(value);
|
||||
case 'QueueResponseDto':
|
||||
return QueueResponseDto.fromJson(value);
|
||||
case 'QueueStatisticsDto':
|
||||
return QueueStatisticsDto.fromJson(value);
|
||||
case 'QueueStatusDto':
|
||||
return QueueStatusDto.fromJson(value);
|
||||
case 'QueuesResponseDto':
|
||||
return QueuesResponseDto.fromJson(value);
|
||||
case 'RandomSearchDto':
|
||||
return RandomSearchDto.fromJson(value);
|
||||
case 'RatingsResponse':
|
||||
@@ -552,6 +568,8 @@ class ApiClient {
|
||||
return SessionUnlockDto.fromJson(value);
|
||||
case 'SessionUpdateDto':
|
||||
return SessionUpdateDto.fromJson(value);
|
||||
case 'SetMaintenanceModeDto':
|
||||
return SetMaintenanceModeDto.fromJson(value);
|
||||
case 'SharedLinkCreateDto':
|
||||
return SharedLinkCreateDto.fromJson(value);
|
||||
case 'SharedLinkEditDto':
|
||||
@@ -790,6 +808,20 @@ class ApiClient {
|
||||
return VideoCodecTypeTransformer().decode(value);
|
||||
case 'VideoContainer':
|
||||
return VideoContainerTypeTransformer().decode(value);
|
||||
case 'WorkflowActionItemDto':
|
||||
return WorkflowActionItemDto.fromJson(value);
|
||||
case 'WorkflowActionResponseDto':
|
||||
return WorkflowActionResponseDto.fromJson(value);
|
||||
case 'WorkflowCreateDto':
|
||||
return WorkflowCreateDto.fromJson(value);
|
||||
case 'WorkflowFilterItemDto':
|
||||
return WorkflowFilterItemDto.fromJson(value);
|
||||
case 'WorkflowFilterResponseDto':
|
||||
return WorkflowFilterResponseDto.fromJson(value);
|
||||
case 'WorkflowResponseDto':
|
||||
return WorkflowResponseDto.fromJson(value);
|
||||
case 'WorkflowUpdateDto':
|
||||
return WorkflowUpdateDto.fromJson(value);
|
||||
default:
|
||||
dynamic match;
|
||||
if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) {
|
||||
|
||||
21
mobile/openapi/lib/api_helper.dart
generated
21
mobile/openapi/lib/api_helper.dart
generated
@@ -94,15 +94,12 @@ String parameterToString(dynamic value) {
|
||||
if (value is ImageFormat) {
|
||||
return ImageFormatTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is JobCommand) {
|
||||
return JobCommandTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is JobName) {
|
||||
return JobNameTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is LogLevel) {
|
||||
return LogLevelTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is MaintenanceAction) {
|
||||
return MaintenanceActionTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is ManualJobName) {
|
||||
return ManualJobNameTypeTransformer().encode(value).toString();
|
||||
}
|
||||
@@ -127,6 +124,18 @@ String parameterToString(dynamic value) {
|
||||
if (value is Permission) {
|
||||
return PermissionTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is PluginContext) {
|
||||
return PluginContextTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is PluginTriggerType) {
|
||||
return PluginTriggerTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is QueueCommand) {
|
||||
return QueueCommandTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is QueueName) {
|
||||
return QueueNameTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is ReactionLevel) {
|
||||
return ReactionLevelTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
2
mobile/openapi/lib/model/asset_response_dto.dart
generated
2
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -87,7 +87,6 @@ class AssetResponseDto {
|
||||
|
||||
bool isTrashed;
|
||||
|
||||
/// This property was deprecated in v1.106.0
|
||||
String? libraryId;
|
||||
|
||||
String? livePhotoVideoId;
|
||||
@@ -119,7 +118,6 @@ class AssetResponseDto {
|
||||
|
||||
List<PersonWithFacesResponseDto> people;
|
||||
|
||||
/// This property was deprecated in v1.113.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
|
||||
94
mobile/openapi/lib/model/job_command.dart
generated
94
mobile/openapi/lib/model/job_command.dart
generated
@@ -1,94 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class JobCommand {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const JobCommand._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const start = JobCommand._(r'start');
|
||||
static const pause = JobCommand._(r'pause');
|
||||
static const resume = JobCommand._(r'resume');
|
||||
static const empty = JobCommand._(r'empty');
|
||||
static const clearFailed = JobCommand._(r'clear-failed');
|
||||
|
||||
/// List of all possible values in this [enum][JobCommand].
|
||||
static const values = <JobCommand>[
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
empty,
|
||||
clearFailed,
|
||||
];
|
||||
|
||||
static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value);
|
||||
|
||||
static List<JobCommand> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <JobCommand>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = JobCommand.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [JobCommand] to String,
|
||||
/// and [decode] dynamic data back to [JobCommand].
|
||||
class JobCommandTypeTransformer {
|
||||
factory JobCommandTypeTransformer() => _instance ??= const JobCommandTypeTransformer._();
|
||||
|
||||
const JobCommandTypeTransformer._();
|
||||
|
||||
String encode(JobCommand data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a JobCommand.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
JobCommand? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'start': return JobCommand.start;
|
||||
case r'pause': return JobCommand.pause;
|
||||
case r'resume': return JobCommand.resume;
|
||||
case r'empty': return JobCommand.empty;
|
||||
case r'clear-failed': return JobCommand.clearFailed;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [JobCommandTypeTransformer] instance.
|
||||
static JobCommandTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
127
mobile/openapi/lib/model/job_name.dart
generated
127
mobile/openapi/lib/model/job_name.dart
generated
@@ -1,127 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class JobName {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const JobName._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const thumbnailGeneration = JobName._(r'thumbnailGeneration');
|
||||
static const metadataExtraction = JobName._(r'metadataExtraction');
|
||||
static const videoConversion = JobName._(r'videoConversion');
|
||||
static const faceDetection = JobName._(r'faceDetection');
|
||||
static const facialRecognition = JobName._(r'facialRecognition');
|
||||
static const smartSearch = JobName._(r'smartSearch');
|
||||
static const duplicateDetection = JobName._(r'duplicateDetection');
|
||||
static const backgroundTask = JobName._(r'backgroundTask');
|
||||
static const storageTemplateMigration = JobName._(r'storageTemplateMigration');
|
||||
static const migration = JobName._(r'migration');
|
||||
static const search = JobName._(r'search');
|
||||
static const sidecar = JobName._(r'sidecar');
|
||||
static const library_ = JobName._(r'library');
|
||||
static const notifications = JobName._(r'notifications');
|
||||
static const backupDatabase = JobName._(r'backupDatabase');
|
||||
static const ocr = JobName._(r'ocr');
|
||||
|
||||
/// List of all possible values in this [enum][JobName].
|
||||
static const values = <JobName>[
|
||||
thumbnailGeneration,
|
||||
metadataExtraction,
|
||||
videoConversion,
|
||||
faceDetection,
|
||||
facialRecognition,
|
||||
smartSearch,
|
||||
duplicateDetection,
|
||||
backgroundTask,
|
||||
storageTemplateMigration,
|
||||
migration,
|
||||
search,
|
||||
sidecar,
|
||||
library_,
|
||||
notifications,
|
||||
backupDatabase,
|
||||
ocr,
|
||||
];
|
||||
|
||||
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
|
||||
|
||||
static List<JobName> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <JobName>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = JobName.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [JobName] to String,
|
||||
/// and [decode] dynamic data back to [JobName].
|
||||
class JobNameTypeTransformer {
|
||||
factory JobNameTypeTransformer() => _instance ??= const JobNameTypeTransformer._();
|
||||
|
||||
const JobNameTypeTransformer._();
|
||||
|
||||
String encode(JobName data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a JobName.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
JobName? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'thumbnailGeneration': return JobName.thumbnailGeneration;
|
||||
case r'metadataExtraction': return JobName.metadataExtraction;
|
||||
case r'videoConversion': return JobName.videoConversion;
|
||||
case r'faceDetection': return JobName.faceDetection;
|
||||
case r'facialRecognition': return JobName.facialRecognition;
|
||||
case r'smartSearch': return JobName.smartSearch;
|
||||
case r'duplicateDetection': return JobName.duplicateDetection;
|
||||
case r'backgroundTask': return JobName.backgroundTask;
|
||||
case r'storageTemplateMigration': return JobName.storageTemplateMigration;
|
||||
case r'migration': return JobName.migration;
|
||||
case r'search': return JobName.search;
|
||||
case r'sidecar': return JobName.sidecar;
|
||||
case r'library': return JobName.library_;
|
||||
case r'notifications': return JobName.notifications;
|
||||
case r'backupDatabase': return JobName.backupDatabase;
|
||||
case r'ocr': return JobName.ocr;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [JobNameTypeTransformer] instance.
|
||||
static JobNameTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
85
mobile/openapi/lib/model/maintenance_action.dart
generated
Normal file
85
mobile/openapi/lib/model/maintenance_action.dart
generated
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class MaintenanceAction {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const MaintenanceAction._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const start = MaintenanceAction._(r'start');
|
||||
static const end = MaintenanceAction._(r'end');
|
||||
|
||||
/// List of all possible values in this [enum][MaintenanceAction].
|
||||
static const values = <MaintenanceAction>[
|
||||
start,
|
||||
end,
|
||||
];
|
||||
|
||||
static MaintenanceAction? fromJson(dynamic value) => MaintenanceActionTypeTransformer().decode(value);
|
||||
|
||||
static List<MaintenanceAction> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MaintenanceAction>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MaintenanceAction.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [MaintenanceAction] to String,
|
||||
/// and [decode] dynamic data back to [MaintenanceAction].
|
||||
class MaintenanceActionTypeTransformer {
|
||||
factory MaintenanceActionTypeTransformer() => _instance ??= const MaintenanceActionTypeTransformer._();
|
||||
|
||||
const MaintenanceActionTypeTransformer._();
|
||||
|
||||
String encode(MaintenanceAction data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a MaintenanceAction.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
MaintenanceAction? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'start': return MaintenanceAction.start;
|
||||
case r'end': return MaintenanceAction.end;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [MaintenanceActionTypeTransformer] instance.
|
||||
static MaintenanceActionTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
99
mobile/openapi/lib/model/maintenance_auth_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/maintenance_auth_dto.dart
generated
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class MaintenanceAuthDto {
|
||||
/// Returns a new [MaintenanceAuthDto] instance.
|
||||
MaintenanceAuthDto({
|
||||
required this.username,
|
||||
});
|
||||
|
||||
String username;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MaintenanceAuthDto &&
|
||||
other.username == username;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(username.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MaintenanceAuthDto[username=$username]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'username'] = this.username;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [MaintenanceAuthDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static MaintenanceAuthDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "MaintenanceAuthDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MaintenanceAuthDto(
|
||||
username: mapValueOfType<String>(json, r'username')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<MaintenanceAuthDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MaintenanceAuthDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MaintenanceAuthDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, MaintenanceAuthDto> mapFromJson(dynamic json) {
|
||||
final map = <String, MaintenanceAuthDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = MaintenanceAuthDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of MaintenanceAuthDto-objects as value to a dart map
|
||||
static Map<String, List<MaintenanceAuthDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<MaintenanceAuthDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = MaintenanceAuthDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'username',
|
||||
};
|
||||
}
|
||||
|
||||
108
mobile/openapi/lib/model/maintenance_login_dto.dart
generated
Normal file
108
mobile/openapi/lib/model/maintenance_login_dto.dart
generated
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class MaintenanceLoginDto {
|
||||
/// Returns a new [MaintenanceLoginDto] instance.
|
||||
MaintenanceLoginDto({
|
||||
this.token,
|
||||
});
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? token;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MaintenanceLoginDto &&
|
||||
other.token == token;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(token == null ? 0 : token!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MaintenanceLoginDto[token=$token]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.token != null) {
|
||||
json[r'token'] = this.token;
|
||||
} else {
|
||||
// json[r'token'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [MaintenanceLoginDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static MaintenanceLoginDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "MaintenanceLoginDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MaintenanceLoginDto(
|
||||
token: mapValueOfType<String>(json, r'token'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<MaintenanceLoginDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MaintenanceLoginDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MaintenanceLoginDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, MaintenanceLoginDto> mapFromJson(dynamic json) {
|
||||
final map = <String, MaintenanceLoginDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = MaintenanceLoginDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of MaintenanceLoginDto-objects as value to a dart map
|
||||
static Map<String, List<MaintenanceLoginDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<MaintenanceLoginDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = MaintenanceLoginDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ class PeopleResponseDto {
|
||||
required this.total,
|
||||
});
|
||||
|
||||
/// This property was added in v1.110.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
|
||||
27
mobile/openapi/lib/model/permission.dart
generated
27
mobile/openapi/lib/model/permission.dart
generated
@@ -73,6 +73,7 @@ class Permission {
|
||||
static const libraryPeriodStatistics = Permission._(r'library.statistics');
|
||||
static const timelinePeriodRead = Permission._(r'timeline.read');
|
||||
static const timelinePeriodDownload = Permission._(r'timeline.download');
|
||||
static const maintenance = Permission._(r'maintenance');
|
||||
static const memoryPeriodCreate = Permission._(r'memory.create');
|
||||
static const memoryPeriodRead = Permission._(r'memory.read');
|
||||
static const memoryPeriodUpdate = Permission._(r'memory.update');
|
||||
@@ -98,6 +99,10 @@ class Permission {
|
||||
static const pinCodePeriodCreate = Permission._(r'pinCode.create');
|
||||
static const pinCodePeriodUpdate = Permission._(r'pinCode.update');
|
||||
static const pinCodePeriodDelete = Permission._(r'pinCode.delete');
|
||||
static const pluginPeriodCreate = Permission._(r'plugin.create');
|
||||
static const pluginPeriodRead = Permission._(r'plugin.read');
|
||||
static const pluginPeriodUpdate = Permission._(r'plugin.update');
|
||||
static const pluginPeriodDelete = Permission._(r'plugin.delete');
|
||||
static const serverPeriodAbout = Permission._(r'server.about');
|
||||
static const serverPeriodApkLinks = Permission._(r'server.apkLinks');
|
||||
static const serverPeriodStorage = Permission._(r'server.storage');
|
||||
@@ -147,6 +152,10 @@ class Permission {
|
||||
static const userProfileImagePeriodRead = Permission._(r'userProfileImage.read');
|
||||
static const userProfileImagePeriodUpdate = Permission._(r'userProfileImage.update');
|
||||
static const userProfileImagePeriodDelete = Permission._(r'userProfileImage.delete');
|
||||
static const workflowPeriodCreate = Permission._(r'workflow.create');
|
||||
static const workflowPeriodRead = Permission._(r'workflow.read');
|
||||
static const workflowPeriodUpdate = Permission._(r'workflow.update');
|
||||
static const workflowPeriodDelete = Permission._(r'workflow.delete');
|
||||
static const adminUserPeriodCreate = Permission._(r'adminUser.create');
|
||||
static const adminUserPeriodRead = Permission._(r'adminUser.read');
|
||||
static const adminUserPeriodUpdate = Permission._(r'adminUser.update');
|
||||
@@ -206,6 +215,7 @@ class Permission {
|
||||
libraryPeriodStatistics,
|
||||
timelinePeriodRead,
|
||||
timelinePeriodDownload,
|
||||
maintenance,
|
||||
memoryPeriodCreate,
|
||||
memoryPeriodRead,
|
||||
memoryPeriodUpdate,
|
||||
@@ -231,6 +241,10 @@ class Permission {
|
||||
pinCodePeriodCreate,
|
||||
pinCodePeriodUpdate,
|
||||
pinCodePeriodDelete,
|
||||
pluginPeriodCreate,
|
||||
pluginPeriodRead,
|
||||
pluginPeriodUpdate,
|
||||
pluginPeriodDelete,
|
||||
serverPeriodAbout,
|
||||
serverPeriodApkLinks,
|
||||
serverPeriodStorage,
|
||||
@@ -280,6 +294,10 @@ class Permission {
|
||||
userProfileImagePeriodRead,
|
||||
userProfileImagePeriodUpdate,
|
||||
userProfileImagePeriodDelete,
|
||||
workflowPeriodCreate,
|
||||
workflowPeriodRead,
|
||||
workflowPeriodUpdate,
|
||||
workflowPeriodDelete,
|
||||
adminUserPeriodCreate,
|
||||
adminUserPeriodRead,
|
||||
adminUserPeriodUpdate,
|
||||
@@ -374,6 +392,7 @@ class PermissionTypeTransformer {
|
||||
case r'library.statistics': return Permission.libraryPeriodStatistics;
|
||||
case r'timeline.read': return Permission.timelinePeriodRead;
|
||||
case r'timeline.download': return Permission.timelinePeriodDownload;
|
||||
case r'maintenance': return Permission.maintenance;
|
||||
case r'memory.create': return Permission.memoryPeriodCreate;
|
||||
case r'memory.read': return Permission.memoryPeriodRead;
|
||||
case r'memory.update': return Permission.memoryPeriodUpdate;
|
||||
@@ -399,6 +418,10 @@ class PermissionTypeTransformer {
|
||||
case r'pinCode.create': return Permission.pinCodePeriodCreate;
|
||||
case r'pinCode.update': return Permission.pinCodePeriodUpdate;
|
||||
case r'pinCode.delete': return Permission.pinCodePeriodDelete;
|
||||
case r'plugin.create': return Permission.pluginPeriodCreate;
|
||||
case r'plugin.read': return Permission.pluginPeriodRead;
|
||||
case r'plugin.update': return Permission.pluginPeriodUpdate;
|
||||
case r'plugin.delete': return Permission.pluginPeriodDelete;
|
||||
case r'server.about': return Permission.serverPeriodAbout;
|
||||
case r'server.apkLinks': return Permission.serverPeriodApkLinks;
|
||||
case r'server.storage': return Permission.serverPeriodStorage;
|
||||
@@ -448,6 +471,10 @@ class PermissionTypeTransformer {
|
||||
case r'userProfileImage.read': return Permission.userProfileImagePeriodRead;
|
||||
case r'userProfileImage.update': return Permission.userProfileImagePeriodUpdate;
|
||||
case r'userProfileImage.delete': return Permission.userProfileImagePeriodDelete;
|
||||
case r'workflow.create': return Permission.workflowPeriodCreate;
|
||||
case r'workflow.read': return Permission.workflowPeriodRead;
|
||||
case r'workflow.update': return Permission.workflowPeriodUpdate;
|
||||
case r'workflow.delete': return Permission.workflowPeriodDelete;
|
||||
case r'adminUser.create': return Permission.adminUserPeriodCreate;
|
||||
case r'adminUser.read': return Permission.adminUserPeriodRead;
|
||||
case r'adminUser.update': return Permission.adminUserPeriodUpdate;
|
||||
|
||||
@@ -25,7 +25,6 @@ class PersonResponseDto {
|
||||
|
||||
DateTime? birthDate;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@@ -36,7 +35,6 @@ class PersonResponseDto {
|
||||
|
||||
String id;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@@ -51,7 +49,6 @@ class PersonResponseDto {
|
||||
|
||||
String thumbnailPath;
|
||||
|
||||
/// This property was added in v1.107.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
|
||||
@@ -26,7 +26,6 @@ class PersonWithFacesResponseDto {
|
||||
|
||||
DateTime? birthDate;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@@ -39,7 +38,6 @@ class PersonWithFacesResponseDto {
|
||||
|
||||
String id;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@@ -54,7 +52,6 @@ class PersonWithFacesResponseDto {
|
||||
|
||||
String thumbnailPath;
|
||||
|
||||
/// This property was added in v1.107.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
|
||||
151
mobile/openapi/lib/model/plugin_action_response_dto.dart
generated
Normal file
151
mobile/openapi/lib/model/plugin_action_response_dto.dart
generated
Normal file
@@ -0,0 +1,151 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class PluginActionResponseDto {
|
||||
/// Returns a new [PluginActionResponseDto] instance.
|
||||
PluginActionResponseDto({
|
||||
required this.description,
|
||||
required this.id,
|
||||
required this.methodName,
|
||||
required this.pluginId,
|
||||
required this.schema,
|
||||
this.supportedContexts = const [],
|
||||
required this.title,
|
||||
});
|
||||
|
||||
String description;
|
||||
|
||||
String id;
|
||||
|
||||
String methodName;
|
||||
|
||||
String pluginId;
|
||||
|
||||
Object? schema;
|
||||
|
||||
List<PluginContext> supportedContexts;
|
||||
|
||||
String title;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginActionResponseDto &&
|
||||
other.description == description &&
|
||||
other.id == id &&
|
||||
other.methodName == methodName &&
|
||||
other.pluginId == pluginId &&
|
||||
other.schema == schema &&
|
||||
_deepEquality.equals(other.supportedContexts, supportedContexts) &&
|
||||
other.title == title;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description.hashCode) +
|
||||
(id.hashCode) +
|
||||
(methodName.hashCode) +
|
||||
(pluginId.hashCode) +
|
||||
(schema == null ? 0 : schema!.hashCode) +
|
||||
(supportedContexts.hashCode) +
|
||||
(title.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginActionResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'description'] = this.description;
|
||||
json[r'id'] = this.id;
|
||||
json[r'methodName'] = this.methodName;
|
||||
json[r'pluginId'] = this.pluginId;
|
||||
if (this.schema != null) {
|
||||
json[r'schema'] = this.schema;
|
||||
} else {
|
||||
// json[r'schema'] = null;
|
||||
}
|
||||
json[r'supportedContexts'] = this.supportedContexts;
|
||||
json[r'title'] = this.title;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PluginActionResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PluginActionResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginActionResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginActionResponseDto(
|
||||
description: mapValueOfType<String>(json, r'description')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
methodName: mapValueOfType<String>(json, r'methodName')!,
|
||||
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
||||
schema: mapValueOfType<Object>(json, r'schema'),
|
||||
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PluginActionResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginActionResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginActionResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PluginActionResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginActionResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PluginActionResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PluginActionResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginActionResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginActionResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PluginActionResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'description',
|
||||
'id',
|
||||
'methodName',
|
||||
'pluginId',
|
||||
'schema',
|
||||
'supportedContexts',
|
||||
'title',
|
||||
};
|
||||
}
|
||||
|
||||
88
mobile/openapi/lib/model/plugin_context.dart
generated
Normal file
88
mobile/openapi/lib/model/plugin_context.dart
generated
Normal file
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class PluginContext {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const PluginContext._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const asset = PluginContext._(r'asset');
|
||||
static const album = PluginContext._(r'album');
|
||||
static const person = PluginContext._(r'person');
|
||||
|
||||
/// List of all possible values in this [enum][PluginContext].
|
||||
static const values = <PluginContext>[
|
||||
asset,
|
||||
album,
|
||||
person,
|
||||
];
|
||||
|
||||
static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value);
|
||||
|
||||
static List<PluginContext> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginContext>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginContext.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [PluginContext] to String,
|
||||
/// and [decode] dynamic data back to [PluginContext].
|
||||
class PluginContextTypeTransformer {
|
||||
factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._();
|
||||
|
||||
const PluginContextTypeTransformer._();
|
||||
|
||||
String encode(PluginContext data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a PluginContext.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
PluginContext? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'asset': return PluginContext.asset;
|
||||
case r'album': return PluginContext.album;
|
||||
case r'person': return PluginContext.person;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [PluginContextTypeTransformer] instance.
|
||||
static PluginContextTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
151
mobile/openapi/lib/model/plugin_filter_response_dto.dart
generated
Normal file
151
mobile/openapi/lib/model/plugin_filter_response_dto.dart
generated
Normal file
@@ -0,0 +1,151 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class PluginFilterResponseDto {
|
||||
/// Returns a new [PluginFilterResponseDto] instance.
|
||||
PluginFilterResponseDto({
|
||||
required this.description,
|
||||
required this.id,
|
||||
required this.methodName,
|
||||
required this.pluginId,
|
||||
required this.schema,
|
||||
this.supportedContexts = const [],
|
||||
required this.title,
|
||||
});
|
||||
|
||||
String description;
|
||||
|
||||
String id;
|
||||
|
||||
String methodName;
|
||||
|
||||
String pluginId;
|
||||
|
||||
Object? schema;
|
||||
|
||||
List<PluginContext> supportedContexts;
|
||||
|
||||
String title;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginFilterResponseDto &&
|
||||
other.description == description &&
|
||||
other.id == id &&
|
||||
other.methodName == methodName &&
|
||||
other.pluginId == pluginId &&
|
||||
other.schema == schema &&
|
||||
_deepEquality.equals(other.supportedContexts, supportedContexts) &&
|
||||
other.title == title;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description.hashCode) +
|
||||
(id.hashCode) +
|
||||
(methodName.hashCode) +
|
||||
(pluginId.hashCode) +
|
||||
(schema == null ? 0 : schema!.hashCode) +
|
||||
(supportedContexts.hashCode) +
|
||||
(title.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginFilterResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'description'] = this.description;
|
||||
json[r'id'] = this.id;
|
||||
json[r'methodName'] = this.methodName;
|
||||
json[r'pluginId'] = this.pluginId;
|
||||
if (this.schema != null) {
|
||||
json[r'schema'] = this.schema;
|
||||
} else {
|
||||
// json[r'schema'] = null;
|
||||
}
|
||||
json[r'supportedContexts'] = this.supportedContexts;
|
||||
json[r'title'] = this.title;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PluginFilterResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PluginFilterResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginFilterResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginFilterResponseDto(
|
||||
description: mapValueOfType<String>(json, r'description')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
methodName: mapValueOfType<String>(json, r'methodName')!,
|
||||
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
||||
schema: mapValueOfType<Object>(json, r'schema'),
|
||||
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PluginFilterResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginFilterResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginFilterResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PluginFilterResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginFilterResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PluginFilterResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PluginFilterResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginFilterResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginFilterResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PluginFilterResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'description',
|
||||
'id',
|
||||
'methodName',
|
||||
'pluginId',
|
||||
'schema',
|
||||
'supportedContexts',
|
||||
'title',
|
||||
};
|
||||
}
|
||||
|
||||
171
mobile/openapi/lib/model/plugin_response_dto.dart
generated
Normal file
171
mobile/openapi/lib/model/plugin_response_dto.dart
generated
Normal file
@@ -0,0 +1,171 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class PluginResponseDto {
|
||||
/// Returns a new [PluginResponseDto] instance.
|
||||
PluginResponseDto({
|
||||
this.actions = const [],
|
||||
required this.author,
|
||||
required this.createdAt,
|
||||
required this.description,
|
||||
this.filters = const [],
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.title,
|
||||
required this.updatedAt,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
List<PluginActionResponseDto> actions;
|
||||
|
||||
String author;
|
||||
|
||||
String createdAt;
|
||||
|
||||
String description;
|
||||
|
||||
List<PluginFilterResponseDto> filters;
|
||||
|
||||
String id;
|
||||
|
||||
String name;
|
||||
|
||||
String title;
|
||||
|
||||
String updatedAt;
|
||||
|
||||
String version;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginResponseDto &&
|
||||
_deepEquality.equals(other.actions, actions) &&
|
||||
other.author == author &&
|
||||
other.createdAt == createdAt &&
|
||||
other.description == description &&
|
||||
_deepEquality.equals(other.filters, filters) &&
|
||||
other.id == id &&
|
||||
other.name == name &&
|
||||
other.title == title &&
|
||||
other.updatedAt == updatedAt &&
|
||||
other.version == version;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(actions.hashCode) +
|
||||
(author.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(description.hashCode) +
|
||||
(filters.hashCode) +
|
||||
(id.hashCode) +
|
||||
(name.hashCode) +
|
||||
(title.hashCode) +
|
||||
(updatedAt.hashCode) +
|
||||
(version.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginResponseDto[actions=$actions, author=$author, createdAt=$createdAt, description=$description, filters=$filters, id=$id, name=$name, title=$title, updatedAt=$updatedAt, version=$version]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'actions'] = this.actions;
|
||||
json[r'author'] = this.author;
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'description'] = this.description;
|
||||
json[r'filters'] = this.filters;
|
||||
json[r'id'] = this.id;
|
||||
json[r'name'] = this.name;
|
||||
json[r'title'] = this.title;
|
||||
json[r'updatedAt'] = this.updatedAt;
|
||||
json[r'version'] = this.version;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PluginResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PluginResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginResponseDto(
|
||||
actions: PluginActionResponseDto.listFromJson(json[r'actions']),
|
||||
author: mapValueOfType<String>(json, r'author')!,
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
description: mapValueOfType<String>(json, r'description')!,
|
||||
filters: PluginFilterResponseDto.listFromJson(json[r'filters']),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
name: mapValueOfType<String>(json, r'name')!,
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
|
||||
version: mapValueOfType<String>(json, r'version')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PluginResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PluginResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PluginResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PluginResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PluginResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'actions',
|
||||
'author',
|
||||
'createdAt',
|
||||
'description',
|
||||
'filters',
|
||||
'id',
|
||||
'name',
|
||||
'title',
|
||||
'updatedAt',
|
||||
'version',
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user