mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:15:22 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c1e7288c7 |
@@ -6,9 +6,6 @@ on:
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
|
||||
|
||||
jobs:
|
||||
parse:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -49,10 +46,9 @@ jobs:
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f labelId="$LABEL_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
@@ -64,34 +60,21 @@ jobs:
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
addLabelsToLabelable(input: {
|
||||
labelableId: $prId,
|
||||
labelIds: [$labelId]
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Reopen PR (sections now present, PR was auto-closed)
|
||||
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
|
||||
- name: Reopen PR (sections now present, PR closed)
|
||||
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f labelId="$LABEL_ID" \
|
||||
-f query='
|
||||
mutation ReopenPR($prId: ID!, $labelId: ID!) {
|
||||
mutation ReopenPR($prId: ID!) {
|
||||
reopenPullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
removeLabelsFromLabelable(input: {
|
||||
labelableId: $prId,
|
||||
labelIds: [$labelId]
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
@@ -151,7 +151,6 @@ jobs:
|
||||
body_path: misc/release/notes.tmpl
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.1",
|
||||
"url": "https://docs.v2.6.1.archive.immich.app"
|
||||
"label": "v2.6.0",
|
||||
"url": "https://docs.v2.6.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -10,21 +10,16 @@ import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
const waitForSelectorTransition = async (page: Page) => {
|
||||
await expect(page.locator('#face-editor-data')).toHaveAttribute('data-face-width', /^[1-9]/, { timeout: 10_000 });
|
||||
await page.locator('#face-selector').evaluate(
|
||||
(el) =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
const animations = el.getAnimations();
|
||||
if (animations.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
void Promise.all(animations.map((a) => a.finished)).then(() => resolve());
|
||||
}),
|
||||
);
|
||||
}),
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
||||
if (!selector) {
|
||||
return false;
|
||||
}
|
||||
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 1000, polling: 50 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,7 +95,7 @@ test.describe('face-editor', () => {
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await waitForSelectorTransition(page);
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
test('Face editor opens with person list', async ({ page }) => {
|
||||
@@ -323,7 +318,7 @@ test.describe('face-editor', () => {
|
||||
const centerY = beforeClick.top + beforeClick.height / 2;
|
||||
|
||||
await page.mouse.click(centerX, centerY);
|
||||
await waitForSelectorTransition(page);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const afterClick = await getFaceBoxRect(page);
|
||||
expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3);
|
||||
|
||||
@@ -148,7 +148,7 @@ test.describe('zoom and face editor interaction', () => {
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
|
||||
const imgLocator = page.getByTestId('preview');
|
||||
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
||||
await expect(async () => {
|
||||
const transform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
@@ -168,82 +168,6 @@ test.describe('zoom and face editor interaction', () => {
|
||||
});
|
||||
expect(afterTransform).not.toBe('none');
|
||||
});
|
||||
|
||||
test('modifier+drag pans zoomed image without repositioning face rect', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.mouse.wheel(0, -3);
|
||||
}
|
||||
|
||||
const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]');
|
||||
await expect(async () => {
|
||||
const transform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(transform).not.toBe('none');
|
||||
}).toPass({ timeout: 2000 });
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
|
||||
const dataEl = page.locator('#face-editor-data');
|
||||
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
||||
const beforeLeft = Number(await dataEl.getAttribute('data-face-left'));
|
||||
const beforeTop = Number(await dataEl.getAttribute('data-face-top'));
|
||||
const transformBefore = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
|
||||
const panModifier = await page.evaluate(() =>
|
||||
/Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? 'Meta' : 'Control',
|
||||
);
|
||||
await page.keyboard.down(panModifier);
|
||||
|
||||
// Verify face editor becomes transparent to pointer events
|
||||
await expect(async () => {
|
||||
const pe = await dataEl.evaluate((el) => getComputedStyle(el).pointerEvents);
|
||||
expect(pe).toBe('none');
|
||||
}).toPass({ timeout: 2000 });
|
||||
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(width / 2 + 100, height / 2 + 50, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up(panModifier);
|
||||
|
||||
const transformAfter = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(transformAfter).not.toBe(transformBefore);
|
||||
|
||||
// Extract translate values from matrix(a, b, c, d, tx, ty)
|
||||
const parseTranslate = (matrix: string) => {
|
||||
const values =
|
||||
matrix
|
||||
.match(/matrix\((.+)\)/)?.[1]
|
||||
.split(',')
|
||||
.map(Number) ?? [];
|
||||
return { tx: values[4], ty: values[5] };
|
||||
};
|
||||
const panBefore = parseTranslate(transformBefore);
|
||||
const panAfter = parseTranslate(transformAfter);
|
||||
const panDeltaX = panAfter.tx - panBefore.tx;
|
||||
const panDeltaY = panAfter.ty - panBefore.ty;
|
||||
|
||||
// Face rect screen position should have moved by the same amount as the pan
|
||||
// (it follows the image), NOT been repositioned by a click
|
||||
const afterLeft = Number(await dataEl.getAttribute('data-face-left'));
|
||||
const afterTop = Number(await dataEl.getAttribute('data-face-top'));
|
||||
const faceDeltaX = afterLeft - beforeLeft;
|
||||
const faceDeltaY = afterTop - beforeTop;
|
||||
expect(Math.abs(faceDeltaX - panDeltaX)).toBeLessThan(3);
|
||||
expect(Math.abs(faceDeltaY - panDeltaY)).toBeLessThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('face overlay via detail panel interaction', () => {
|
||||
@@ -328,7 +252,7 @@ test.describe('face overlay via edit faces side panel', () => {
|
||||
await ensureDetailPanelVisible(page);
|
||||
await page.getByLabel('Edit people').click();
|
||||
|
||||
const faceThumbnail = page.getByTestId('face-thumbnail').first();
|
||||
const faceThumbnail = page.locator('section div[role="button"]').first();
|
||||
await expect(faceThumbnail).toBeVisible();
|
||||
|
||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const zoomIn = async (page: Page) => {
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
const getImageTransform = (page: Page) => {
|
||||
return page.getByTestId('preview').evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('zoom minimap', () => {
|
||||
const fixture = setupAssetViewerFixture(950);
|
||||
|
||||
test('minimap is not visible at 1x zoom', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('minimap appears when zoomed in', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
|
||||
});
|
||||
|
||||
test('minimap contains thumbnail image', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
const canvas = page.getByTestId('zoom-minimap-canvas');
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
const img = canvas.locator('img');
|
||||
await expect(img).toBeVisible();
|
||||
await expect(img).toHaveAttribute('src', /thumbnail/);
|
||||
});
|
||||
|
||||
test('viewport rect is visible when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
const viewport = page.getByTestId('zoom-minimap-viewport');
|
||||
await expect(viewport).toBeVisible();
|
||||
|
||||
const box = await viewport.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
expect(box!.width).toBeGreaterThan(0);
|
||||
expect(box!.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('clicking minimap pans the image', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
const transformBefore = await getImageTransform(page);
|
||||
|
||||
const canvas = page.getByTestId('zoom-minimap-canvas');
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
expect(canvasBox).toBeTruthy();
|
||||
|
||||
// Click near the top-left corner of the minimap
|
||||
await page.mouse.click(canvasBox!.x + 20, canvasBox!.y + 20);
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const transformAfter = await getImageTransform(page);
|
||||
expect(transformAfter).not.toBe(transformBefore);
|
||||
});
|
||||
|
||||
test('zoom slider adjusts zoom level', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
const slider = page.getByTestId('zoom-minimap-slider');
|
||||
await expect(slider).toBeVisible();
|
||||
|
||||
const sliderBox = await slider.boundingBox();
|
||||
expect(sliderBox).toBeTruthy();
|
||||
|
||||
const fillBefore = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
|
||||
return element.style.width;
|
||||
});
|
||||
|
||||
// Click near the right end of the slider to increase zoom
|
||||
await page.mouse.click(sliderBox!.x + sliderBox!.width * 0.8, sliderBox!.y + sliderBox!.height / 2);
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const fillAfter = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
|
||||
return element.style.width;
|
||||
});
|
||||
|
||||
expect(fillAfter).not.toBe(fillBefore);
|
||||
});
|
||||
|
||||
test('minimap auto-hides after inactivity', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
|
||||
|
||||
// Wait for the hide delay (1500ms) plus fade duration
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1275,7 +1275,6 @@
|
||||
"hide_schema": "Hide schema",
|
||||
"hide_text_recognition": "Hide text recognition",
|
||||
"hide_unnamed_people": "Hide unnamed people",
|
||||
"hold_key_to_pan": "Hold {key} to pan",
|
||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
|
||||
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.6.1"
|
||||
version = "2.6.0"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -898,7 +898,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.6.1"
|
||||
version = "2.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -23,18 +23,10 @@ import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import org.chromium.net.CronetEngine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.SimpleFileVisitor
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.net.Authenticator
|
||||
import java.net.CookieHandler
|
||||
import java.net.PasswordAuthentication
|
||||
@@ -285,13 +277,10 @@ object HttpClientManager {
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun rebuildCronetEngine(): Result<Long> {
|
||||
return runCatching {
|
||||
cronetEngine?.shutdown()
|
||||
val deletionResult = deleteFolderAndGetSize(cronetStoragePath.toPath())
|
||||
cronetEngine = buildCronetEngine()
|
||||
deletionResult
|
||||
}
|
||||
fun rebuildCronetEngine(): CronetEngine {
|
||||
val old = cronetEngine!!
|
||||
cronetEngine = buildCronetEngine()
|
||||
return old
|
||||
}
|
||||
|
||||
val cronetStoragePath: File get() = cronetStorageDir
|
||||
@@ -312,7 +301,7 @@ object HttpClientManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun buildCronetEngine(): CronetEngine {
|
||||
private fun buildCronetEngine(): CronetEngine {
|
||||
return CronetEngine.Builder(appContext)
|
||||
.enableHttp2(true)
|
||||
.enableQuic(true)
|
||||
@@ -323,27 +312,6 @@ object HttpClientManager {
|
||||
.build()
|
||||
}
|
||||
|
||||
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
|
||||
var totalSize = 0L
|
||||
|
||||
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
|
||||
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
|
||||
totalSize += attrs.size()
|
||||
Files.delete(file)
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
|
||||
if (dir != root) {
|
||||
Files.delete(dir)
|
||||
}
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
})
|
||||
|
||||
totalSize
|
||||
}
|
||||
|
||||
private fun build(cacheDir: File): OkHttpClient {
|
||||
val connectionPool = ConnectionPool(
|
||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
||||
|
||||
@@ -21,6 +21,11 @@ import java.io.EOFException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.SimpleFileVisitor
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||
@@ -200,15 +205,18 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
|
||||
private fun onDrained() {
|
||||
val onCacheCleared = synchronized(stateLock) {
|
||||
val onCacheCleared = this.onCacheCleared
|
||||
val onCacheCleared = onCacheCleared
|
||||
this.onCacheCleared = null
|
||||
onCacheCleared
|
||||
} ?: return
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val result = HttpClientManager.rebuildCronetEngine()
|
||||
synchronized(stateLock) { draining = false }
|
||||
onCacheCleared(result)
|
||||
}
|
||||
if (onCacheCleared != null) {
|
||||
val oldEngine = HttpClientManager.rebuildCronetEngine()
|
||||
oldEngine.shutdown()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
|
||||
synchronized(stateLock) { draining = false }
|
||||
onCacheCleared(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +306,26 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
|
||||
var totalSize = 0L
|
||||
|
||||
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
|
||||
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
|
||||
totalSize += attrs.size()
|
||||
Files.delete(file)
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
|
||||
if (dir != root) {
|
||||
Files.delete(dir)
|
||||
}
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
})
|
||||
|
||||
totalSize
|
||||
}
|
||||
}
|
||||
|
||||
private class OkHttpImageFetcher private constructor(
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3039,
|
||||
"android.injected.version.name" => "2.6.1",
|
||||
"android.injected.version.code" => 3038,
|
||||
"android.injected.version.name" => "2.6.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.6.1</string>
|
||||
<string>2.6.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -7,7 +7,7 @@ const Map<String, Locale> locales = {
|
||||
'Arabic (ar)': Locale('ar'),
|
||||
'Bulgarian (bg)': Locale('bg'),
|
||||
'Catalan (ca)': Locale('ca'),
|
||||
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
|
||||
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'SIMPLIFIED'),
|
||||
'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
|
||||
'Croatian (hr)': Locale('hr'),
|
||||
'Czech (cs)': Locale('cs'),
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -185,8 +184,8 @@ class ApiService {
|
||||
if (externalJson != null) {
|
||||
final List<dynamic> list = jsonDecode(externalJson);
|
||||
for (final entry in list) {
|
||||
final url = AuxilaryEndpoint.fromJson(entry).url;
|
||||
if (url.isNotEmpty) urls.add(url);
|
||||
final url = entry['url'] as String?;
|
||||
if (url != null && url.isNotEmpty) urls.add(url);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ final class CustomImageCache implements ImageCache {
|
||||
set maximumSize(int value) => _small.maximumSize = value;
|
||||
|
||||
@override
|
||||
set maximumSizeBytes(int value) => _small.maximumSizeBytes = value;
|
||||
set maximumSizeBytes(int value) => _small.maximumSize = value;
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.6.1
|
||||
- API version: 2.6.0
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.6.1+3039
|
||||
version: 2.6.0+3038
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -15166,7 +15166,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.6.1
|
||||
* 2.6.0
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
|
||||
Generated
+84
-89
@@ -747,8 +747,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../open-api/typescript-sdk
|
||||
'@immich/ui':
|
||||
specifier: ^0.65.3
|
||||
version: 0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
|
||||
specifier: ^0.64.0
|
||||
version: 0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
|
||||
'@mapbox/mapbox-gl-rtl-text':
|
||||
specifier: 0.3.0
|
||||
version: 0.3.0
|
||||
@@ -781,7 +781,7 @@ importers:
|
||||
version: 0.42.0
|
||||
'@zoom-image/svelte':
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.9(svelte@5.53.13)
|
||||
version: 0.3.9(svelte@5.53.7)
|
||||
dom-to-image:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@@ -832,16 +832,16 @@ importers:
|
||||
version: 5.2.2
|
||||
svelte-i18n:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(svelte@5.53.13)
|
||||
version: 4.0.1(svelte@5.53.7)
|
||||
svelte-jsoneditor:
|
||||
specifier: ^3.10.0
|
||||
version: 3.11.0(svelte@5.53.13)
|
||||
version: 3.11.0(svelte@5.53.7)
|
||||
svelte-maplibre:
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.6(svelte@5.53.13)
|
||||
version: 1.2.6(svelte@5.53.7)
|
||||
svelte-persisted-store:
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0(svelte@5.53.13)
|
||||
version: 0.12.0(svelte@5.53.7)
|
||||
tabbable:
|
||||
specifier: ^6.2.0
|
||||
version: 6.4.0
|
||||
@@ -875,16 +875,16 @@ importers:
|
||||
version: 3.1.2
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
|
||||
version: 3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
|
||||
'@sveltejs/enhanced-img':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.27.1
|
||||
version: 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: 6.2.4
|
||||
version: 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.7
|
||||
version: 4.2.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
@@ -893,7 +893,7 @@ importers:
|
||||
version: 6.9.1
|
||||
'@testing-library/svelte':
|
||||
specifier: ^5.2.8
|
||||
version: 5.3.1(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 5.3.1(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.5.2
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
@@ -932,7 +932,7 @@ importers:
|
||||
version: 6.2.1(eslint@10.0.2(jiti@2.6.1))
|
||||
eslint-plugin-svelte:
|
||||
specifier: ^3.12.4
|
||||
version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.13)
|
||||
version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7)
|
||||
eslint-plugin-unicorn:
|
||||
specifier: ^63.0.0
|
||||
version: 63.0.0(eslint@10.0.2(jiti@2.6.1))
|
||||
@@ -953,19 +953,19 @@ importers:
|
||||
version: 4.2.0(prettier@3.8.1)
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^3.3.3
|
||||
version: 3.5.1(prettier@3.8.1)(svelte@5.53.13)
|
||||
version: 3.5.1(prettier@3.8.1)(svelte@5.53.7)
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.11(rollup@4.55.1)
|
||||
svelte:
|
||||
specifier: 5.53.13
|
||||
version: 5.53.13
|
||||
specifier: 5.53.7
|
||||
version: 5.53.7
|
||||
svelte-check:
|
||||
specifier: ^4.1.5
|
||||
version: 4.4.4(picomatch@4.0.3)(svelte@5.53.13)(typescript@5.9.3)
|
||||
version: 4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3)
|
||||
svelte-eslint-parser:
|
||||
specifier: ^1.3.3
|
||||
version: 1.6.0(svelte@5.53.13)
|
||||
version: 1.6.0(svelte@5.53.7)
|
||||
tailwindcss:
|
||||
specifier: ^4.1.7
|
||||
version: 4.2.1
|
||||
@@ -3035,8 +3035,8 @@ packages:
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
'@immich/ui@0.65.3':
|
||||
resolution: {integrity: sha512-jMXzCzMNTcCdWXt9IUP7GkALE5oEvPQk/jCOuI2bfxsxCZFzMkUfUS+AV83Vg1vQ6l+g39PbKSPKBEzv125ATQ==}
|
||||
'@immich/ui@0.64.0':
|
||||
resolution: {integrity: sha512-jbPN1x9KAAcW18h4RO7skbFYjkR4Lg+mEVjSDzsPC2NBNzSi4IA0PIHhFEwnD5dk4OS7+UjRG8m5/QTyotrm4A==}
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
@@ -6752,9 +6752,6 @@ packages:
|
||||
devalue@5.6.3:
|
||||
resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==}
|
||||
|
||||
devalue@5.6.4:
|
||||
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
|
||||
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
@@ -11221,8 +11218,8 @@ packages:
|
||||
peerDependencies:
|
||||
svelte: ^5.30.2
|
||||
|
||||
svelte@5.53.13:
|
||||
resolution: {integrity: sha512-9P6I/jGcQMzAMb76Uyd6L6RELAC7qt53GOSBLCke9lubh9iJjmjCo+EffRH4gOPnTB/x4RR2Tmt6s3o9ywQO3g==}
|
||||
svelte@5.53.7:
|
||||
resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
svg-parser@2.0.4:
|
||||
@@ -14951,22 +14948,22 @@ snapshots:
|
||||
pg-connection-string: 2.12.0
|
||||
postgres: 3.4.8
|
||||
|
||||
'@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.13)':
|
||||
'@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.7)':
|
||||
dependencies:
|
||||
front-matter: 4.0.2
|
||||
marked: 17.0.3
|
||||
node-emoji: 2.2.0
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
|
||||
'@immich/ui@0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)':
|
||||
'@immich/ui@0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)':
|
||||
dependencies:
|
||||
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.13)
|
||||
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.7)
|
||||
'@internationalized/date': 3.10.0
|
||||
'@mdi/js': 7.4.47
|
||||
bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
|
||||
bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
|
||||
luxon: 3.7.2
|
||||
simple-icons: 16.9.0
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
svelte-highlight: 7.9.0
|
||||
tailwind-merge: 3.5.0
|
||||
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
|
||||
@@ -16303,17 +16300,17 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
|
||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
|
||||
'@sveltejs/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@sveltejs/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
magic-string: 0.30.21
|
||||
sharp: 0.34.5
|
||||
svelte: 5.53.13
|
||||
svelte-parse-markup: 0.1.5(svelte@5.53.13)
|
||||
svelte: 5.53.7
|
||||
svelte-parse-markup: 0.1.5(svelte@5.53.7)
|
||||
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-imagetools: 9.0.3(rollup@4.55.1)
|
||||
zimmerframe: 1.1.4
|
||||
@@ -16321,11 +16318,11 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
'@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@types/cookie': 0.6.0
|
||||
acorn: 8.16.0
|
||||
cookie: 0.6.0
|
||||
@@ -16336,28 +16333,28 @@ snapshots:
|
||||
mrmime: 2.0.1
|
||||
set-cookie-parser: 3.0.1
|
||||
sirv: 3.0.2
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
typescript: 5.9.3
|
||||
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
debug: 4.4.3
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
deepmerge: 4.3.1
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitefu: 1.1.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
transitivePeerDependencies:
|
||||
@@ -16605,15 +16602,15 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
redent: 3.0.0
|
||||
|
||||
'@testing-library/svelte-core@1.0.0(svelte@5.53.13)':
|
||||
'@testing-library/svelte-core@1.0.0(svelte@5.53.7)':
|
||||
dependencies:
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
|
||||
'@testing-library/svelte@5.3.1(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@testing-library/svelte@5.3.1(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/svelte-core': 1.0.0(svelte@5.53.13)
|
||||
svelte: 5.53.13
|
||||
'@testing-library/svelte-core': 1.0.0(svelte@5.53.7)
|
||||
svelte: 5.53.7
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
@@ -17530,10 +17527,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@namnode/store': 0.1.0
|
||||
|
||||
'@zoom-image/svelte@0.3.9(svelte@5.53.13)':
|
||||
'@zoom-image/svelte@0.3.9(svelte@5.53.7)':
|
||||
dependencies:
|
||||
'@zoom-image/core': 0.42.0
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
@@ -17897,15 +17894,15 @@ snapshots:
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
|
||||
bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
'@floating-ui/dom': 1.7.4
|
||||
'@internationalized/date': 3.10.0
|
||||
esm-env: 1.2.2
|
||||
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
|
||||
svelte: 5.53.13
|
||||
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
|
||||
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
|
||||
svelte: 5.53.7
|
||||
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
|
||||
tabbable: 6.4.0
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
@@ -18998,8 +18995,6 @@ snapshots:
|
||||
|
||||
devalue@5.6.3: {}
|
||||
|
||||
devalue@5.6.4: {}
|
||||
|
||||
devlop@1.1.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@@ -19408,7 +19403,7 @@ snapshots:
|
||||
'@types/eslint': 9.6.1
|
||||
eslint-config-prettier: 10.1.8(eslint@10.0.2(jiti@2.6.1))
|
||||
|
||||
eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.13):
|
||||
eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1))
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -19420,9 +19415,9 @@ snapshots:
|
||||
postcss-load-config: 3.1.4(postcss@8.5.8)
|
||||
postcss-safe-parser: 7.0.1(postcss@8.5.8)
|
||||
semver: 7.7.4
|
||||
svelte-eslint-parser: 1.6.0(svelte@5.53.13)
|
||||
svelte-eslint-parser: 1.6.0(svelte@5.53.7)
|
||||
optionalDependencies:
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
@@ -23160,10 +23155,10 @@ snapshots:
|
||||
dependencies:
|
||||
prettier: 3.8.1
|
||||
|
||||
prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.13):
|
||||
prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7):
|
||||
dependencies:
|
||||
prettier: 3.8.1
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
|
||||
prettier@3.8.1: {}
|
||||
|
||||
@@ -23768,14 +23763,14 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
runed@0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
|
||||
runed@0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
esm-env: 1.2.2
|
||||
lz-string: 1.5.0
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
optionalDependencies:
|
||||
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
|
||||
rw@1.3.3: {}
|
||||
|
||||
@@ -24399,23 +24394,23 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
svelte-awesome@3.3.5(svelte@5.53.13):
|
||||
svelte-awesome@3.3.5(svelte@5.53.7):
|
||||
dependencies:
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
|
||||
svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.13)(typescript@5.9.3):
|
||||
svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
chokidar: 4.0.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picocolors: 1.1.1
|
||||
sade: 1.8.1
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
svelte-eslint-parser@1.6.0(svelte@5.53.13):
|
||||
svelte-eslint-parser@1.6.0(svelte@5.53.7):
|
||||
dependencies:
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
@@ -24425,7 +24420,7 @@ snapshots:
|
||||
postcss-selector-parser: 7.1.1
|
||||
semver: 7.7.4
|
||||
optionalDependencies:
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
|
||||
svelte-floating-ui@1.5.8:
|
||||
dependencies:
|
||||
@@ -24438,7 +24433,7 @@ snapshots:
|
||||
dependencies:
|
||||
highlight.js: 11.11.1
|
||||
|
||||
svelte-i18n@4.0.1(svelte@5.53.13):
|
||||
svelte-i18n@4.0.1(svelte@5.53.7):
|
||||
dependencies:
|
||||
cli-color: 2.0.4
|
||||
deepmerge: 4.3.1
|
||||
@@ -24446,10 +24441,10 @@ snapshots:
|
||||
estree-walker: 2.0.2
|
||||
intl-messageformat: 10.7.18
|
||||
sade: 1.8.1
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
tiny-glob: 0.2.9
|
||||
|
||||
svelte-jsoneditor@3.11.0(svelte@5.53.13):
|
||||
svelte-jsoneditor@3.11.0(svelte@5.53.7):
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/commands': 6.10.1
|
||||
@@ -24476,42 +24471,42 @@ snapshots:
|
||||
memoize-one: 6.0.0
|
||||
natural-compare-lite: 1.4.0
|
||||
sass: 1.97.1
|
||||
svelte: 5.53.13
|
||||
svelte-awesome: 3.3.5(svelte@5.53.13)
|
||||
svelte: 5.53.7
|
||||
svelte-awesome: 3.3.5(svelte@5.53.7)
|
||||
svelte-select: 5.8.3
|
||||
vanilla-picker: 2.12.3
|
||||
|
||||
svelte-maplibre@1.2.6(svelte@5.53.13):
|
||||
svelte-maplibre@1.2.6(svelte@5.53.7):
|
||||
dependencies:
|
||||
d3-geo: 3.1.1
|
||||
dequal: 2.0.3
|
||||
just-compare: 2.3.0
|
||||
maplibre-gl: 5.19.0
|
||||
pmtiles: 3.2.1
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
|
||||
svelte-parse-markup@0.1.5(svelte@5.53.13):
|
||||
svelte-parse-markup@0.1.5(svelte@5.53.7):
|
||||
dependencies:
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
|
||||
svelte-persisted-store@0.12.0(svelte@5.53.13):
|
||||
svelte-persisted-store@0.12.0(svelte@5.53.7):
|
||||
dependencies:
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
|
||||
svelte-select@5.8.3:
|
||||
dependencies:
|
||||
svelte-floating-ui: 1.5.8
|
||||
|
||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
|
||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
|
||||
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
|
||||
style-to-object: 1.0.14
|
||||
svelte: 5.53.13
|
||||
svelte: 5.53.7
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
svelte@5.53.13:
|
||||
svelte@5.53.7:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -24522,7 +24517,7 @@ snapshots:
|
||||
aria-query: 5.3.1
|
||||
axobject-query: 4.1.0
|
||||
clsx: 2.1.1
|
||||
devalue: 5.6.4
|
||||
devalue: 5.6.3
|
||||
esm-env: 1.2.2
|
||||
esrap: 2.2.3
|
||||
is-reference: 3.0.3
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -24,7 +24,7 @@
|
||||
"typeorm": "typeorm",
|
||||
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug",
|
||||
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
|
||||
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create",
|
||||
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
|
||||
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run",
|
||||
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
|
||||
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",
|
||||
|
||||
@@ -169,7 +169,6 @@ export type AuthSharedLink = {
|
||||
id: string;
|
||||
expiresAt: Date | null;
|
||||
userId: string;
|
||||
albumId: string | null;
|
||||
showExif: boolean;
|
||||
allowUpload: boolean;
|
||||
allowDownload: boolean;
|
||||
@@ -358,6 +357,15 @@ export const columns = {
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||
authSharedLink: [
|
||||
'shared_link.id',
|
||||
'shared_link.userId',
|
||||
'shared_link.expiresAt',
|
||||
'shared_link.showExif',
|
||||
'shared_link.allowUpload',
|
||||
'shared_link.allowDownload',
|
||||
'shared_link.password',
|
||||
],
|
||||
user: userColumns,
|
||||
userWithPrefix: userWithPrefixColumns,
|
||||
userAdmin: [
|
||||
|
||||
@@ -173,7 +173,6 @@ order by
|
||||
select
|
||||
"shared_link"."id",
|
||||
"shared_link"."userId",
|
||||
"shared_link"."albumId",
|
||||
"shared_link"."expiresAt",
|
||||
"shared_link"."showExif",
|
||||
"shared_link"."allowUpload",
|
||||
@@ -212,7 +211,6 @@ where
|
||||
select
|
||||
"shared_link"."id",
|
||||
"shared_link"."userId",
|
||||
"shared_link"."albumId",
|
||||
"shared_link"."expiresAt",
|
||||
"shared_link"."showExif",
|
||||
"shared_link"."allowUpload",
|
||||
|
||||
@@ -330,7 +330,6 @@ export class AlbumRepository {
|
||||
await db
|
||||
.insertInto('album_asset')
|
||||
.values(assetIds.map((assetId) => ({ albumId, assetId })))
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -119,12 +119,8 @@ export class MetadataRepository {
|
||||
}
|
||||
|
||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||
// If exiftool assigns a field with ^= instead of =, empty values will be written too.
|
||||
// Since exiftool-vendored doesn't support an option for this, we append the ^ to the name of the tag instead.
|
||||
// https://exiftool.org/exiftool_pod.html#:~:text=is%20used%20to%20write%20an%20empty%20string
|
||||
const tagsToWrite = Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key}^`, value]));
|
||||
try {
|
||||
await this.exiftool.write(path, tagsToWrite);
|
||||
await this.exiftool.write(path, tags);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||
}
|
||||
|
||||
@@ -202,14 +202,7 @@ export class SharedLinkRepository {
|
||||
.leftJoin('album', 'album.id', 'shared_link.albumId')
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select((eb) => [
|
||||
'shared_link.id',
|
||||
'shared_link.userId',
|
||||
'shared_link.albumId',
|
||||
'shared_link.expiresAt',
|
||||
'shared_link.showExif',
|
||||
'shared_link.allowUpload',
|
||||
'shared_link.allowDownload',
|
||||
'shared_link.password',
|
||||
...columns.authSharedLink,
|
||||
jsonObjectFrom(
|
||||
eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'),
|
||||
).as('user'),
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
DELETE FROM "shared_link_asset"
|
||||
USING "shared_link"
|
||||
WHERE "shared_link_asset"."sharedLinkId" = "shared_link"."id" AND "shared_link"."type" = 'ALBUM';
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// noop
|
||||
}
|
||||
@@ -64,9 +64,8 @@ export class UserTable {
|
||||
@Column({ unique: true, nullable: true, default: null })
|
||||
storageLabel!: string | null;
|
||||
|
||||
// TODO remove default, make nullable, and convert empty spaces to null
|
||||
@Column({ default: '' })
|
||||
name!: string;
|
||||
name!: Generated<string>;
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
quotaSizeInBytes!: ColumnType<number> | null;
|
||||
|
||||
@@ -165,12 +165,6 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
if (auth.sharedLink) {
|
||||
this.logger.deprecate(
|
||||
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
|
||||
);
|
||||
}
|
||||
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
|
||||
|
||||
@@ -201,12 +195,6 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
||||
if (auth.sharedLink) {
|
||||
this.logger.deprecate(
|
||||
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
|
||||
);
|
||||
}
|
||||
|
||||
const results: AlbumsAddAssetsResponseDto = {
|
||||
success: false,
|
||||
error: BulkIdErrorReason.DUPLICATE,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
|
||||
import { extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset, AuthSharedLink } from 'src/database';
|
||||
import { Asset } from 'src/database';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
@@ -152,7 +152,7 @@ export class AssetMediaService extends BaseService {
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, asset.id);
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]);
|
||||
}
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
@@ -326,12 +326,6 @@ export class AssetMediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) {
|
||||
await (sharedLink.albumId
|
||||
? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId])
|
||||
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
|
||||
}
|
||||
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
@@ -353,7 +347,7 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
|
||||
}
|
||||
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AuthService } from 'src/services/auth.service';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { OAuthProfileFactory } from 'test/factories/oauth-profile.factory';
|
||||
import { SessionFactory } from 'test/factories/session.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
@@ -16,7 +15,31 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const oauthResponse = ({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
profileImagePath,
|
||||
}: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
profileImagePath?: string;
|
||||
}) => ({
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: id,
|
||||
userEmail: email,
|
||||
name,
|
||||
profileImagePath,
|
||||
isAdmin: false,
|
||||
isOnboarded: false,
|
||||
shouldChangePassword: false,
|
||||
});
|
||||
|
||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
const loginDetails = {
|
||||
isSecure: true,
|
||||
clientIp: '127.0.0.1',
|
||||
@@ -25,9 +48,11 @@ const loginDetails = {
|
||||
appVersion: null,
|
||||
};
|
||||
|
||||
const dto = {
|
||||
email,
|
||||
password: 'password',
|
||||
const fixtures = {
|
||||
login: {
|
||||
email,
|
||||
password: 'password',
|
||||
},
|
||||
};
|
||||
|
||||
describe(AuthService.name, () => {
|
||||
@@ -38,6 +63,7 @@ describe(AuthService.name, () => {
|
||||
({ sut, mocks } = newTestService(AuthService));
|
||||
|
||||
mocks.oauth.authorize.mockResolvedValue({ url: 'http://test', state: 'state', codeVerifier: 'codeVerifier' });
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email });
|
||||
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
|
||||
});
|
||||
|
||||
@@ -49,13 +75,13 @@ describe(AuthService.name, () => {
|
||||
it('should throw an error if password login is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
|
||||
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should check the user exists', async () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -63,7 +89,7 @@ describe(AuthService.name, () => {
|
||||
it('should check the user has a password', async () => {
|
||||
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
|
||||
|
||||
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -74,7 +100,7 @@ describe(AuthService.name, () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(session);
|
||||
|
||||
await expect(sut.login(dto, loginDetails)).resolves.toEqual({
|
||||
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
@@ -598,7 +624,6 @@ describe(AuthService.name, () => {
|
||||
it('should not allow auto registering', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -613,31 +638,31 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should not link to a user with a different oauth sub', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'existing-sub' });
|
||||
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -652,30 +677,35 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||
expect(mocks.user.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
||||
const user = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: 'sub' });
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -695,9 +725,10 @@ describe(AuthService.name, () => {
|
||||
'app.immich:///oauth-callback?code=abc123',
|
||||
]) {
|
||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||
const user = UserFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||
@@ -712,136 +743,135 @@ describe(AuthService.name, () => {
|
||||
}
|
||||
|
||||
it('should use the default quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
||||
it('should infer name from given and family names', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(
|
||||
OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
|
||||
);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Given Family' }));
|
||||
});
|
||||
|
||||
it('should fallback to email when no username is provided', async () => {
|
||||
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
|
||||
});
|
||||
|
||||
it('should ignore an invalid storage quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 'abc' }));
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
||||
it('should ignore a negative quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: -5 }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
||||
it('should set quota for 0 quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 0 }));
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 0 }));
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
isAdmin: false,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: 0,
|
||||
storageLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a valid storage quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 5 }));
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 5_368_709_120 }));
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
isAdmin: false,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: 5_368_709_120,
|
||||
storageLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sync the profile picture', async () => {
|
||||
const fileId = newUuid();
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
||||
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.oauth.getProfile.mockResolvedValue({
|
||||
sub: user.oauthId,
|
||||
email: user.email,
|
||||
picture: pictureUrl,
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
||||
mocks.oauth.getProfilePicture.mockResolvedValue({
|
||||
@@ -851,96 +881,131 @@ describe(AuthService.name, () => {
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
|
||||
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`),
|
||||
profileChangedAt: expect.any(Date),
|
||||
});
|
||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
|
||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
|
||||
});
|
||||
|
||||
it('should not sync the profile picture if the user already has one', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(
|
||||
OAuthProfileFactory.create({
|
||||
sub: user.oauthId,
|
||||
email: user.email,
|
||||
picture: 'https://auth.immich.cloud/profiles/1.jpg',
|
||||
}),
|
||||
);
|
||||
mocks.oauth.getProfile.mockResolvedValue({
|
||||
sub: user.oauthId,
|
||||
email: user.email,
|
||||
picture: 'https://auth.immich.cloud/profiles/1.jpg',
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only allow "admin" and "user" for the role claim', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'foo' }));
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
isAdmin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create an admin user if the role claim is set to admin', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'admin' }));
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
isAdmin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept a custom role claim', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
|
||||
});
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ my_role: 'admin' }));
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, my_role: 'admin' });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
isAdmin: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -948,10 +1013,8 @@ describe(AuthService.name, () => {
|
||||
it('should link an account', async () => {
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
|
||||
await sut.link(
|
||||
@@ -960,7 +1023,7 @@ describe(AuthService.name, () => {
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should not link an already linked oauth.sub', async () => {
|
||||
@@ -969,7 +1032,6 @@ describe(AuthService.name, () => {
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -261,11 +261,6 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
if (!oauth.enabled) {
|
||||
throw new BadRequestException('OAuth is not enabled');
|
||||
}
|
||||
|
||||
const expectedState = dto.state ?? this.getCookieOauthState(headers);
|
||||
if (!expectedState?.length) {
|
||||
throw new BadRequestException('OAuth state is missing');
|
||||
@@ -276,6 +271,7 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException('OAuth code verifier is missing');
|
||||
}
|
||||
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
const url = this.resolveRedirectUri(oauth, dto.url);
|
||||
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier);
|
||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
|
||||
@@ -302,8 +298,7 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||
}
|
||||
|
||||
const email = profile.email;
|
||||
if (!email) {
|
||||
if (!profile.email) {
|
||||
throw new BadRequestException('OAuth profile does not have an email address');
|
||||
}
|
||||
|
||||
@@ -325,13 +320,10 @@ export class AuthService extends BaseService {
|
||||
isValid: (value: unknown) => isString(value) && ['admin', 'user'].includes(value),
|
||||
});
|
||||
|
||||
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
|
||||
user = await this.createUser({
|
||||
name:
|
||||
profile.name ||
|
||||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
|
||||
profile.preferred_username ||
|
||||
email,
|
||||
email,
|
||||
name: userName,
|
||||
email: profile.email,
|
||||
oauthId: profile.sub,
|
||||
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
|
||||
storageLabel: storageLabel || null,
|
||||
|
||||
@@ -467,7 +467,7 @@ export class MetadataService extends BaseService {
|
||||
GPSLatitude: latitude,
|
||||
GPSLongitude: longitude,
|
||||
Rating: rating,
|
||||
TagsList: tags,
|
||||
TagsList: tags?.length ? tags : undefined,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { SemVer } from 'semver';
|
||||
import { defaults } from 'src/config';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
@@ -131,32 +130,6 @@ describe(VersionService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onConfigUpdate', () => {
|
||||
it('should queue a version check job when newVersionCheck is enabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
});
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
|
||||
});
|
||||
|
||||
it('should not queue a version check job when newVersionCheck is disabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
|
||||
});
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not queue a version check job when newVersionCheck was already enabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
});
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onWebsocketConnection', () => {
|
||||
it('should send on_server_version client event', async () => {
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
|
||||
@@ -55,13 +55,6 @@ export class VersionService extends BaseService {
|
||||
return this.versionRepository.getAll();
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigUpdate' })
|
||||
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'ConfigUpdate'>) {
|
||||
if (!oldConfig.newVersionCheck.enabled && newConfig.newVersionCheck.enabled) {
|
||||
await this.handleQueueVersionCheck();
|
||||
}
|
||||
}
|
||||
|
||||
async handleQueueVersionCheck() {
|
||||
await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} });
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||
import { OAuthProfileLike } from 'test/factories/types';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
|
||||
export class OAuthProfileFactory {
|
||||
private constructor(private value: OAuthProfile) {}
|
||||
|
||||
static create(dto: OAuthProfileLike = {}) {
|
||||
return OAuthProfileFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: OAuthProfileLike = {}) {
|
||||
const sub = newUuid();
|
||||
return new OAuthProfileFactory({
|
||||
sub,
|
||||
name: 'Name',
|
||||
given_name: 'Given',
|
||||
family_name: 'Family',
|
||||
email: `oauth-${sub}@immich.cloud`,
|
||||
email_verified: true,
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
@@ -35,4 +34,3 @@ export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
||||
export type ActivityLike = Partial<Selectable<ActivityTable>>;
|
||||
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
|
||||
export type SessionLike = Partial<Selectable<SessionTable>>;
|
||||
export type OAuthProfileLike = Partial<OAuthProfile>;
|
||||
|
||||
Vendored
-1
@@ -48,7 +48,6 @@ export const authStub = {
|
||||
showExif: true,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
albumId: null,
|
||||
expiresAt: null,
|
||||
password: null,
|
||||
userId: '42',
|
||||
|
||||
@@ -220,9 +220,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
||||
return { result };
|
||||
}
|
||||
|
||||
async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
|
||||
async newAlbum(dto: Insertable<AlbumTable>) {
|
||||
const album = mediumFactory.albumInsert(dto);
|
||||
const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []);
|
||||
const result = await this.get(AlbumRepository).create(album, [], []);
|
||||
return { album, result };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { newDate } from 'test/small.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let database: Kysely<DB>;
|
||||
|
||||
const setup = () => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
return { ctx, sut: ctx.get(MetadataRepository) };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(MetadataRepository.name, () => {
|
||||
describe('writeTags', () => {
|
||||
it('should write an empty description', async () => {
|
||||
const { sut } = setup();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
|
||||
const sidecarFile = join(dir, 'sidecar.xmp');
|
||||
|
||||
await sut.writeTags(sidecarFile, { Description: '' });
|
||||
expect(readFileSync(sidecarFile).toString()).toEqual(expect.stringContaining('rdf:Description'));
|
||||
});
|
||||
|
||||
it('should write an empty tags list', async () => {
|
||||
const { sut } = setup();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
|
||||
const sidecarFile = join(dir, 'sidecar.xmp');
|
||||
|
||||
await sut.writeTags(sidecarFile, { TagsList: [] });
|
||||
const fileContent = readFileSync(sidecarFile).toString();
|
||||
expect(fileContent).toEqual(expect.stringContaining('digiKam:TagsList'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<rdf:li/>'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should write tags', async () => {
|
||||
const { sut } = setup();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
|
||||
const sidecarFile = join(dir, 'sidecar.xmp');
|
||||
|
||||
await sut.writeTags(sidecarFile, {
|
||||
Description: 'my-description',
|
||||
ImageDescription: 'my-image-description',
|
||||
DateTimeOriginal: newDate().toISOString(),
|
||||
GPSLatitude: 42,
|
||||
GPSLongitude: 69,
|
||||
Rating: 3,
|
||||
TagsList: ['tagA'],
|
||||
});
|
||||
|
||||
const fileContent = readFileSync(sidecarFile).toString();
|
||||
expect(fileContent).toEqual(expect.stringContaining('my-description'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('my-image-description'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('exif:DateTimeOriginal'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLatitude>42,0.0N</exif:GPSLatitude>'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLongitude>69,0.0E</exif:GPSLongitude>'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<xmp:Rating>3</xmp:Rating>'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('tagA'));
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
|
||||
import { AssetFileType, SharedLinkType } from 'src/enum';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { DB } from 'src/schema';
|
||||
@@ -25,7 +22,7 @@ let defaultDatabase: Kysely<DB>;
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(AssetMediaService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [AccessRepository, AlbumRepository, AssetRepository, SharedLinkRepository, UserRepository],
|
||||
real: [AccessRepository, AssetRepository, UserRepository],
|
||||
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
|
||||
});
|
||||
};
|
||||
@@ -47,6 +44,7 @@ describe(AssetService.name, () => {
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const file = mediumFactory.uploadFile();
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
@@ -58,7 +56,7 @@ describe(AssetService.name, () => {
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
},
|
||||
mediumFactory.uploadFile(),
|
||||
file,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
id: expect.any(String),
|
||||
@@ -101,168 +99,6 @@ describe(AssetService.name, () => {
|
||||
status: AssetMediaStatus.CREATED,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add to a shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Individual,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const file = mediumFactory.uploadFile();
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, file);
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
|
||||
|
||||
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
|
||||
const assets = update!.assets;
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toMatchObject({ id: response.id });
|
||||
});
|
||||
|
||||
it('should handle adding a duplicate asset to a shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Individual,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
|
||||
|
||||
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
|
||||
const assets = update!.assets;
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toMatchObject({ id: response.id });
|
||||
});
|
||||
|
||||
it('should add to an album shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile());
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
|
||||
|
||||
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
|
||||
const assets = [...result];
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toEqual(response.id);
|
||||
});
|
||||
|
||||
it('should handle adding a duplicate asset to an album shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]);
|
||||
// await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
|
||||
|
||||
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
|
||||
const assets = [...result];
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toEqual(response.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewThumbnail', () => {
|
||||
|
||||
@@ -47,15 +47,15 @@ describe(UserService.name, () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
const user = mediumFactory.userInsert();
|
||||
await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email });
|
||||
await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('User exists');
|
||||
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email });
|
||||
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists');
|
||||
});
|
||||
|
||||
it('should not return password', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
const dto = mediumFactory.userInsert({ password: 'password' });
|
||||
const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' });
|
||||
const user = await sut.createUser({ email: dto.email, password: 'password' });
|
||||
expect((user as any).password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,22 +63,12 @@ const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
|
||||
expiresAt = null,
|
||||
userId = newUuid(),
|
||||
showExif = true,
|
||||
albumId = null,
|
||||
allowUpload = false,
|
||||
allowDownload = true,
|
||||
password = null,
|
||||
} = sharedLink;
|
||||
|
||||
return {
|
||||
id,
|
||||
albumId,
|
||||
expiresAt,
|
||||
userId,
|
||||
showExif,
|
||||
allowUpload,
|
||||
allowDownload,
|
||||
password,
|
||||
};
|
||||
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password };
|
||||
};
|
||||
|
||||
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.65.3",
|
||||
"@immich/ui": "^0.64.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.14.0",
|
||||
@@ -100,7 +100,7 @@
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "5.53.13",
|
||||
"svelte": "5.53.7",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
|
||||
@@ -1,74 +1,28 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
|
||||
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
|
||||
type TouchEventLike = {
|
||||
touches: Iterable<{ clientX: number; clientY: number }> & { length: number };
|
||||
targetTouches: ArrayLike<unknown>;
|
||||
};
|
||||
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
|
||||
|
||||
export const MAX_ZOOM = 10;
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
|
||||
let zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: MAX_ZOOM,
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: assetViewerManager.zoomState,
|
||||
zoomTarget: options?.zoomTarget,
|
||||
});
|
||||
|
||||
let needsResync = false;
|
||||
|
||||
const createInstance = () => {
|
||||
zoomInstance.cleanup();
|
||||
zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: MAX_ZOOM,
|
||||
initialState: { ...assetViewerManager.zoomState, enable: true },
|
||||
zoomTarget: options?.zoomTarget,
|
||||
});
|
||||
node.style.overflow = 'visible';
|
||||
unsubscribeStore?.();
|
||||
unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
|
||||
needsResync = false;
|
||||
};
|
||||
|
||||
const applyDirectTransform = (state: ZoomImageWheelState) => {
|
||||
const target = options?.zoomTarget ?? node.querySelector('img');
|
||||
if (target) {
|
||||
(target as HTMLElement).style.transformOrigin = '0 0';
|
||||
(target as HTMLElement).style.transform =
|
||||
`translate(${state.currentPositionX}px, ${state.currentPositionY}px) scale(${state.currentZoom})`;
|
||||
needsResync = true;
|
||||
}
|
||||
};
|
||||
|
||||
const resyncIfNeeded = () => {
|
||||
if (needsResync) {
|
||||
createInstance();
|
||||
}
|
||||
};
|
||||
|
||||
let unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
|
||||
|
||||
const unsubscribeManager = assetViewerManager.on({
|
||||
ZoomChange: (state) => zoomInstance.setState(state),
|
||||
DirectTransform: (state) => applyDirectTransform(state),
|
||||
ZoomEnabled: (enabled) => {
|
||||
if (enabled && needsResync) {
|
||||
createInstance();
|
||||
} else {
|
||||
zoomInstance.setState({ enable: enabled });
|
||||
}
|
||||
},
|
||||
});
|
||||
const unsubscribes = [
|
||||
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||
];
|
||||
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, signal });
|
||||
node.addEventListener('pointerdown', resyncIfNeeded, { signal });
|
||||
node.addEventListener('wheel', resyncIfNeeded, { signal });
|
||||
|
||||
// Intercept events in capture phase to prevent zoom-image from seeing interactions on
|
||||
// overlay elements (e.g. OCR text boxes), preserving browser defaults like text selection.
|
||||
@@ -169,9 +123,6 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
|
||||
{ capture: true, signal },
|
||||
);
|
||||
|
||||
if (options?.zoomTarget) {
|
||||
options.zoomTarget.style.willChange = 'transform';
|
||||
}
|
||||
node.style.overflow = 'visible';
|
||||
node.style.touchAction = 'none';
|
||||
return {
|
||||
@@ -183,11 +134,9 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
|
||||
},
|
||||
destroy() {
|
||||
controller.abort();
|
||||
if (options?.zoomTarget) {
|
||||
options.zoomTarget.style.willChange = '';
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
unsubscribeManager();
|
||||
unsubscribeStore?.();
|
||||
zoomInstance.cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
onError?: () => void;
|
||||
ref?: HTMLDivElement;
|
||||
imgRef?: HTMLImageElement;
|
||||
imgNaturalSize?: Size;
|
||||
imgScaledSize?: Size;
|
||||
backdrop?: Snippet;
|
||||
overlays?: Snippet;
|
||||
};
|
||||
@@ -33,10 +31,6 @@
|
||||
ref = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgRef = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgNaturalSize = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgScaledSize = $bindable(),
|
||||
asset,
|
||||
sharedLink,
|
||||
objectFit = 'contain',
|
||||
@@ -104,21 +98,9 @@
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
imgNaturalSize = imageDimensions;
|
||||
});
|
||||
|
||||
const scaledDimensions = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
return scaleFn(imageDimensions, container);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
imgScaledSize = scaledDimensions;
|
||||
});
|
||||
|
||||
const { width, height, left, top } = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { sendTestEmailAdmin } from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -142,7 +142,6 @@
|
||||
<Button
|
||||
size="small"
|
||||
shape="round"
|
||||
loading={isSending}
|
||||
disabled={!configToEdit.notifications.smtp.enabled}
|
||||
onclick={handleSendTestEmail}
|
||||
>
|
||||
@@ -152,6 +151,9 @@
|
||||
{$t('admin.notification_email_sent_test_email_button')}
|
||||
{/if}
|
||||
</Button>
|
||||
{#if isSending}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
@@ -33,11 +33,13 @@
|
||||
{#if isOwned}
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
variant="ghost"
|
||||
class="outline-none border-b max-h-32 border-transparent pl-0 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||
rows={1}
|
||||
grow
|
||||
shape="rectangle"
|
||||
onfocusout={handleFocusOut}
|
||||
placeholder={$t('add_a_description')}
|
||||
data-testid="autogrow-textarea"
|
||||
class="max-h-32"
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
|
||||
@@ -3,56 +3,59 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { Textarea } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
id: string;
|
||||
albumName: string;
|
||||
isOwned: boolean;
|
||||
onUpdate: (albumName: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props();
|
||||
|
||||
let newAlbumName = $derived(albumName);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
newAlbumName = newAlbumName.replaceAll('\n', ' ').trim();
|
||||
|
||||
const handleUpdateName = async () => {
|
||||
if (newAlbumName === albumName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName } });
|
||||
const response = await updateAlbumInfo({
|
||||
id,
|
||||
updateAlbumDto: {
|
||||
albumName: newAlbumName,
|
||||
},
|
||||
});
|
||||
({ albumName } = response);
|
||||
eventManager.emit('AlbumUpdate', response);
|
||||
onUpdate(albumName);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const textClasses = 'text-2xl lg:text-6xl text-primary';
|
||||
const styles = tv({
|
||||
base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90',
|
||||
variants: {
|
||||
isOwned: {
|
||||
true: 'hover:border-gray-400',
|
||||
false: 'hover:border-transparent',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mb-2">
|
||||
{#if isOwned}
|
||||
<Textarea
|
||||
bind:value={newAlbumName}
|
||||
variant="ghost"
|
||||
title={$t('edit_title')}
|
||||
onblur={handleUpdate}
|
||||
placeholder={$t('add_a_title')}
|
||||
class={textClasses}
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: (event) => event.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
{:else}
|
||||
<div class={textClasses}>{newAlbumName}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
|
||||
onblur={handleUpdateName}
|
||||
class={styles({ isOwned })}
|
||||
type="text"
|
||||
bind:value={newAlbumName}
|
||||
disabled={!isOwned}
|
||||
title={$t('edit_title')}
|
||||
placeholder={$t('add_a_title')}
|
||||
/>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
previousAsset?: AssetResponseDto;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
showNavigation?: boolean;
|
||||
withStacked?: boolean;
|
||||
@@ -72,7 +72,7 @@
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
onClose?: (asset: AssetResponseDto) => void;
|
||||
onRandom?: () => Promise<{ id: string } | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
@@ -291,9 +291,6 @@
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||
if (isMouseOver) {
|
||||
isFaceEditMode.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
@@ -625,7 +622,6 @@
|
||||
onClick={() => {
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
isFaceEditMode.value = false;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
readonly
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
@@ -50,15 +50,15 @@
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $derived(isEditFacesPanelOpen.value);
|
||||
let showEditFaces = $state(false);
|
||||
let isOwner = $derived($user?.id === asset.ownerId);
|
||||
let people = $derived(asset.people || []);
|
||||
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||
@@ -107,7 +107,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
isEditFacesPanelOpen.value = false;
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
const handleRefreshPeople = async () => {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
eventManager.emit('AssetUpdate', asset);
|
||||
isEditFacesPanelOpen.value = false;
|
||||
showEditFaces = false;
|
||||
};
|
||||
|
||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||
@@ -221,7 +221,7 @@
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => (isEditFacesPanelOpen.value = true)}
|
||||
onclick={() => (showEditFaces = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -230,9 +230,8 @@
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each people as person, index (person.id)}
|
||||
{#if showingHiddenPeople || !person.isHidden}
|
||||
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
|
||||
<a
|
||||
class="group w-22 outline-none"
|
||||
class="w-22"
|
||||
href={Route.viewPerson(person, { previousRoute })}
|
||||
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onblur={() => ($boundingBoxesArray = [])}
|
||||
@@ -249,8 +248,6 @@
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
hidden={person.isHidden}
|
||||
highlighted={isHighlighted}
|
||||
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||
@@ -579,7 +576,7 @@
|
||||
<PersonSidePanel
|
||||
assetId={asset.id}
|
||||
assetType={asset.type}
|
||||
onClose={() => (isEditFacesPanelOpen.value = false)}
|
||||
onClose={() => (showEditFaces = false)}
|
||||
onRefresh={handleRefreshPeople}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
|
||||
import { computeContentMetrics, getNaturalSize, mapContentRectToNatural } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { scaleFaceRectOnResize, type ResizeContext } from '$lib/utils/people-utils';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
@@ -14,16 +14,15 @@
|
||||
import { clamp } from 'lodash-es';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
imageSize: Size;
|
||||
interface Props {
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { imageSize, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
@@ -38,7 +37,6 @@
|
||||
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||
let userMovedRect = false;
|
||||
let previousMetrics: ResizeContext | null = null;
|
||||
let panModifierHeld = $state(false);
|
||||
|
||||
let filteredCandidates = $derived(
|
||||
searchTerm
|
||||
@@ -60,7 +58,7 @@
|
||||
};
|
||||
|
||||
const setupCanvas = () => {
|
||||
if (!canvasEl) {
|
||||
if (!canvasEl || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,12 +130,9 @@
|
||||
};
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
if (imageSize.width === 0 || imageSize.height === 0) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
return computeContentMetrics(imageSize, { width: containerWidth, height: containerHeight });
|
||||
});
|
||||
const imageContentMetrics = $derived(
|
||||
computeContentMetrics(getNaturalSize(htmlElement), { width: containerWidth, height: containerHeight }),
|
||||
);
|
||||
|
||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||
@@ -209,9 +204,6 @@
|
||||
const gap = 15;
|
||||
const padding = faceRect.padding ?? 0;
|
||||
const rawBox = faceRect.getBoundingRect();
|
||||
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
|
||||
return;
|
||||
}
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
const faceBox = {
|
||||
left: (rawBox.left - padding) * currentZoom + currentPositionX,
|
||||
@@ -299,57 +291,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
const panModifierKey = isMac ? 'Meta' : 'Control';
|
||||
const panModifierLabel = isMac ? '⌘' : 'Ctrl';
|
||||
const isZoomed = $derived(assetViewerManager.zoom > 1);
|
||||
|
||||
$effect(() => {
|
||||
if (!containerEl) {
|
||||
return;
|
||||
}
|
||||
const element = containerEl;
|
||||
const parent = element.parentElement;
|
||||
|
||||
const activate = () => {
|
||||
panModifierHeld = true;
|
||||
element.style.pointerEvents = 'none';
|
||||
if (parent) {
|
||||
parent.style.cursor = 'move';
|
||||
}
|
||||
};
|
||||
|
||||
const deactivate = () => {
|
||||
panModifierHeld = false;
|
||||
element.style.pointerEvents = '';
|
||||
if (parent) {
|
||||
parent.style.cursor = '';
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === panModifierKey) {
|
||||
activate();
|
||||
}
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === panModifierKey) {
|
||||
deactivate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', deactivate);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', deactivate);
|
||||
deactivate();
|
||||
};
|
||||
});
|
||||
|
||||
const trapEvents = (node: HTMLElement) => {
|
||||
const stop = (e: Event) => e.stopPropagation();
|
||||
const eventTypes = ['keydown', 'pointerdown', 'pointermove', 'pointerup'] as const;
|
||||
@@ -371,12 +312,13 @@
|
||||
};
|
||||
|
||||
const getFaceCroppedCoordinates = () => {
|
||||
if (!faceRect || imageSize.width === 0 || imageSize.height === 0) {
|
||||
if (!faceRect || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaledWidth = faceRect.getScaledWidth();
|
||||
const scaledHeight = faceRect.getScaledHeight();
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
|
||||
const imageRect = mapContentRectToNatural(
|
||||
{
|
||||
@@ -386,12 +328,12 @@
|
||||
height: scaledHeight,
|
||||
},
|
||||
imageContentMetrics,
|
||||
imageSize,
|
||||
natural,
|
||||
);
|
||||
|
||||
return {
|
||||
imageWidth: imageSize.width,
|
||||
imageHeight: imageSize.height,
|
||||
imageWidth: natural.width,
|
||||
imageHeight: natural.height,
|
||||
x: Math.floor(imageRect.left),
|
||||
y: Math.floor(imageRect.top),
|
||||
width: Math.floor(imageRect.width),
|
||||
@@ -449,7 +391,7 @@
|
||||
<div
|
||||
id="face-selector"
|
||||
bind:this={faceSelectorEl}
|
||||
class="fixed z-20 w-[min(200px,45vw)] min-w-48 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
|
||||
class="fixed w-[min(200px,45vw)] min-w-48 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
|
||||
use:trapEvents
|
||||
onwheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -492,15 +434,4 @@
|
||||
|
||||
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
|
||||
</div>
|
||||
|
||||
{#if isZoomed && !panModifierHeld}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="absolute bottom-4 inset-s-1/2 -translate-x-1/2 pointer-events-none z-10"
|
||||
>
|
||||
<p class="bg-black/60 text-white text-xs px-3 py-1.5 rounded-full whitespace-nowrap">
|
||||
{$t('hold_key_to_pan', { values: { key: panModifierLabel } })}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -43,15 +43,12 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'absolute left-0 top-0 flex items-center justify-center',
|
||||
'border-2 border-blue-500 pointer-events-auto cursor-text',
|
||||
'focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none',
|
||||
isTouch
|
||||
? 'text-white bg-black/60 select-all'
|
||||
: 'select-text text-transparent bg-blue-500/10 transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3',
|
||||
ocrBox.verticalMode === 'none' ? 'px-2 py-1 whitespace-nowrap' : 'px-1 py-2',
|
||||
]}
|
||||
class="absolute left-0 top-0 flex items-center justify-center border-2 border-blue-500 pointer-events-auto cursor-text focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none {isTouch
|
||||
? 'text-white bg-black/60 select-all'
|
||||
: 'select-text text-transparent bg-blue-500/10 transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3'} {ocrBox.verticalMode ===
|
||||
'none'
|
||||
? 'px-2 py-1 whitespace-nowrap'
|
||||
: 'px-1 py-2'}"
|
||||
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0; touch-action: none; {verticalStyle}"
|
||||
data-testid="ocr-box"
|
||||
data-overlay-interactive
|
||||
|
||||
@@ -5,17 +5,16 @@
|
||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import ZoomMinimap from '$lib/components/asset-viewer/zoom-minimap.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToFit, type Size } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
@@ -26,14 +25,14 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
|
||||
@@ -68,10 +67,13 @@
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
||||
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
|
||||
const overlaySize = $derived.by((): Size => {
|
||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const overlaySize = $derived(visibleImageReady ? scaledDimensions : { width: 0, height: 0 });
|
||||
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
|
||||
});
|
||||
|
||||
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []);
|
||||
|
||||
@@ -139,14 +141,16 @@
|
||||
|
||||
const faceToNameMap = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string | undefined>();
|
||||
const map = new Map<Faces, string>();
|
||||
for (const person of asset.people ?? []) {
|
||||
for (const face of person.faces ?? []) {
|
||||
map.set(face, person.name);
|
||||
}
|
||||
}
|
||||
for (const face of asset.unassignedFaces ?? []) {
|
||||
map.set(face, undefined);
|
||||
if (isFaceEditMode.value) {
|
||||
for (const face of asset.unassignedFaces ?? []) {
|
||||
map.set(face, '');
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
@@ -193,8 +197,6 @@
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
bind:imgNaturalSize={imageDimensions}
|
||||
bind:imgScaledSize={scaledDimensions}
|
||||
bind:ref={adaptiveImage}
|
||||
>
|
||||
{#snippet backdrop()}
|
||||
@@ -210,16 +212,14 @@
|
||||
{#each boundingBoxes as boundingbox, index (boundingbox.id)}
|
||||
{@const face = faces[index]}
|
||||
{@const name = faceToNameMap.get(face)}
|
||||
{#if name !== undefined || isEditFacesPanelOpen.value}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute pointer-events-auto outline-none rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
aria-label="{$t('person')}: {name ?? $t('unknown')}"
|
||||
onpointerenter={() => ($boundingBoxesArray = [face])}
|
||||
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||
></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute pointer-events-auto outline-none rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
aria-label="{$t('person')}: {name || $t('unknown')}"
|
||||
onpointerenter={() => ($boundingBoxesArray = [face])}
|
||||
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||
></div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -248,9 +248,7 @@
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
<ZoomMinimap {containerWidth} {containerHeight} {asset} {sharedLink} />
|
||||
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -11,16 +11,14 @@
|
||||
videoViewerVolume,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
assetId: string;
|
||||
imageSize: Size;
|
||||
loopVideo: boolean;
|
||||
cacheKey: string | null;
|
||||
playOriginalVideo: boolean;
|
||||
@@ -29,11 +27,10 @@
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
let {
|
||||
assetId,
|
||||
imageSize,
|
||||
loopVideo,
|
||||
cacheKey,
|
||||
playOriginalVideo,
|
||||
@@ -176,7 +173,7 @@
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor {imageSize} {containerWidth} {containerHeight} {assetId} />
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
assetId?: string;
|
||||
projectionType: string | null | undefined;
|
||||
@@ -16,7 +16,7 @@
|
||||
onNextAsset?: () => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
let {
|
||||
asset,
|
||||
@@ -42,7 +42,6 @@
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
assetId={effectiveAssetId}
|
||||
imageSize={{ width: asset.width ?? 1, height: asset.height ?? 1 }}
|
||||
{playOriginalVideo}
|
||||
{onPreviousAsset}
|
||||
{onNextAsset}
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { MAX_ZOOM } from '$lib/actions/zoom-image';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getAssetUrls } from '$lib/utils';
|
||||
import { scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
}
|
||||
|
||||
let { containerWidth, containerHeight, asset, sharedLink }: Props = $props();
|
||||
|
||||
const MINIMAP_MAX = 192;
|
||||
const MINIMAP_MIN = 100;
|
||||
const minimapSize = $derived(clamp(Math.min(containerWidth, containerHeight) * 0.25, MINIMAP_MIN, MINIMAP_MAX));
|
||||
|
||||
const thumbnailUrl = $derived(getAssetUrls(asset, sharedLink).thumbnail);
|
||||
|
||||
const imageDimensions = $derived({
|
||||
width: asset.width && asset.width > 0 ? asset.width : 1,
|
||||
height: asset.height && asset.height > 0 ? asset.height : 1,
|
||||
});
|
||||
|
||||
const container = $derived({ width: containerWidth, height: containerHeight });
|
||||
|
||||
// Scale the full container into the minimap square
|
||||
const containerInMinimap = $derived(scaleToFit(container, { width: minimapSize, height: minimapSize }));
|
||||
const minimapContainerScale = $derived(containerInMinimap.width / containerWidth);
|
||||
const containerOffsetX = $derived((minimapSize - containerInMinimap.width) / 2);
|
||||
const containerOffsetY = $derived((minimapSize - containerInMinimap.height) / 2);
|
||||
|
||||
// Position the image within the minimap's container representation
|
||||
const imageInMinimap: ContentMetrics = $derived.by(() => {
|
||||
const fitted = scaleToFit(imageDimensions, containerInMinimap);
|
||||
return {
|
||||
contentWidth: fitted.width,
|
||||
contentHeight: fitted.height,
|
||||
offsetX: containerOffsetX + (containerInMinimap.width - fitted.width) / 2,
|
||||
offsetY: containerOffsetY + (containerInMinimap.height - fitted.height) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
const { FADE_DURATION, HIDE_DELAY } = TUNABLES.MINIMAP;
|
||||
|
||||
let isDragging = $state(false);
|
||||
let isDraggingZoom = $state(false);
|
||||
let isRecentActivity = $state(false);
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const resetHideTimer = () => {
|
||||
isRecentActivity = true;
|
||||
if (hideTimer !== null) {
|
||||
clearTimeout(hideTimer);
|
||||
}
|
||||
hideTimer = setTimeout(() => {
|
||||
isRecentActivity = false;
|
||||
hideTimer = null;
|
||||
}, HIDE_DELAY);
|
||||
};
|
||||
|
||||
const isZoomed = $derived(assetViewerManager.zoom > 1);
|
||||
const isVisible = $derived((isZoomed && isRecentActivity) || isDragging || isDraggingZoom);
|
||||
|
||||
$effect(() => {
|
||||
// Track zoom state changes to reset the hide timer
|
||||
const _state = assetViewerManager.zoomState;
|
||||
void _state;
|
||||
if (isZoomed) {
|
||||
resetHideTimer();
|
||||
}
|
||||
return () => {
|
||||
if (hideTimer !== null) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const zoomPercent = $derived(((assetViewerManager.zoom - 1) / (MAX_ZOOM - 1)) * 100);
|
||||
const zoomLabel = $derived(assetViewerManager.zoom.toFixed(1) + 'x');
|
||||
|
||||
const clampPanPosition = (positionX: number, positionY: number, zoom: number) => ({
|
||||
positionX: clamp(positionX, -(containerWidth * (zoom - 1)), 0),
|
||||
positionY: clamp(positionY, -(containerHeight * (zoom - 1)), 0),
|
||||
});
|
||||
|
||||
const minimapToContainerPosition = (minimapX: number, minimapY: number) => {
|
||||
const containerX = (minimapX - containerOffsetX) / minimapContainerScale;
|
||||
const containerY = (minimapY - containerOffsetY) / minimapContainerScale;
|
||||
const { currentZoom } = assetViewerManager.zoomState;
|
||||
const rawPositionX = containerWidth / 2 - containerX * currentZoom;
|
||||
const rawPositionY = containerHeight / 2 - containerY * currentZoom;
|
||||
return clampPanPosition(rawPositionX, rawPositionY, currentZoom);
|
||||
};
|
||||
|
||||
const panToMinimapPosition = (event: PointerEvent) => {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const minimapX = event.clientX - rect.left;
|
||||
const minimapY = event.clientY - rect.top;
|
||||
const { positionX, positionY } = minimapToContainerPosition(minimapX, minimapY);
|
||||
assetViewerManager.directTransform({ currentPositionX: positionX, currentPositionY: positionY });
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
isDragging = true;
|
||||
assetViewerManager.setZoomEnabled(false);
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
panToMinimapPosition(event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
panToMinimapPosition(event);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
isDragging = false;
|
||||
assetViewerManager.setZoomEnabled(true);
|
||||
};
|
||||
|
||||
const zoomAroundCenter = (newZoom: number) => {
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
const centerX = containerWidth / 2;
|
||||
const centerY = containerHeight / 2;
|
||||
const zoomTargetX = (centerX - currentPositionX) / currentZoom;
|
||||
const zoomTargetY = (centerY - currentPositionY) / currentZoom;
|
||||
const newPositionX = -zoomTargetX * newZoom + centerX;
|
||||
const newPositionY = -zoomTargetY * newZoom + centerY;
|
||||
|
||||
assetViewerManager.directTransform({
|
||||
currentZoom: newZoom,
|
||||
currentPositionX: clamp(newPositionX, -(containerWidth * (newZoom - 1)), 0),
|
||||
currentPositionY: clamp(newPositionY, -(containerHeight * (newZoom - 1)), 0),
|
||||
});
|
||||
};
|
||||
|
||||
const setZoomFromSlider = (event: PointerEvent) => {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const percent = clamp((event.clientX - rect.left) / rect.width, 0, 1);
|
||||
zoomAroundCenter(1 + percent * (MAX_ZOOM - 1));
|
||||
};
|
||||
|
||||
const WHEEL_ZOOM_RATIO = 0.1;
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const { currentZoom } = assetViewerManager.zoomState;
|
||||
const delta = -clamp(event.deltaY, -0.5, 0.5);
|
||||
const newZoom = clamp(currentZoom + delta * WHEEL_ZOOM_RATIO * currentZoom, 1, MAX_ZOOM);
|
||||
zoomAroundCenter(newZoom);
|
||||
};
|
||||
|
||||
const onZoomSliderPointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
isDraggingZoom = true;
|
||||
assetViewerManager.setZoomEnabled(false);
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
setZoomFromSlider(event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const onZoomSliderPointerMove = (event: PointerEvent) => {
|
||||
if (!isDraggingZoom) {
|
||||
return;
|
||||
}
|
||||
setZoomFromSlider(event);
|
||||
};
|
||||
|
||||
const onZoomSliderPointerUp = () => {
|
||||
isDraggingZoom = false;
|
||||
assetViewerManager.setZoomEnabled(true);
|
||||
};
|
||||
|
||||
const viewportRect = $derived.by(() => {
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
// Visible area in container coordinates
|
||||
const visibleLeft = -currentPositionX / currentZoom;
|
||||
const visibleTop = -currentPositionY / currentZoom;
|
||||
const visibleWidth = containerWidth / currentZoom;
|
||||
const visibleHeight = containerHeight / currentZoom;
|
||||
|
||||
// Map to minimap coordinates
|
||||
return {
|
||||
left: visibleLeft * minimapContainerScale + containerOffsetX,
|
||||
top: visibleTop * minimapContainerScale + containerOffsetY,
|
||||
width: visibleWidth * minimapContainerScale,
|
||||
height: visibleHeight * minimapContainerScale,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isVisible}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute top-[68px] right-14 md:right-4 z-10 rounded-lg border border-white/30 bg-black/60 p-1 backdrop-blur-sm"
|
||||
data-testid="zoom-minimap"
|
||||
transition:fade={{ duration: FADE_DURATION }}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative overflow-hidden rounded bg-black"
|
||||
class:cursor-grabbing={isDragging}
|
||||
class:cursor-pointer={!isDragging}
|
||||
data-testid="zoom-minimap-canvas"
|
||||
style="width: {minimapSize}px; height: {minimapSize}px;"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
onwheel={onWheel}
|
||||
>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
class="absolute pointer-events-none"
|
||||
draggable="false"
|
||||
style="left: {imageInMinimap.offsetX}px; top: {imageInMinimap.offsetY}px; width: {imageInMinimap.contentWidth}px; height: {imageInMinimap.contentHeight}px;"
|
||||
/>
|
||||
<div
|
||||
class={[
|
||||
'absolute border-2 border-white bg-white/20 pointer-events-none rounded-sm',
|
||||
isDragging && 'border-white/80',
|
||||
]}
|
||||
data-testid="zoom-minimap-viewport"
|
||||
style="left: {viewportRect.left}px; top: {viewportRect.top}px; width: {viewportRect.width}px; height: {viewportRect.height}px;"
|
||||
></div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative mt-1 h-3 rounded-full bg-white/20 cursor-pointer"
|
||||
class:cursor-grabbing={isDraggingZoom}
|
||||
data-testid="zoom-minimap-slider"
|
||||
style="width: {minimapSize}px;"
|
||||
onpointerdown={onZoomSliderPointerDown}
|
||||
onpointermove={onZoomSliderPointerMove}
|
||||
onpointerup={onZoomSliderPointerUp}
|
||||
onpointercancel={onZoomSliderPointerUp}
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full rounded-full bg-white/80 pointer-events-none"
|
||||
data-testid="zoom-minimap-slider-fill"
|
||||
style="width: {zoomPercent}%;"
|
||||
></div>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center text-[9px] font-semibold pointer-events-none select-none leading-none"
|
||||
style="color: #000; text-shadow: 0 0 3px rgba(255,255,255,0.8);"
|
||||
>
|
||||
{zoomLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -16,7 +16,6 @@
|
||||
circle?: boolean;
|
||||
hidden?: boolean;
|
||||
border?: boolean;
|
||||
highlighted?: boolean;
|
||||
hiddenIconClass?: string;
|
||||
class?: ClassValue;
|
||||
brokenAssetClass?: ClassValue;
|
||||
@@ -35,7 +34,6 @@
|
||||
circle = false,
|
||||
hidden = false,
|
||||
border = false,
|
||||
highlighted = false,
|
||||
hiddenIconClass = 'text-white',
|
||||
onComplete = undefined,
|
||||
class: imageClass = '',
|
||||
@@ -85,10 +83,6 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if highlighted}
|
||||
<span class={['absolute inset-0 pointer-events-none border-2 border-white', sharedClasses]} {style}></span>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||
<!-- TODO fix `title` type -->
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
assetId: string;
|
||||
assetType: AssetTypeEnum;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
let { assetId, assetType, onClose, onRefresh }: Props = $props();
|
||||
|
||||
@@ -58,8 +58,6 @@
|
||||
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const thumbnailWidth = '90px';
|
||||
const focusHighlightClass =
|
||||
'group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary';
|
||||
|
||||
async function loadPeople() {
|
||||
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
||||
@@ -228,13 +226,11 @@
|
||||
{:else}
|
||||
{#each peopleWithFaces as face, index (face.id)}
|
||||
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
|
||||
{@const isHighlighted = $boundingBoxesArray.some((f) => f.id === face.id)}
|
||||
<div class="relative h-29 w-24">
|
||||
<div
|
||||
role="button"
|
||||
tabindex={index}
|
||||
data-testid="face-thumbnail"
|
||||
class="group absolute inset-s-0 top-0 h-22.5 w-22.5 cursor-default outline-none"
|
||||
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
|
||||
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||
@@ -249,8 +245,6 @@
|
||||
title={$t('new_person')}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{:else if selectedPersonToReassign[face.id]}
|
||||
<ImageThumbnail
|
||||
@@ -265,8 +259,6 @@
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{:else if face.person}
|
||||
<ImageThumbnail
|
||||
@@ -278,8 +270,6 @@
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={face.person.isHidden}
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{:else}
|
||||
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
|
||||
@@ -291,8 +281,6 @@
|
||||
title={$t('face_unassigned')}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{:then data}
|
||||
<ImageThumbnail
|
||||
@@ -303,8 +291,6 @@
|
||||
title={$t('face_unassigned')}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -212,12 +212,12 @@
|
||||
bottom: `${rootHeight - top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${boundary.width}px`,
|
||||
maxHeight: maxHeight(boundary.top - dropdownOffset),
|
||||
maxHeight: maxHeight(top - dropdownOffset),
|
||||
};
|
||||
}
|
||||
|
||||
const viewportHeight = visualViewport?.height || window.innerHeight;
|
||||
const availableHeight = viewportHeight - boundary.bottom;
|
||||
const viewportHeight = visualViewport?.height || rootHeight;
|
||||
const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom;
|
||||
return {
|
||||
top: `${bottom}px`,
|
||||
left: `${left}px`,
|
||||
|
||||
@@ -18,8 +18,6 @@ const createDefaultZoomState = (): ZoomImageWheelState => ({
|
||||
export type Events = {
|
||||
Zoom: [];
|
||||
ZoomChange: [ZoomImageWheelState];
|
||||
DirectTransform: [ZoomImageWheelState];
|
||||
ZoomEnabled: [boolean];
|
||||
Copy: [];
|
||||
};
|
||||
|
||||
@@ -89,15 +87,6 @@ export class AssetViewerManager extends BaseEventManager<Events> {
|
||||
this.#zoomState = state;
|
||||
}
|
||||
|
||||
directTransform(state: Partial<ZoomImageWheelState>) {
|
||||
this.#zoomState = { ...this.#zoomState, ...state };
|
||||
this.emit('DirectTransform', this.#zoomState);
|
||||
}
|
||||
|
||||
setZoomEnabled(enabled: boolean) {
|
||||
this.emit('ZoomEnabled', enabled);
|
||||
}
|
||||
|
||||
cancelZoomAnimation() {
|
||||
if (this.#animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.#animationFrameId);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { cancelUploadRequests } from '$lib/utils';
|
||||
import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk';
|
||||
|
||||
class UploadManager {
|
||||
@@ -14,7 +13,6 @@ class UploadManager {
|
||||
}
|
||||
|
||||
reset() {
|
||||
cancelUploadRequests();
|
||||
uploadAssetsStore.reset();
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_album'), { notify });
|
||||
handleError(error, $t('errors.unable_to_delete_album'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export const isFaceEditMode = $state({ value: false });
|
||||
export const isEditFacesPanelOpen = $state({ value: false });
|
||||
|
||||
+2
-25
@@ -78,40 +78,17 @@ export const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
let unsubscribeId = 0;
|
||||
const uploads: Record<number, () => void> = {};
|
||||
|
||||
const trackUpload = (unsubscribe: () => void) => {
|
||||
const id = unsubscribeId++;
|
||||
uploads[id] = unsubscribe;
|
||||
return () => {
|
||||
delete uploads[id];
|
||||
};
|
||||
};
|
||||
|
||||
export const cancelUploadRequests = () => {
|
||||
for (const unsubscribe of Object.values(uploads)) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
|
||||
const { onUploadProgress: onProgress, data, url } = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const unsubscribe = trackUpload(() => xhr.abort());
|
||||
|
||||
xhr.addEventListener('error', (error) => {
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', (error) => reject(error));
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
|
||||
unsubscribe();
|
||||
resolve({ data: xhr.response as T, status: xhr.status });
|
||||
} else {
|
||||
unsubscribe();
|
||||
reject(new ApiError(xhr.statusText, xhr.status, xhr.response));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,13 +193,6 @@ describe('mapNormalizedToContent', () => {
|
||||
expect(mapNormalizedToContent({ x: 0, y: 0 }, letterboxed)).toEqual({ x: 250, y: 0 });
|
||||
expect(mapNormalizedToContent({ x: 1, y: 1 }, letterboxed)).toEqual({ x: 550, y: 600 });
|
||||
});
|
||||
|
||||
it('should accept Size (zero offsets)', () => {
|
||||
const size = { width: 800, height: 400 };
|
||||
expect(mapNormalizedToContent({ x: 0, y: 0 }, size)).toEqual({ x: 0, y: 0 });
|
||||
expect(mapNormalizedToContent({ x: 1, y: 1 }, size)).toEqual({ x: 800, y: 400 });
|
||||
expect(mapNormalizedToContent({ x: 0.5, y: 0.5 }, size)).toEqual({ x: 400, y: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapContentToNatural', () => {
|
||||
@@ -245,12 +238,6 @@ describe('mapNormalizedRectToContent', () => {
|
||||
const rect = mapNormalizedRectToContent({ x: 0, y: 0 }, { x: 1, y: 1 }, letterboxed);
|
||||
expect(rect).toEqual({ left: 250, top: 0, width: 300, height: 600 });
|
||||
});
|
||||
|
||||
it('should accept Size (zero offsets)', () => {
|
||||
const size = { width: 800, height: 400 };
|
||||
const rect = mapNormalizedRectToContent({ x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 }, size);
|
||||
expect(rect).toEqual({ left: 200, top: 100, width: 400, height: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapContentRectToNatural', () => {
|
||||
|
||||
@@ -12,22 +12,22 @@
|
||||
// "Metadata pixel space": coordinates from face detection / OCR models, in pixels relative
|
||||
// to face.imageWidth/imageHeight. Divide by those dimensions to get normalized coords.
|
||||
|
||||
export type Point = {
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type Size = {
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type ContentMetrics = {
|
||||
export interface ContentMetrics {
|
||||
contentWidth: number;
|
||||
contentHeight: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const scaleToCover = (dimensions: Size, container: Size): Size => {
|
||||
const scaleX = container.width / dimensions.width;
|
||||
@@ -83,16 +83,10 @@ export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement):
|
||||
return computeContentMetrics(natural, client);
|
||||
};
|
||||
|
||||
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
||||
if ('contentWidth' in sizeOrMetrics) {
|
||||
return {
|
||||
x: point.x * sizeOrMetrics.contentWidth + sizeOrMetrics.offsetX,
|
||||
y: point.y * sizeOrMetrics.contentHeight + sizeOrMetrics.offsetY,
|
||||
};
|
||||
}
|
||||
export function mapNormalizedToContent(point: Point, metrics: ContentMetrics): Point {
|
||||
return {
|
||||
x: point.x * sizeOrMetrics.width,
|
||||
y: point.y * sizeOrMetrics.height,
|
||||
x: point.x * metrics.contentWidth + metrics.offsetX,
|
||||
y: point.y * metrics.contentHeight + metrics.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,20 +97,16 @@ export function mapContentToNatural(point: Point, metrics: ContentMetrics, natur
|
||||
};
|
||||
}
|
||||
|
||||
export type Rect = {
|
||||
export interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function mapNormalizedRectToContent(
|
||||
topLeft: Point,
|
||||
bottomRight: Point,
|
||||
sizeOrMetrics: Size | ContentMetrics,
|
||||
): Rect {
|
||||
const tl = mapNormalizedToContent(topLeft, sizeOrMetrics);
|
||||
const br = mapNormalizedToContent(bottomRight, sizeOrMetrics);
|
||||
export function mapNormalizedRectToContent(topLeft: Point, bottomRight: Point, metrics: ContentMetrics): Rect {
|
||||
const tl = mapNormalizedToContent(topLeft, metrics);
|
||||
const br = mapNormalizedToContent(bottomRight, metrics);
|
||||
return {
|
||||
top: tl.y,
|
||||
left: tl.x,
|
||||
|
||||
@@ -216,7 +216,7 @@ async function fileUploader({
|
||||
uploadAssetsStore.track('success');
|
||||
}
|
||||
|
||||
if (albumId && !authManager.isSharedLink) {
|
||||
if (albumId) {
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
|
||||
await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
|
||||
|
||||
@@ -23,8 +23,7 @@ export function standardizeError(error: unknown) {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
export function handleError(error: unknown, localizedMessage: string, options?: { notify?: boolean }) {
|
||||
const { notify = true } = options ?? {};
|
||||
export function handleError(error: unknown, localizedMessage: string) {
|
||||
const standardizedError = standardizeError(error);
|
||||
if (standardizedError.name === 'AbortError') {
|
||||
return;
|
||||
@@ -40,9 +39,7 @@ export function handleError(error: unknown, localizedMessage: string, options?:
|
||||
|
||||
const errorMessage = serverMessage || localizedMessage;
|
||||
|
||||
if (notify) {
|
||||
toastManager.danger(errorMessage);
|
||||
}
|
||||
toastManager.danger(errorMessage);
|
||||
|
||||
return errorMessage;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { mapNormalizedToContent, type Point, type Size } from '$lib/utils/container-utils';
|
||||
import { mapNormalizedToContent, type ContentMetrics, type Point, type Size } from '$lib/utils/container-utils';
|
||||
import { clamp } from 'lodash-es';
|
||||
export type { Point } from '$lib/utils/container-utils';
|
||||
|
||||
@@ -7,13 +7,13 @@ const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||||
|
||||
export type VerticalMode = 'none' | 'cjk' | 'rotated';
|
||||
|
||||
export type OcrBox = {
|
||||
export interface OcrBox {
|
||||
id: string;
|
||||
points: Point[];
|
||||
text: string;
|
||||
confidence: number;
|
||||
verticalMode: VerticalMode;
|
||||
};
|
||||
}
|
||||
|
||||
const CJK_PATTERN =
|
||||
/[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF\uFF00-\uFFEF]/;
|
||||
@@ -160,6 +160,12 @@ export const calculateFittedFontSize = (
|
||||
};
|
||||
|
||||
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], imageSize: Size): OcrBox[] => {
|
||||
const metrics: ContentMetrics = {
|
||||
contentWidth: imageSize.width,
|
||||
contentHeight: imageSize.height,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
const boxes: OcrBox[] = [];
|
||||
for (const ocr of ocrData) {
|
||||
const points = [
|
||||
@@ -167,7 +173,7 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], imageSize: Size):
|
||||
{ x: ocr.x2, y: ocr.y2 },
|
||||
{ x: ocr.x3, y: ocr.y3 },
|
||||
{ x: ocr.x4, y: ocr.y4 },
|
||||
].map((point) => mapNormalizedToContent(point, imageSize));
|
||||
].map((point) => mapNormalizedToContent(point, metrics));
|
||||
|
||||
const boxWidth = Math.max(distance(points[0], points[1]), distance(points[3], points[2]));
|
||||
const boxHeight = Math.max(distance(points[0], points[3]), distance(points[1], points[2]));
|
||||
|
||||
@@ -6,13 +6,19 @@ import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
||||
export type BoundingBox = Rect & { id: string };
|
||||
|
||||
export const getBoundingBox = (faces: Faces[], imageSize: Size): BoundingBox[] => {
|
||||
const metrics: ContentMetrics = {
|
||||
contentWidth: imageSize.width,
|
||||
contentHeight: imageSize.height,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
const boxes: BoundingBox[] = [];
|
||||
|
||||
for (const face of faces) {
|
||||
const rect = mapNormalizedRectToContent(
|
||||
{ x: face.boundingBoxX1 / face.imageWidth, y: face.boundingBoxY1 / face.imageHeight },
|
||||
{ x: face.boundingBoxX2 / face.imageWidth, y: face.boundingBoxY2 / face.imageHeight },
|
||||
imageSize,
|
||||
metrics,
|
||||
);
|
||||
|
||||
boxes.push({ id: face.id, ...rect });
|
||||
|
||||
@@ -31,8 +31,4 @@ export const TUNABLES = {
|
||||
IMAGE_THUMBNAIL: {
|
||||
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
|
||||
},
|
||||
MINIMAP: {
|
||||
FADE_DURATION: getNumber(storage.getItem('MINIMAP.FADE_DURATION'), 150),
|
||||
HIDE_DELAY: getNumber(storage.getItem('MINIMAP.HIDE_DELAY'), 1500),
|
||||
},
|
||||
};
|
||||
|
||||
+1
-5
@@ -287,11 +287,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onAlbumAddAssets = async ({ albumIds }: { albumIds: string[] }) => {
|
||||
if (!albumIds.includes(album.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onAlbumAddAssets = async () => {
|
||||
await refreshAlbum();
|
||||
timelineInteraction.clearMultiselect();
|
||||
await setModeToView();
|
||||
|
||||
Reference in New Issue
Block a user