mirror of
https://github.com/immich-app/immich.git
synced 2026-03-29 21:44:22 -07:00
Compare commits
20 Commits
feat/notif
...
claude/aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b374d7eb87 | ||
|
|
d20def9f66 | ||
|
|
13e8a0121f | ||
|
|
fe4c0a95d5 | ||
|
|
c5abd18a64 | ||
|
|
cf1a9ed3f5 | ||
|
|
feacf9b134 | ||
|
|
67eb33b3a7 | ||
|
|
23e3d43578 | ||
|
|
028c8a2276 | ||
|
|
b7f4cc8171 | ||
|
|
5c11d15008 | ||
|
|
f4e156494f | ||
|
|
84abad564e | ||
|
|
02d356f5dd | ||
|
|
e963eedd26 | ||
|
|
3da4acfe67 | ||
|
|
e06cedb626 | ||
|
|
ac5ef6a56d | ||
|
|
d6c724b13b |
406
.github/workflows/visual-review.yml
vendored
Normal file
406
.github/workflows/visual-review.yml
vendored
Normal file
@@ -0,0 +1,406 @@
|
||||
name: Visual Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
visual-diff:
|
||||
name: Visual Diff Screenshots
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'visual-review') ||
|
||||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'visual-review'))
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine changed web files
|
||||
id: changed-files
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const files = [];
|
||||
const perPage = 100;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
const webPrefixes = ['web/', 'i18n/', 'open-api/typescript-sdk/'];
|
||||
const webFiles = files
|
||||
.map(f => f.filename)
|
||||
.filter(f => webPrefixes.some(p => f.startsWith(p)));
|
||||
|
||||
console.log(`Total PR files: ${files.length}`);
|
||||
console.log(`Web-related files: ${webFiles.length}`);
|
||||
for (const f of webFiles) {
|
||||
console.log(` ${f}`);
|
||||
}
|
||||
|
||||
core.setOutput('files', webFiles.join('\n'));
|
||||
core.setOutput('has_changes', webFiles.length > 0 ? 'true' : 'false');
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Install e2e dependencies
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
run: pnpm exec playwright install chromium --only-shell
|
||||
|
||||
- name: Analyze affected routes
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
id: routes
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
|
||||
run: |
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
echo "---"
|
||||
|
||||
ROUTES=$(echo "$CHANGED_FILES" | xargs pnpm exec tsx src/screenshots/analyze-deps.ts 2>&1 | tee /dev/stderr | grep "^ /" | sed 's/^ //' || true)
|
||||
|
||||
echo "routes<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$ROUTES" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -z "$ROUTES" ]; then
|
||||
echo "has_routes=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_routes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build the scenario filter JSON array
|
||||
SCENARIO_NAMES=$(pnpm exec tsx -e "
|
||||
import { getScenariosForRoutes } from './src/screenshots/page-map.ts';
|
||||
const routes = process.argv.slice(1);
|
||||
const scenarios = getScenariosForRoutes(routes);
|
||||
console.log(JSON.stringify(scenarios.map(s => s.name)));
|
||||
" $ROUTES)
|
||||
echo "scenarios=$SCENARIO_NAMES" >> "$GITHUB_OUTPUT"
|
||||
echo "Scenarios: $SCENARIO_NAMES"
|
||||
fi
|
||||
|
||||
- name: Post initial comment
|
||||
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
AFFECTED_ROUTES: ${{ steps.routes.outputs.routes }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const routes = process.env.AFFECTED_ROUTES || '';
|
||||
const body = `## Visual Review\n\nGenerating screenshots for affected pages...\n\nAffected routes:\n\`\`\`\n${routes}\n\`\`\``;
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
# === Screenshot PR version ===
|
||||
- name: Build SDK (PR)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Build web (PR)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./web
|
||||
|
||||
- name: Take screenshots (PR)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/pr
|
||||
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||
run: |
|
||||
# Start the preview server in background
|
||||
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||
echo "Server ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run screenshot tests
|
||||
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||
|
||||
# Stop the preview server and all children (pnpm spawns vite as child)
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
sleep 1
|
||||
# Ensure port is fully released — kill any lingering vite process
|
||||
fuser -k 4173/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# === Screenshot base version ===
|
||||
# Disable pnpm's verifyDepsBeforeRun for all base steps since the base
|
||||
# checkout changes package.json files, making them mismatch the lockfile.
|
||||
- name: Checkout base web directory
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
# Restore web directory from base branch
|
||||
git checkout "$BASE_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||
# Clear SvelteKit build cache to avoid stale artifacts from the PR build
|
||||
rm -rf web/.svelte-kit web/build
|
||||
working-directory: .
|
||||
|
||||
- name: Build SDK (base)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
continue-on-error: true
|
||||
id: base-sdk
|
||||
env:
|
||||
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||
run: pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Build web (base)
|
||||
if: steps.routes.outputs.has_routes == 'true' && steps.base-sdk.outcome == 'success'
|
||||
continue-on-error: true
|
||||
id: base-web
|
||||
env:
|
||||
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||
run: pnpm build
|
||||
working-directory: ./web
|
||||
|
||||
- name: Take screenshots (base)
|
||||
if: steps.routes.outputs.has_routes == 'true' && steps.base-web.outcome == 'success'
|
||||
env:
|
||||
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/base
|
||||
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||
run: |
|
||||
# Kill any process still on port 4173 from the PR step
|
||||
fuser -k 4173/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Start the preview server in background
|
||||
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||
echo "Server ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run screenshot tests
|
||||
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||
|
||||
# Stop the preview server
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
fuser -k 4173/tcp 2>/dev/null || true
|
||||
|
||||
- name: Restore PR source
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git checkout "$HEAD_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||
working-directory: .
|
||||
|
||||
# === Compare and report ===
|
||||
- name: Compare screenshots
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
WORKSPACE_DIR: ${{ github.workspace }}
|
||||
run: |
|
||||
# Ensure directories exist even if base screenshots were skipped
|
||||
mkdir -p "$WORKSPACE_DIR/screenshots/base" "$WORKSPACE_DIR/screenshots/pr" "$WORKSPACE_DIR/screenshots/diff"
|
||||
pnpm exec tsx src/screenshots/compare.ts \
|
||||
"$WORKSPACE_DIR/screenshots/base" \
|
||||
"$WORKSPACE_DIR/screenshots/pr" \
|
||||
"$WORKSPACE_DIR/screenshots/diff"
|
||||
|
||||
- name: Upload screenshot artifacts
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: visual-review-screenshots
|
||||
path: screenshots/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload HTML report
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
id: html-report
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
path: screenshots/diff/visual-review.html
|
||||
archive: false
|
||||
retention-days: 14
|
||||
|
||||
- name: Post comparison results
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
REPORT_URL: ${{ steps.html-report.outputs.artifact-url }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const reportPath = path.join(process.env.GITHUB_WORKSPACE, 'screenshots', 'diff', 'report.md');
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(reportPath, 'utf8');
|
||||
} catch {
|
||||
body = '## Visual Review\n\nScreenshot comparison failed. Check the workflow artifacts for details.';
|
||||
}
|
||||
|
||||
// Append links to the HTML report artifact and workflow run
|
||||
const reportUrl = process.env.REPORT_URL;
|
||||
const runUrl = process.env.RUN_URL;
|
||||
body += '\n---\n';
|
||||
if (reportUrl) {
|
||||
body += `[View full visual comparison](${reportUrl}) | `;
|
||||
}
|
||||
body += `[Download all screenshots](${runUrl}#artifacts)\n`;
|
||||
|
||||
// Find and update existing comment or create new one
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.data.find(c =>
|
||||
c.body && c.body.includes('## Visual Review')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: No web changes
|
||||
if: steps.changed-files.outputs.has_changes != 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const body = '## Visual Review\n\nNo web-related file changes detected in this PR. Visual review not needed.';
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
comment_id: existing.id, body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: context.issue.number, body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: No affected routes
|
||||
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes != 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const body = '## Visual Review\n\nChanged files don\'t affect any pages with screenshot scenarios configured.\nTo add coverage, define new scenarios in `e2e/src/screenshots/page-map.ts`.';
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
comment_id: existing.id, body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: context.issue.number, body,
|
||||
});
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,6 +24,7 @@ open-api/typescript-sdk/build
|
||||
mobile/android/fastlane/report.xml
|
||||
mobile/ios/fastlane/report.xml
|
||||
|
||||
screenshots-output
|
||||
vite.config.js.timestamp-*
|
||||
.pnpm-store
|
||||
.devcontainer/library
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
"format:fix": "prettier --write .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"check": "tsc --noEmit"
|
||||
"check": "tsc --noEmit",
|
||||
"screenshots": "pnpm exec playwright test --config playwright.screenshot.config.ts",
|
||||
"screenshots:compare": "pnpm exec tsx src/screenshots/compare.ts",
|
||||
"screenshots:analyze": "pnpm exec tsx src/screenshots/analyze-deps.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -51,6 +54,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
|
||||
27
e2e/playwright.screenshot.config.ts
Normal file
27
e2e/playwright.screenshot.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:4173';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src/screenshots',
|
||||
testMatch: /run-scenarios\.ts/,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: baseUrl,
|
||||
screenshot: 'off',
|
||||
trace: 'off',
|
||||
},
|
||||
workers: 1,
|
||||
projects: [
|
||||
{
|
||||
name: 'screenshots',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
249
e2e/src/screenshots/analyze-deps.ts
Normal file
249
e2e/src/screenshots/analyze-deps.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Reverse dependency analyzer for the Immich web app.
|
||||
*
|
||||
* Given a list of changed files, traces upward through the import graph
|
||||
* to find which +page.svelte routes are affected, then maps those to URL paths.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
|
||||
const WEB_SRC = resolve(import.meta.dirname, '../../../web/src');
|
||||
const LIB_ALIAS = resolve(WEB_SRC, 'lib');
|
||||
|
||||
/** Collect all .svelte, .ts, .js files under web/src/ */
|
||||
function collectFiles(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === 'node_modules' || entry === '.svelte-kit') {
|
||||
continue;
|
||||
}
|
||||
results.push(...collectFiles(full));
|
||||
} else if (/\.(svelte|ts|js)$/.test(entry)) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Extract import specifiers from a file's source text. */
|
||||
function extractImports(source: string): string[] {
|
||||
const specifiers: string[] = [];
|
||||
|
||||
// Match: import ... from '...' / import '...' / export ... from '...'
|
||||
const importRegex = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
||||
let match;
|
||||
while ((match = importRegex.exec(source)) !== null) {
|
||||
specifiers.push(match[1]);
|
||||
}
|
||||
|
||||
// Match dynamic imports: import('...')
|
||||
const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
while ((match = dynamicRegex.exec(source)) !== null) {
|
||||
specifiers.push(match[1]);
|
||||
}
|
||||
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
/** Resolve an import specifier to an absolute file path (or null if external). */
|
||||
function resolveImport(specifier: string, fromFile: string, allFiles: Set<string>): string | null {
|
||||
// Handle $lib alias
|
||||
let resolved: string;
|
||||
if (specifier.startsWith('$lib/') || specifier === '$lib') {
|
||||
resolved = specifier.replace('$lib', LIB_ALIAS);
|
||||
} else if (specifier.startsWith('./') || specifier.startsWith('../')) {
|
||||
resolved = resolve(dirname(fromFile), specifier);
|
||||
} else {
|
||||
// External package import — not relevant
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try exact match, then common extensions
|
||||
const extensions = ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js', '/index.svelte'];
|
||||
for (const ext of extensions) {
|
||||
const candidate = resolved + ext;
|
||||
if (allFiles.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the forward dependency graph: file → set of files it imports. */
|
||||
function buildDependencyGraph(files: string[]): Map<string, Set<string>> {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map<string, Set<string>>();
|
||||
|
||||
for (const file of files) {
|
||||
const deps = new Set<string>();
|
||||
graph.set(file, deps);
|
||||
|
||||
try {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const specifier of extractImports(source)) {
|
||||
const resolved = resolveImport(specifier, file, fileSet);
|
||||
if (resolved) {
|
||||
deps.add(resolved);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Invert the dependency graph: file → set of files that import it. */
|
||||
function buildReverseDependencyGraph(forwardGraph: Map<string, Set<string>>): Map<string, Set<string>> {
|
||||
const reverse = new Map<string, Set<string>>();
|
||||
|
||||
for (const [file, deps] of forwardGraph) {
|
||||
for (const dep of deps) {
|
||||
let importers = reverse.get(dep);
|
||||
if (!importers) {
|
||||
importers = new Set();
|
||||
reverse.set(dep, importers);
|
||||
}
|
||||
importers.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
return reverse;
|
||||
}
|
||||
|
||||
/** BFS from changed files upward through reverse deps to find +page.svelte files. */
|
||||
function findAffectedPages(changedFiles: string[], reverseGraph: Map<string, Set<string>>): Set<string> {
|
||||
const visited = new Set<string>();
|
||||
const pages = new Set<string>();
|
||||
const queue = [...changedFiles];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const file = queue.shift()!;
|
||||
if (visited.has(file)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(file);
|
||||
|
||||
if (file.endsWith('+page.svelte') || file.endsWith('+layout.svelte')) {
|
||||
pages.add(file);
|
||||
// If it's a layout, keep tracing upward because the layout itself
|
||||
// isn't a page — but the pages under it are affected.
|
||||
// If it's a +page.svelte, we still want to continue in case
|
||||
// this page is imported by others.
|
||||
}
|
||||
|
||||
const importers = reverseGraph.get(file);
|
||||
if (importers) {
|
||||
for (const importer of importers) {
|
||||
if (!visited.has(importer)) {
|
||||
queue.push(importer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For +layout.svelte hits, also find all +page.svelte under the same directory tree
|
||||
const layoutDirs: string[] = [];
|
||||
for (const page of pages) {
|
||||
if (page.endsWith('+layout.svelte')) {
|
||||
layoutDirs.push(dirname(page));
|
||||
pages.delete(page);
|
||||
}
|
||||
}
|
||||
|
||||
if (layoutDirs.length > 0) {
|
||||
for (const file of reverseGraph.keys()) {
|
||||
if (file.endsWith('+page.svelte')) {
|
||||
for (const layoutDir of layoutDirs) {
|
||||
if (file.startsWith(layoutDir)) {
|
||||
pages.add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check the forward graph keys for page files under layout dirs
|
||||
for (const layoutDir of layoutDirs) {
|
||||
const allFiles = collectFiles(layoutDir);
|
||||
for (const f of allFiles) {
|
||||
if (f.endsWith('+page.svelte')) {
|
||||
pages.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** Convert a +page.svelte file path to its URL route. */
|
||||
export function pageFileToRoute(pageFile: string): string {
|
||||
const routesDir = resolve(WEB_SRC, 'routes');
|
||||
let rel = relative(routesDir, dirname(pageFile));
|
||||
|
||||
// Remove SvelteKit group markers: (user), (list), etc.
|
||||
rel = rel.replaceAll(/\([^)]+\)\/?/g, '');
|
||||
|
||||
// Remove parameter segments: [albumId=id], [[photos=photos]], [[assetId=id]]
|
||||
rel = rel.replaceAll(/\[\[?[^\]]+\]\]?\/?/g, '');
|
||||
|
||||
// Clean up trailing slashes and normalize
|
||||
rel = rel.replaceAll(/\/+/g, '/').replace(/\/$/, '');
|
||||
|
||||
return '/' + rel;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
affectedPages: string[];
|
||||
affectedRoutes: string[];
|
||||
}
|
||||
|
||||
/** Main entry: analyze which routes are affected by the given changed files. */
|
||||
export function analyzeAffectedRoutes(changedFiles: string[]): AnalysisResult {
|
||||
// Resolve changed files to absolute paths relative to web/src
|
||||
const webRoot = resolve(WEB_SRC, '..');
|
||||
const resolvedChanged = changedFiles
|
||||
.filter((f) => f.startsWith('web/'))
|
||||
.map((f) => resolve(webRoot, '..', f))
|
||||
.filter((f) => statSync(f, { throwIfNoEntry: false })?.isFile());
|
||||
|
||||
if (resolvedChanged.length === 0) {
|
||||
return { affectedPages: [], affectedRoutes: [] };
|
||||
}
|
||||
|
||||
const allFiles = collectFiles(WEB_SRC);
|
||||
const forwardGraph = buildDependencyGraph(allFiles);
|
||||
const reverseGraph = buildReverseDependencyGraph(forwardGraph);
|
||||
|
||||
const pages = findAffectedPages(resolvedChanged, reverseGraph);
|
||||
|
||||
const affectedPages = [...pages].toSorted();
|
||||
const affectedRoutes = [...new Set(affectedPages.map((f) => pageFileToRoute(f)))].toSorted();
|
||||
|
||||
return { affectedPages, affectedRoutes };
|
||||
}
|
||||
|
||||
// CLI usage: node --import tsx analyze-deps.ts file1 file2 ...
|
||||
if (process.argv[1]?.endsWith('analyze-deps.ts') || process.argv[1]?.endsWith('analyze-deps.js')) {
|
||||
const files = process.argv.slice(2);
|
||||
if (files.length === 0) {
|
||||
console.log('Usage: analyze-deps.ts <changed-file1> <changed-file2> ...');
|
||||
console.log('Files should be relative to the repo root (e.g. web/src/lib/components/Button.svelte)');
|
||||
throw new Error('No files provided');
|
||||
}
|
||||
|
||||
const result = analyzeAffectedRoutes(files);
|
||||
console.log('Affected pages:');
|
||||
for (const page of result.affectedPages) {
|
||||
console.log(` ${page}`);
|
||||
}
|
||||
console.log('\nAffected routes:');
|
||||
for (const route of result.affectedRoutes) {
|
||||
console.log(` ${route}`);
|
||||
}
|
||||
}
|
||||
335
e2e/src/screenshots/compare.ts
Normal file
335
e2e/src/screenshots/compare.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Pixel-level comparison of base vs PR screenshots.
|
||||
*
|
||||
* Uses pixelmatch to generate diff images and calculate change percentages.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx e2e/src/screenshots/compare.ts <base-dir> <pr-dir> <output-dir>
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
// pixelmatch is a lightweight dependency — use a simple inline implementation
|
||||
// based on the approach from the pixelmatch library to avoid adding a new dependency.
|
||||
// The e2e package already has pngjs.
|
||||
|
||||
function pixelMatch(img1Data: Uint8Array, img2Data: Uint8Array, diffData: Uint8Array): number {
|
||||
let diffCount = 0;
|
||||
|
||||
for (let i = 0; i < img1Data.length; i += 4) {
|
||||
const r1 = img1Data[i];
|
||||
const g1 = img1Data[i + 1];
|
||||
const b1 = img1Data[i + 2];
|
||||
|
||||
const r2 = img2Data[i];
|
||||
const g2 = img2Data[i + 1];
|
||||
const b2 = img2Data[i + 2];
|
||||
|
||||
const dr = Math.abs(r1 - r2);
|
||||
const dg = Math.abs(g1 - g2);
|
||||
const db = Math.abs(b1 - b2);
|
||||
|
||||
// Threshold: if any channel differs by more than 25, mark as different
|
||||
const isDiff = dr > 25 || dg > 25 || db > 25;
|
||||
|
||||
if (isDiff) {
|
||||
// Red highlight for diff pixels
|
||||
diffData[i] = 255;
|
||||
diffData[i + 1] = 0;
|
||||
diffData[i + 2] = 0;
|
||||
diffData[i + 3] = 255;
|
||||
diffCount++;
|
||||
} else {
|
||||
// Dimmed original for unchanged pixels
|
||||
const gray = Math.round(0.299 * r1 + 0.587 * g1 + 0.114 * b1);
|
||||
diffData[i] = gray;
|
||||
diffData[i + 1] = gray;
|
||||
diffData[i + 2] = gray;
|
||||
diffData[i + 3] = 128;
|
||||
}
|
||||
}
|
||||
|
||||
return diffCount;
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
name: string;
|
||||
baseExists: boolean;
|
||||
prExists: boolean;
|
||||
diffPixels: number;
|
||||
totalPixels: number;
|
||||
changePercent: number;
|
||||
diffImagePath: string | null;
|
||||
baseImagePath: string | null;
|
||||
prImagePath: string | null;
|
||||
}
|
||||
|
||||
export function compareScreenshots(baseDir: string, prDir: string, outputDir: string): ComparisonResult[] {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// Collect all screenshot names from both directories
|
||||
const baseFiles = existsSync(baseDir)
|
||||
? new Set(readdirSync(baseDir).filter((f) => f.endsWith('.png')))
|
||||
: new Set<string>();
|
||||
const prFiles = existsSync(prDir) ? new Set(readdirSync(prDir).filter((f) => f.endsWith('.png'))) : new Set<string>();
|
||||
|
||||
const allNames = new Set([...baseFiles, ...prFiles]);
|
||||
const results: ComparisonResult[] = [];
|
||||
|
||||
for (const fileName of [...allNames].toSorted()) {
|
||||
const name = basename(fileName, '.png');
|
||||
const basePath = join(baseDir, fileName);
|
||||
const prPath = join(prDir, fileName);
|
||||
const baseExists = baseFiles.has(fileName);
|
||||
const prExists = prFiles.has(fileName);
|
||||
|
||||
if (!baseExists || !prExists) {
|
||||
// New or removed page
|
||||
results.push({
|
||||
name,
|
||||
baseExists,
|
||||
prExists,
|
||||
diffPixels: -1,
|
||||
totalPixels: -1,
|
||||
changePercent: 100,
|
||||
diffImagePath: null,
|
||||
baseImagePath: baseExists ? basePath : null,
|
||||
prImagePath: prExists ? prPath : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load both PNGs
|
||||
const basePng = PNG.sync.read(readFileSync(basePath));
|
||||
const prPng = PNG.sync.read(readFileSync(prPath));
|
||||
|
||||
// Handle size mismatches by comparing the overlapping region
|
||||
const width = Math.max(basePng.width, prPng.width);
|
||||
const height = Math.max(basePng.height, prPng.height);
|
||||
|
||||
// Resize images to the same dimensions (pad with transparent)
|
||||
const normalizedBase = normalizeImage(basePng, width, height);
|
||||
const normalizedPr = normalizeImage(prPng, width, height);
|
||||
|
||||
const diffPng = new PNG({ width, height });
|
||||
const totalPixels = width * height;
|
||||
const diffPixels = pixelMatch(normalizedBase, normalizedPr, diffPng.data as unknown as Uint8Array);
|
||||
|
||||
const diffImagePath = join(outputDir, `${name}-diff.png`);
|
||||
writeFileSync(diffImagePath, PNG.sync.write(diffPng));
|
||||
|
||||
results.push({
|
||||
name,
|
||||
baseExists,
|
||||
prExists,
|
||||
diffPixels,
|
||||
totalPixels,
|
||||
changePercent: totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0,
|
||||
diffImagePath,
|
||||
baseImagePath: basePath,
|
||||
prImagePath: prPath,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function normalizeImage(png: PNG, targetWidth: number, targetHeight: number): Uint8Array {
|
||||
if (png.width === targetWidth && png.height === targetHeight) {
|
||||
return png.data as unknown as Uint8Array;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(targetWidth * targetHeight * 4);
|
||||
for (let y = 0; y < targetHeight; y++) {
|
||||
for (let x = 0; x < targetWidth; x++) {
|
||||
const targetIdx = (y * targetWidth + x) * 4;
|
||||
if (x < png.width && y < png.height) {
|
||||
const sourceIdx = (y * png.width + x) * 4;
|
||||
data[targetIdx] = png.data[sourceIdx];
|
||||
data[targetIdx + 1] = png.data[sourceIdx + 1];
|
||||
data[targetIdx + 2] = png.data[sourceIdx + 2];
|
||||
data[targetIdx + 3] = png.data[sourceIdx + 3];
|
||||
} else {
|
||||
// Transparent padding
|
||||
data[targetIdx + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Generate a text-only markdown summary for the PR comment. */
|
||||
export function generateMarkdownReport(results: ComparisonResult[]): string {
|
||||
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||
|
||||
if (changed.length === 0) {
|
||||
return '## Visual Review\n\nNo visual changes detected in the affected pages.';
|
||||
}
|
||||
|
||||
let md = '## Visual Review\n\n';
|
||||
md += `Found **${changed.length}** page(s) with visual changes`;
|
||||
if (unchanged.length > 0) {
|
||||
md += ` (${unchanged.length} unchanged)`;
|
||||
}
|
||||
md += '.\n\n';
|
||||
|
||||
md += '| Page | Status | Change |\n';
|
||||
md += '|------|--------|--------|\n';
|
||||
|
||||
for (const result of changed) {
|
||||
if (result.baseExists && result.prExists) {
|
||||
md += `| ${result.name} | Changed | ${result.changePercent.toFixed(1)}% |\n`;
|
||||
} else if (result.prExists) {
|
||||
md += `| ${result.name} | New | - |\n`;
|
||||
} else {
|
||||
md += `| ${result.name} | Removed | - |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
md += '\n';
|
||||
|
||||
if (unchanged.length > 0) {
|
||||
md += '<details>\n<summary>Unchanged pages</summary>\n\n';
|
||||
for (const result of unchanged) {
|
||||
md += `- ${result.name}\n`;
|
||||
}
|
||||
md += '\n</details>\n';
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
function imgTag(filePath: string | null, alt: string): string {
|
||||
if (!filePath || !existsSync(filePath)) {
|
||||
return `<div class="no-image">${alt} not available</div>`;
|
||||
}
|
||||
const data = readFileSync(filePath);
|
||||
return `<img src="data:image/png;base64,${data.toString('base64')}" alt="${alt}" loading="lazy" />`;
|
||||
}
|
||||
|
||||
/** Generate an HTML report with embedded base64 images for the artifact. */
|
||||
export function generateHtmlReport(results: ComparisonResult[]): string {
|
||||
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visual Review</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
background: #0d1117; color: #e6edf3; padding: 32px; line-height: 1.5; }
|
||||
.container { max-width: 1800px; margin: 0 auto; }
|
||||
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 24px; }
|
||||
.summary { color: #8b949e; margin-bottom: 32px; font-size: 16px; }
|
||||
.scenario { margin-bottom: 40px; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
|
||||
.scenario-header { background: #161b22; padding: 12px 16px; display: flex; align-items: center; gap: 12px; }
|
||||
.scenario-header h2 { font-size: 16px; font-weight: 600; }
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.badge-changed { background: #da363380; color: #f85149; }
|
||||
.badge-new { background: #1f6feb80; color: #58a6ff; }
|
||||
.badge-removed { background: #6e767e80; color: #8b949e; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #30363d; }
|
||||
.grid-cell { background: #0d1117; }
|
||||
.grid-label { text-align: center; padding: 8px; font-size: 13px; color: #8b949e; font-weight: 600;
|
||||
background: #161b22; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.grid-cell img { width: 100%; display: block; }
|
||||
.no-image { padding: 40px; text-align: center; color: #484f58; font-style: italic; }
|
||||
.unchanged-section { margin-top: 32px; color: #8b949e; }
|
||||
.unchanged-section summary { cursor: pointer; font-size: 14px; }
|
||||
.unchanged-section ul { margin-top: 8px; padding-left: 24px; }
|
||||
.unchanged-section li { font-size: 14px; margin: 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Visual Review</h1>
|
||||
`;
|
||||
|
||||
if (changed.length === 0) {
|
||||
html += '<p class="summary">No visual changes detected in the affected pages.</p>';
|
||||
} else {
|
||||
html += `<p class="summary">Found <strong>${changed.length}</strong> page(s) with visual changes`;
|
||||
if (unchanged.length > 0) {
|
||||
html += ` (${unchanged.length} unchanged)`;
|
||||
}
|
||||
html += '.</p>\n';
|
||||
|
||||
for (const result of changed) {
|
||||
html += '<div class="scenario">\n<div class="scenario-header">\n';
|
||||
html += `<h2>${result.name}</h2>\n`;
|
||||
|
||||
if (!result.baseExists) {
|
||||
html += '<span class="badge badge-new">New</span>\n';
|
||||
html += '</div>\n';
|
||||
html += `<div style="padding: 16px;">${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||
html += '</div>\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.prExists) {
|
||||
html += '<span class="badge badge-removed">Removed</span>\n';
|
||||
html += '</div>\n</div>\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
html += `<span class="badge badge-changed">${result.changePercent.toFixed(1)}% changed</span>\n`;
|
||||
html += '</div>\n';
|
||||
html += '<div class="grid">\n';
|
||||
html += `<div class="grid-cell"><div class="grid-label">Base</div>${imgTag(result.baseImagePath, 'Base')}</div>\n`;
|
||||
html += `<div class="grid-cell"><div class="grid-label">PR</div>${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||
html += `<div class="grid-cell"><div class="grid-label">Diff</div>${imgTag(result.diffImagePath, 'Diff')}</div>\n`;
|
||||
html += '</div>\n</div>\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (unchanged.length > 0) {
|
||||
html += '<div class="unchanged-section">\n<details>\n<summary>Unchanged pages</summary>\n<ul>\n';
|
||||
for (const result of unchanged) {
|
||||
html += `<li>${result.name}</li>\n`;
|
||||
}
|
||||
html += '</ul>\n</details>\n</div>\n';
|
||||
}
|
||||
|
||||
html += '</div>\n</body>\n</html>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// CLI usage
|
||||
if (process.argv[1]?.endsWith('compare.ts') || process.argv[1]?.endsWith('compare.js')) {
|
||||
const [baseDir, prDir, outputDir] = process.argv.slice(2);
|
||||
|
||||
if (!baseDir || !prDir || !outputDir) {
|
||||
throw new Error('Usage: compare.ts <base-dir> <pr-dir> <output-dir>');
|
||||
}
|
||||
|
||||
const resolvedOutputDir = resolve(outputDir);
|
||||
const results = compareScreenshots(resolve(baseDir), resolve(prDir), resolvedOutputDir);
|
||||
|
||||
console.log('\nComparison Results:');
|
||||
console.log('==================');
|
||||
for (const r of results) {
|
||||
const status = r.changePercent > 0.1 ? 'CHANGED' : 'unchanged';
|
||||
console.log(` ${r.name}: ${status} (${r.changePercent.toFixed(1)}%)`);
|
||||
}
|
||||
|
||||
const report = generateMarkdownReport(results);
|
||||
const reportPath = join(resolvedOutputDir, 'report.md');
|
||||
writeFileSync(reportPath, report);
|
||||
console.log(`\nMarkdown report written to: ${reportPath}`);
|
||||
|
||||
const htmlReport = generateHtmlReport(results);
|
||||
const htmlPath = join(resolvedOutputDir, 'visual-review.html');
|
||||
writeFileSync(htmlPath, htmlReport);
|
||||
console.log(`HTML report written to: ${htmlPath}`);
|
||||
|
||||
const jsonPath = join(resolvedOutputDir, 'results.json');
|
||||
writeFileSync(jsonPath, JSON.stringify(results, null, 2));
|
||||
console.log(`Results JSON written to: ${jsonPath}`);
|
||||
}
|
||||
187
e2e/src/screenshots/page-map.ts
Normal file
187
e2e/src/screenshots/page-map.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Maps URL routes to screenshot scenario keys.
|
||||
*
|
||||
* Routes discovered by the dependency analyzer are matched against this map
|
||||
* to determine which screenshot scenarios to run. Routes not in this map
|
||||
* are skipped (they don't have a scenario defined yet).
|
||||
*/
|
||||
|
||||
export interface ScenarioDefinition {
|
||||
/** The URL path to navigate to */
|
||||
url: string;
|
||||
/** Human-readable name for the screenshot file */
|
||||
name: string;
|
||||
/** Which mock networks this scenario needs */
|
||||
mocks: ('base' | 'timeline' | 'memory')[];
|
||||
/** Optional: selector to wait for before screenshotting */
|
||||
waitForSelector?: string;
|
||||
/** Optional: time to wait after page load (ms) for animations to settle */
|
||||
settleTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map from route paths (as output by analyze-deps) to scenario definitions.
|
||||
* A single route might map to multiple scenarios (e.g., different states).
|
||||
*/
|
||||
export const PAGE_SCENARIOS: Record<string, ScenarioDefinition[]> = {
|
||||
'/photos': [
|
||||
{
|
||||
url: '/photos',
|
||||
name: 'photos-timeline',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '[data-thumbnail-focus-container]',
|
||||
settleTime: 500,
|
||||
},
|
||||
],
|
||||
'/albums': [
|
||||
{
|
||||
url: '/albums',
|
||||
name: 'albums-list',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/explore': [
|
||||
{
|
||||
url: '/explore',
|
||||
name: 'explore',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/favorites': [
|
||||
{
|
||||
url: '/favorites',
|
||||
name: 'favorites',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '#asset-grid',
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/archive': [
|
||||
{
|
||||
url: '/archive',
|
||||
name: 'archive',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '#asset-grid',
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/trash': [
|
||||
{
|
||||
url: '/trash',
|
||||
name: 'trash',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '#asset-grid',
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/people': [
|
||||
{
|
||||
url: '/people',
|
||||
name: 'people',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/sharing': [
|
||||
{
|
||||
url: '/sharing',
|
||||
name: 'sharing',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/search': [
|
||||
{
|
||||
url: '/search',
|
||||
name: 'search',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/memory': [
|
||||
{
|
||||
url: '/memory',
|
||||
name: 'memory',
|
||||
mocks: ['base', 'memory'],
|
||||
settleTime: 500,
|
||||
},
|
||||
],
|
||||
'/user-settings': [
|
||||
{
|
||||
url: '/user-settings',
|
||||
name: 'user-settings',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/map': [
|
||||
{
|
||||
url: '/map',
|
||||
name: 'map',
|
||||
mocks: ['base'],
|
||||
settleTime: 500,
|
||||
},
|
||||
],
|
||||
'/admin': [
|
||||
{
|
||||
url: '/admin',
|
||||
name: 'admin-dashboard',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/admin/system-settings': [
|
||||
{
|
||||
url: '/admin/system-settings',
|
||||
name: 'admin-system-settings',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/admin/users': [
|
||||
{
|
||||
url: '/admin/users',
|
||||
name: 'admin-users',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/auth/login': [
|
||||
{
|
||||
url: '/auth/login',
|
||||
name: 'login',
|
||||
mocks: [],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/': [
|
||||
{
|
||||
url: '/',
|
||||
name: 'landing',
|
||||
mocks: [],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Given a list of routes from the analyzer, return the matching scenarios. */
|
||||
export function getScenariosForRoutes(routes: string[]): ScenarioDefinition[] {
|
||||
const scenarios: ScenarioDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const route of routes) {
|
||||
const defs = PAGE_SCENARIOS[route];
|
||||
if (defs) {
|
||||
for (const def of defs) {
|
||||
if (!seen.has(def.name)) {
|
||||
seen.add(def.name);
|
||||
scenarios.push(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scenarios;
|
||||
}
|
||||
140
e2e/src/screenshots/run-scenarios.ts
Normal file
140
e2e/src/screenshots/run-scenarios.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Playwright script to capture screenshots for visual diff scenarios.
|
||||
*
|
||||
* Usage:
|
||||
* npx playwright test --config e2e/playwright.screenshot.config.ts
|
||||
*
|
||||
* Environment variables:
|
||||
* SCREENSHOT_SCENARIOS - JSON array of scenario names to run (from page-map.ts)
|
||||
* If not set, runs all scenarios.
|
||||
* SCREENSHOT_OUTPUT_DIR - Directory to save screenshots to. Defaults to e2e/screenshots-output.
|
||||
*/
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory';
|
||||
import {
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
type TimelineAssetConfig,
|
||||
type TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { PAGE_SCENARIOS, type ScenarioDefinition } from './page-map';
|
||||
|
||||
const OUTPUT_DIR = process.env.SCREENSHOT_OUTPUT_DIR || resolve(import.meta.dirname, '../../../screenshots-output');
|
||||
const SCENARIO_FILTER: string[] | null = process.env.SCREENSHOT_SCENARIOS
|
||||
? JSON.parse(process.env.SCREENSHOT_SCENARIOS)
|
||||
: null;
|
||||
|
||||
// Collect scenarios to run
|
||||
const allScenarios: ScenarioDefinition[] = [];
|
||||
for (const defs of Object.values(PAGE_SCENARIOS)) {
|
||||
for (const def of defs) {
|
||||
if (!SCENARIO_FILTER || SCENARIO_FILTER.includes(def.name)) {
|
||||
allScenarios.push(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use a fixed seed so screenshots are deterministic across runs
|
||||
faker.seed(42);
|
||||
|
||||
let adminUserId: string;
|
||||
let timelineData: TimelineData;
|
||||
let timelineAssets: TimelineAssetConfig[];
|
||||
let memories: MemoryResponseDto[];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
timelineData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
|
||||
timelineAssets = [];
|
||||
for (const timeBucket of timelineData.buckets.values()) {
|
||||
timelineAssets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
timelineAssets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 3 },
|
||||
{ year: 2023, assetCount: 2 },
|
||||
],
|
||||
42,
|
||||
);
|
||||
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
for (const scenario of allScenarios) {
|
||||
test(`Screenshot: ${scenario.name}`, async ({ context, page }) => {
|
||||
// Set up mocks based on scenario requirements
|
||||
if (scenario.mocks.includes('base')) {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
}
|
||||
|
||||
if (scenario.mocks.includes('timeline')) {
|
||||
const testContext = new TimelineTestContext();
|
||||
testContext.adminId = adminUserId;
|
||||
await setupTimelineMockApiRoutes(
|
||||
context,
|
||||
timelineData,
|
||||
{
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
},
|
||||
testContext,
|
||||
);
|
||||
}
|
||||
|
||||
if (scenario.mocks.includes('memory')) {
|
||||
await setupMemoryMockApiRoutes(context, memories, {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
// Navigate to the page. Use networkidle so SvelteKit hydrates and API
|
||||
// calls complete, but fall back to domcontentloaded if it times out
|
||||
// (e.g. a persistent connection the catch-all mock didn't cover).
|
||||
try {
|
||||
await page.goto(scenario.url, { waitUntil: 'networkidle', timeout: 15_000 });
|
||||
} catch {
|
||||
console.warn(`networkidle timed out for ${scenario.name}, falling back to current state`);
|
||||
// Page has already navigated, just continue with what we have
|
||||
}
|
||||
|
||||
// Wait for specific selector if specified
|
||||
if (scenario.waitForSelector) {
|
||||
try {
|
||||
await page.waitForSelector(scenario.waitForSelector, { timeout: 15_000 });
|
||||
} catch {
|
||||
console.warn(`Selector ${scenario.waitForSelector} not found for ${scenario.name}, continuing...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for loading spinners to disappear
|
||||
await page
|
||||
.waitForFunction(() => document.querySelectorAll('[data-testid="loading-spinner"]').length === 0, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Wait for animations/transitions to settle
|
||||
await page.waitForTimeout(scenario.settleTime ?? 500);
|
||||
|
||||
// Take the screenshot
|
||||
await page.screenshot({
|
||||
path: resolve(OUTPUT_DIR, `${scenario.name}.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
|
||||
// Block socket.io connections — these are persistent WebSocket connections
|
||||
// that prevent networkidle from resolving since there's no real server.
|
||||
await context.route('**/api/socket.io**', async (route) => {
|
||||
return route.abort('connectionrefused');
|
||||
});
|
||||
|
||||
// Catch-all for any /api/ endpoint not explicitly mocked below.
|
||||
// Registered FIRST so specific routes (registered after) take priority
|
||||
// (Playwright checks routes in reverse registration order).
|
||||
// Without this, unmocked API calls hit the static preview server which
|
||||
// either hangs or returns HTML, preventing networkidle and causing timeouts.
|
||||
await context.route('**/api/**', async (route) => {
|
||||
const method = route.request().method();
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: method === 'GET' ? [] : {},
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/users/me', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -689,7 +689,6 @@
|
||||
"backup_settings_subtitle": "Manage upload settings",
|
||||
"backup_upload_details_page_more_details": "Tap for more details",
|
||||
"backward": "Backward",
|
||||
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
|
||||
"biometric_auth_enabled": "Biometric authentication enabled",
|
||||
"biometric_locked_out": "You are locked out of biometric authentication",
|
||||
"biometric_no_options": "No biometric options available",
|
||||
@@ -1624,7 +1623,6 @@
|
||||
"not_selected": "Not selected",
|
||||
"notes": "Notes",
|
||||
"nothing_here_yet": "Nothing here yet",
|
||||
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
|
||||
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
|
||||
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
|
||||
"notification_permission_list_tile_enable_button": "Enable Notifications",
|
||||
|
||||
@@ -16,8 +16,6 @@ import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
@@ -50,7 +48,6 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
PermissionApi.setUp(messenger, PermissionApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
|
||||
@@ -59,7 +59,7 @@ private open class LocalImagesPigeonCodec : StandardMessageCodec() {
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface LocalImageApi {
|
||||
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||
fun cancelRequest(requestId: Long)
|
||||
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
|
||||
|
||||
@@ -82,7 +82,8 @@ interface LocalImageApi {
|
||||
val widthArg = args[2] as Long
|
||||
val heightArg = args[3] as Long
|
||||
val isVideoArg = args[4] as Boolean
|
||||
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>?> ->
|
||||
val preferEncodedArg = args[5] as Boolean
|
||||
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(LocalImagesPigeonUtils.wrapError(error))
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.util.Size
|
||||
import androidx.annotation.RequiresApi
|
||||
import app.alextran.immich.NativeBuffer
|
||||
import kotlin.math.*
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
@@ -99,12 +100,17 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
width: Long,
|
||||
height: Long,
|
||||
isVideo: Boolean,
|
||||
preferEncoded: Boolean,
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
val task = threadPool.submit {
|
||||
try {
|
||||
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
|
||||
if (preferEncoded) {
|
||||
getEncodedImageInternal(assetId, callback, signal)
|
||||
} else {
|
||||
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is OperationCanceledException -> callback(CANCELLED)
|
||||
@@ -133,6 +139,35 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEncodedImageInternal(
|
||||
assetId: String,
|
||||
callback: (Result<Map<String, Long>?>) -> Unit,
|
||||
signal: CancellationSignal
|
||||
) {
|
||||
signal.throwIfCanceled()
|
||||
val id = assetId.toLong()
|
||||
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
|
||||
signal.throwIfCanceled()
|
||||
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: throw IOException("Could not read image data for $assetId")
|
||||
|
||||
signal.throwIfCanceled()
|
||||
val pointer = NativeBuffer.allocate(bytes.size)
|
||||
try {
|
||||
val buffer = NativeBuffer.wrap(pointer, bytes.size)
|
||||
buffer.put(bytes)
|
||||
signal.throwIfCanceled()
|
||||
callback(Result.success(mapOf(
|
||||
"pointer" to pointer,
|
||||
"length" to bytes.size.toLong()
|
||||
)))
|
||||
} catch (e: Exception) {
|
||||
NativeBuffer.free(pointer)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun getThumbnailBufferInternal(
|
||||
assetId: String,
|
||||
width: Long,
|
||||
|
||||
@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface RemoteImageApi {
|
||||
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||
fun cancelRequest(requestId: Long)
|
||||
fun clearCache(callback: (Result<Long>) -> Unit)
|
||||
|
||||
@@ -68,7 +68,8 @@ interface RemoteImageApi {
|
||||
val urlArg = args[0] as String
|
||||
val headersArg = args[1] as Map<String, String>
|
||||
val requestIdArg = args[2] as Long
|
||||
api.requestImage(urlArg, headersArg, requestIdArg) { result: Result<Map<String, Long>?> ->
|
||||
val preferEncodedArg = args[3] as Boolean
|
||||
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
||||
|
||||
@@ -51,6 +51,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
requestId: Long,
|
||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object PermissionApiPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
|
||||
enum class PermissionStatus(val raw: Int) {
|
||||
GRANTED(0),
|
||||
DENIED(1),
|
||||
PERMANENTLY_DENIED(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): PermissionStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
PermissionStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is PermissionStatus -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface PermissionApi {
|
||||
fun isIgnoringBatteryOptimizations(): PermissionStatus
|
||||
|
||||
companion object {
|
||||
/** The codec used by PermissionApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
PermissionApiPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.isIgnoringBatteryOptimizations())
|
||||
} catch (exception: Throwable) {
|
||||
PermissionApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
|
||||
class PermissionApiImpl(context: Context) : PermissionApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private val powerManager =
|
||||
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
|
||||
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
|
||||
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
|
||||
return PermissionStatus.GRANTED
|
||||
}
|
||||
return PermissionStatus.DENIED
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,21 @@ class FlutterError (
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
|
||||
enum class PlatformAssetPlaybackStyle(val raw: Int) {
|
||||
UNKNOWN(0),
|
||||
IMAGE(1),
|
||||
VIDEO(2),
|
||||
IMAGE_ANIMATED(3),
|
||||
LIVE_PHOTO(4),
|
||||
VIDEO_LOOPING(5);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): PlatformAssetPlaybackStyle? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class PlatformAsset (
|
||||
val id: String,
|
||||
@@ -92,7 +107,8 @@ data class PlatformAsset (
|
||||
val isFavorite: Boolean,
|
||||
val adjustmentTime: Long? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null
|
||||
val longitude: Double? = null,
|
||||
val playbackStyle: PlatformAssetPlaybackStyle
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
@@ -110,7 +126,8 @@ data class PlatformAsset (
|
||||
val adjustmentTime = pigeonVar_list[10] as Long?
|
||||
val latitude = pigeonVar_list[11] as Double?
|
||||
val longitude = pigeonVar_list[12] as Double?
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
|
||||
val playbackStyle = pigeonVar_list[13] as PlatformAssetPlaybackStyle
|
||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
@@ -128,6 +145,7 @@ data class PlatformAsset (
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@@ -290,26 +308,31 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAsset.fromList(it)
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
PlatformAssetPlaybackStyle.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAlbum.fromList(it)
|
||||
PlatformAsset.fromList(it)
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SyncDelta.fromList(it)
|
||||
PlatformAlbum.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
SyncDelta.fromList(it)
|
||||
}
|
||||
}
|
||||
133.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
}
|
||||
}
|
||||
134.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
CloudIdResult.fromList(it)
|
||||
}
|
||||
@@ -319,26 +342,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is PlatformAsset -> {
|
||||
is PlatformAssetPlaybackStyle -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.toList())
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
is PlatformAlbum -> {
|
||||
is PlatformAsset -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SyncDelta -> {
|
||||
is PlatformAlbum -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is HashResult -> {
|
||||
is SyncDelta -> {
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is CloudIdResult -> {
|
||||
is HashResult -> {
|
||||
stream.write(133)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is CloudIdResult -> {
|
||||
stream.write(134)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@ import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.database.getStringOrNull
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -28,6 +34,8 @@ sealed class AssetResult {
|
||||
data class InvalidAsset(val assetId: String) : AssetResult()
|
||||
}
|
||||
|
||||
private const val TAG = "NativeSyncApiImplBase"
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
private val ctx: Context = context.applicationContext
|
||||
@@ -39,6 +47,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
|
||||
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
|
||||
|
||||
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
|
||||
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
|
||||
private const val SPECIAL_FORMAT_COLUMN = "_special_format"
|
||||
private const val SPECIAL_FORMAT_GIF = 1
|
||||
private const val SPECIAL_FORMAT_MOTION_PHOTO = 2
|
||||
private const val SPECIAL_FORMAT_ANIMATED_WEBP = 3
|
||||
|
||||
const val MEDIA_SELECTION =
|
||||
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
||||
val MEDIA_SELECTION_ARGS = arrayOf(
|
||||
@@ -60,9 +75,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
add(MediaStore.MediaColumns.DURATION)
|
||||
add(MediaStore.MediaColumns.ORIENTATION)
|
||||
// IS_FAVORITE is only available on Android 11 and above
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
add(SPECIAL_FORMAT_COLUMN)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||
add(MediaStore.MediaColumns.XMP)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||
@@ -109,9 +130,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
val orientationColumn =
|
||||
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
|
||||
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
|
||||
val specialFormatColumn = c.getColumnIndex(SPECIAL_FORMAT_COLUMN)
|
||||
val xmpColumn = c.getColumnIndex(MediaStore.MediaColumns.XMP)
|
||||
|
||||
while (c.moveToNext()) {
|
||||
val id = c.getLong(idColumn).toString()
|
||||
val numericId = c.getLong(idColumn)
|
||||
val id = numericId.toString()
|
||||
val name = c.getStringOrNull(nameColumn)
|
||||
val bucketId = c.getStringOrNull(bucketIdColumn)
|
||||
val path = c.getStringOrNull(dataColumn)
|
||||
@@ -125,10 +149,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
continue
|
||||
}
|
||||
|
||||
val mediaType = when (c.getInt(mediaTypeColumn)) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2
|
||||
else -> 0
|
||||
val rawMediaType = c.getInt(mediaTypeColumn)
|
||||
val assetType: Long = when (rawMediaType) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1L
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
|
||||
else -> 0L
|
||||
}
|
||||
// Date taken is milliseconds since epoch, Date added is seconds since epoch
|
||||
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
|
||||
@@ -138,15 +163,19 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
val width = c.getInt(widthColumn).toLong()
|
||||
val height = c.getInt(heightColumn).toLong()
|
||||
// Duration is milliseconds
|
||||
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
|
||||
val duration = if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0L
|
||||
else c.getLong(durationColumn) / 1000
|
||||
val orientation = c.getInt(orientationColumn)
|
||||
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
||||
|
||||
val playbackStyle = detectPlaybackStyle(
|
||||
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
|
||||
)
|
||||
|
||||
val asset = PlatformAsset(
|
||||
id,
|
||||
name,
|
||||
mediaType.toLong(),
|
||||
assetType,
|
||||
createdAt,
|
||||
modifiedAt,
|
||||
width,
|
||||
@@ -154,6 +183,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
duration,
|
||||
orientation.toLong(),
|
||||
isFavorite,
|
||||
playbackStyle = playbackStyle,
|
||||
)
|
||||
yield(AssetResult.ValidAsset(asset, bucketId))
|
||||
}
|
||||
@@ -161,6 +191,81 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the playback style for an asset using _special_format (API 33+)
|
||||
* or XMP / MIME / RIFF header fallbacks (pre-33).
|
||||
*/
|
||||
@SuppressLint("NewApi")
|
||||
private fun detectPlaybackStyle(
|
||||
assetId: Long,
|
||||
rawMediaType: Int,
|
||||
specialFormatColumn: Int,
|
||||
xmpColumn: Int,
|
||||
cursor: Cursor
|
||||
): PlatformAssetPlaybackStyle {
|
||||
// video currently has no special formats, so we can short circuit and avoid unnecessary work
|
||||
if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
|
||||
return PlatformAssetPlaybackStyle.VIDEO
|
||||
}
|
||||
|
||||
// API 33+: use _special_format from cursor
|
||||
if (specialFormatColumn != -1) {
|
||||
val specialFormat = cursor.getInt(specialFormatColumn)
|
||||
return when {
|
||||
specialFormat == SPECIAL_FORMAT_MOTION_PHOTO -> PlatformAssetPlaybackStyle.LIVE_PHOTO
|
||||
specialFormat == SPECIAL_FORMAT_GIF || specialFormat == SPECIAL_FORMAT_ANIMATED_WEBP -> PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
||||
rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> PlatformAssetPlaybackStyle.IMAGE
|
||||
else -> PlatformAssetPlaybackStyle.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
if (rawMediaType != MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) {
|
||||
return PlatformAssetPlaybackStyle.UNKNOWN
|
||||
}
|
||||
|
||||
// Pre-API 33 fallback
|
||||
val uri = ContentUris.withAppendedId(
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||
assetId
|
||||
)
|
||||
|
||||
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
|
||||
val xmp: String? = if (xmpColumn != -1) {
|
||||
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
||||
} else {
|
||||
try {
|
||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
||||
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
val glide = Glide.get(ctx)
|
||||
val type = ImageHeaderParserUtils.getType(
|
||||
glide.registry.imageHeaderParsers,
|
||||
stream,
|
||||
glide.arrayPool
|
||||
)
|
||||
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
|
||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
||||
}
|
||||
|
||||
return PlatformAssetPlaybackStyle.IMAGE
|
||||
}
|
||||
|
||||
fun getAlbums(): List<PlatformAlbum> {
|
||||
val albums = mutableListOf<PlatformAlbum>()
|
||||
val albumsCount = mutableMapOf<String, Int>()
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v21.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v21.json
generated
Normal file
File diff suppressed because one or more lines are too long
@@ -70,7 +70,7 @@ class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol LocalImageApi {
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||
func cancelRequest(requestId: Int64) throws
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
|
||||
}
|
||||
@@ -90,7 +90,8 @@ class LocalImageApiSetup {
|
||||
let widthArg = args[2] as! Int64
|
||||
let heightArg = args[3] as! Int64
|
||||
let isVideoArg = args[4] as! Bool
|
||||
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
|
||||
let preferEncodedArg = args[5] as! Bool
|
||||
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg, preferEncoded: preferEncodedArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
|
||||
@@ -7,7 +7,7 @@ class LocalImageRequest {
|
||||
weak var workItem: DispatchWorkItem?
|
||||
var isCancelled = false
|
||||
let callback: (Result<[String: Int64]?, any Error>) -> Void
|
||||
|
||||
|
||||
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
self.callback = callback
|
||||
}
|
||||
@@ -30,11 +30,11 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
requestOptions.version = .current
|
||||
return requestOptions
|
||||
}()
|
||||
|
||||
|
||||
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
|
||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
|
||||
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
|
||||
|
||||
|
||||
private static var rgbaFormat = vImage_CGImageFormat(
|
||||
bitsPerComponent: 8,
|
||||
bitsPerPixel: 32,
|
||||
@@ -48,12 +48,12 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
assetCache.countLimit = 10000
|
||||
return assetCache
|
||||
}()
|
||||
|
||||
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||
ImageProcessing.queue.async {
|
||||
guard let data = Data(base64Encoded: thumbhash)
|
||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||
|
||||
|
||||
let (width, height, pointer) = thumbHashToRGBA(hash: data)
|
||||
completion(.success([
|
||||
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
|
||||
@@ -63,34 +63,77 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
let request = LocalImageRequest(callback: completion)
|
||||
let item = DispatchWorkItem {
|
||||
if request.isCancelled {
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
ImageProcessing.semaphore.wait()
|
||||
defer {
|
||||
ImageProcessing.semaphore.signal()
|
||||
}
|
||||
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
guard let asset = Self.requestAsset(assetId: assetId)
|
||||
else {
|
||||
Self.remove(requestId: requestId)
|
||||
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
if preferEncoded {
|
||||
let dataOptions = PHImageRequestOptions()
|
||||
dataOptions.isNetworkAccessAllowed = true
|
||||
dataOptions.isSynchronous = true
|
||||
dataOptions.version = .current
|
||||
|
||||
var imageData: Data?
|
||||
Self.imageManager.requestImageDataAndOrientation(
|
||||
for: asset,
|
||||
options: dataOptions,
|
||||
resultHandler: { (data, _, _, _) in
|
||||
imageData = data
|
||||
}
|
||||
)
|
||||
|
||||
if request.isCancelled {
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
guard let data = imageData else {
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
let length = data.count
|
||||
let pointer = malloc(length)!
|
||||
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
|
||||
|
||||
if request.isCancelled {
|
||||
free(pointer)
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
request.callback(.success([
|
||||
"pointer": Int64(Int(bitPattern: pointer)),
|
||||
"length": Int64(length),
|
||||
]))
|
||||
Self.remove(requestId: requestId)
|
||||
return
|
||||
}
|
||||
|
||||
var image: UIImage?
|
||||
Self.imageManager.requestImage(
|
||||
for: asset,
|
||||
@@ -101,29 +144,29 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
image = _image
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
guard let image = image,
|
||||
let cgImage = image.cgImage else {
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
|
||||
|
||||
|
||||
if request.isCancelled {
|
||||
buffer.free()
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
request.callback(.success([
|
||||
"pointer": Int64(Int(bitPattern: buffer.data)),
|
||||
"width": Int64(buffer.width),
|
||||
@@ -136,24 +179,24 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
request.workItem = item
|
||||
Self.add(requestId: requestId, request: request)
|
||||
ImageProcessing.queue.async(execute: item)
|
||||
}
|
||||
|
||||
|
||||
func cancelRequest(requestId: Int64) {
|
||||
Self.cancel(requestId: requestId)
|
||||
}
|
||||
|
||||
|
||||
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
|
||||
requestQueue.sync { requests[requestId] = request }
|
||||
}
|
||||
|
||||
|
||||
private static func remove(requestId: Int64) -> Void {
|
||||
requestQueue.sync { requests[requestId] = nil }
|
||||
}
|
||||
|
||||
|
||||
private static func cancel(requestId: Int64) -> Void {
|
||||
requestQueue.async {
|
||||
guard let request = requests.removeValue(forKey: requestId) else { return }
|
||||
@@ -164,12 +207,12 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static func requestAsset(assetId: String) -> PHAsset? {
|
||||
var asset: PHAsset?
|
||||
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
|
||||
if asset != nil { return asset }
|
||||
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
|
||||
else { return nil }
|
||||
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||
|
||||
@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol RemoteImageApi {
|
||||
func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||
func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||
func cancelRequest(requestId: Int64) throws
|
||||
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
||||
}
|
||||
@@ -88,7 +88,8 @@ class RemoteImageApiSetup {
|
||||
let urlArg = args[0] as! String
|
||||
let headersArg = args[1] as! [String: String]
|
||||
let requestIdArg = args[2] as! Int64
|
||||
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in
|
||||
let preferEncodedArg = args[3] as! Bool
|
||||
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
|
||||
@@ -8,7 +8,7 @@ class RemoteImageRequest {
|
||||
let id: Int64
|
||||
var isCancelled = false
|
||||
let completion: (Result<[String: Int64]?, any Error>) -> Void
|
||||
|
||||
|
||||
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
self.id = id
|
||||
self.task = task
|
||||
@@ -32,75 +32,93 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true
|
||||
] as CFDictionary
|
||||
|
||||
func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||
|
||||
func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||
var urlRequest = URLRequest(url: URL(string: url)!)
|
||||
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
||||
for (key, value) in headers {
|
||||
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
|
||||
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
||||
Self.handleCompletion(requestId: requestId, data: data, response: response, error: error)
|
||||
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
|
||||
}
|
||||
|
||||
|
||||
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
|
||||
|
||||
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
Self.requests[requestId] = request
|
||||
os_unfair_lock_unlock(&Self.lock)
|
||||
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) {
|
||||
|
||||
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
guard let request = requests[requestId] else {
|
||||
return os_unfair_lock_unlock(&Self.lock)
|
||||
}
|
||||
requests[requestId] = nil
|
||||
os_unfair_lock_unlock(&Self.lock)
|
||||
|
||||
|
||||
if let error = error {
|
||||
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
return request.completion(.failure(error))
|
||||
}
|
||||
|
||||
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
guard let data = data else {
|
||||
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
||||
}
|
||||
|
||||
|
||||
ImageProcessing.queue.async {
|
||||
ImageProcessing.semaphore.wait()
|
||||
defer { ImageProcessing.semaphore.signal() }
|
||||
|
||||
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
// Return raw encoded bytes when requested (for animated images)
|
||||
if encoded {
|
||||
let length = data.count
|
||||
let pointer = malloc(length)!
|
||||
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
|
||||
|
||||
if request.isCancelled {
|
||||
free(pointer)
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
return request.completion(
|
||||
.success([
|
||||
"pointer": Int64(Int(bitPattern: pointer)),
|
||||
"length": Int64(length),
|
||||
]))
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
|
||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
||||
}
|
||||
|
||||
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
|
||||
|
||||
|
||||
if request.isCancelled {
|
||||
buffer.free()
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
|
||||
request.completion(
|
||||
.success([
|
||||
"pointer": Int64(Int(bitPattern: buffer.data)),
|
||||
@@ -113,17 +131,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func cancelRequest(requestId: Int64) {
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
let request = Self.requests[requestId]
|
||||
os_unfair_lock_unlock(&Self.lock)
|
||||
|
||||
|
||||
guard let request = request else { return }
|
||||
request.isCancelled = true
|
||||
request.task?.cancel()
|
||||
}
|
||||
|
||||
|
||||
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
|
||||
Task {
|
||||
let cache = URLSessionManager.shared.session.configuration.urlCache!
|
||||
|
||||
@@ -128,6 +128,15 @@ func deepHashMessages(value: Any?, hasher: inout Hasher) {
|
||||
|
||||
|
||||
|
||||
enum PlatformAssetPlaybackStyle: Int {
|
||||
case unknown = 0
|
||||
case image = 1
|
||||
case video = 2
|
||||
case imageAnimated = 3
|
||||
case livePhoto = 4
|
||||
case videoLooping = 5
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct PlatformAsset: Hashable {
|
||||
var id: String
|
||||
@@ -143,6 +152,7 @@ struct PlatformAsset: Hashable {
|
||||
var adjustmentTime: Int64? = nil
|
||||
var latitude: Double? = nil
|
||||
var longitude: Double? = nil
|
||||
var playbackStyle: PlatformAssetPlaybackStyle
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
@@ -160,6 +170,7 @@ struct PlatformAsset: Hashable {
|
||||
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
|
||||
let latitude: Double? = nilOrValue(pigeonVar_list[11])
|
||||
let longitude: Double? = nilOrValue(pigeonVar_list[12])
|
||||
let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle
|
||||
|
||||
return PlatformAsset(
|
||||
id: id,
|
||||
@@ -174,7 +185,8 @@ struct PlatformAsset: Hashable {
|
||||
isFavorite: isFavorite,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
@@ -192,6 +204,7 @@ struct PlatformAsset: Hashable {
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
]
|
||||
}
|
||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||
@@ -349,14 +362,20 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return PlatformAssetPlaybackStyle(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||
case 131:
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
case 132:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
case 133:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
case 134:
|
||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
@@ -366,21 +385,24 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
|
||||
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? PlatformAsset {
|
||||
if let value = value as? PlatformAssetPlaybackStyle {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? PlatformAlbum {
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? PlatformAsset {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? SyncDelta {
|
||||
} else if let value = value as? PlatformAlbum {
|
||||
super.writeByte(131)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? HashResult {
|
||||
} else if let value = value as? SyncDelta {
|
||||
super.writeByte(132)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? CloudIdResult {
|
||||
} else if let value = value as? HashResult {
|
||||
super.writeByte(133)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? CloudIdResult {
|
||||
super.writeByte(134)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
|
||||
@@ -173,7 +173,8 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
type: 0,
|
||||
durationInSeconds: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false
|
||||
isFavorite: false,
|
||||
playbackStyle: .unknown
|
||||
)
|
||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||
continue
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import Photos
|
||||
|
||||
extension PHAsset {
|
||||
var platformPlaybackStyle: PlatformAssetPlaybackStyle {
|
||||
switch playbackStyle {
|
||||
case .image: return .image
|
||||
case .imageAnimated: return .imageAnimated
|
||||
case .livePhoto: return .livePhoto
|
||||
case .video: return .video
|
||||
case .videoLooping: return .videoLooping
|
||||
@unknown default: return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
func toPlatformAsset() -> PlatformAsset {
|
||||
return PlatformAsset(
|
||||
id: localIdentifier,
|
||||
@@ -15,7 +26,8 @@ extension PHAsset {
|
||||
isFavorite: isFavorite,
|
||||
adjustmentTime: adjustmentTimestamp,
|
||||
latitude: location?.coordinate.latitude,
|
||||
longitude: location?.coordinate.longitude
|
||||
longitude: location?.coordinate.longitude,
|
||||
playbackStyle: platformPlaybackStyle
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +38,7 @@ extension PHAsset {
|
||||
var filename: String? {
|
||||
return value(forKey: "filename") as? String
|
||||
}
|
||||
|
||||
|
||||
var adjustmentTimestamp: Int64? {
|
||||
if let date = value(forKey: "adjustmentTimestamp") as? Date {
|
||||
return Int64(date.timeIntervalSince1970)
|
||||
|
||||
@@ -11,6 +11,10 @@ enum AssetType {
|
||||
|
||||
enum AssetState { local, remote, merged }
|
||||
|
||||
// do not change!
|
||||
// keep in sync with PlatformAssetPlaybackStyle
|
||||
enum AssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
sealed class BaseAsset {
|
||||
final String name;
|
||||
final String? checksum;
|
||||
@@ -43,6 +47,14 @@ sealed class BaseAsset {
|
||||
|
||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||
|
||||
AssetPlaybackStyle get playbackStyle {
|
||||
if (isVideo) return AssetPlaybackStyle.video;
|
||||
if (isMotionPhoto) return AssetPlaybackStyle.livePhoto;
|
||||
if (isImage && durationInSeconds != null && durationInSeconds! > 0) return AssetPlaybackStyle.imageAnimated;
|
||||
if (isImage) return AssetPlaybackStyle.image;
|
||||
return AssetPlaybackStyle.unknown;
|
||||
}
|
||||
|
||||
Duration get duration {
|
||||
final durationInSeconds = this.durationInSeconds;
|
||||
if (durationInSeconds != null) {
|
||||
|
||||
@@ -5,6 +5,8 @@ class LocalAsset extends BaseAsset {
|
||||
final String? remoteAssetId;
|
||||
final String? cloudId;
|
||||
final int orientation;
|
||||
@override
|
||||
final AssetPlaybackStyle playbackStyle;
|
||||
|
||||
final DateTime? adjustmentTime;
|
||||
final double? latitude;
|
||||
@@ -25,6 +27,7 @@ class LocalAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
required this.playbackStyle,
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
@@ -56,6 +59,7 @@ class LocalAsset extends BaseAsset {
|
||||
width: ${width ?? "<NA>"},
|
||||
height: ${height ?? "<NA>"},
|
||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||
playbackStyle: $playbackStyle,
|
||||
remoteId: ${remoteId ?? "<NA>"},
|
||||
cloudId: ${cloudId ?? "<NA>"},
|
||||
checksum: ${checksum ?? "<NA>"},
|
||||
@@ -76,6 +80,7 @@ class LocalAsset extends BaseAsset {
|
||||
id == other.id &&
|
||||
cloudId == other.cloudId &&
|
||||
orientation == other.orientation &&
|
||||
playbackStyle == other.playbackStyle &&
|
||||
adjustmentTime == other.adjustmentTime &&
|
||||
latitude == other.latitude &&
|
||||
longitude == other.longitude;
|
||||
@@ -87,6 +92,7 @@ class LocalAsset extends BaseAsset {
|
||||
id.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
orientation.hashCode ^
|
||||
playbackStyle.hashCode ^
|
||||
adjustmentTime.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode;
|
||||
@@ -105,6 +111,7 @@ class LocalAsset extends BaseAsset {
|
||||
int? durationInSeconds,
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
AssetPlaybackStyle? playbackStyle,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
@@ -124,6 +131,7 @@ class LocalAsset extends BaseAsset {
|
||||
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
|
||||
@@ -435,9 +435,19 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
durationInSeconds: durationInSeconds,
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
playbackStyle: _toPlaybackStyle(playbackStyle),
|
||||
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
||||
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
||||
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
||||
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
|
||||
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
|
||||
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
|
||||
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
RealColumn get longitude => real().nullable()();
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -43,6 +45,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
width: width,
|
||||
remoteId: remoteId,
|
||||
orientation: orientation,
|
||||
playbackStyle: playbackStyle,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
|
||||
@@ -25,6 +25,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<DateTime?> adjustmentTime,
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -43,6 +44,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<DateTime?> adjustmentTime,
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -129,6 +131,16 @@ class $$LocalAssetEntityTableFilterComposer
|
||||
column: $table.longitude,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnWithTypeConverterFilters<
|
||||
i2.AssetPlaybackStyle,
|
||||
i2.AssetPlaybackStyle,
|
||||
int
|
||||
>
|
||||
get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableOrderingComposer
|
||||
@@ -214,6 +226,11 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||
column: $table.longitude,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableAnnotationComposer
|
||||
@@ -277,6 +294,12 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||
|
||||
i0.GeneratedColumn<double> get longitude =>
|
||||
$composableBuilder(column: $table.longitude, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
||||
get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableTableManager
|
||||
@@ -334,6 +357,8 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -350,6 +375,7 @@ class $$LocalAssetEntityTableTableManager
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -368,6 +394,8 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -384,6 +412,7 @@ class $$LocalAssetEntityTableTableManager
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -596,6 +625,19 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
||||
playbackStyle =
|
||||
i0.GeneratedColumn<int>(
|
||||
'playback_style',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const i4.Constant(0),
|
||||
).withConverter<i2.AssetPlaybackStyle>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
type,
|
||||
@@ -612,6 +654,7 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -793,6 +836,12 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
i0.DriftSqlType.double,
|
||||
data['${effectivePrefix}longitude'],
|
||||
),
|
||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromSql(
|
||||
attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}playback_style'],
|
||||
)!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -803,6 +852,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
|
||||
static i0.JsonTypeConverter2<i2.AssetType, int, int> $convertertype =
|
||||
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
|
||||
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
|
||||
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
|
||||
i2.AssetPlaybackStyle.values,
|
||||
);
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
@@ -826,6 +879,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
final DateTime? adjustmentTime;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final i2.AssetPlaybackStyle playbackStyle;
|
||||
const LocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -842,6 +896,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -881,6 +936,11 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
if (!nullToAbsent || longitude != null) {
|
||||
map['longitude'] = i0.Variable<double>(longitude);
|
||||
}
|
||||
{
|
||||
map['playback_style'] = i0.Variable<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -907,6 +967,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
|
||||
latitude: serializer.fromJson<double?>(json['latitude']),
|
||||
longitude: serializer.fromJson<double?>(json['longitude']),
|
||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
||||
serializer.fromJson<int>(json['playbackStyle']),
|
||||
),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -930,6 +993,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
|
||||
'latitude': serializer.toJson<double?>(latitude),
|
||||
'longitude': serializer.toJson<double?>(longitude),
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -949,6 +1015,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i2.AssetPlaybackStyle? playbackStyle,
|
||||
}) => i1.LocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -969,6 +1036,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
: this.adjustmentTime,
|
||||
latitude: latitude.present ? latitude.value : this.latitude,
|
||||
longitude: longitude.present ? longitude.value : this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||
return LocalAssetEntityData(
|
||||
@@ -995,6 +1063,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
: this.adjustmentTime,
|
||||
latitude: data.latitude.present ? data.latitude.value : this.latitude,
|
||||
longitude: data.longitude.present ? data.longitude.value : this.longitude,
|
||||
playbackStyle: data.playbackStyle.present
|
||||
? data.playbackStyle.value
|
||||
: this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1015,7 +1086,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('iCloudId: $iCloudId, ')
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1037,6 +1109,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1056,7 +1129,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.iCloudId == this.iCloudId &&
|
||||
other.adjustmentTime == this.adjustmentTime &&
|
||||
other.latitude == this.latitude &&
|
||||
other.longitude == this.longitude);
|
||||
other.longitude == this.longitude &&
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -1076,6 +1150,7 @@ class LocalAssetEntityCompanion
|
||||
final i0.Value<DateTime?> adjustmentTime;
|
||||
final i0.Value<double?> latitude;
|
||||
final i0.Value<double?> longitude;
|
||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||
const LocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -1092,6 +1167,7 @@ class LocalAssetEntityCompanion
|
||||
this.adjustmentTime = const i0.Value.absent(),
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
});
|
||||
LocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1109,6 +1185,7 @@ class LocalAssetEntityCompanion
|
||||
this.adjustmentTime = const i0.Value.absent(),
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id);
|
||||
@@ -1128,6 +1205,7 @@ class LocalAssetEntityCompanion
|
||||
i0.Expression<DateTime>? adjustmentTime,
|
||||
i0.Expression<double>? latitude,
|
||||
i0.Expression<double>? longitude,
|
||||
i0.Expression<int>? playbackStyle,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1145,6 +1223,7 @@ class LocalAssetEntityCompanion
|
||||
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1164,6 +1243,7 @@ class LocalAssetEntityCompanion
|
||||
i0.Value<DateTime?>? adjustmentTime,
|
||||
i0.Value<double?>? latitude,
|
||||
i0.Value<double?>? longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||
}) {
|
||||
return i1.LocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1181,6 +1261,7 @@ class LocalAssetEntityCompanion
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1234,6 +1315,13 @@ class LocalAssetEntityCompanion
|
||||
if (longitude.present) {
|
||||
map['longitude'] = i0.Variable<double>(longitude.value);
|
||||
}
|
||||
if (playbackStyle.present) {
|
||||
map['playback_style'] = i0.Variable<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(
|
||||
playbackStyle.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1254,7 +1342,8 @@ class LocalAssetEntityCompanion
|
||||
..write('iCloudId: $iCloudId, ')
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ SELECT
|
||||
NULL as latitude,
|
||||
NULL as longitude,
|
||||
NULL as adjustmentTime,
|
||||
rae.is_edited
|
||||
rae.is_edited,
|
||||
0 as playback_style
|
||||
FROM
|
||||
remote_asset_entity rae
|
||||
LEFT JOIN
|
||||
@@ -63,7 +64,8 @@ SELECT
|
||||
lae.latitude,
|
||||
lae.longitude,
|
||||
lae.adjustment_time,
|
||||
0 as is_edited
|
||||
0 as is_edited,
|
||||
lae.playback_style
|
||||
FROM
|
||||
local_asset_entity lae
|
||||
WHERE NOT EXISTS (
|
||||
|
||||
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
);
|
||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||
return customSelect(
|
||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
variables: [
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
...generatedlimit.introducedVariables,
|
||||
@@ -67,6 +67,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
longitude: row.readNullable<double>('longitude'),
|
||||
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
|
||||
isEdited: row.read<bool>('is_edited'),
|
||||
playbackStyle: row.read<int>('playback_style'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -139,6 +140,7 @@ class MergedAssetResult {
|
||||
final double? longitude;
|
||||
final DateTime? adjustmentTime;
|
||||
final bool isEdited;
|
||||
final int playbackStyle;
|
||||
MergedAssetResult({
|
||||
this.remoteId,
|
||||
this.localId,
|
||||
@@ -161,6 +163,7 @@ class MergedAssetResult {
|
||||
this.longitude,
|
||||
this.adjustmentTime,
|
||||
required this.isEdited,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity
|
||||
|
||||
IntColumn get source => intEnum<TrashOrigin>()();
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id, albumId};
|
||||
}
|
||||
@@ -45,6 +47,7 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
|
||||
height: height,
|
||||
width: width,
|
||||
orientation: orientation,
|
||||
playbackStyle: playbackStyle,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
required i3.TrashOrigin source,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.TrashedLocalAssetEntityCompanion Function({
|
||||
@@ -39,6 +40,7 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
i0.Value<i3.TrashOrigin> source,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
|
||||
class $$TrashedLocalAssetEntityTableFilterComposer
|
||||
@@ -117,6 +119,16 @@ class $$TrashedLocalAssetEntityTableFilterComposer
|
||||
column: $table.source,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnWithTypeConverterFilters<
|
||||
i2.AssetPlaybackStyle,
|
||||
i2.AssetPlaybackStyle,
|
||||
int
|
||||
>
|
||||
get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashedLocalAssetEntityTableOrderingComposer
|
||||
@@ -193,6 +205,11 @@ class $$TrashedLocalAssetEntityTableOrderingComposer
|
||||
column: $table.source,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashedLocalAssetEntityTableAnnotationComposer
|
||||
@@ -249,6 +266,12 @@ class $$TrashedLocalAssetEntityTableAnnotationComposer
|
||||
|
||||
i0.GeneratedColumnWithTypeConverter<i3.TrashOrigin, int> get source =>
|
||||
$composableBuilder(column: $table.source, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
||||
get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashedLocalAssetEntityTableTableManager
|
||||
@@ -310,6 +333,8 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||
i0.Value<int> orientation = const i0.Value.absent(),
|
||||
i0.Value<i3.TrashOrigin> source = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
}) => i1.TrashedLocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -324,6 +349,7 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
source: source,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -340,6 +366,8 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||
i0.Value<int> orientation = const i0.Value.absent(),
|
||||
required i3.TrashOrigin source,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
}) => i1.TrashedLocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -354,6 +382,7 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
source: source,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -550,6 +579,19 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource,
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
||||
playbackStyle =
|
||||
i0.GeneratedColumn<int>(
|
||||
'playback_style',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const i4.Constant(0),
|
||||
).withConverter<i2.AssetPlaybackStyle>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
type,
|
||||
@@ -564,6 +606,7 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
isFavorite,
|
||||
orientation,
|
||||
source,
|
||||
playbackStyle,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -720,6 +763,13 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
data['${effectivePrefix}source'],
|
||||
)!,
|
||||
),
|
||||
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
|
||||
.fromSql(
|
||||
attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}playback_style'],
|
||||
)!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -732,6 +782,10 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
|
||||
static i0.JsonTypeConverter2<i3.TrashOrigin, int, int> $convertersource =
|
||||
const i0.EnumIndexConverter<i3.TrashOrigin>(i3.TrashOrigin.values);
|
||||
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
|
||||
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
|
||||
i2.AssetPlaybackStyle.values,
|
||||
);
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
@@ -753,6 +807,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
final bool isFavorite;
|
||||
final int orientation;
|
||||
final i3.TrashOrigin source;
|
||||
final i2.AssetPlaybackStyle playbackStyle;
|
||||
const TrashedLocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -767,6 +822,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
required this.isFavorite,
|
||||
required this.orientation,
|
||||
required this.source,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -800,6 +856,13 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source),
|
||||
);
|
||||
}
|
||||
{
|
||||
map['playback_style'] = i0.Variable<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
|
||||
playbackStyle,
|
||||
),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -826,6 +889,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
source: i1.$TrashedLocalAssetEntityTable.$convertersource.fromJson(
|
||||
serializer.fromJson<int>(json['source']),
|
||||
),
|
||||
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
|
||||
.fromJson(serializer.fromJson<int>(json['playbackStyle'])),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -849,6 +914,11 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
'source': serializer.toJson<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toJson(source),
|
||||
),
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toJson(
|
||||
playbackStyle,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -866,6 +936,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
i3.TrashOrigin? source,
|
||||
i2.AssetPlaybackStyle? playbackStyle,
|
||||
}) => i1.TrashedLocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -882,6 +953,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
source: source ?? this.source,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
TrashedLocalAssetEntityData copyWithCompanion(
|
||||
i1.TrashedLocalAssetEntityCompanion data,
|
||||
@@ -906,6 +978,9 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
? data.orientation.value
|
||||
: this.orientation,
|
||||
source: data.source.present ? data.source.value : this.source,
|
||||
playbackStyle: data.playbackStyle.present
|
||||
? data.playbackStyle.value
|
||||
: this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -924,7 +999,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('source: $source')
|
||||
..write('source: $source, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -944,6 +1020,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
isFavorite,
|
||||
orientation,
|
||||
source,
|
||||
playbackStyle,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -961,7 +1038,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
other.checksum == this.checksum &&
|
||||
other.isFavorite == this.isFavorite &&
|
||||
other.orientation == this.orientation &&
|
||||
other.source == this.source);
|
||||
other.source == this.source &&
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
}
|
||||
|
||||
class TrashedLocalAssetEntityCompanion
|
||||
@@ -979,6 +1057,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
final i0.Value<bool> isFavorite;
|
||||
final i0.Value<int> orientation;
|
||||
final i0.Value<i3.TrashOrigin> source;
|
||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||
const TrashedLocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -993,6 +1072,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
this.isFavorite = const i0.Value.absent(),
|
||||
this.orientation = const i0.Value.absent(),
|
||||
this.source = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
});
|
||||
TrashedLocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1008,6 +1088,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
this.isFavorite = const i0.Value.absent(),
|
||||
this.orientation = const i0.Value.absent(),
|
||||
required i3.TrashOrigin source,
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id),
|
||||
@@ -1027,6 +1108,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
i0.Expression<bool>? isFavorite,
|
||||
i0.Expression<int>? orientation,
|
||||
i0.Expression<int>? source,
|
||||
i0.Expression<int>? playbackStyle,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1042,6 +1124,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
if (isFavorite != null) 'is_favorite': isFavorite,
|
||||
if (orientation != null) 'orientation': orientation,
|
||||
if (source != null) 'source': source,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1059,6 +1142,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
i0.Value<bool>? isFavorite,
|
||||
i0.Value<int>? orientation,
|
||||
i0.Value<i3.TrashOrigin>? source,
|
||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||
}) {
|
||||
return i1.TrashedLocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1074,6 +1158,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
source: source ?? this.source,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1123,6 +1208,13 @@ class TrashedLocalAssetEntityCompanion
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source.value),
|
||||
);
|
||||
}
|
||||
if (playbackStyle.present) {
|
||||
map['playback_style'] = i0.Variable<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
|
||||
playbackStyle.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1141,7 +1233,8 @@ class TrashedLocalAssetEntityCompanion
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('source: $source')
|
||||
..write('source: $source, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ abstract class ImageRequest {
|
||||
|
||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
|
||||
|
||||
Future<ui.Codec?> loadCodec();
|
||||
|
||||
void cancel() {
|
||||
if (_isCancelled) {
|
||||
return;
|
||||
@@ -34,7 +36,7 @@ abstract class ImageRequest {
|
||||
|
||||
void _onCancelled();
|
||||
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
|
||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
malloc.free(pointer);
|
||||
@@ -67,6 +69,20 @@ abstract class ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (codec, descriptor);
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
|
||||
final result = await _codecFromEncodedPlatformImage(address, length);
|
||||
if (result == null) return null;
|
||||
|
||||
final (codec, descriptor) = result;
|
||||
if (_isCancelled) {
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final frame = await codec.getNextFrame();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
|
||||
@@ -22,6 +22,7 @@ class LocalImageRequest extends ImageRequest {
|
||||
width: width,
|
||||
height: height,
|
||||
isVideo: assetType == AssetType.video,
|
||||
preferEncoded: false,
|
||||
);
|
||||
if (info == null) {
|
||||
return null;
|
||||
@@ -31,6 +32,26 @@ class LocalImageRequest extends ImageRequest {
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ui.Codec?> loadCodec() async {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final info = await localImageApi.requestImage(
|
||||
localId,
|
||||
requestId: requestId,
|
||||
width: width,
|
||||
height: height,
|
||||
isVideo: assetType == AssetType.video,
|
||||
preferEncoded: true,
|
||||
);
|
||||
if (info == null) return null;
|
||||
|
||||
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
||||
return codec;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> _onCancelled() {
|
||||
return localImageApi.cancelRequest(requestId);
|
||||
|
||||
@@ -12,7 +12,8 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
|
||||
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false);
|
||||
// Android always returns encoded data, so we need to check for both shapes of the response.
|
||||
final frame = switch (info) {
|
||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||
@@ -22,6 +23,19 @@ class RemoteImageRequest extends ImageRequest {
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ui.Codec?> loadCodec() async {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true);
|
||||
if (info == null) return null;
|
||||
|
||||
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
||||
return codec;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> _onCancelled() {
|
||||
return remoteImageApi.cancelRequest(requestId);
|
||||
|
||||
@@ -16,6 +16,9 @@ class ThumbhashImageRequest extends ImageRequest {
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ui.Codec?> loadCodec() => throw UnsupportedError('Thumbhash does not support codec loading');
|
||||
|
||||
@override
|
||||
void _onCancelled() {}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 20;
|
||||
int get schemaVersion => 21;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -230,6 +230,10 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
|
||||
},
|
||||
from20To21: (m, v21) async {
|
||||
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
|
||||
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -8904,6 +8904,591 @@ i1.GeneratedColumn<bool> _column_102(String aliasedName) =>
|
||||
),
|
||||
defaultValue: const CustomExpression('1'),
|
||||
);
|
||||
|
||||
final class Schema21 extends i0.VersionedSchema {
|
||||
Schema21({required super.database}) : super(version: 21);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxRemoteAlbumOwnerId,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxStackPrimaryAssetId,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetLocalDateTimeDay,
|
||||
idxRemoteAssetLocalDateTimeMonth,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
late final Shape20 userEntity = Shape20(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_91,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape28 remoteAssetEntity = Shape28(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_86,
|
||||
_column_101,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape3 stackEntity = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape30 localAssetEntity = Shape30(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_98,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_103,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape9 remoteAlbumEntity = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_56,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_57,
|
||||
_column_58,
|
||||
_column_59,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape19 localAlbumEntity = Shape19(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_5,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_90,
|
||||
_column_33,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_34, _column_35, _column_33],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
|
||||
'idx_remote_album_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
|
||||
'idx_remote_asset_local_date_time_day',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
|
||||
'idx_remote_asset_local_date_time_month',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
|
||||
);
|
||||
late final Shape21 authUserEntity = Shape21(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_2,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_92,
|
||||
_column_93,
|
||||
_column_7,
|
||||
_column_94,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_25, _column_26, _column_27],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 partnerEntity = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_28, _column_29, _column_30],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 remoteExifEntity = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
_column_38,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_41,
|
||||
_column_11,
|
||||
_column_10,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_48,
|
||||
_column_49,
|
||||
_column_50,
|
||||
_column_51,
|
||||
_column_52,
|
||||
_column_53,
|
||||
_column_54,
|
||||
_column_55,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_36, _column_60],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_60, _column_25, _column_61],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape27 remoteAssetCloudIdEntity = Shape27(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_99,
|
||||
_column_100,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 memoryEntity = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_18,
|
||||
_column_15,
|
||||
_column_8,
|
||||
_column_62,
|
||||
_column_63,
|
||||
_column_64,
|
||||
_column_65,
|
||||
_column_66,
|
||||
_column_67,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_36, _column_68],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape14 personEntity = Shape14(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_1,
|
||||
_column_69,
|
||||
_column_71,
|
||||
_column_72,
|
||||
_column_73,
|
||||
_column_74,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape29 assetFaceEntity = Shape29(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_76,
|
||||
_column_77,
|
||||
_column_78,
|
||||
_column_79,
|
||||
_column_80,
|
||||
_column_81,
|
||||
_column_82,
|
||||
_column_83,
|
||||
_column_102,
|
||||
_column_18,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_87, _column_88, _column_89],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape31 trashedLocalAssetEntity = Shape31(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_95,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_97,
|
||||
_column_103,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape30 extends i0.VersionedTable {
|
||||
Shape30({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationInSeconds =>
|
||||
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get iCloudId =>
|
||||
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_103(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'playback_style',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
defaultValue: const CustomExpression('0'),
|
||||
);
|
||||
|
||||
class Shape31 extends i0.VersionedTable {
|
||||
Shape31({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationInSeconds =>
|
||||
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get albumId =>
|
||||
columnsByName['album_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get source =>
|
||||
columnsByName['source']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -8924,6 +9509,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -9022,6 +9608,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from19To20(migrator, schema);
|
||||
return 20;
|
||||
case 20:
|
||||
final schema = Schema21(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from20To21(migrator, schema);
|
||||
return 21;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -9048,6 +9639,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -9069,5 +9661,6 @@ i1.OnUpgrade stepByStep({
|
||||
from17To18: from17To18,
|
||||
from18To19: from18To19,
|
||||
from19To20: from19To20,
|
||||
from20To21: from20To21,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -301,6 +301,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
id: asset.id,
|
||||
orientation: Value(asset.orientation),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
playbackStyle: Value(asset.playbackStyle),
|
||||
latitude: Value(asset.latitude),
|
||||
longitude: Value(asset.longitude),
|
||||
adjustmentTime: Value(asset.adjustmentTime),
|
||||
@@ -333,6 +334,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
checksum: const Value(null),
|
||||
orientation: Value(asset.orientation),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
playbackStyle: Value(asset.playbackStyle),
|
||||
);
|
||||
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||
_db.localAssetEntity,
|
||||
|
||||
@@ -101,6 +101,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
isFavorite: row.isFavorite,
|
||||
durationInSeconds: row.durationInSeconds,
|
||||
orientation: row.orientation,
|
||||
playbackStyle: AssetPlaybackStyle.values[row.playbackStyle],
|
||||
cloudId: row.iCloudId,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
|
||||
@@ -85,6 +85,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
durationInSeconds: Value(item.asset.durationInSeconds),
|
||||
isFavorite: Value(item.asset.isFavorite),
|
||||
orientation: Value(item.asset.orientation),
|
||||
playbackStyle: Value(item.asset.playbackStyle),
|
||||
source: TrashOrigin.localSync,
|
||||
);
|
||||
|
||||
@@ -147,6 +148,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
durationInSeconds: Value(asset.durationInSeconds),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
orientation: Value(asset.orientation),
|
||||
playbackStyle: Value(asset.playbackStyle),
|
||||
createdAt: Value(asset.createdAt),
|
||||
updatedAt: Value(asset.updatedAt),
|
||||
source: const Value(TrashOrigin.remoteSync),
|
||||
@@ -195,6 +197,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
checksum: Value(e.checksum),
|
||||
isFavorite: Value(e.isFavorite),
|
||||
orientation: Value(e.orientation),
|
||||
playbackStyle: Value(e.playbackStyle),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -245,6 +248,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
checksum: Value(e.asset.checksum),
|
||||
isFavorite: Value(e.asset.isFavorite),
|
||||
orientation: Value(e.asset.orientation),
|
||||
playbackStyle: Value(e.asset.playbackStyle),
|
||||
source: TrashOrigin.localUser,
|
||||
albumId: e.albumId,
|
||||
);
|
||||
|
||||
@@ -8,25 +8,18 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as pm;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -168,7 +161,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
),
|
||||
),
|
||||
},
|
||||
const _BackupFooter(),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text("view_details".t(context: context)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -179,130 +176,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupFooter extends ConsumerStatefulWidget {
|
||||
const _BackupFooter();
|
||||
|
||||
@override
|
||||
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
|
||||
}
|
||||
|
||||
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
|
||||
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
|
||||
unawaited(ref.read(_batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
|
||||
}
|
||||
}
|
||||
|
||||
void showPermissionsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
content: Text(context.t.notification_permission_dialog_content),
|
||||
actions: [
|
||||
TextButton(child: Text(context.t.cancel), onPressed: () => ctx.pop()),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
pm.openAppSettings();
|
||||
},
|
||||
child: Text(context.t.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showBatteryOptimizationInfo() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.backup_controller_page_background_battery_info_title),
|
||||
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
|
||||
child: Text(
|
||||
context.t.backup_controller_page_background_battery_info_link,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
child: Text(
|
||||
context.t.backup_controller_page_background_battery_info_ok,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
onPressed: () => ctx.pop(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBackupEnabled = ref.watch(_backupStatusProvider).valueOrNull ?? false;
|
||||
final notificationStatus = ref.watch(notificationPermissionProvider);
|
||||
final batteryOptimizationStatus = ref.watch(_batteryOptimizationProvider).valueOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
|
||||
if (notificationStatus != pm.PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.notification_backup_reliability,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
|
||||
if (p == pm.PermissionStatus.permanentlyDenied) {
|
||||
showPermissionsDialog();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
// Show only after notification permission is granted
|
||||
if (notificationStatus == pm.PermissionStatus.granted &&
|
||||
batteryOptimizationStatus != pm.PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.battery_optimization_backup_reliability,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: showBatteryOptimizationInfo,
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text(context.t.view_details),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupAlbumSelectionCard extends ConsumerWidget {
|
||||
const _BackupAlbumSelectionCard();
|
||||
|
||||
@@ -648,28 +521,3 @@ class _PreparingStatusState extends ConsumerState {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _backupStatusProvider = StreamProvider.autoDispose<bool?>((ref) async* {
|
||||
yield* ref.watch(storeServiceProvider).watch(StoreKey.enableBackup);
|
||||
});
|
||||
|
||||
final _batteryOptimizationProvider = AsyncNotifierProvider<_BatteryOptimizationNotifier, pm.PermissionStatus>(
|
||||
_BatteryOptimizationNotifier.new,
|
||||
);
|
||||
|
||||
class _BatteryOptimizationNotifier extends AsyncNotifier<pm.PermissionStatus> {
|
||||
Future<pm.PermissionStatus> getBatteryOptimizationPermission() async {
|
||||
final pm.PermissionStatus status;
|
||||
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations();
|
||||
if (isIgnoring == PermissionStatus.granted) {
|
||||
status = pm.PermissionStatus.granted;
|
||||
} else {
|
||||
status = pm.PermissionStatus.denied;
|
||||
}
|
||||
state = AsyncValue.data(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<pm.PermissionStatus> build() => getBatteryOptimizationPermission();
|
||||
}
|
||||
|
||||
2
mobile/lib/platform/local_image_api.g.dart
generated
2
mobile/lib/platform/local_image_api.g.dart
generated
@@ -55,6 +55,7 @@ class LocalImageApi {
|
||||
required int width,
|
||||
required int height,
|
||||
required bool isVideo,
|
||||
required bool preferEncoded,
|
||||
}) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||
@@ -69,6 +70,7 @@ class LocalImageApi {
|
||||
width,
|
||||
height,
|
||||
isVideo,
|
||||
preferEncoded,
|
||||
]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
|
||||
33
mobile/lib/platform/native_sync_api.g.dart
generated
33
mobile/lib/platform/native_sync_api.g.dart
generated
@@ -29,6 +29,8 @@ bool _deepEquals(Object? a, Object? b) {
|
||||
return a == b;
|
||||
}
|
||||
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
class PlatformAsset {
|
||||
PlatformAsset({
|
||||
required this.id,
|
||||
@@ -44,6 +46,7 @@ class PlatformAsset {
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
|
||||
String id;
|
||||
@@ -72,6 +75,8 @@ class PlatformAsset {
|
||||
|
||||
double? longitude;
|
||||
|
||||
PlatformAssetPlaybackStyle playbackStyle;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
id,
|
||||
@@ -87,6 +92,7 @@ class PlatformAsset {
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -110,6 +116,7 @@ class PlatformAsset {
|
||||
adjustmentTime: result[10] as int?,
|
||||
latitude: result[11] as double?,
|
||||
longitude: result[12] as double?,
|
||||
playbackStyle: result[13]! as PlatformAssetPlaybackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,21 +323,24 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is PlatformAsset) {
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is PlatformAlbum) {
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SyncDelta) {
|
||||
} else if (value is PlatformAlbum) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is HashResult) {
|
||||
} else if (value is SyncDelta) {
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
} else if (value is HashResult) {
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
buffer.putUint8(134);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
@@ -340,14 +350,17 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
return PlatformAsset.decode(readValue(buffer)!);
|
||||
final int? value = readValue(buffer) as int?;
|
||||
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
||||
case 130:
|
||||
return PlatformAlbum.decode(readValue(buffer)!);
|
||||
return PlatformAsset.decode(readValue(buffer)!);
|
||||
case 131:
|
||||
return SyncDelta.decode(readValue(buffer)!);
|
||||
return PlatformAlbum.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
return HashResult.decode(readValue(buffer)!);
|
||||
return SyncDelta.decode(readValue(buffer)!);
|
||||
case 133:
|
||||
return HashResult.decode(readValue(buffer)!);
|
||||
case 134:
|
||||
return CloudIdResult.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
|
||||
87
mobile/lib/platform/permission_api.g.dart
generated
87
mobile/lib/platform/permission_api.g.dart
generated
@@ -1,87 +0,0 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
PlatformException _createConnectionError(String channelName) {
|
||||
return PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
|
||||
enum PermissionStatus { granted, denied, permanentlyDenied }
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is PermissionStatus) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final int? value = readValue(buffer) as int?;
|
||||
return value == null ? null : PermissionStatus.values[value];
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionApi {
|
||||
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as PermissionStatus?)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
mobile/lib/platform/remote_image_api.g.dart
generated
8
mobile/lib/platform/remote_image_api.g.dart
generated
@@ -53,6 +53,7 @@ class RemoteImageApi {
|
||||
String url, {
|
||||
required Map<String, String> headers,
|
||||
required int requestId,
|
||||
required bool preferEncoded,
|
||||
}) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||
@@ -61,7 +62,12 @@ class RemoteImageApi {
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
|
||||
url,
|
||||
headers,
|
||||
requestId,
|
||||
preferEncoded,
|
||||
]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
|
||||
@@ -36,7 +36,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
const UnArchiveActionButton(source: ActionSource.timeline),
|
||||
const FavoriteActionButton(source: ActionSource.timeline),
|
||||
const DownloadActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -75,7 +75,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
const UnFavoriteActionButton(source: ActionSource.timeline),
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
const DownloadActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -108,7 +108,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
const DownloadActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
@@ -119,10 +119,11 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyLocal || multiselect.hasMerged)
|
||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: multiselect.hasRemote
|
||||
? [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@@ -75,6 +77,29 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||
}
|
||||
}
|
||||
|
||||
Future<ui.Codec?> loadCodecRequest(ImageRequest request) async {
|
||||
if (isCancelled) {
|
||||
this.request = null;
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final codec = await request.loadCodec();
|
||||
if (codec == null || isCancelled) {
|
||||
codec?.dispose();
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return null;
|
||||
}
|
||||
return codec;
|
||||
} catch (e) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
rethrow;
|
||||
} finally {
|
||||
this.request = null;
|
||||
}
|
||||
}
|
||||
|
||||
Stream<ImageInfo> initialImageStream() async* {
|
||||
final cachedOperation = this.cachedOperation;
|
||||
if (cachedOperation == null) {
|
||||
|
||||
@@ -3,10 +3,9 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||
|
||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||
@@ -19,8 +18,6 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||
|
||||
final localImageApi = LocalImageApi();
|
||||
|
||||
final remoteImageApi = RemoteImageApi();
|
||||
|
||||
@@ -24,10 +24,12 @@ class MultiSelectState {
|
||||
|
||||
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
|
||||
|
||||
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
|
||||
|
||||
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
|
||||
|
||||
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
|
||||
|
||||
bool get onlyRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote);
|
||||
|
||||
MultiSelectState copyWith({
|
||||
Set<BaseAsset>? selectedAssets,
|
||||
Set<BaseAsset>? lockedSelectionAssets,
|
||||
|
||||
@@ -25,6 +25,7 @@ class FileMediaRepository {
|
||||
type: AssetType.image,
|
||||
createdAt: entity.createDateTime,
|
||||
updatedAt: entity.modifiedDateTime,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:io';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
||||
@@ -17,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
@@ -33,7 +35,7 @@ import 'package:isar/isar.dart';
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 22;
|
||||
const int targetVersion = 23;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
@@ -99,6 +101,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 23 && Store.isBetaTimelineEnabled) {
|
||||
await _populateLocalAssetPlaybackStyle(drift);
|
||||
}
|
||||
|
||||
if (version < 22 && !Store.isBetaTimelineEnabled) {
|
||||
await Store.put(StoreKey.needBetaMigration, true);
|
||||
}
|
||||
@@ -392,6 +398,52 @@ Future<void> migrateStoreToIsar(Isar db, Drift drift) async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
|
||||
try {
|
||||
final nativeApi = NativeSyncApi();
|
||||
|
||||
final albums = await nativeApi.getAlbums();
|
||||
for (final album in albums) {
|
||||
final assets = await nativeApi.getAssetsForAlbum(album.id);
|
||||
await db.batch((batch) {
|
||||
for (final asset in assets) {
|
||||
batch.update(
|
||||
db.localAssetEntity,
|
||||
LocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
|
||||
where: (t) => t.id.equals(asset.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final trashedAssetMap = await nativeApi.getTrashedAssets();
|
||||
for (final assets in trashedAssetMap.values) {
|
||||
await db.batch((batch) {
|
||||
for (final asset in assets) {
|
||||
batch.update(
|
||||
db.trashedLocalAssetEntity,
|
||||
TrashedLocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
|
||||
where: (t) => t.id.equals(asset.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error");
|
||||
}
|
||||
}
|
||||
|
||||
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
||||
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
||||
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
||||
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
|
||||
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
|
||||
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
|
||||
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
|
||||
};
|
||||
|
||||
class _DeviceAsset {
|
||||
final String assetId;
|
||||
final List<int>? hash;
|
||||
|
||||
@@ -13,7 +13,6 @@ pigeon:
|
||||
dart run pigeon --input pigeon/background_worker_lock_api.dart
|
||||
dart run pigeon --input pigeon/connectivity_api.dart
|
||||
dart run pigeon --input pigeon/network_api.dart
|
||||
dart run pigeon --input pigeon/permission_api.dart
|
||||
dart format lib/platform/native_sync_api.g.dart
|
||||
dart format lib/platform/local_image_api.g.dart
|
||||
dart format lib/platform/remote_image_api.g.dart
|
||||
@@ -21,7 +20,6 @@ pigeon:
|
||||
dart format lib/platform/background_worker_lock_api.g.dart
|
||||
dart format lib/platform/connectivity_api.g.dart
|
||||
dart format lib/platform/network_api.g.dart
|
||||
dart format lib/platform/permission_api.g.dart
|
||||
|
||||
watch:
|
||||
dart run build_runner watch --delete-conflicting-outputs
|
||||
|
||||
@@ -40,13 +40,7 @@ depends = [
|
||||
[tasks."codegen:translation"]
|
||||
alias = "translation"
|
||||
description = "Generate translations from i18n JSONs"
|
||||
run = [
|
||||
{ task = "//:i18n:format-fix" },
|
||||
{ tasks = [
|
||||
"i18n:loader",
|
||||
"i18n:keys",
|
||||
] },
|
||||
]
|
||||
run = [{ task = "//:i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }]
|
||||
|
||||
[tasks."codegen:app-icon"]
|
||||
description = "Generate app icons"
|
||||
@@ -152,19 +146,6 @@ run = [
|
||||
"dart format lib/platform/connectivity_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."pigeon:permission"]
|
||||
description = "Generate permission API pigeon code"
|
||||
hide = true
|
||||
sources = ["pigeon/permission_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/permission_api.g.dart",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/permission_api.dart",
|
||||
"dart format lib/platform/permission_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."i18n:loader"]
|
||||
description = "Generate i18n loader"
|
||||
hide = true
|
||||
|
||||
@@ -21,6 +21,7 @@ abstract class LocalImageApi {
|
||||
required int width,
|
||||
required int height,
|
||||
required bool isVideo,
|
||||
required bool preferEncoded,
|
||||
});
|
||||
|
||||
void cancelRequest(int requestId);
|
||||
|
||||
@@ -11,6 +11,15 @@ import 'package:pigeon/pigeon.dart';
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
enum PlatformAssetPlaybackStyle {
|
||||
unknown,
|
||||
image,
|
||||
video,
|
||||
imageAnimated,
|
||||
livePhoto,
|
||||
videoLooping,
|
||||
}
|
||||
|
||||
class PlatformAsset {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -31,6 +40,8 @@ class PlatformAsset {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
final PlatformAssetPlaybackStyle playbackStyle;
|
||||
|
||||
const PlatformAsset({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -45,6 +56,7 @@ class PlatformAsset {
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.playbackStyle = PlatformAssetPlaybackStyle.unknown,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
enum PermissionStatus { granted, denied, permanentlyDenied }
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/permission_api.g.dart',
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
@HostApi()
|
||||
abstract class PermissionApi {
|
||||
PermissionStatus isIgnoringBatteryOptimizations();
|
||||
}
|
||||
@@ -19,6 +19,7 @@ abstract class RemoteImageApi {
|
||||
String url, {
|
||||
required Map<String, String> headers,
|
||||
required int requestId,
|
||||
required bool preferEncoded,
|
||||
});
|
||||
|
||||
void cancelRequest(int requestId);
|
||||
|
||||
@@ -131,6 +131,7 @@ void main() {
|
||||
durationInSeconds: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
@@ -214,6 +215,7 @@ void main() {
|
||||
isFavorite: false,
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
4
mobile/test/drift/main/generated/schema.dart
generated
4
mobile/test/drift/main/generated/schema.dart
generated
@@ -23,6 +23,7 @@ import 'schema_v17.dart' as v17;
|
||||
import 'schema_v18.dart' as v18;
|
||||
import 'schema_v19.dart' as v19;
|
||||
import 'schema_v20.dart' as v20;
|
||||
import 'schema_v21.dart' as v21;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -68,6 +69,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v19.DatabaseAtV19(db);
|
||||
case 20:
|
||||
return v20.DatabaseAtV20(db);
|
||||
case 21:
|
||||
return v21.DatabaseAtV21(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -94,5 +97,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
];
|
||||
}
|
||||
|
||||
8545
mobile/test/drift/main/generated/schema_v21.dart
generated
Normal file
8545
mobile/test/drift/main/generated/schema_v21.dart
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
mobile/test/fixtures/asset.stub.dart
vendored
2
mobile/test/fixtures/asset.stub.dart
vendored
@@ -64,6 +64,7 @@ abstract final class LocalAssetStub {
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025, 2),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -73,6 +74,7 @@ abstract final class LocalAssetStub {
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2000),
|
||||
updatedAt: DateTime(20021),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ void main() {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
adjustmentTime: DateTime(2026, 1, 2),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -243,6 +244,7 @@ void main() {
|
||||
cloudId: 'cloud-id-123',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -281,6 +283,7 @@ void main() {
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: null, // No cloudId
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -323,6 +326,7 @@ void main() {
|
||||
cloudId: 'cloud-id-livephoto',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ abstract final class TestUtils {
|
||||
width: width,
|
||||
height: height,
|
||||
orientation: orientation,
|
||||
playbackStyle: domain.AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class MediumFactory {
|
||||
type: type ?? AssetType.image,
|
||||
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
|
||||
updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ LocalAsset createLocalAsset({
|
||||
createdAt: createdAt ?? DateTime.now(),
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
isFavorite: isFavorite,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
574
pnpm-lock.yaml
generated
574
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -102,22 +102,30 @@ order by
|
||||
"shared_link"."createdAt" desc
|
||||
|
||||
-- SharedLinkRepository.getAll
|
||||
select distinct
|
||||
on ("shared_link"."createdAt") "shared_link".*,
|
||||
"assets"."assets",
|
||||
select
|
||||
"shared_link".*,
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"shared_link_asset"
|
||||
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
|
||||
where
|
||||
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
|
||||
and "asset"."deletedAt" is null
|
||||
order by
|
||||
"asset"."fileCreatedAt" asc
|
||||
limit
|
||||
$1
|
||||
) as agg
|
||||
) as "assets",
|
||||
to_json("album") as "album"
|
||||
from
|
||||
"shared_link"
|
||||
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
|
||||
left join lateral (
|
||||
select
|
||||
json_agg("asset") as "assets"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = "shared_link_asset"."assetId"
|
||||
and "asset"."deletedAt" is null
|
||||
) as "assets" on true
|
||||
left join lateral (
|
||||
select
|
||||
"album".*,
|
||||
@@ -152,12 +160,12 @@ from
|
||||
and "album"."deletedAt" is null
|
||||
) as "album" on true
|
||||
where
|
||||
"shared_link"."userId" = $1
|
||||
"shared_link"."userId" = $2
|
||||
and (
|
||||
"shared_link"."type" = $2
|
||||
"shared_link"."type" = $3
|
||||
or "album"."id" is not null
|
||||
)
|
||||
and "shared_link"."albumId" = $3
|
||||
and "shared_link"."albumId" = $4
|
||||
order by
|
||||
"shared_link"."createdAt" desc
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { Insertable, Kysely, sql, Updateable } from 'kysely';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import _ from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Album, columns } from 'src/database';
|
||||
@@ -124,19 +124,20 @@ export class SharedLinkRepository {
|
||||
.selectFrom('shared_link')
|
||||
.selectAll('shared_link')
|
||||
.where('shared_link.userId', '=', userId)
|
||||
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset')
|
||||
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
|
||||
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
|
||||
.selectFrom('shared_link_asset')
|
||||
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
|
||||
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.as('assets'),
|
||||
(join) => join.onTrue(),
|
||||
.selectAll('asset')
|
||||
.orderBy('asset.fileCreatedAt', 'asc')
|
||||
.limit(1),
|
||||
)
|
||||
.$castTo<MapAsset[]>()
|
||||
.as('assets'),
|
||||
)
|
||||
.select('assets.assets')
|
||||
.$narrowType<{ assets: NotNull }>()
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
@@ -179,7 +180,6 @@ export class SharedLinkRepository {
|
||||
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
|
||||
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
|
||||
.orderBy('shared_link.createdAt', 'desc')
|
||||
.distinctOn(['shared_link.createdAt'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,14 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
||||
return { albumUser: { albumId, userId, role }, result };
|
||||
}
|
||||
|
||||
async softDeleteAsset(assetId: string) {
|
||||
await this.database.updateTable('asset').set({ deletedAt: new Date() }).where('id', '=', assetId).execute();
|
||||
}
|
||||
|
||||
async softDeleteAlbum(albumId: string) {
|
||||
await this.database.updateTable('album').set({ deletedAt: new Date() }).where('id', '=', albumId).execute();
|
||||
}
|
||||
|
||||
async newJobStatus(dto: Partial<Insertable<AssetJobStatusTable>> & { assetId: string }) {
|
||||
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: dto.assetId });
|
||||
const result = await this.get(AssetRepository).upsertJobStatus(jobStatus);
|
||||
|
||||
@@ -95,6 +95,469 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all shared links even when they share the same createdAt', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
const sameTimestamp = '2024-01-01T00:00:00.000Z';
|
||||
|
||||
const link1 = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
createdAt: sameTimestamp,
|
||||
});
|
||||
|
||||
const link2 = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
createdAt: sameTimestamp,
|
||||
});
|
||||
|
||||
const result = await sut.getAll(auth, {});
|
||||
expect(result).toHaveLength(2);
|
||||
const ids = result.map((r) => r.id);
|
||||
expect(ids).toContain(link1.id);
|
||||
expect(ids).toContain(link2.id);
|
||||
});
|
||||
|
||||
it('should return shared links sorted by createdAt in descending order', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const link1 = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
createdAt: '2021-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const link2 = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const link3 = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = await sut.getAll(auth, {});
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((r) => r.id)).toEqual([link2.id, link3.id, link1.id]);
|
||||
});
|
||||
|
||||
it('should not return shared links belonging to other users', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const { user: userA } = await ctx.newUser();
|
||||
const { user: userB } = await ctx.newUser();
|
||||
const authA = factory.auth({ user: userA });
|
||||
const authB = factory.auth({ user: userB });
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const linkA = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: userA.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
});
|
||||
|
||||
await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: userB.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
});
|
||||
|
||||
const resultA = await sut.getAll(authA, {});
|
||||
expect(resultA).toHaveLength(1);
|
||||
expect(resultA[0].id).toBe(linkA.id);
|
||||
|
||||
const resultB = await sut.getAll(authB, {});
|
||||
expect(resultB).toHaveLength(1);
|
||||
expect(resultB[0].id).not.toBe(linkA.id);
|
||||
});
|
||||
|
||||
it('should filter by albumId', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
|
||||
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const link1 = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album1.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album2.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
const result = await sut.getAll(auth, { albumId: album1.id });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(link1.id);
|
||||
});
|
||||
|
||||
it('should return album shared links with album data', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
const result = await sut.getAll(auth, {});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].album).toBeDefined();
|
||||
expect(result[0].album!.id).toBe(album.id);
|
||||
});
|
||||
|
||||
it('should return multiple album shared links without sql error from json group by', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
|
||||
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const link1 = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album1.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
const link2 = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album2.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
const result = await sut.getAll(auth, {});
|
||||
expect(result).toHaveLength(2);
|
||||
const ids = result.map((r) => r.id);
|
||||
expect(ids).toContain(link1.id);
|
||||
expect(ids).toContain(link2.id);
|
||||
expect(result[0].album).toBeDefined();
|
||||
expect(result[1].album).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return mixed album and individual shared links together', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const albumLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
const albumLink2 = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
const individualLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
|
||||
const result = await sut.getAll(auth, {});
|
||||
expect(result).toHaveLength(3);
|
||||
const ids = result.map((r) => r.id);
|
||||
expect(ids).toContain(albumLink.id);
|
||||
expect(ids).toContain(albumLink2.id);
|
||||
expect(ids).toContain(individualLink.id);
|
||||
});
|
||||
|
||||
it('should return only the first asset as cover for an individual shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const assets = await Promise.all([
|
||||
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2021-01-01T00:00:00.000Z' }),
|
||||
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2023-01-01T00:00:00.000Z' }),
|
||||
ctx.newAsset({ ownerId: user.id, fileCreatedAt: '2022-01-01T00:00:00.000Z' }),
|
||||
]);
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: assets.map(({ asset }) => asset.id),
|
||||
});
|
||||
|
||||
const result = await sut.getAll(auth, {});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].assets).toHaveLength(1);
|
||||
expect(result[0].assets[0].id).toBe(assets[0].asset.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should not return trashed assets for an individual shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' });
|
||||
|
||||
const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' });
|
||||
await ctx.softDeleteAsset(trashedAsset.id);
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [visibleAsset.id, trashedAsset.id],
|
||||
});
|
||||
|
||||
const result = await sut.get(auth, sharedLink.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.assets).toHaveLength(1);
|
||||
expect(result!.assets[0].id).toBe(visibleAsset.id);
|
||||
});
|
||||
|
||||
it('should return empty assets when all individually shared assets are trashed', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
|
||||
await ctx.softDeleteAsset(asset.id);
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
|
||||
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
|
||||
assets: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return trashed assets in a shared album', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const { asset: visibleAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: visibleAsset.id, make: 'Canon' });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: visibleAsset.id });
|
||||
|
||||
const { asset: trashedAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: trashedAsset.id, make: 'Canon' });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: trashedAsset.id });
|
||||
await ctx.softDeleteAsset(trashedAsset.id);
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
allowUpload: true,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
|
||||
album: expect.objectContaining({ assetCount: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty asset count when all album assets are trashed', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
await ctx.softDeleteAsset(asset.id);
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
|
||||
album: expect.objectContaining({ assetCount: 0 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return an album shared link when the album is trashed', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
await ctx.softDeleteAlbum(album.id);
|
||||
|
||||
await expect(sut.get(auth, sharedLink.id)).rejects.toThrow('Shared link not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should not return trashed assets as cover for an individual shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const { asset: trashedAsset } = await ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
fileCreatedAt: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
await ctx.softDeleteAsset(trashedAsset.id);
|
||||
|
||||
const { asset: visibleAsset } = await ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
fileCreatedAt: '2021-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Individual,
|
||||
assetIds: [trashedAsset.id, visibleAsset.id],
|
||||
});
|
||||
|
||||
const result = await sut.getAll(auth, {});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].assets).toHaveLength(1);
|
||||
expect(result[0].assets[0].id).toBe(visibleAsset.id);
|
||||
});
|
||||
|
||||
it('should not return an album shared link when the album is trashed', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
allowUpload: false,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
await ctx.softDeleteAlbum(album.id);
|
||||
|
||||
const result = await sut.getAll(auth, {});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove individually shared asset', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "5.53.0",
|
||||
"svelte": "5.53.5",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
|
||||
@@ -50,7 +50,10 @@
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
|
||||
<nav
|
||||
id="dashboard-navbar"
|
||||
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm bg-red-50 dark:bg-red-950"
|
||||
>
|
||||
<SkipLink text={$t('skip_to_content')} />
|
||||
<div
|
||||
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
||||
@@ -80,6 +83,7 @@
|
||||
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
|
||||
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
|
||||
</a>
|
||||
<span class="text-xs font-bold text-red-500 ms-2">[VISUAL TEST]</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
|
||||
<div class="hidden w-full max-w-5xl flex-1 tall:ps-0 sm:block">
|
||||
|
||||
Reference in New Issue
Block a user