mirror of
https://github.com/immich-app/immich.git
synced 2026-06-13 03:21:45 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 439b7fe3be | |||
| b430f5188b | |||
| b9b1cc2f65 | |||
| 7d198956a6 | |||
| a7b5f81701 | |||
| 5c38373808 | |||
| 1ce961fbb3 | |||
| 4bc411b7c7 | |||
| 11c1025271 | |||
| 8b5385f94b | |||
| d3438cf4a7 | |||
| 6c5c6a1035 | |||
| c928787b3e | |||
| fe9ca4f40a | |||
| a665cec920 | |||
| 568283a8eb | |||
| 54c1fbebde |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
@@ -60,6 +60,7 @@
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"*.js": "${capture}.spec.js,${capture}.mock.js",
|
||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||
},
|
||||
"search.exclude": {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4e
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-core-2_2.34.4+21428_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-opencl-2_2.34.4+21428_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/intel-opencl-icd_26.18.38308.1-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/libigdgmm12_22.10.0_amd64.deb && \
|
||||
dpkg -i *.deb && \
|
||||
rm *.deb && \
|
||||
apt-get remove wget -yqq && \
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#! /usr/bin/env node
|
||||
const { readFileSync, writeFileSync } = require('node:fs');
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const asVersion = (item) => {
|
||||
const { label, url } = item;
|
||||
const [major, minor, patch] = label.substring(1).split('.').map(Number);
|
||||
const [version] = label.substring(1).split('-');
|
||||
const [major, minor, patch] = version.split('.').map(Number);
|
||||
return { major, minor, patch, label, url };
|
||||
};
|
||||
|
||||
@@ -31,7 +32,7 @@ for (const item of versions) {
|
||||
) {
|
||||
versions = versions.filter((item) => item.label !== version.label);
|
||||
console.log(
|
||||
`Removed ${version.label} (replaced with ${lastVersion.label})`
|
||||
`Removed ${version.label} (replaced with ${lastVersion.label})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -41,5 +42,5 @@ for (const item of versions) {
|
||||
|
||||
writeFileSync(
|
||||
filename,
|
||||
JSON.stringify([newVersion, ...versions], null, 2) + '\n'
|
||||
JSON.stringify([newVersion, ...versions], null, 2) + '\n',
|
||||
);
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
#
|
||||
# Pump one or both of the server/mobile versions in appropriate files
|
||||
#
|
||||
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false>
|
||||
# usage: './scripts/pump-version.sh -s <minor|patch|premajor|preminor|prepatch|prerelease> <-m> <true|false>
|
||||
#
|
||||
# examples:
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -s premajor # 1.0.0+50 => 2.0.0-rc.0+50
|
||||
# ./scripts/pump-version.sh -s prerelease # 2.0.0-rc.0+50 => 2.0.0-rc.1+50
|
||||
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
|
||||
#
|
||||
|
||||
SERVER_PUMP="false"
|
||||
@@ -25,31 +27,15 @@ while getopts 's:m:' flag; do
|
||||
esac
|
||||
done
|
||||
|
||||
CURRENT_SERVER=$(jq -r '.version' server/package.json)
|
||||
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
|
||||
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
|
||||
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
|
||||
|
||||
if [[ $SERVER_PUMP == "major" ]]; then
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "minor" ]]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "patch" ]]; then
|
||||
PATCH=$((PATCH + 1))
|
||||
elif [[ $SERVER_PUMP == "false" ]]; then
|
||||
echo 'Skipping Server Pump'
|
||||
else
|
||||
echo 'Expected <major|minor|patch|false> for the server argument'
|
||||
CURRENT_SERVER=$(jq -r '.version' package.json)
|
||||
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
|
||||
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEXT_SERVER=$MAJOR.$MINOR.$PATCH
|
||||
|
||||
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
|
||||
NEXT_MOBILE=$CURRENT_MOBILE
|
||||
|
||||
if [[ $MOBILE_PUMP == "true" ]]; then
|
||||
set $((NEXT_MOBILE++))
|
||||
elif [[ $MOBILE_PUMP == "false" ]]; then
|
||||
@@ -59,15 +45,17 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/sdk
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
|
||||
|
||||
# copy version to open-api spec
|
||||
mise run //:open-api
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { pump } from './pump.js';
|
||||
|
||||
const [versionRaw, type] = process.argv.slice(2);
|
||||
const { message, exitCode } = pump(versionRaw, type);
|
||||
|
||||
console.log(message);
|
||||
process.exit(exitCode);
|
||||
@@ -0,0 +1,105 @@
|
||||
import semver, { SemVer } from 'semver';
|
||||
|
||||
const printUsage = () => {
|
||||
return {
|
||||
message:
|
||||
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
|
||||
exitCode: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const isPrerelease = (version) => version.prerelease.length > 0;
|
||||
|
||||
/**
|
||||
* @param {SemVer} version
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
|
||||
|
||||
/** @param {string} version */
|
||||
const normalize = (version) => {
|
||||
if (version.startsWith('v')) {
|
||||
version = version.slice(1);
|
||||
}
|
||||
|
||||
return version;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} versionRaw
|
||||
* @param {string} type
|
||||
*/
|
||||
export const pump = (versionRaw, type) => {
|
||||
if (!versionRaw) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
versionRaw = normalize(versionRaw);
|
||||
|
||||
const version = semver.parse(versionRaw);
|
||||
if (!version) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
let newVersionRaw;
|
||||
let valid = true;
|
||||
|
||||
switch (type) {
|
||||
case 'patch':
|
||||
case 'prepatch':
|
||||
case 'minor':
|
||||
case 'preminor':
|
||||
case 'premajor': {
|
||||
newVersionRaw = inc(version, type);
|
||||
// can only use while not in a prerelease
|
||||
valid = !isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'prerelease': {
|
||||
newVersionRaw = inc(version, type);
|
||||
// can only use while in a prerelease
|
||||
valid = isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'release': {
|
||||
// drop prerelease part
|
||||
newVersionRaw = `${version.major}.${version.minor}.${version.patch}`;
|
||||
// can only use to promote a prerelease to a release (no version change)
|
||||
valid = isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
return printUsage();
|
||||
}
|
||||
}
|
||||
|
||||
if (!newVersionRaw) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
newVersionRaw = normalize(newVersionRaw);
|
||||
|
||||
const newVersion = semver.parse(newVersionRaw);
|
||||
if (!newVersion) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
const invalidUpgrade =
|
||||
isPrerelease(version) &&
|
||||
!isPrerelease(newVersion) &&
|
||||
(version.major !== newVersion.major ||
|
||||
version.minor !== newVersion.minor ||
|
||||
version.patch !== newVersion.patch);
|
||||
|
||||
if (!valid || invalidUpgrade) {
|
||||
return {
|
||||
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
|
||||
exitCode: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: newVersionRaw, exitCode: 0 };
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pump } from './pump';
|
||||
|
||||
describe(pump.name, () => {
|
||||
describe('usage', () => {
|
||||
it.each([
|
||||
[],
|
||||
['2.7.5'],
|
||||
['2.7.5', 'invalid'],
|
||||
['invalid', 'patch'],
|
||||
['2.7.5', 'major'],
|
||||
])('should not accept $0, $1 as inputs', (version, type) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Usage: '),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitions', () => {
|
||||
const valid = [
|
||||
{
|
||||
name: 'patch',
|
||||
items: [['patch', '2.7.5', '2.7.6']],
|
||||
},
|
||||
{
|
||||
name: 'prepatch',
|
||||
items: [
|
||||
['prepatch', '2.7.5', '2.7.6-rc.0'],
|
||||
['prerelease', '2.7.6-rc.0', '2.7.6-rc.1'],
|
||||
['release', '2.7.6-rc.1', '2.7.6'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'minor',
|
||||
items: [['minor', '2.7.5', '2.8.0']],
|
||||
},
|
||||
{
|
||||
name: 'preminor',
|
||||
items: [
|
||||
['preminor', '2.7.5', '2.8.0-rc.0'],
|
||||
['prerelease', '2.8.0-rc.0', '2.8.0-rc.1'],
|
||||
['release', '2.8.0-rc.1', '2.8.0'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'premajor',
|
||||
items: [
|
||||
['premajor', '2.7.5', '3.0.0-rc.0'],
|
||||
['prerelease', '3.0.0-rc.0', '3.0.0-rc.1'],
|
||||
['release', '3.0.0-rc.1', '3.0.0'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const group of valid) {
|
||||
describe(group.name, () => {
|
||||
it.each(group.items)(
|
||||
'should allow a $0 from $1 to $2',
|
||||
(type, version, next) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: next,
|
||||
exitCode: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('invalid', () => {
|
||||
it.each([
|
||||
['patch', 'v3.0.0-rc.0'],
|
||||
['prepatch', 'v3.0.0-rc.0'],
|
||||
['minor', 'v3.0.0-rc.0'],
|
||||
['preminor', 'v3.0.0-rc.0'],
|
||||
['premajor', 'v3.0.0-rc.0'],
|
||||
['prerelease', 'v3.0.0'],
|
||||
['release', 'v3.0.0'],
|
||||
])('should not allow a $0 on $1', (type, version) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Invalid pump'),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (0–1) to viewport space
|
||||
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
||||
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
|
||||
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
|
||||
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
|
||||
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
|
||||
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
|
||||
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
|
||||
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
|
||||
|
||||
// Bounding rectangle for hit testing and Positioned placement
|
||||
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
|
||||
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
|
||||
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
||||
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
||||
|
||||
quads.add([Offset(x1, y1), Offset(x2, y2), Offset(x3, y3), Offset(x4, y4)]);
|
||||
|
||||
boxes.add(
|
||||
_OcrBoxItem(
|
||||
key: ValueKey(index),
|
||||
ocr: ocr,
|
||||
index: index,
|
||||
isSelected: selectedBoxIndex == index,
|
||||
points: [
|
||||
Offset(x1 - minX, y1 - minY),
|
||||
Offset(x2 - minX, y2 - minY),
|
||||
Offset(x3 - minX, y3 - minY),
|
||||
Offset(x4 - minX, y4 - minY),
|
||||
],
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
angle: math.atan2(y2 - y1, x2 - x1),
|
||||
labelDx: (minX + maxX) / 2 - minX,
|
||||
labelDy: (minY + maxY) / 2 - minY,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => onSelectionChanged(null),
|
||||
@@ -165,47 +214,13 @@ class _OcrBoxes extends StatelessWidget {
|
||||
children: [
|
||||
// Fills the viewport so taps outside boxes deselect
|
||||
SizedBox(width: viewportWidth, height: viewportHeight),
|
||||
...ocrData.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ocr = entry.value;
|
||||
|
||||
// Map normalized image coords (0–1) to viewport space
|
||||
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
||||
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
|
||||
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
|
||||
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
|
||||
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
|
||||
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
|
||||
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
|
||||
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
|
||||
|
||||
// Bounding rectangle for hit testing and Positioned placement
|
||||
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
|
||||
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
|
||||
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
||||
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return _OcrBoxItem(
|
||||
key: ValueKey(index),
|
||||
ocr: ocr,
|
||||
index: index,
|
||||
isSelected: selectedBoxIndex == index,
|
||||
points: [
|
||||
Offset(x1 - minX, y1 - minY),
|
||||
Offset(x2 - minX, y2 - minY),
|
||||
Offset(x3 - minX, y3 - minY),
|
||||
Offset(x4 - minX, y4 - minY),
|
||||
],
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
angle: math.atan2(y2 - y1, x2 - x1),
|
||||
labelDx: (minX + maxX) / 2 - minX,
|
||||
labelDy: (minY + maxY) / 2 - minY,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
);
|
||||
}),
|
||||
// Dark scrim with the text boxes punched out
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(painter: _OcrScrimPainter(quads: quads)),
|
||||
),
|
||||
),
|
||||
...boxes,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -307,6 +322,35 @@ class _OcrBoxItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrScrimPainter extends CustomPainter {
|
||||
final List<List<Offset>> quads;
|
||||
|
||||
const _OcrScrimPainter({required this.quads});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// Fill the whole viewport, then subtract each text quad using the even-odd
|
||||
// rule so the original image shows through the boxes.
|
||||
final path = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size);
|
||||
|
||||
for (final quad in quads) {
|
||||
path
|
||||
..moveTo(quad[0].dx, quad[0].dy)
|
||||
..lineTo(quad[1].dx, quad[1].dy)
|
||||
..lineTo(quad[2].dx, quad[2].dy)
|
||||
..lineTo(quad[3].dx, quad[3].dy)
|
||||
..close();
|
||||
}
|
||||
|
||||
canvas.drawPath(path, Paint()..color = Colors.black54);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_OcrScrimPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
class _OcrBoxPainter extends CustomPainter {
|
||||
final List<Offset> points;
|
||||
final bool isSelected;
|
||||
@@ -322,7 +366,7 @@ class _OcrBoxPainter extends CustomPainter {
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
final fillPaint = Paint()
|
||||
..color = (isSelected ? colorScheme.primary : colorScheme.secondary).withValues(alpha: 0.1)
|
||||
..color = isSelected ? colorScheme.primary.withValues(alpha: 0.45) : Colors.transparent
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final path = Path()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
|
||||
|
||||
class OcrToggleButton extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
const OcrToggleButton({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: !hasOcr
|
||||
? const SizedBox.shrink()
|
||||
: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 32, bottom: 8),
|
||||
child: Material(
|
||||
color: showingOcr ? context.primaryColor : Colors.black.withValues(alpha: 0.4),
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: ref.read(assetViewerProvider.notifier).toggleOcr,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(10.0),
|
||||
child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Generated
+230
-218
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), 1–5 (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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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), 1–5 (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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+6
-3
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user