Compare commits

...

17 Commits

Author SHA1 Message Date
Mees Frensel 439b7fe3be move setLocale and add comment 2026-06-10 16:23:55 +02:00
Mees Frensel b430f5188b Merge branch 'main' into fix/date-range-formatting 2026-06-10 13:33:15 +02:00
Stefan Yoshovski b9b1cc2f65 feat(web): warn before overwriting existing locations in geolocation utility (#28840) 2026-06-10 11:09:12 +00:00
Pedro Vieira 7d198956a6 fix(web): Prevent face editor from closing when dismissing tag confirmation (#28900) 2026-06-10 12:31:52 +02:00
Pedro Vieira a7b5f81701 fix: normalize diacritics in person name search in Web & Mobile (#28887) 2026-06-10 12:05:07 +02:00
Timon 5c38373808 refactor(server): allow -1 rating again (#28886) 2026-06-10 10:55:51 +02:00
Ben Beckford 1ce961fbb3 feat: geolocation workflow filter (#28961)
* feat: geolocation workflow filter

* refactor: geolocation workflow filter

* feat: location filter workflow example
2026-06-10 05:05:01 +00:00
shenlong 4bc411b7c7 revert: clear album description sends null instead of empty string (#28956)
Revert "fix(mobile): clear album description sends null instead of empty string (#28817)"

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-09 22:05:37 -05:00
Santo Shakil 11c1025271 fix(mobile): add album picker to archive bottom sheet (#28953) 2026-06-09 14:45:32 -05:00
Jason Rasmussen 8b5385f94b feat: add prerelease support to pump version (#28922)
refactor: pump script
2026-06-09 14:42:10 -04:00
Alex d3438cf4a7 chore: improve OCR button and display on mobile (#28926)
* chore: improve OCR button and display on mobile

* Refactor

* format

* simplify ocr toggle button

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-09 13:20:18 -05:00
Alex 6c5c6a1035 fix: realign badge icon (#28951) 2026-06-09 11:44:29 -05:00
Santo Shakil c928787b3e fix(mobile): show error when creating an album fails (#28942)
it failed silently when the server was down. also disable create for blank titles.
2026-06-09 16:41:32 +00:00
Santo Shakil fe9ca4f40a fix(mobile): show memory and folder dates in local time (#28941) 2026-06-09 10:55:43 -05:00
Savely Krasovsky a665cec920 feat(ml): update Intel graphics compiler and compute runtime (#28924)
feat(ml): update Intel graphics compiler and compute runtime to latest versions
2026-06-09 11:08:03 -04:00
Alex 568283a8eb fix: stale translation generation (#28949) 2026-06-09 14:28:48 +00:00
Mees Frensel 54c1fbebde fix(web): album date range formatting 2026-05-22 16:53:28 +02:00
61 changed files with 1328 additions and 533 deletions
+6 -2
View File
@@ -10,9 +10,13 @@ on:
type: choice
options:
- 'false'
- major
- minor
- patch
- premajor
- preminor
- prepatch
- prerelease
- release
mobileBump:
description: 'Bump mobile build number'
required: false
@@ -74,7 +78,7 @@ jobs:
env:
SERVER_BUMP: ${{ inputs.serverBump }}
MOBILE_BUMP: ${{ inputs.mobileBump }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- id: output
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
+32
View File
@@ -28,6 +28,10 @@ jobs:
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
root:
- 'misc/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
i18n:
- 'i18n/**'
- 'mise.toml'
@@ -62,6 +66,34 @@ jobs:
- '.github/workflows/test.yml'
force-events: 'workflow_dispatch'
root-unit-tests:
name: Test the root workspace
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).root == true }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
- name: Run unit tests
run: pnpm test
server-unit-tests:
name: Test & Lint Server
needs: pre-job
+1
View File
@@ -60,6 +60,7 @@
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"*.js": "${capture}.spec.js,${capture}.mock.js",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
},
"search.exclude": {
@@ -492,6 +492,20 @@ describe('/asset', () => {
expect(status).toEqual(200);
});
it('should set the negative rating', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ rating: -1 });
expect(body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
rating: -1,
}),
});
expect(status).toEqual(200);
});
it('should return tagged people', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
+1
View File
@@ -2248,6 +2248,7 @@
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings",
"smart_album": "Smart album",
"some_assets_already_have_a_location_warning": "Some of the selected assets already have a location",
"sort_albums_by": "Sort albums by...",
"sort_created": "Date created",
"sort_items": "Number of items",
+4 -4
View File
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4e
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-core-2_2.34.4+21428_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-opencl-2_2.34.4+21428_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/intel-opencl-icd_26.18.38308.1-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/libigdgmm12_22.10.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \
+5 -4
View File
@@ -1,9 +1,10 @@
#! /usr/bin/env node
const { readFileSync, writeFileSync } = require('node:fs');
import { readFileSync, writeFileSync } from 'node:fs';
const asVersion = (item) => {
const { label, url } = item;
const [major, minor, patch] = label.substring(1).split('.').map(Number);
const [version] = label.substring(1).split('-');
const [major, minor, patch] = version.split('.').map(Number);
return { major, minor, patch, label, url };
};
@@ -31,7 +32,7 @@ for (const item of versions) {
) {
versions = versions.filter((item) => item.label !== version.label);
console.log(
`Removed ${version.label} (replaced with ${lastVersion.label})`
`Removed ${version.label} (replaced with ${lastVersion.label})`,
);
continue;
}
@@ -41,5 +42,5 @@ for (const item of versions) {
writeFileSync(
filename,
JSON.stringify([newVersion, ...versions], null, 2) + '\n'
JSON.stringify([newVersion, ...versions], null, 2) + '\n',
);
+18 -30
View File
@@ -3,12 +3,14 @@
#
# Pump one or both of the server/mobile versions in appropriate files
#
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false>
# usage: './scripts/pump-version.sh -s <minor|patch|premajor|preminor|prepatch|prerelease> <-m> <true|false>
#
# examples:
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
# ./scripts/pump-version.sh -s premajor # 1.0.0+50 => 2.0.0-rc.0+50
# ./scripts/pump-version.sh -s prerelease # 2.0.0-rc.0+50 => 2.0.0-rc.1+50
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
#
SERVER_PUMP="false"
@@ -25,31 +27,15 @@ while getopts 's:m:' flag; do
esac
done
CURRENT_SERVER=$(jq -r '.version' server/package.json)
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
if [[ $SERVER_PUMP == "major" ]]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [[ $SERVER_PUMP == "minor" ]]; then
MINOR=$((MINOR + 1))
PATCH=0
elif [[ $SERVER_PUMP == "patch" ]]; then
PATCH=$((PATCH + 1))
elif [[ $SERVER_PUMP == "false" ]]; then
echo 'Skipping Server Pump'
else
echo 'Expected <major|minor|patch|false> for the server argument'
CURRENT_SERVER=$(jq -r '.version' package.json)
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
exit 1
fi
NEXT_SERVER=$MAJOR.$MINOR.$PATCH
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
NEXT_MOBILE=$CURRENT_MOBILE
if [[ $MOBILE_PUMP == "true" ]]; then
set $((NEXT_MOBILE++))
elif [[ $MOBILE_PUMP == "false" ]]; then
@@ -59,15 +45,17 @@ else
exit 1
fi
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
pnpm version "$NEXT_SERVER" --no-git-tag-version
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix e2e
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/sdk
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
# copy version to open-api spec
mise run //:open-api
+7
View File
@@ -0,0 +1,7 @@
import { pump } from './pump.js';
const [versionRaw, type] = process.argv.slice(2);
const { message, exitCode } = pump(versionRaw, type);
console.log(message);
process.exit(exitCode);
+105
View File
@@ -0,0 +1,105 @@
import semver, { SemVer } from 'semver';
const printUsage = () => {
return {
message:
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
exitCode: 1,
};
};
const isPrerelease = (version) => version.prerelease.length > 0;
/**
* @param {SemVer} version
* @returns {boolean}
*/
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
/** @param {string} version */
const normalize = (version) => {
if (version.startsWith('v')) {
version = version.slice(1);
}
return version;
};
/**
* @param {string} versionRaw
* @param {string} type
*/
export const pump = (versionRaw, type) => {
if (!versionRaw) {
return printUsage();
}
versionRaw = normalize(versionRaw);
const version = semver.parse(versionRaw);
if (!version) {
return printUsage();
}
let newVersionRaw;
let valid = true;
switch (type) {
case 'patch':
case 'prepatch':
case 'minor':
case 'preminor':
case 'premajor': {
newVersionRaw = inc(version, type);
// can only use while not in a prerelease
valid = !isPrerelease(version);
break;
}
case 'prerelease': {
newVersionRaw = inc(version, type);
// can only use while in a prerelease
valid = isPrerelease(version);
break;
}
case 'release': {
// drop prerelease part
newVersionRaw = `${version.major}.${version.minor}.${version.patch}`;
// can only use to promote a prerelease to a release (no version change)
valid = isPrerelease(version);
break;
}
default: {
return printUsage();
}
}
if (!newVersionRaw) {
return printUsage();
}
newVersionRaw = normalize(newVersionRaw);
const newVersion = semver.parse(newVersionRaw);
if (!newVersion) {
return printUsage();
}
const invalidUpgrade =
isPrerelease(version) &&
!isPrerelease(newVersion) &&
(version.major !== newVersion.major ||
version.minor !== newVersion.minor ||
version.patch !== newVersion.patch);
if (!valid || invalidUpgrade) {
return {
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
exitCode: 1,
};
}
return { message: newVersionRaw, exitCode: 0 };
};
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { pump } from './pump';
describe(pump.name, () => {
describe('usage', () => {
it.each([
[],
['2.7.5'],
['2.7.5', 'invalid'],
['invalid', 'patch'],
['2.7.5', 'major'],
])('should not accept $0, $1 as inputs', (version, type) => {
expect(pump(version, type)).toEqual({
message: expect.stringContaining('Usage: '),
exitCode: 1,
});
});
});
describe('transitions', () => {
const valid = [
{
name: 'patch',
items: [['patch', '2.7.5', '2.7.6']],
},
{
name: 'prepatch',
items: [
['prepatch', '2.7.5', '2.7.6-rc.0'],
['prerelease', '2.7.6-rc.0', '2.7.6-rc.1'],
['release', '2.7.6-rc.1', '2.7.6'],
],
},
{
name: 'minor',
items: [['minor', '2.7.5', '2.8.0']],
},
{
name: 'preminor',
items: [
['preminor', '2.7.5', '2.8.0-rc.0'],
['prerelease', '2.8.0-rc.0', '2.8.0-rc.1'],
['release', '2.8.0-rc.1', '2.8.0'],
],
},
{
name: 'premajor',
items: [
['premajor', '2.7.5', '3.0.0-rc.0'],
['prerelease', '3.0.0-rc.0', '3.0.0-rc.1'],
['release', '3.0.0-rc.1', '3.0.0'],
],
},
];
for (const group of valid) {
describe(group.name, () => {
it.each(group.items)(
'should allow a $0 from $1 to $2',
(type, version, next) => {
expect(pump(version, type)).toEqual({
message: next,
exitCode: 0,
});
},
);
});
}
describe('invalid', () => {
it.each([
['patch', 'v3.0.0-rc.0'],
['prepatch', 'v3.0.0-rc.0'],
['minor', 'v3.0.0-rc.0'],
['preminor', 'v3.0.0-rc.0'],
['premajor', 'v3.0.0-rc.0'],
['prerelease', 'v3.0.0'],
['release', 'v3.0.0'],
])('should not allow a $0 on $1', (type, version) => {
expect(pump(version, type)).toEqual({
message: expect.stringContaining('Invalid pump'),
exitCode: 1,
});
});
});
});
});
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
@@ -138,7 +137,7 @@ class RemoteAlbumService {
Future<RemoteAlbum> updateAlbum(
String albumId, {
String? name,
Option<String?> description = const Option.none(),
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -1,11 +1,15 @@
import 'dart:convert';
import 'package:diacritic/diacritic.dart' as diacritic;
extension StringExtension on String {
String capitalize() {
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
}
String? get nullIfEmpty => isEmpty ? null : this;
String removeDiacritics() => diacritic.removeDiacritics(this);
}
extension DurationExtension on String {
@@ -197,7 +197,7 @@ class FolderContent extends HookConsumerWidget {
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
"${asset.exifInfo.fileSize != null ? formatBytes(asset.exifInfo.fileSize ?? 0) : ""}${DateFormat.yMMMd().format(asset.createdAt)}",
"${asset.exifInfo.fileSize != null ? "${formatBytes(asset.exifInfo.fileSize ?? 0)}" : ""}${DateFormat.yMMMd().format(asset.createdAt.toLocal())}",
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
),
@@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class DriftCreateAlbumPage extends ConsumerStatefulWidget {
@@ -47,7 +48,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
super.dispose();
}
bool get _canCreateAlbum => albumTitleController.text.isNotEmpty;
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty;
String _getEffectiveTitle() {
return albumTitleController.text.isNotEmpty
@@ -169,25 +170,23 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
onBackgroundTapped();
final title = _getEffectiveTitle().trim();
if (title.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('create_album_title_required'.t()), backgroundColor: context.colorScheme.error),
);
try {
final album = await ref
.read(remoteAlbumProvider.notifier)
.createAlbumWithAssets(
title: title,
description: albumDescriptionController.text.trim(),
assets: selectedAssets,
);
if (album != null && context.mounted) {
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
}
} catch (_) {
if (context.mounted) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t());
}
return;
}
final album = await ref
.read(remoteAlbumProvider.notifier)
.createAlbumWithAssets(
title: title,
description: albumDescriptionController.text.trim(),
assets: selectedAssets,
);
if (album != null && context.mounted) {
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
}
}
@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -64,7 +65,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
data: (people) {
if (_search != null) {
people = people.where((person) {
return person.name.toLowerCase().contains(_search!.toLowerCase());
return person.name.toLowerCase().removeDiacritics().contains(
_search!.toLowerCase().removeDiacritics(),
);
}).toList();
}
return GridView.builder(
@@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dar
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
@@ -248,13 +247,10 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
try {
final newTitle = titleController.text.trim();
final newDescription = descriptionController.text.trim();
final description = newDescription.isEmpty
? const Option<String?>.some(null)
: Option<String?>.some(newDescription);
await ref
.read(remoteAlbumProvider.notifier)
.updateAlbum(widget.album.id, name: newTitle, description: description);
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
if (mounted) {
Navigator.of(
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -100,6 +101,7 @@ class ViewerBottomBar extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
OcrToggleButton(asset: asset),
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
@@ -157,6 +157,55 @@ class _OcrBoxes extends StatelessWidget {
final cx = viewportWidth / 2 + position.dx;
final cy = viewportHeight / 2 + position.dy;
final quads = <List<Offset>>[];
final boxes = <Widget>[];
for (final entry in ocrData.asMap().entries) {
final index = entry.key;
final ocr = entry.value;
// Map normalized image coords (01) to viewport space
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
// Bounding rectangle for hit testing and Positioned placement
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
quads.add([Offset(x1, y1), Offset(x2, y2), Offset(x3, y3), Offset(x4, y4)]);
boxes.add(
_OcrBoxItem(
key: ValueKey(index),
ocr: ocr,
index: index,
isSelected: selectedBoxIndex == index,
points: [
Offset(x1 - minX, y1 - minY),
Offset(x2 - minX, y2 - minY),
Offset(x3 - minX, y3 - minY),
Offset(x4 - minX, y4 - minY),
],
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY,
angle: math.atan2(y2 - y1, x2 - x1),
labelDx: (minX + maxX) / 2 - minX,
labelDy: (minY + maxY) / 2 - minY,
onSelectionChanged: onSelectionChanged,
),
);
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => onSelectionChanged(null),
@@ -165,47 +214,13 @@ class _OcrBoxes extends StatelessWidget {
children: [
// Fills the viewport so taps outside boxes deselect
SizedBox(width: viewportWidth, height: viewportHeight),
...ocrData.asMap().entries.map((entry) {
final index = entry.key;
final ocr = entry.value;
// Map normalized image coords (01) to viewport space
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
// Bounding rectangle for hit testing and Positioned placement
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
return _OcrBoxItem(
key: ValueKey(index),
ocr: ocr,
index: index,
isSelected: selectedBoxIndex == index,
points: [
Offset(x1 - minX, y1 - minY),
Offset(x2 - minX, y2 - minY),
Offset(x3 - minX, y3 - minY),
Offset(x4 - minX, y4 - minY),
],
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY,
angle: math.atan2(y2 - y1, x2 - x1),
labelDx: (minX + maxX) / 2 - minX,
labelDy: (minY + maxY) / 2 - minY,
onSelectionChanged: onSelectionChanged,
);
}),
// Dark scrim with the text boxes punched out
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(painter: _OcrScrimPainter(quads: quads)),
),
),
...boxes,
],
),
),
@@ -307,6 +322,35 @@ class _OcrBoxItem extends StatelessWidget {
}
}
class _OcrScrimPainter extends CustomPainter {
final List<List<Offset>> quads;
const _OcrScrimPainter({required this.quads});
@override
void paint(Canvas canvas, Size size) {
// Fill the whole viewport, then subtract each text quad using the even-odd
// rule so the original image shows through the boxes.
final path = Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size);
for (final quad in quads) {
path
..moveTo(quad[0].dx, quad[0].dy)
..lineTo(quad[1].dx, quad[1].dy)
..lineTo(quad[2].dx, quad[2].dy)
..lineTo(quad[3].dx, quad[3].dy)
..close();
}
canvas.drawPath(path, Paint()..color = Colors.black54);
}
@override
bool shouldRepaint(_OcrScrimPainter oldDelegate) => true;
}
class _OcrBoxPainter extends CustomPainter {
final List<Offset> points;
final bool isSelected;
@@ -322,7 +366,7 @@ class _OcrBoxPainter extends CustomPainter {
..strokeWidth = 2.0;
final fillPaint = Paint()
..color = (isSelected ? colorScheme.primary : colorScheme.secondary).withValues(alpha: 0.1)
..color = isSelected ? colorScheme.primary.withValues(alpha: 0.45) : Colors.transparent
..style = PaintingStyle.fill;
final path = Path()
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
class OcrToggleButton extends ConsumerWidget {
final BaseAsset asset;
const OcrToggleButton({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
return AnimatedSwitcher(
duration: Durations.short4,
child: !hasOcr
? const SizedBox.shrink()
: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 32, bottom: 8),
child: Material(
color: showingOcr ? context.primaryColor : Colors.black.withValues(alpha: 0.4),
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: ref.read(assetViewerProvider.notifier).toggleOcr,
child: const Padding(
padding: EdgeInsets.all(10.0),
child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white),
),
),
),
),
),
);
}
}
@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -36,7 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
@@ -48,15 +46,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
final actions = <Widget>[
if (hasOcr)
IconButton(
icon: Icon(showingOcr ? Icons.text_fields : Icons.text_fields_outlined),
onPressed: ref.read(assetViewerProvider.notifier).toggleOcr,
color: showingOcr ? context.primaryColor : null,
),
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
@@ -1,6 +1,8 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
@@ -14,21 +16,68 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class ArchiveBottomSheet extends ConsumerWidget {
class ArchiveBottomSheet extends ConsumerStatefulWidget {
const ArchiveBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ArchiveBottomSheet> createState() => _ArchiveBottomSheetState();
}
class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
late final DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
if (!context.mounted) {
return;
}
if (!result.success) {
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
return;
}
ImmichToast.show(
context: context,
msg: result.count == 0
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
}
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
maxChildSize: 0.4,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
@@ -48,6 +97,10 @@ class ArchiveBottomSheet extends ConsumerWidget {
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
);
}
}
@@ -30,7 +30,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
style: TextStyle(color: Colors.grey[400], fontSize: 13.0, fontWeight: FontWeight.w500),
),
Text(
df.format(fileCreatedDate),
df.format(fileCreatedDate.toLocal()),
style: const TextStyle(color: Colors.white, fontSize: 15.0, fontWeight: FontWeight.w500),
),
],
@@ -41,7 +41,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
minWidth: 0,
onPressed: () async {
await context.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate));
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate.toLocal()));
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -154,7 +153,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
Future<RemoteAlbum?> updateAlbum(
String albumId, {
String? name,
Option<String?> description = const Option.none(),
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -3,7 +3,6 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart' hide AlbumUserRole;
@@ -72,7 +71,7 @@ class DriftAlbumApiRepository extends ApiRepository {
String albumId,
UserDto owner, {
String? name,
Option<String?> description = const Option.none(),
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -87,7 +86,7 @@ class DriftAlbumApiRepository extends ApiRepository {
albumId,
UpdateAlbumDto(
albumName: name == null ? const Optional.absent() : Optional.present(name),
description: description.toOptional(),
description: description == null ? const Optional.absent() : Optional.present(description),
albumThumbnailAssetId: thumbnailAssetId == null
? const Optional.absent()
: Optional.present(thumbnailAssetId),
@@ -141,7 +141,7 @@ class _ProfileIndicator extends ConsumerWidget {
color: serverInfoState.versionStatus == VersionStatus.error
? context.colorScheme.error
: context.primaryColor,
size: widgetSize / 2,
size: widgetSize / 2 - 3,
semanticLabel: 'new_version_available'.tr(),
),
),
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
@@ -44,16 +45,19 @@ class PeoplePicker extends HookConsumerWidget {
Expanded(
child: people.widgetWhen(
onData: (people) {
final filtered = people
.where(
(person) => person.name.toLowerCase().removeDiacritics().contains(
searchQuery.value.toLowerCase().removeDiacritics(),
),
)
.toList();
return ListView.builder(
shrinkWrap: true,
itemCount: people
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.length,
itemCount: filtered.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final person = people
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList()[index];
final person = filtered[index];
final isSelected = selectedPeople.value.contains(person);
return Padding(
-4
View File
@@ -102,8 +102,6 @@ run = "flutter run"
[tasks."i18n:loader"]
description = "Generate i18n loader"
hide = true
sources = ["i18n/"]
outputs = "lib/generated/codegen_loader.g.dart"
run = [
"dart run easy_localization:generate -S ../i18n",
"dart format lib/generated/codegen_loader.g.dart",
@@ -112,8 +110,6 @@ run = [
[tasks."i18n:keys"]
description = "Generate i18n keys"
hide = true
sources = ["i18n/en.json"]
outputs = "lib/generated/translations.g.dart"
run = [
"dart run bin/generate_keys.dart",
"dart format lib/generated/translations.g.dart",
+2 -2
View File
@@ -54,7 +54,7 @@ class AlbumResponseDto {
/// Album description
String description;
/// End date (latest asset)
/// UTC representation of (local) end date (latest asset)
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@@ -92,7 +92,7 @@ class AlbumResponseDto {
/// Is shared album
bool shared;
/// Start date (earliest asset)
/// UTC representation of (local) start date (earliest asset)
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
+2 -2
View File
@@ -95,9 +95,9 @@ class AssetBulkUpdateDto {
///
Optional<num?> longitude;
/// Rating in range [1-5], or null for unrated
/// Rating in range [1-5] (starred), -1 (rejected), or null (unrated)
///
/// Minimum value: 1
/// Minimum value: -1
/// Maximum value: 5
Optional<int?> rating;
+2 -2
View File
@@ -77,9 +77,9 @@ class UpdateAssetDto {
///
Optional<num?> longitude;
/// Rating in range [1-5], or null for unrated
/// Rating in range [1-5] (starred), -1 (rejected), or null (unrated)
///
/// Minimum value: 1
/// Minimum value: -1
/// Maximum value: 5
Optional<int?> rating;
+8
View File
@@ -354,6 +354,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.3"
diacritic:
dependency: "direct main"
description:
name: diacritic
sha256: "12981945ec38931748836cd76f2b38773118d0baef3c68404bdfde9566147876"
url: "https://pub.dev"
source: hosted
version: "0.1.6"
drift:
dependency: "direct main"
description:
+1
View File
@@ -18,6 +18,7 @@ dependencies:
crop_image: ^1.0.17
crypto: ^3.0.7
device_info_plus: ^12.4.0
diacritic: ^0.1.6
drift: ^2.32.1
drift_sqlite_async: 0.3.1
dynamic_color: ^1.8.1
@@ -18,6 +18,56 @@ void main() {
expect("a:b:c".toDuration(), isNull);
});
});
group('Test removeDiacritics', () {
test('removes acute accents', () {
expect('Amélie'.removeDiacritics(), 'Amelie');
});
test('removes grave accents', () {
expect('À la carte'.removeDiacritics(), 'A la carte');
});
test('removes circumflex', () {
expect('hôpital'.removeDiacritics(), 'hopital');
});
test('removes tilde', () {
expect('São João'.removeDiacritics(), 'Sao Joao');
});
test('removes diaeresis', () => expect('naïve'.removeDiacritics(), 'naive'));
test('removes cedilla', () => expect('ça va'.removeDiacritics(), 'ca va'));
test('handles Hungarian exteded characters (ű/ő)', () {
expect('árvíztűrő tükörfúrógép'.removeDiacritics(), 'arvizturo tukorfurogep');
});
test('handles Polish characters', () {
expect('Jędrzej Łącki'.removeDiacritics(), 'Jedrzej Lacki');
});
test('handles German umlauts', () => expect('Müller'.removeDiacritics(), 'Muller'));
test('handles Nordic characters', () => expect('Göteborg'.removeDiacritics(), 'Goteborg'));
test('handles empty string', () => expect(''.removeDiacritics(), ''));
test('handles string with no diacritics', () {
expect('hello world'.removeDiacritics(), 'hello world');
});
test('handles Ñ/ñ', () => expect('Niño'.removeDiacritics(), 'Nino'));
test('diacritic removal is order-independent', () {
const raw = 'Árvíztűrő';
expect(
raw.toLowerCase().removeDiacritics(),
raw.removeDiacritics().toLowerCase(),
);
});
});
group('Test uniqueConsecutive', () {
test('empty', () {
final a = [];
+8 -18
View File
@@ -16235,7 +16235,7 @@
"type": "string"
},
"endDate": {
"description": "End date (latest asset)",
"description": "UTC representation of (local) end date (latest asset)",
"format": "date-time",
"type": "string"
},
@@ -16264,7 +16264,7 @@
"type": "boolean"
},
"startDate": {
"description": "Start date (earliest asset)",
"description": "UTC representation of (local) start date (earliest asset)",
"format": "date-time",
"type": "string"
},
@@ -16602,9 +16602,9 @@
"type": "number"
},
"rating": {
"description": "Rating in range [1-5], or null for unrated",
"description": "Rating in range [1-5] (starred), -1 (rejected), or null (unrated)",
"maximum": 5,
"minimum": 1,
"minimum": -1,
"nullable": true,
"type": "integer",
"x-immich-history": [
@@ -16616,15 +16616,10 @@
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
},
{
"version": "v3",
"state": "Updated",
"description": "Using -1 as a rating is no longer valid."
"description": "Using 0 as a rating is no longer valid."
}
],
"x-immich-state": "Stable"
@@ -26430,9 +26425,9 @@
"type": "number"
},
"rating": {
"description": "Rating in range [1-5], or null for unrated",
"description": "Rating in range [1-5] (starred), -1 (rejected), or null (unrated)",
"maximum": 5,
"minimum": 1,
"minimum": -1,
"nullable": true,
"type": "integer",
"x-immich-history": [
@@ -26444,15 +26439,10 @@
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
},
{
"version": "v3",
"state": "Updated",
"description": "Using -1 as a rating is no longer valid."
"description": "Using 0 as a rating is no longer valid."
}
],
"x-immich-state": "Stable"
+9 -2
View File
@@ -2,17 +2,24 @@
"name": "immich-monorepo",
"version": "2.7.5",
"description": "Monorepo for Immich",
"type": "module",
"private": true,
"scripts": {
"format": "prettier --cache --check i18n/",
"format:fix": "prettier --cache --write --list-different i18n"
"format:fix": "prettier --cache --write --list-different i18n",
"test": "vitest",
"release": "./misc/release/pump-version.sh",
"pump": "node ./misc/release/pump-wrapper.js"
},
"packageManager": "pnpm@11.4.0",
"engines": {
"pnpm": ">=10.0.0"
},
"devDependencies": {
"@types/node": "^24.12.4",
"prettier": "^3.8.3",
"prettier-plugin-sort-json": "^4.2.0"
"prettier-plugin-sort-json": "^4.2.0",
"semver": "^7.8.1",
"vitest": "^4.1.8"
}
}
+76
View File
@@ -55,6 +55,26 @@
}
],
"uiHints": ["SmartAlbum"]
},
{
"name": "location-smart-album",
"title": "Location-based album",
"description": "Automatically add assets taken in a specific location to an album",
"trigger": "AssetMetadataExtraction",
"steps": [
{
"method": "immich-plugin-core#assetLocationFilter",
"config": { "region": { "city": "Vancouver", "state": "British Columbia", "country": "Canada" } }
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumName": "Vancouver photos & videos",
"albumIds": []
}
}
],
"uiHints": ["SmartAlbum"]
}
],
"methods": [
@@ -107,6 +127,62 @@
},
"uiHints": ["Filter"]
},
{
"name": "assetLocationFilter",
"title": "Filter assets by geolocation",
"description": "Filter assets by where they were taken",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"region": {
"type": "object",
"title": "Region",
"description": "Filter by region name",
"properties": {
"country": {
"type": "string",
"title": "Country",
"description": "Exact name of the country the asset must be taken in"
},
"state": {
"type": "string",
"title": "State/province",
"description": "Exact name of the state/province the asset must be taken in"
},
"city": {
"type": "string",
"title": "City",
"description": "Exact name of the city the asset must be taken in"
}
}
},
"coordinate": {
"type": "object",
"title": "Coordinate",
"description": "Filter by distance to a coordinate",
"properties": {
"latitude": {
"type": "string",
"title": "Latitude",
"description": "GPS latitude of a coordinate which the asset must be close to"
},
"longitude": {
"type": "string",
"title": "Longitude",
"description": "GPS longitude of a coordinate which the asset must be close to"
},
"radius": {
"type": "number",
"title": "Maximum distance",
"description": "How close in kilometres the asset must be to the given point"
}
}
}
}
},
"uiHints": ["Filter"]
},
{
"name": "filterFileType",
"title": "Filter by file type",
+1
View File
@@ -13,6 +13,7 @@ declare module 'main' {
// filters
export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
export function assetLocationFilter(): I32;
// updates
export function assetFavorite(): I32;
+45
View File
@@ -50,6 +50,51 @@ export const assetMissingTimeZoneFilter = () => {
});
};
export const assetLocationFilter = () => {
return wrapper<
WorkflowType.AssetV1,
{
region?: { country?: string; state?: string; city?: string };
coordinate?: { latitude?: string; longitude?: string; radius?: number };
}
>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
}
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
}
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
}
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
});
};
export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true;
+4 -4
View File
@@ -484,7 +484,7 @@ export type AlbumResponseDto = {
createdAt: string;
/** Album description */
description: string;
/** End date (latest asset) */
/** UTC representation of (local) end date (latest asset) */
endDate?: string;
/** Has shared link */
hasSharedLink: boolean;
@@ -497,7 +497,7 @@ export type AlbumResponseDto = {
order?: AssetOrder;
/** Is shared album */
shared: boolean;
/** Start date (earliest asset) */
/** UTC representation of (local) start date (earliest asset) */
startDate?: string;
/** Last update date */
updatedAt: string;
@@ -672,7 +672,7 @@ export type AssetBulkUpdateDto = {
latitude?: number;
/** Longitude coordinate */
longitude?: number;
/** Rating in range [1-5], or null for unrated */
/** Rating in range [1-5] (starred), -1 (rejected), or null (unrated) */
rating?: number | null;
/** Time zone (IANA timezone) */
timeZone?: string;
@@ -919,7 +919,7 @@ export type UpdateAssetDto = {
livePhotoVideoId?: string | null;
/** Longitude coordinate */
longitude?: number;
/** Rating in range [1-5], or null for unrated */
/** Rating in range [1-5] (starred), -1 (rejected), or null (unrated) */
rating?: number | null;
visibility?: AssetVisibility;
};
+230 -218
View File
File diff suppressed because it is too large Load Diff
@@ -240,7 +240,16 @@ describe(AssetController.name, () => {
for (const [test, errors] of [
[{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]],
[{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]],
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=1' }]],
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]],
[
{ rating: 0 },
[
{
path: ['rating'],
message: 'Rating must be -1 (rejected), 15 (starred), or null (unrated); 0 is not valid',
},
],
],
] as const) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
+10 -2
View File
@@ -131,9 +131,17 @@ export const AlbumResponseSchema = z
.optional()
.describe('Last modified asset timestamp'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
startDate: z.string().meta({ format: 'date-time' }).optional().describe('Start date (earliest asset)'),
startDate: z
.string()
.meta({ format: 'date-time' })
.optional()
.describe('UTC representation of (local) start date (earliest asset)'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
endDate: z.string().meta({ format: 'date-time' }).optional().describe('End date (latest asset)'),
endDate: z
.string()
.meta({ format: 'date-time' })
.optional()
.describe('UTC representation of (local) end date (latest asset)'),
isActivityEnabled: z.boolean().describe('Activity feed enabled'),
order: AssetOrderSchema.optional(),
contributorCounts: z.array(ContributorCountResponseSchema).optional(),
+6 -4
View File
@@ -15,16 +15,18 @@ const UpdateAssetBaseSchema = z
longitude: longitudeSchema.optional().describe('Longitude coordinate'),
rating: z
.int()
.min(1)
.min(-1)
.max(5)
.nullish()
.describe('Rating in range [1-5], or null for unrated')
.refine((v) => v !== 0, {
error: 'Rating must be -1 (rejected), 15 (starred), or null (unrated); 0 is not valid',
})
.describe('Rating in range [1-5] (starred), -1 (rejected), or null (unrated)')
.meta({
...new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
.updated('v3', 'Using -1 as a rating is no longer valid.')
.updated('v3', 'Using 0 as a rating is no longer valid.')
.getExtensions(),
}),
description: z.string().optional().describe('Asset description'),
@@ -1,7 +1,5 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = -1;`.execute(db);
export async function up(): Promise<void> {
// await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = -1;`.execute(db);
}
export async function down(): Promise<void> {
@@ -332,4 +332,75 @@ describe('core plugin', () => {
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
});
});
describe('assetLocationFilter', () => {
it('should favorite an asset within a given radius', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, latitude: 49.273_353_221_145_36, longitude: -123.103_871_440_787_64 });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetLocationFilter',
config: { coordinate: { latitude: 49.288_821_679_949_29, longitude: -123.111_153_098_813_7, radius: 2 } },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
it('should not favorite asset outside a given radius', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, latitude: 49.261_266_052_570_35, longitude: -123.248_959_390_781_96 });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetLocationFilter',
config: { coordinate: { latitude: 49.288_821_679_949_29, longitude: -123.111_153_098_813_7, radius: 10 } },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
it('should favorite asset by location name', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, city: 'Vancouver' });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetLocationFilter',
config: { region: { city: 'Vancouver' } },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['misc/**/*.spec.js'],
},
});
@@ -3,15 +3,18 @@
import type { AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
album: AlbumResponseDto;
}
};
let { album }: Props = $props();
const { album }: Props = $props();
const startDate = album.startDate;
</script>
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<span>{getAlbumDateRange(album)}</span>
<span></span>
{#if startDate}
<span>{getAlbumDateRange(startDate, album.endDate ?? startDate)}</span>
<span></span>
{/if}
<span>{$t('items_count', { values: { count: album.assetCount } })}</span>
</span>
@@ -13,9 +13,9 @@
let { asset, isOwner }: Props = $props();
let rating = $derived(asset.exifInfo?.rating || null) as Rating;
let rating = $derived(asset.exifInfo?.rating ?? null) as Rating;
const handleChangeRating = async (rating: number | null) => {
const handleChangeRating = async (rating: Rating) => {
try {
await updateAsset({ id: asset.id, updateAssetDto: { rating } });
} catch (error) {
@@ -7,6 +7,7 @@
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
@@ -37,7 +38,7 @@
let filteredCandidates = $derived(
searchTerm
? candidates.filter((person) => person.name.toLowerCase().includes(searchTerm.toLowerCase()))
? candidates.filter((person) => normalizeSearchString(person.name).includes(normalizeSearchString(searchTerm)))
: candidates,
);
@@ -328,9 +329,9 @@
await assetViewerManager.setAssetId(assetId);
faceManager.clear();
onClose();
} catch (error) {
handleError(error, 'Error tagging face');
} finally {
onClose();
}
};
+6
View File
@@ -34,6 +34,12 @@ export const dateFormats = {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
} satisfies Intl.DateTimeFormatOptions,
albumShort: {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
} satisfies Intl.DateTimeFormatOptions,
settings: {
month: 'short',
+4 -10
View File
@@ -6,7 +6,7 @@
import { mdiStar, mdiStarOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export type Rating = 1 | 2 | 3 | 4 | 5 | null;
export type Rating = -1 | 1 | 2 | 3 | 4 | 5 | null;
interface Props {
count?: number;
@@ -33,6 +33,7 @@
return;
}
ratingSelection = newRating;
onRating(newRating);
};
@@ -70,7 +71,7 @@
<div class="flex flex-row" data-testid="star-container">
{#each { length: count } as _, index (index)}
{@const value = index + 1}
{@const filled = hoverRating === null ? (ratingSelection || 0) >= value : hoverRating >= value}
{@const filled = hoverRating === null ? (ratingSelection ?? 0) >= value : hoverRating >= value}
{@const starId = `${id}-${value}`}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
@@ -102,14 +103,7 @@
</div>
</fieldset>
{#if ratingSelection !== null && !readOnly}
<button
type="button"
onclick={() => {
ratingSelection = null;
handleSelect(ratingSelection);
}}
class="cursor-pointer text-xs text-primary"
>
<button type="button" onclick={() => handleSelect(null)} class="cursor-pointer text-xs text-primary">
{$t('rating_clear')}
</button>
{/if}
@@ -15,7 +15,7 @@ import {
fromTimelinePlainDate,
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
fromISODateTimeUTC,
fromISODateTimeUTCToObject,
getTimes,
setDifference,
type TimelineDateTime,
@@ -190,7 +190,7 @@ export class TimelineMonth {
isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime,
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).setZone('local'),
createdAt: fromISODateTimeUTCToObject(bucketAssets.createdAt[i]),
fileCreatedAt,
ownerId: bucketAssets.ownerId[i],
projectionType: bucketAssets.projectionType[i],
@@ -1,6 +1,7 @@
<script lang="ts">
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { LatLng } from '$lib/types';
import { ConfirmModal } from '@immich/ui';
import { Alert, ConfirmModal } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
@@ -9,11 +10,18 @@
onClose: (confirm: boolean) => void;
};
let { point, assetCount, onClose }: Props = $props();
const { point, assetCount, onClose }: Props = $props();
const hasExistingLocations = $derived(
assetMultiSelectManager.assets.some((asset) => asset.latitude != null || asset.longitude != null),
);
</script>
<ConfirmModal title={$t('confirm')} size="small" confirmColor="primary" {onClose}>
{#snippet prompt()}
{#if hasExistingLocations}
<Alert color="warning" class="mb-4">{$t('some_assets_already_have_a_location_warning')}</Alert>
{/if}
<p>{$t('update_location_action_prompt', { values: { count: assetCount } })}</p>
<p>- {$t('latitude')}: {point.lat}</p>
<p>- {$t('longitude')}: {point.lng}</p>
+4 -1
View File
@@ -3,6 +3,7 @@
import SearchBar from '$lib/elements/SearchBar.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { onMount } from 'svelte';
@@ -24,7 +25,9 @@
const filteredPeople = $derived(
people
.filter((person) => !excludedIds.includes(person.id))
.filter((person) => !searchName || person.name.toLowerCase().includes(searchName.toLowerCase())),
.filter(
(person) => !searchName || normalizeSearchString(person.name).includes(normalizeSearchString(searchName)),
),
);
onMount(async () => {
+78 -44
View File
@@ -1,7 +1,71 @@
import { writable } from 'svelte/store';
import { locale } from '$lib/stores/preferences.store';
import { getAlbumDateRange, getShortDateRange } from './date-time';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
locale.set('en');
});
afterAll(() => {
vi.unstubAllEnvs();
locale.set('en');
});
it('should correctly return long month if start and end date are within the same month', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('January 2022');
});
it('should correctly return month range if start and end date are in separate months within the same year', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan  Feb 2022');
});
it('should correctly return range if start and end date are in separate months and years', () => {
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021  Jan 2022');
});
it('should correctly return long month if start and end date are within the same month, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('January 2022');
});
it('should correctly return long month if start and end date are within the same month, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC-6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('January 2022');
});
it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan  Feb 2022');
});
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021  Jan 2022');
});
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC-6');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021  Jan 2022');
});
it('should use the correct locale to return month range', () => {
locale.set('fr');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('janv.févr. 2022');
});
it('should use the correct locale to return month-year range', () => {
locale.set('fr');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('déc. 2021  janv. 2022');
});
});
describe('getAlbumDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
});
@@ -10,57 +74,27 @@ describe('getShortDateRange', () => {
vi.unstubAllEnvs();
});
it('should correctly return month if start and end date are within the same month', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
it('should work', () => {
expect(getAlbumDateRange('2021-01-01T00:00:00Z', '2021-01-05T00:00:00Z')).toEqual('Jan 1  5, 2021');
});
it('should correctly return month range if start and end date are in separate months within the same year', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
it('should work with a single day range', () => {
expect(getAlbumDateRange('2021-01-01T09:00:00Z', '2021-01-01T10:00:00Z')).toEqual('Jan 1, 2021');
});
it('should correctly return range if start and end date are in separate months and years', () => {
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
});
it('should correctly return month if start and end date are within the same month, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
});
it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
it('should use the proper locale', () => {
locale.set('fr');
expect(getAlbumDateRange('2020-03-26T12:00:00Z', '2021-12-01T00:00:00Z')).toEqual('26 mars 2020  1 déc. 2021');
locale.set('en');
});
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
});
});
describe('getAlbumDate', () => {
beforeAll(() => {
process.env.TZ = 'UTC';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
});
it('should work with only a start date', () => {
expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00Z' })).toEqual('Jan 1, 2021');
});
it('should work with a start and end date', () => {
expect(
getAlbumDateRange({
startDate: '2021-01-01T00:00:00Z',
endDate: '2021-01-05T00:00:00Z',
}),
).toEqual('Jan 1, 2021 - Jan 5, 2021');
});
it('should work with the new date format', () => {
expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021');
expect(getAlbumDateRange('2021-12-01T00:00:00Z', '2022-01-01T00:00:00Z')).toEqual('Dec 1, 2021 Jan 1, 2022');
});
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC-6');
expect(getAlbumDateRange('2021-12-01T00:00:00Z', '2022-01-01T00:00:00Z')).toEqual('Dec 1, 2021  Jan 1, 2022');
});
});
+21 -56
View File
@@ -7,69 +7,34 @@ export function parseUtcDate(date: string) {
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
}
export const getShortDateRange = (startTimestamp: string, endTimestamp: string) => {
const getDateRange = (startTimestamp: string, endTimestamp: string, format: 'short' | 'long') => {
// We don't need to check if the locale is set/nonempty. MDN's Intl docs:
// "If the application doesn't provide a locales argument, or the runtime doesn't have a locale that matches the request, then the runtime's default locale is used."
const userLocale = get(locale);
let startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
let endDate = DateTime.fromISO(endTimestamp).setZone('UTC');
const startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
const endDate = DateTime.fromISO(endTimestamp).setZone('UTC');
if (userLocale) {
startDate = startDate.setLocale(userLocale);
endDate = endDate.setLocale(userLocale);
if (startDate.year === endDate.year && startDate.month === endDate.month && format === 'short') {
return endDate.setLocale(userLocale).toLocaleString({ month: 'long', year: 'numeric' });
}
const endDateLocalized = endDate.toLocaleString({
month: 'short',
year: 'numeric',
});
if (startDate.year === endDate.year) {
if (startDate.month === endDate.month) {
// Same year and month.
// e.g.: aug. 2024
return endDateLocalized;
} else {
// Same year but different month.
// e.g.: jul. - sept. 2024
const startMonthLocalized = startDate.toLocaleString({
month: 'short',
});
return `${startMonthLocalized} - ${endDateLocalized}`;
}
} else {
// Different year.
// e.g.: feb. 2021 - sept. 2024
const startDateLocalized = startDate.toLocaleString({
month: 'short',
year: 'numeric',
});
return `${startDateLocalized} - ${endDateLocalized}`;
}
const formatter = new Intl.DateTimeFormat(
userLocale,
format === 'short' ? dateFormats.albumShort : dateFormats.album,
);
return formatter.formatRange(startDate.toJSDate(), endDate.toJSDate());
};
const formatDate = (date?: string) => {
if (!date) {
return;
}
/**
* Get localized date range in short format like 'Oct Nov 2026', with full month if start and end are the same: 'October 2026'.
* Timestamps are expected to be date-only in UTC.
*/
export const getShortDateRange = (start: string, end: string) => getDateRange(start, end, 'short');
// without timezone
const localDate = date.replace(/Z$/, '').replace(/\+.+$/, '');
return localDate ? new Date(localDate).toLocaleDateString(get(locale), dateFormats.album) : undefined;
};
export const getAlbumDateRange = (album: { startDate?: string; endDate?: string }) => {
const start = formatDate(album.startDate);
const end = formatDate(album.endDate);
if (start && end && start !== end) {
return `${start} - ${end}`;
}
if (start) {
return start;
}
return '';
};
/**
* Get localized date range in long format. Timestamps are expected to be date-only in UTC.
*/
export const getAlbumDateRange = (start: string, end: string) => getDateRange(start, end, 'long');
/**
* Use this to convert from "5pm EST" to "5pm UTC"
+118
View File
@@ -0,0 +1,118 @@
import type { PersonResponseDto } from '@immich/sdk';
import { searchNameLocal } from './person';
const makePerson = (overrides: Partial<PersonResponseDto> = {}): PersonResponseDto => ({
id: 'person-1',
name: 'Amélie',
thumbnailPath: '',
isHidden: false,
birthDate: null,
...overrides,
});
describe('searchNameLocal with single-word names', () => {
it('should find a person by exact name match', () => {
const people = [makePerson({ id: '1', name: 'Amélie' })];
expect(searchNameLocal('Amélie', people, 10)).toEqual([people[0]]);
});
it('should find a person with accent-insensitive search', () => {
const people = [makePerson({ id: '1', name: 'Amélie' })];
expect(searchNameLocal('amelie', people, 10)).toEqual([people[0]]);
});
it('should find a person by prefix match', () => {
const people = [makePerson({ id: '1', name: 'Amélie' })];
expect(searchNameLocal('ame', people, 10)).toEqual([people[0]]);
});
it('should not match partial name where prefix does not match', () => {
const people = [makePerson({ id: '1', name: 'Amélie' })];
expect(searchNameLocal('lie', people, 10)).toEqual([]);
});
it('should be case insensitive', () => {
const people = [makePerson({ id: '1', name: 'AMÉLIE' })];
expect(searchNameLocal('amelie', people, 10)).toEqual([people[0]]);
});
it('should handle Hungarian accented characters', () => {
const people = [makePerson({ id: '1', name: 'Árvíztűrő' })];
expect(searchNameLocal('arvizturo', people, 10)).toEqual([people[0]]);
});
it('should handle Polish accented characters', () => {
const people = [makePerson({ id: '1', name: 'Jędrzej' })];
expect(searchNameLocal('jedrzej', people, 10)).toEqual([people[0]]);
});
it('should handle no matches', () => {
const people = [makePerson({ id: '1', name: 'Amélie' })];
expect(searchNameLocal('xyz', people, 10)).toEqual([]);
});
it('should respect the slice parameter', () => {
const people = [
makePerson({ id: '1', name: 'Amélie' }),
makePerson({ id: '2', name: 'Amadeus' }),
makePerson({ id: '3', name: 'Aminta' }),
];
expect(searchNameLocal('am', people, 2)).toHaveLength(2);
});
});
describe('searchNameLocal with multi-word names', () => {
it('should find a person matching the first name', () => {
const people = [makePerson({ id: '1', name: 'Jean Amélie' })];
expect(searchNameLocal('jean', people, 10)).toEqual([people[0]]);
});
it('should find a person matching the last name with accent insensitivity', () => {
const people = [makePerson({ id: '1', name: 'Amélie Dupont' })];
expect(searchNameLocal('dupont', people, 10)).toEqual([people[0]]);
});
it('should find a person matching any space-separated word', () => {
const people = [makePerson({ id: '1', name: 'Jean Amélie Dupont' })];
expect(searchNameLocal('dupont', people, 10)).toEqual([people[0]]);
expect(searchNameLocal('jean', people, 10)).toEqual([people[0]]);
});
it('should match prefix of any word in a multi-word name', () => {
const people = [makePerson({ id: '1', name: 'Maria João Silva' })];
expect(searchNameLocal('joão', people, 10)).toEqual([people[0]]);
expect(searchNameLocal('joao', people, 10)).toEqual([people[0]]);
expect(searchNameLocal('sil', people, 10)).toEqual([people[0]]);
});
it('should match when search term is a multi-word prefix of the full name', () => {
const people = [makePerson({ id: '1', name: 'Jean Amélie Dupont' })];
expect(searchNameLocal('jean amélie', people, 10)).toEqual([people[0]]);
});
it('should not match when search term does not prefix the full name', () => {
const people = [makePerson({ id: '1', name: 'Jean Amélie' })];
expect(searchNameLocal('jean x', people, 10)).toEqual([]);
});
});
describe('searchNameLocal with personId exclusion', () => {
it('should exclude the person with the given id', () => {
const people = [makePerson({ id: '1', name: 'Amélie' }), makePerson({ id: '2', name: 'Amélie' })];
const result = searchNameLocal('amélie', people, 10, '1');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('2');
});
it('should return empty when only the excluded person matches', () => {
const people = [makePerson({ id: '1', name: 'Amélie' })];
expect(searchNameLocal('amélie', people, 10, '1')).toEqual([]);
});
it('should still exclude when search is accent-insensitive', () => {
const people = [makePerson({ id: '1', name: 'Amélie' }), makePerson({ id: '2', name: 'Amélie' })];
const result = searchNameLocal('amelie', people, 10, '1');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('2');
});
});
+6 -4
View File
@@ -1,6 +1,7 @@
import type { PersonResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { derived } from 'svelte/store';
import { normalizeSearchString } from './string-utils';
export const searchNameLocal = (
name: string,
@@ -8,21 +9,22 @@ export const searchNameLocal = (
slice: number,
personId?: string,
): PersonResponseDto[] => {
const normalizedName = normalizeSearchString(name);
return name.includes(' ')
? people
.filter((person: PersonResponseDto) => {
return personId
? person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== personId
: person.name.toLowerCase().startsWith(name.toLowerCase());
? normalizeSearchString(person.name).startsWith(normalizedName) && person.id !== personId
: normalizeSearchString(person.name).startsWith(normalizedName);
})
.slice(0, slice)
: people
.filter((person: PersonResponseDto) => {
const nameParts = person.name.split(' ');
return personId
? nameParts.some((splitName) => splitName.toLowerCase().startsWith(name.toLowerCase())) &&
? nameParts.some((splitName) => normalizeSearchString(splitName).startsWith(normalizedName)) &&
person.id !== personId
: nameParts.some((splitName) => splitName.toLowerCase().startsWith(name.toLowerCase()));
: nameParts.some((splitName) => normalizeSearchString(splitName).startsWith(normalizedName));
})
.slice(0, slice);
};
+5 -3
View File
@@ -13,6 +13,7 @@
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { websocketEvents } from '$lib/stores/websocket';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { clearQueryParam } from '$lib/utils/navigation';
@@ -237,8 +238,8 @@
potentialMergePeople = people
.filter(
(person: PersonResponseDto) =>
personMerge2?.name.toLowerCase() === person.name.toLowerCase() &&
person.id !== personMerge2.id &&
normalizeSearchString(personMerge2?.name ?? '') === normalizeSearchString(person.name) &&
person.id !== personMerge2?.id &&
person.id !== personMerge1?.id &&
!person.isHidden,
)
@@ -269,8 +270,9 @@
const findPeopleWithSimilarName = async (name: string, personId: string) => {
const searchResult = await searchPerson({ name, withHidden: true });
const normalizedName = normalizeSearchString(name);
return searchResult.find(
(person) => person.name.toLowerCase() === name.toLowerCase() && person.id !== personId && person.name,
(person) => normalizeSearchString(person.name) === normalizedName && person.id !== personId && person.name,
);
};
@@ -36,6 +36,7 @@
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { isExternalUrl } from '$lib/utils/navigation';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
import {
ActionButton,
@@ -236,8 +237,10 @@
const result = await searchPerson({ name: personName, withHidden: true });
const normalizedPersonName = normalizeSearchString(personName);
const existingPerson = result.find(
({ name, id }: PersonResponseDto) => name.toLowerCase() === personName.toLowerCase() && id !== person.id && name,
({ name, id }: PersonResponseDto) =>
normalizeSearchString(name) === normalizedPersonName && id !== person.id && name,
);
if (existingPerson) {
personMerge2 = existingPerson;
@@ -245,8 +248,8 @@
potentialMergePeople = result
.filter(
(person: PersonResponseDto) =>
personMerge2?.name.toLowerCase() === person.name.toLowerCase() &&
person.id !== personMerge2.id &&
normalizeSearchString(personMerge2?.name ?? '') === normalizeSearchString(person.name) &&
person.id !== personMerge2?.id &&
person.id !== personMerge1?.id &&
!person.isHidden,
)