Compare commits

..

23 Commits

Author SHA1 Message Date
github-actions
3434544864 chore: version v1.135.1 2025-06-19 17:37:39 +00:00
Brandon Wees
269bf4b344 fix: iOS 17.0 target version for widget (#19308) 2025-06-19 17:00:54 +00:00
Zack Pollard
f9435a538b revert: fix(web): wrap long names with textarea (#19305)
Revert "fix(web): wrap long names with textarea (#19301)"

This reverts commit 747a72120e.
2025-06-19 16:28:10 +00:00
Alex
10e2ec2841 chore: skip truncating table in this release (#19300) 2025-06-19 16:11:18 +00:00
Zack Pollard
fe91b44ab9 fix: people ordering incorrect (#19298) 2025-06-19 16:05:03 +00:00
Jin Xuan
747a72120e fix(web): wrap long names with textarea (#19301) 2025-06-19 15:57:54 +00:00
Jason Rasmussen
910661e75c chore: remove unused mocks (#19299) 2025-06-19 10:35:09 -05:00
Alex
c8a135a7ae fix: .find() iterator api combat (#19293)
* fix: .find() iterator api combar

* Update web/src/lib/managers/timeline-manager/month-group.svelte.ts

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-06-19 14:59:14 +00:00
xCJPECKOVERx
08d1cf5bde fix(web): Stack assets in asset-viewer cut off on the left (#19253)
* - move overflow and scrollbar to stack-slideshow inner div

* - format
2025-06-19 09:20:25 -05:00
Alex
3e62497fd0 fix: local network permission (#19285) 2025-06-19 14:18:51 +00:00
Alex
a1bc862a32 chore: post release tasks (#19249) 2025-06-19 09:11:24 -05:00
Alex
75bf3aa1be chore: correct vchord version in docker-compose.yml (#19251) 2025-06-19 09:11:15 -05:00
Dag Stuan
38e68d16f9 fix(web): exit slideshow when exiting fullscreen. (#19247)
Exit slideshow when exiting fullscreen.

Browsers do not send a keyboard event when exiting fullscreen, so if
the user exits fullscreen with the escape key, the slideshow
remains open, requiring another escape key press to close it. Fix this
by listening for the fullscreenchange event and closing the slideshow
when exiting fullscreen.
2025-06-19 14:10:10 +00:00
Daniel Dietzler
caf11fbb96 fix: album asset viewer (#19252) 2025-06-19 09:09:23 -05:00
Mert
f99c6feac5 fix(server): unset prewarm dim parameter (#19271)
unset prewarm dim
2025-06-19 09:04:52 -05:00
Matthew Momjian
5122512f19 fix(docs): REINDEX vchord on upgrade (#19282)
* reindex

* lint

* collapse migrations

* remove title

* reformat
2025-06-19 09:04:18 -05:00
Mert
49ed212af8 fix(server): drop vector indices before updating extension (#19283)
drop indices before updating
2025-06-19 09:03:40 -05:00
Paul Larsen
e29103b69f fix album list CSS margins (#19262) 2025-06-19 14:03:14 +00:00
Min Idzelis
14b771d7c7 fix: devcontainer in codespaces (#19259)
* fix: devcontainer perms

* Fix host based auth

* use path tricks to get to volume mount, but remain compat with current meaning of variables

* eureka, i think

* bit of cleanup
2025-06-19 08:29:22 -05:00
Daniel Dietzler
07aa51638c fix: panning interrupted while moving around the map (#19276) 2025-06-19 11:28:53 +00:00
SGT
0a9a520ed2 feat(server): sql-tools support for class level composite fk (#19242)
* feat: support for class level composite fk

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-06-18 14:30:39 -04:00
Daniel Dietzler
de81006367 fix: album share modal navigation (#19245) 2025-06-18 16:10:35 +00:00
Jason Rasmussen
e0144b4ece feat: backfill album users (#19234) 2025-06-18 10:48:11 -04:00
76 changed files with 1392 additions and 244 deletions

View File

@@ -55,7 +55,7 @@
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored
"UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:upload-devcontainer-volume}",
"UPLOAD_LOCATION": "${localEnv:UPLOAD_LOCATION:./library}",
// Connection secret for postgres. You should change it to a random password
// Please use only the characters `A-Za-z0-9`, without special characters or spaces
"DB_PASSWORD": "${localEnv:DB_PASSWORD:postgres}",

View File

@@ -51,14 +51,19 @@ fix_permissions() {
run_cmd sudo find "${IMMICH_WORKSPACE}/server/upload" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres/*" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres" -exec chown node {} +
run_cmd sudo chown node -R "${IMMICH_WORKSPACE}/.vscode" \
# Change ownership for directories that exist
for dir in "${IMMICH_WORKSPACE}/.vscode" \
"${IMMICH_WORKSPACE}/cli/node_modules" \
"${IMMICH_WORKSPACE}/e2e/node_modules" \
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
"${IMMICH_WORKSPACE}/server/node_modules" \
"${IMMICH_WORKSPACE}/server/dist" \
"${IMMICH_WORKSPACE}/web/node_modules" \
"${IMMICH_WORKSPACE}/web/dist"
"${IMMICH_WORKSPACE}/web/dist"; do
if [ -d "$dir" ]; then
run_cmd sudo chown node -R "$dir"
fi
done
log ""
}

View File

@@ -12,8 +12,8 @@ services:
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
- server_node_modules:/workspaces/immich/server/node_modules
- web_node_modules:/workspaces/immich/web/node_modules
- ${UPLOAD_LOCATION-./Library}/photos:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION-./Library}/photos/upload:/workspaces/immich/server/upload/upload
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/workspaces/immich/server/upload
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/workspaces/immich/server/upload/upload
- /etc/localtime:/etc/localtime:ro
immich-web:
@@ -29,8 +29,9 @@ services:
POSTGRES_USER: ${DB_USERNAME-postgres}
POSTGRES_DB: ${DB_DATABASE_NAME-immich}
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- ${UPLOAD_LOCATION-./Library}/postgres:/var/lib/postgresql/data
POSTGRES_HOST_AUTH_METHOD: md5
volumes:
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
redis:
env_file: !reset []
@@ -42,4 +43,6 @@ volumes:
open_api_node_modules:
server_node_modules:
web_node_modules:
upload-devcontainer-volume:
upload1-devcontainer-volume:
upload2-devcontainer-volume:
postgres-devcontainer-volume:

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.69",
"version": "2.2.70",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.69",
"version": "2.2.70",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"chokidar": "^4.0.3",
@@ -54,7 +54,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.135.0",
"version": "1.135.1",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.69",
"version": "2.2.70",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -16,7 +16,7 @@ name: immich-dev
services:
immich-server:
container_name: immich_server
command: [ '/usr/src/app/bin/immich-dev' ]
command: ['/usr/src/app/bin/immich-dev']
image: immich-server-dev:latest
# extends:
# file: hwaccel.transcoding.yml
@@ -70,7 +70,7 @@ services:
# user: 0:0
build:
context: ../web
command: [ '/usr/src/app/bin/immich-web' ]
command: ['/usr/src/app/bin/immich-web']
env_file:
- .env
ports:
@@ -122,7 +122,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.1-pgvectors0.2.0
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.2-pgvectors0.2.0
env_file:
- .env
environment:

View File

@@ -63,7 +63,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.1-pgvectors0.2.0
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.2-pgvectors0.2.0
env_file:
- .env
environment:
@@ -91,7 +91,7 @@ services:
# add data source for http://immich-prometheus:9090 to get started
immich-grafana:
container_name: immich_grafana
command: [ './run.sh', '-disable-reporting' ]
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.0.1-ubuntu@sha256:65575bb9c761335e2ff30e364f21d38632e3b2e75f5f81d83cc92f44b9bbc055

View File

@@ -56,7 +56,7 @@ services:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.1-pgvectors0.2.0
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.2-pgvectors0.2.0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}

View File

@@ -64,7 +64,13 @@ COMMIT;
### Updating VectorChord
When installing a new version of VectorChord, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vchord UPDATE;`.
When installing a new version of VectorChord, you will need to manually update the extension and reindex by connecting to the Immich database and running:
```
ALTER EXTENSION vchord UPDATE;
REINDEX INDEX face_index;
REINDEX INDEX clip_index;
```
## Migrating to VectorChord
@@ -76,6 +82,8 @@ Support for pgvecto.rs will be dropped in a later release, hence we recommend al
The easiest option is to have both extensions installed during the migration:
<details>
<summary>Migration steps (automatic)</summary>
1. Ensure you still have pgvecto.rs installed
2. Install `pgvector` (`>= 0.7.0, < 1.0.0`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`)
3. [Install VectorChord][vchord-install]
@@ -89,8 +97,12 @@ The easiest option is to have both extensions installed during the migration:
11. Restart the Postgres database
12. Uninstall pgvecto.rs (e.g. `apt-get purge vectors-pg14` on Debian-based environments, replacing `pg14` as appropriate). `pgvector` must remain installed as it provides the data types used by `vchord`
</details>
If it is not possible to have both VectorChord and pgvecto.rs installed at the same time, you can perform the migration with more manual steps:
<details>
<summary>Migration steps (manual)</summary>
1. While pgvecto.rs is still installed, run the following SQL command using psql or your choice of database client. Take note of the number outputted by this command as you will need it later
```sql
@@ -123,14 +135,20 @@ ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512);
5. Start Immich and let it create new indices using VectorChord
</details>
### Migrating from pgvector
<details>
<summary>Migration steps</summary>
1. Ensure you have at least 0.7.0 of pgvector installed. If it is below that, please upgrade it and run the SQL command `ALTER EXTENSION vector UPDATE;` using psql or your choice of database client
2. Follow the Prerequisites to install VectorChord
3. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;`
4. Remove the `DB_VECTOR_EXTENSION=pgvector` environmental variable as it will make Immich still use pgvector if set
5. Start Immich and let it create new indices using VectorChord
</details>
Note that VectorChord itself uses pgvector types, so you should not uninstall pgvector after following these steps.
[vchord-install]: https://docs.vectorchord.ai/vectorchord/getting-started/installation.html

View File

@@ -1,4 +1,8 @@
[
{
"label": "v1.135.1",
"url": "https://v1.135.1.archive.immich.app"
},
{
"label": "v1.135.0",
"url": "https://v1.135.0.archive.immich.app"

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.135.0",
"version": "1.135.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.135.0",
"version": "1.135.1",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -44,7 +44,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.69",
"version": "2.2.70",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -93,7 +93,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.135.0",
"version": "1.135.1",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.135.0",
"version": "1.135.1",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -14,7 +14,11 @@ describe('/people', () => {
let nameAlicePerson: PersonResponseDto;
let nameBobPerson: PersonResponseDto;
let nameCharliePerson: PersonResponseDto;
let nameNullPerson: PersonResponseDto;
let nameNullPerson4Assets: PersonResponseDto;
let nameNullPerson3Assets: PersonResponseDto;
let nameNullPerson1Asset: PersonResponseDto;
let nameBillPersonFavourite: PersonResponseDto;
let nameFreddyPersonFavourite: PersonResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
@@ -27,7 +31,11 @@ describe('/people', () => {
nameCharliePerson,
nameBobPerson,
nameAlicePerson,
nameNullPerson,
nameNullPerson4Assets,
nameNullPerson3Assets,
nameNullPerson1Asset,
nameBillPersonFavourite,
nameFreddyPersonFavourite,
] = await Promise.all([
utils.createPerson(admin.accessToken, {
name: 'visible_person',
@@ -52,11 +60,26 @@ describe('/people', () => {
utils.createPerson(admin.accessToken, {
name: '',
}),
utils.createPerson(admin.accessToken, {
name: '',
}),
utils.createPerson(admin.accessToken, {
name: '',
}),
utils.createPerson(admin.accessToken, {
name: 'Bill',
isFavorite: true,
}),
utils.createPerson(admin.accessToken, {
name: 'Freddy',
isFavorite: true,
}),
]);
const asset1 = await utils.createAsset(admin.accessToken);
const asset2 = await utils.createAsset(admin.accessToken);
const asset3 = await utils.createAsset(admin.accessToken);
const asset4 = await utils.createAsset(admin.accessToken);
await Promise.all([
utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
@@ -64,15 +87,27 @@ describe('/people', () => {
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), // 4 assets
// Named persons
utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset
utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }),
utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets
utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset
// Null-named person
utils.createFace({ assetId: asset1.id, personId: nameNullPerson.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson.id }), // 2 assets
// Null-named person 4 assets
utils.createFace({ assetId: asset1.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset3.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset4.id, personId: nameNullPerson4Assets.id }), // 4 assets
// Null-named person 3 assets
utils.createFace({ assetId: asset1.id, personId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset3.id, personId: nameNullPerson3Assets.id }), // 3 assets
// Null-named person 1 asset
utils.createFace({ assetId: asset3.id, personId: nameNullPerson1Asset.id }),
// Favourite People
utils.createFace({ assetId: asset1.id, personId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset2.id, personId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset1.id, personId: nameBillPersonFavourite.id }),
]);
});
@@ -87,15 +122,19 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: false,
total: 7,
total: 11,
hidden: 1,
people: [
expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'Bob' }),
expect.objectContaining({ name: 'Bill' }),
expect.objectContaining({ name: 'Freddy' }),
expect.objectContaining({ name: 'Alice' }),
expect.objectContaining({ name: 'Bob' }),
expect.objectContaining({ name: 'Charlie' }),
expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ name: 'hidden_person' }),
expect.objectContaining({ id: nameNullPerson4Assets.id, name: '' }),
expect.objectContaining({ id: nameNullPerson3Assets.id, name: '' }),
expect.objectContaining({ name: 'hidden_person' }), // Should really be before the null names
],
});
});
@@ -105,17 +144,21 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body.hasNextPage).toBe(false);
expect(body.total).toBe(7); // All persons
expect(body.total).toBe(11); // All persons
expect(body.hidden).toBe(1); // 'hidden_person'
const people = body.people as PersonResponseDto[];
expect(people.map((p) => p.id)).toEqual([
multipleAssetsPerson.id, // name: 'multiple_assets_person', count: 3
nameBobPerson.id, // name: 'Bob', count: 2
nameBillPersonFavourite.id, // name: 'Bill', count: 2
nameFreddyPersonFavourite.id, // name: 'Freddy', count: 2
nameAlicePerson.id, // name: 'Alice', count: 1
nameBobPerson.id, // name: 'Bob', count: 2
nameCharliePerson.id, // name: 'Charlie', count: 1
multipleAssetsPerson.id, // name: 'multiple_assets_person', count: 3
visiblePerson.id, // name: 'visible_person', count: 1
nameNullPerson4Assets.id, // name: '', count: 4
nameNullPerson3Assets.id, // name: '', count: 3
]);
expect(people.some((p) => p.id === hiddenPerson.id)).toBe(false);
@@ -127,14 +170,18 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: false,
total: 7,
total: 11,
hidden: 1,
people: [
expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'Bob' }),
expect.objectContaining({ name: 'Bill' }),
expect.objectContaining({ name: 'Freddy' }),
expect.objectContaining({ name: 'Alice' }),
expect.objectContaining({ name: 'Bob' }),
expect.objectContaining({ name: 'Charlie' }),
expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ id: nameNullPerson4Assets.id, name: '' }),
expect.objectContaining({ id: nameNullPerson3Assets.id, name: '' }),
],
});
});
@@ -148,9 +195,9 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: true,
total: 7,
total: 11,
hidden: 1,
people: [expect.objectContaining({ name: 'visible_person' })],
people: [expect.objectContaining({ name: 'Charlie' })],
});
});
});

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 201,
"android.injected.version.name" => "1.135.0",
"android.injected.version.code" => 202,
"android.injected.version.name" => "1.135.1",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -117,8 +117,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -346,6 +344,7 @@
};
F0B57D372DF764BD00DC5BCC = {
CreatedOnToolsVersion = 16.4;
ProvisioningStyle = Automatic;
};
FAC6F88F2D287C890078CB2F = {
CreatedOnToolsVersion = 16.0;
@@ -472,10 +471,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -504,10 +507,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -648,7 +655,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 208;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -792,7 +799,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 208;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -822,7 +829,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 208;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -856,7 +863,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 209;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -864,7 +871,7 @@
INFOPLIST_FILE = WidgetExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Widget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -899,7 +906,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 209;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -907,7 +914,7 @@
INFOPLIST_FILE = WidgetExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Widget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -939,7 +946,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 209;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -947,7 +954,7 @@
INFOPLIST_FILE = WidgetExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Widget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -978,7 +985,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 208;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1022,7 +1029,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 208;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1063,7 +1070,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 208;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.134.0</string>
<string>1.135.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>208</string>
<string>209</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
@@ -115,8 +115,8 @@
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
@@ -168,5 +168,8 @@
<true />
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string>
</dict>
</plist>
</plist>

View File

@@ -43,7 +43,7 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
"Choose an album to show images from"
}
@Parameter(title: "Album", default: NO_ALBUM)
@Parameter(title: "Album")
var album: Album?
@Parameter(title: "Show Album Name", default: false)

View File

@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.135.0"
version_number: "1.135.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -57,7 +57,13 @@ Future<void> migrateDatabaseIfNeeded(Isar db) async {
}
final shouldTruncate = version < 8 || version < targetVersion;
if (shouldTruncate) {
if (targetVersion == 12) {
await Store.put(StoreKey.version, targetVersion);
return;
}
await _migrateTo(db, targetVersion);
}
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.135.0
- API version: 1.135.1
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -38,6 +38,7 @@ class SyncEntityType {
static const albumV1 = SyncEntityType._(r'AlbumV1');
static const albumDeleteV1 = SyncEntityType._(r'AlbumDeleteV1');
static const albumUserV1 = SyncEntityType._(r'AlbumUserV1');
static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1');
static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1');
static const syncAckV1 = SyncEntityType._(r'SyncAckV1');
@@ -58,6 +59,7 @@ class SyncEntityType {
albumV1,
albumDeleteV1,
albumUserV1,
albumUserBackfillV1,
albumUserDeleteV1,
syncAckV1,
];
@@ -113,6 +115,7 @@ class SyncEntityTypeTypeTransformer {
case r'AlbumV1': return SyncEntityType.albumV1;
case r'AlbumDeleteV1': return SyncEntityType.albumDeleteV1;
case r'AlbumUserV1': return SyncEntityType.albumUserV1;
case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1;
case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1;
case r'SyncAckV1': return SyncEntityType.syncAckV1;
default:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.135.0+201
version: 1.135.1+202
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@@ -8503,7 +8503,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.135.0",
"version": "1.135.1",
"contact": {}
},
"tags": [],
@@ -13706,6 +13706,7 @@
"AlbumV1",
"AlbumDeleteV1",
"AlbumUserV1",
"AlbumUserBackfillV1",
"AlbumUserDeleteV1",
"SyncAckV1"
],

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.135.0",
"version": "1.135.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.135.0",
"version": "1.135.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.135.0",
"version": "1.135.1",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.135.0
* 1.135.1
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -4059,6 +4059,7 @@ export enum SyncEntityType {
AlbumV1 = "AlbumV1",
AlbumDeleteV1 = "AlbumDeleteV1",
AlbumUserV1 = "AlbumUserV1",
AlbumUserBackfillV1 = "AlbumUserBackfillV1",
AlbumUserDeleteV1 = "AlbumUserDeleteV1",
SyncAckV1 = "SyncAckV1"
}

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.135.0",
"version": "1.135.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.135.0",
"version": "1.135.1",
"hasInstallScript": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.135.0",
"version": "1.135.1",
"description": "",
"author": "",
"private": true,

View File

@@ -355,6 +355,12 @@ export const columns = {
'updateId',
'duration',
],
syncAlbumUser: [
'albums_shared_users_users.albumsId as albumId',
'albums_shared_users_users.usersId as userId',
'albums_shared_users_users.role',
'albums_shared_users_users.updateId',
],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
syncAssetExif: [
'exif.assetId',

4
server/src/db.d.ts vendored
View File

@@ -98,8 +98,10 @@ export interface AlbumsSharedUsersUsers {
albumsId: string;
role: Generated<AlbumUserRole>;
usersId: string;
updatedAt: Generated<Timestamp>;
createId: Generated<string>;
createdAt: Generated<Timestamp>;
updateId: Generated<string>;
updatedAt: Generated<Timestamp>;
}
export interface ApiKeys {

View File

@@ -161,6 +161,7 @@ export type SyncItem = {
[SyncEntityType.AlbumV1]: SyncAlbumV1;
[SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1;
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
[SyncEntityType.SyncAckV1]: object;
};

View File

@@ -603,6 +603,7 @@ export enum SyncEntityType {
AlbumV1 = 'AlbumV1',
AlbumDeleteV1 = 'AlbumDeleteV1',
AlbumUserV1 = 'AlbumUserV1',
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
SyncAckV1 = 'SyncAckV1',

View File

@@ -12,6 +12,37 @@ delete from "person"
where
"person"."id" in ($1)
-- PersonRepository.getAllForUser
select
"person".*
from
"person"
inner join "asset_faces" on "asset_faces"."personId" = "person"."id"
inner join "assets" on "asset_faces"."assetId" = "assets"."id"
and "assets"."visibility" = 'timeline'
and "assets"."deletedAt" is null
where
"person"."ownerId" = $1
and "asset_faces"."deletedAt" is null
and "person"."isHidden" = $2
group by
"person"."id"
having
(
"person"."name" != $3
or count("asset_faces"."assetId") >= $4
)
order by
"person"."isHidden" asc,
"person"."isFavorite" desc,
NULLIF(person.name, '') asc nulls last,
count("asset_faces"."assetId") desc,
"person"."createdAt"
limit
$5
offset
$6
-- PersonRepository.getAllWithoutFaces
select
"person".*

View File

@@ -394,6 +394,35 @@ where
order by
"id" asc
-- SyncRepository.getAlbumBackfill
select
"albumsId" as "id",
"createId"
from
"albums_shared_users_users"
where
"usersId" = $1
and "createId" >= $2
and "createdAt" < now() - interval '1 millisecond'
order by
"createId" asc
-- SyncRepository.getAlbumUsersBackfill
select
"albums_shared_users_users"."albumsId" as "albumId",
"albums_shared_users_users"."usersId" as "userId",
"albums_shared_users_users"."role",
"albums_shared_users_users"."updateId"
from
"albums_shared_users_users"
where
"albumsId" = $1
and "updatedAt" < now() - interval '1 millisecond'
and "updateId" < $2
and "updateId" >= $3
order by
"updateId" asc
-- SyncRepository.getAlbumUserUpserts
select
"albums_shared_users_users"."albumsId" as "albumId",

View File

@@ -119,8 +119,6 @@ export class DatabaseRepository {
await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)} CASCADE`.execute(this.db);
if (extension === DatabaseExtension.VECTORCHORD) {
const dbName = sql.id(await this.getDatabaseName());
await sql`ALTER DATABASE ${dbName} SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db);
await sql`SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db);
await sql`ALTER DATABASE ${dbName} SET vchordrq.probes = 1`.execute(this.db);
await sql`SET vchordrq.probes = 1`.execute(this.db);
}
@@ -142,21 +140,29 @@ export class DatabaseRepository {
}
targetVersion ??= availableVersion;
const isVectors = extension === DatabaseExtension.VECTORS;
let restartRequired = false;
const diff = semver.diff(installedVersion, targetVersion);
if (!diff) {
return { restartRequired: false };
}
await Promise.all([
this.db.schema.dropIndex(VectorIndex.CLIP).ifExists().execute(),
this.db.schema.dropIndex(VectorIndex.FACE).ifExists().execute(),
]);
await this.db.transaction().execute(async (tx) => {
await this.setSearchPath(tx);
await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx);
if (isVectors && (diff === 'major' || diff === 'minor')) {
if (extension === DatabaseExtension.VECTORS && (diff === 'major' || diff === 'minor')) {
await sql`SELECT pgvectors_upgrade()`.execute(tx);
restartRequired = true;
}
});
if (diff && !restartRequired) {
if (!restartRequired) {
await Promise.all([this.reindexVectors(VectorIndex.CLIP), this.reindexVectors(VectorIndex.FACE)]);
}

View File

@@ -138,6 +138,7 @@ export class PersonRepository {
.stream();
}
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
const items = await this.db
.selectFrom('person')
@@ -179,8 +180,8 @@ export class PersonRepository {
)
.$if(!options?.closestFaceAssetId, (qb) =>
qb
.orderBy(sql`NULLIF(person.name, '')`, (om) => om.asc().nullsLast())
.orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc')
.orderBy(sql`NULLIF(person.name, '') asc nulls last`)
.orderBy('person.createdAt'),
)
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))

View File

@@ -254,16 +254,36 @@ export class SyncRepository {
.stream();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getAlbumBackfill(userId: string, afterCreateId?: string) {
return this.db
.selectFrom('albums_shared_users_users')
.select(['albumsId as id', 'createId'])
.where('usersId', '=', userId)
.$if(!!afterCreateId, (qb) => qb.where('createId', '>=', afterCreateId!))
.where('createdAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.orderBy('createId', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getAlbumUsersBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('albums_shared_users_users')
.select(columns.syncAlbumUser)
.where('albumsId', '=', albumId)
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('updateId', '<', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('updateId', '>=', afterUpdateId!))
.orderBy('updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumUserUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('albums_shared_users_users')
.select([
'albums_shared_users_users.albumsId as albumId',
'albums_shared_users_users.usersId as userId',
'albums_shared_users_users.role',
'albums_shared_users_users.updateId',
])
.select(columns.syncAlbumUser)
.where('albums_shared_users_users.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('albums_shared_users_users.updateId', '>', ack!.updateId))
.orderBy('albums_shared_users_users.updateId', 'asc')

View File

@@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "albums_shared_users_users" ADD "createId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`ALTER TABLE "albums_shared_users_users" ADD "createdAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`CREATE INDEX "IDX_album_users_create_id" ON "albums_shared_users_users" ("createId")`.execute(db);
await sql`CREATE INDEX "IDX_partners_create_id" ON "partners" ("createId")`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_partners_create_id";`.execute(db);
await sql`DROP INDEX "IDX_album_users_create_id";`.execute(db);
await sql`ALTER TABLE "albums_shared_users_users" DROP COLUMN "createId";`.execute(db);
await sql`ALTER TABLE "albums_shared_users_users" DROP COLUMN "createdAt";`.execute(db);
}

View File

@@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
const { rows } = await sql<{ db: string }>`SELECT current_database() as db;`.execute(db);
const databaseName = rows[0].db;
await sql.raw(`ALTER DATABASE "${databaseName}" RESET vchordrq.prewarm_dim;`).execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
const { rows } = await sql<{ db: string }>`SELECT current_database() as db;`.execute(db);
const databaseName = rows[0].db;
await sql
.raw(`ALTER DATABASE "${databaseName}" SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536';`)
.execute(db);
}

View File

@@ -1,4 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_after_insert, album_users_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
@@ -7,6 +7,7 @@ import {
AfterDeleteTrigger,
AfterInsertTrigger,
Column,
CreateDateColumn,
ForeignKeyColumn,
Index,
Table,
@@ -51,6 +52,12 @@ export class AlbumUserTable {
@Column({ type: 'character varying', default: AlbumUserRole.EDITOR })
role!: AlbumUserRole;
@CreateIdColumn({ indexName: 'IDX_album_users_create_id' })
createId?: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateIdColumn({ indexName: 'IDX_album_users_update_id' })
updateId?: string;

View File

@@ -27,7 +27,7 @@ export class PartnerTable {
@CreateDateColumn()
createdAt!: Date;
@CreateIdColumn()
@CreateIdColumn({ indexName: 'IDX_partners_create_id' })
createId!: string;
@UpdateDateColumn()

View File

@@ -151,11 +151,11 @@ describe(SearchService.name, () => {
},
});
const id = assetStub.livePhotoMotionAsset.id;
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
const result = await sut.handleSearchDuplicates({ id });
expect(result).toBe(JobStatus.SKIPPED);
expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled();
});
it('should skip if duplicate detection is disabled', async () => {
@@ -168,11 +168,11 @@ describe(SearchService.name, () => {
},
});
const id = assetStub.livePhotoMotionAsset.id;
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
const result = await sut.handleSearchDuplicates({ id });
expect(result).toBe(JobStatus.SKIPPED);
expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled();
});
it('should fail if asset is not found', async () => {

View File

@@ -1113,8 +1113,6 @@ describe(LibraryService.name, () => {
mocks.library.get.mockResolvedValue(library);
mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.image1]));
mocks.asset.getById.mockResolvedValue(assetStub.image1);
await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.SUCCESS);
});
});

View File

@@ -268,7 +268,7 @@ describe(NotificationService.name, () => {
mocks.album.getById.mockResolvedValue(albumStub.empty);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.asset.getById).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should skip if the recipient has email notifications disabled', async () => {

View File

@@ -138,14 +138,14 @@ export class SyncService extends BaseService {
break;
}
case SyncRequestType.PartnerAssetsV1: {
await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId);
case SyncRequestType.AssetExifsV1: {
await this.syncAssetExifsV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.AssetExifsV1: {
await this.syncAssetExifsV1(response, checkpointMap, auth);
case SyncRequestType.PartnerAssetsV1: {
await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId);
break;
}
@@ -160,7 +160,7 @@ export class SyncService extends BaseService {
}
case SyncRequestType.AlbumUsersV1: {
await this.syncAlbumUsersV1(response, checkpointMap, auth);
await this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId);
break;
}
@@ -330,18 +330,50 @@ export class SyncService extends BaseService {
}
}
private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deletes = this.syncRepository.getAlbumUserDeletes(
auth.user.id,
checkpointMap[SyncEntityType.AlbumUserDeleteV1],
);
private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) {
const backfillType = SyncEntityType.AlbumUserBackfillV1;
const upsertType = SyncEntityType.AlbumUserV1;
const deleteType = SyncEntityType.AlbumUserDeleteV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const deletes = this.syncRepository.getAlbumUserDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.AlbumUserDeleteV1, ids: [id], data });
send(response, { type: deleteType, ids: [id], data });
}
const upserts = this.syncRepository.getAlbumUserUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumUserV1]);
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
for (const album of albums) {
if (isEntityBackfillComplete(album, backfillCheckpoint)) {
continue;
}
const startId = getStartId(album, backfillCheckpoint);
const backfill = this.syncRepository.getAlbumUsersBackfill(album.id, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [updateId], data });
}
sendEntityBackfillCompleteAck(response, backfillType, album.id);
}
} else if (albums.length > 0) {
await this.upsertBackfillCheckpoint({
type: backfillType,
sessionId,
createId: albums.at(-1)!.createId,
});
}
const upserts = this.syncRepository.getAlbumUserUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.AlbumUserV1, ids: [updateId], data });
send(response, { type: upsertType, ids: [updateId], data });
}
}

View File

@@ -1,11 +1,10 @@
import { ColumnBaseOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
import { ForeignKeyAction } from 'src/sql-tools/from-code/decorators/foreign-key-constraint.decorator';
import { register } from 'src/sql-tools/from-code/register';
type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
export type ForeignKeyColumnOptions = ColumnBaseOptions & {
onUpdate?: Action;
onDelete?: Action;
onUpdate?: ForeignKeyAction;
onDelete?: ForeignKeyAction;
constraintName?: string;
};

View File

@@ -0,0 +1,23 @@
import { register } from 'src/sql-tools/from-code/register';
export type ForeignKeyAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
export type ForeignKeyConstraintOptions = {
name?: string;
index?: boolean;
indexName?: string;
columns: string[];
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
referenceTable: () => Function;
referenceColumns?: string[];
onUpdate?: ForeignKeyAction;
onDelete?: ForeignKeyAction;
synchronize?: boolean;
};
export const ForeignKeyConstraint = (options: ForeignKeyConstraintOptions): ClassDecorator => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
return (target: Function) => {
register({ type: 'foreignKeyConstraint', item: { object: target, options } });
};
};

View File

@@ -5,7 +5,8 @@ import { processConfigurationParameters } from 'src/sql-tools/from-code/processo
import { processDatabases } from 'src/sql-tools/from-code/processors/database.processor';
import { processEnums } from 'src/sql-tools/from-code/processors/enum.processor';
import { processExtensions } from 'src/sql-tools/from-code/processors/extension.processor';
import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constriant.processor';
import { processForeignKeyColumns } from 'src/sql-tools/from-code/processors/foreign-key-column.processor';
import { processForeignKeyConstraints } from 'src/sql-tools/from-code/processors/foreign-key-constraint.processor';
import { processFunctions } from 'src/sql-tools/from-code/processors/function.processor';
import { processIndexes } from 'src/sql-tools/from-code/processors/index.processor';
import { processPrimaryKeyConstraints } from 'src/sql-tools/from-code/processors/primary-key-contraint.processor';
@@ -32,10 +33,11 @@ const processors: Processor[] = [
processFunctions,
processTables,
processColumns,
processForeignKeyColumns,
processForeignKeyConstraints,
processUniqueConstraints,
processCheckConstraints,
processPrimaryKeyConstraints,
processForeignKeyConstraints,
processIndexes,
processTriggers,
];

View File

@@ -1,7 +1,7 @@
import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
import { asMetadataKey, fromColumnValue } from 'src/sql-tools/helpers';
import { addWarning, asMetadataKey, fromColumnValue } from 'src/sql-tools/helpers';
import { DatabaseColumn } from 'src/sql-tools/types';
export const processColumns: Processor = (builder, items) => {
@@ -81,7 +81,7 @@ export const onMissingColumn = (
propertyName?: symbol | string,
) => {
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
builder.warnings.push(`[${context}] Unable to find column (${label})`);
addWarning(builder, context, `Unable to find column (${label})`);
};
const METADATA_KEY = asMetadataKey('table-metadata');

View File

@@ -1,10 +1,10 @@
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor } from 'src/sql-tools/from-code/processors/type';
import { asKey } from 'src/sql-tools/helpers';
import { asForeignKeyConstraintName, asKey } from 'src/sql-tools/helpers';
import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types';
export const processForeignKeyConstraints: Processor = (builder, items) => {
export const processForeignKeyColumns: Processor = (builder, items) => {
for (const {
item: { object, propertyName, options, target },
} of items.filter((item) => item.type === 'foreignKeyColumn')) {
@@ -34,13 +34,16 @@ export const processForeignKeyConstraints: Processor = (builder, items) => {
column.type = referenceColumns[0].type;
}
const referenceColumnNames = referenceColumns.map((column) => column.name);
const name = options.constraintName || asForeignKeyConstraintName(table.name, columnNames);
table.constraints.push({
name: options.constraintName || asForeignKeyConstraintName(table.name, columnNames),
name,
tableName: table.name,
columnNames,
type: DatabaseConstraintType.FOREIGN_KEY,
referenceTableName: referenceTable.name,
referenceColumnNames: referenceColumns.map((column) => column.name),
referenceColumnNames,
onUpdate: options.onUpdate as DatabaseActionType,
onDelete: options.onDelete as DatabaseActionType,
synchronize: options.synchronize ?? true,
@@ -58,5 +61,4 @@ export const processForeignKeyConstraints: Processor = (builder, items) => {
}
};
const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns);
const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns);

View File

@@ -0,0 +1,86 @@
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor } from 'src/sql-tools/from-code/processors/type';
import { addWarning, asForeignKeyConstraintName, asIndexName } from 'src/sql-tools/helpers';
import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types';
export const processForeignKeyConstraints: Processor = (builder, items, config) => {
for (const {
item: { object, options },
} of items.filter((item) => item.type === 'foreignKeyConstraint')) {
const table = resolveTable(builder, object);
if (!table) {
onMissingTable(builder, '@ForeignKeyConstraint', { name: 'referenceTable' });
continue;
}
const referenceTable = resolveTable(builder, options.referenceTable());
if (!referenceTable) {
const referenceTableName = options.referenceTable()?.name;
addWarning(
builder,
'@ForeignKeyConstraint.referenceTable',
`Unable to find table` + (referenceTableName ? ` (${referenceTableName})` : ''),
);
continue;
}
let missingColumn = false;
for (const columnName of options.columns) {
if (!table.columns.some(({ name }) => name === columnName)) {
addWarning(
builder,
'@ForeignKeyConstraint.columns',
`Unable to find column (${table.metadata.object.name}.${columnName})`,
);
missingColumn = true;
}
}
for (const columnName of options.referenceColumns || []) {
if (!referenceTable.columns.some(({ name }) => name === columnName)) {
addWarning(
builder,
'@ForeignKeyConstraint.referenceColumns',
`Unable to find column (${referenceTable.metadata.object.name}.${columnName})`,
);
missingColumn = true;
}
}
if (missingColumn) {
continue;
}
const referenceColumns =
options.referenceColumns || referenceTable.columns.filter(({ primary }) => primary).map(({ name }) => name);
const name = options.name || asForeignKeyConstraintName(table.name, options.columns);
table.constraints.push({
type: DatabaseConstraintType.FOREIGN_KEY,
name,
tableName: table.name,
columnNames: options.columns,
referenceTableName: referenceTable.name,
referenceColumnNames: referenceColumns,
onUpdate: options.onUpdate as DatabaseActionType,
onDelete: options.onDelete as DatabaseActionType,
synchronize: options.synchronize ?? true,
});
if (options.index === false) {
continue;
}
if (options.index || options.indexName || config.createForeignKeyIndexes) {
table.indexes.push({
name: options.indexName || asIndexName(table.name, options.columns),
tableName: table.name,
columnNames: options.columns,
unique: false,
synchronize: options.synchronize ?? true,
});
}
}
};

View File

@@ -1,7 +1,7 @@
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
import { Processor } from 'src/sql-tools/from-code/processors/type';
import { asKey } from 'src/sql-tools/helpers';
import { asIndexName } from 'src/sql-tools/helpers';
export const processIndexes: Processor = (builder, items, config) => {
for (const {
@@ -75,16 +75,3 @@ export const processIndexes: Processor = (builder, items, config) => {
});
}
};
const asIndexName = (table: string, columns?: string[], where?: string) => {
const items: string[] = [];
for (const columnName of columns ?? []) {
items.push(columnName);
}
if (where) {
items.push(where);
}
return asKey('IDX_', table, items);
};

View File

@@ -1,6 +1,6 @@
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
import { asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers';
import { addWarning, asMetadataKey, asSnakeCase } from 'src/sql-tools/helpers';
export const processTables: Processor = (builder, items) => {
for (const {
@@ -45,7 +45,7 @@ export const onMissingTable = (
propertyName?: symbol | string,
) => {
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
builder.warnings.push(`[${context}] Unable to find table (${label})`);
addWarning(builder, context, `Unable to find table (${label})`);
};
const METADATA_KEY = asMetadataKey('table-metadata');

View File

@@ -4,6 +4,7 @@ import { ConfigurationParameterOptions } from 'src/sql-tools/from-code/decorator
import { DatabaseOptions } from 'src/sql-tools/from-code/decorators/database.decorator';
import { ExtensionOptions } from 'src/sql-tools/from-code/decorators/extension.decorator';
import { ForeignKeyColumnOptions } from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator';
import { ForeignKeyConstraintOptions } from 'src/sql-tools/from-code/decorators/foreign-key-constraint.decorator';
import { IndexOptions } from 'src/sql-tools/from-code/decorators/index.decorator';
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator';
@@ -25,5 +26,6 @@ export type RegisterItem =
| { type: 'trigger'; item: ClassBased<{ options: TriggerOptions }> }
| { type: 'extension'; item: ClassBased<{ options: ExtensionOptions }> }
| { type: 'configurationParameter'; item: ClassBased<{ options: ConfigurationParameterOptions }> }
| { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> };
| { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> }
| { type: 'foreignKeyConstraint'; item: ClassBased<{ options: ForeignKeyConstraintOptions }> };
export type RegisterItemType<T extends RegisterItem['type']> = Extract<RegisterItem, { type: T }>['item'];

View File

@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator';
import { SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
import {
Comparer,
DatabaseColumn,
@@ -211,3 +212,22 @@ export const asColumnComment = (tableName: string, columnName: string, comment:
};
export const asColumnList = (columns: string[]) => columns.map((column) => `"${column}"`).join(', ');
export const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, [...columns]);
export const asIndexName = (table: string, columns?: string[], where?: string) => {
const items: string[] = [];
for (const columnName of columns ?? []) {
items.push(columnName);
}
if (where) {
items.push(where);
}
return asKey('IDX_', table, items);
};
export const addWarning = (builder: SchemaBuilder, context: string, message: string) => {
builder.warnings.push(`[${context}] ${message}`);
};

View File

@@ -12,6 +12,7 @@ export * from 'src/sql-tools/from-code/decorators/delete-date-column.decorator';
export * from 'src/sql-tools/from-code/decorators/extension.decorator';
export * from 'src/sql-tools/from-code/decorators/extensions.decorator';
export * from 'src/sql-tools/from-code/decorators/foreign-key-column.decorator';
export * from 'src/sql-tools/from-code/decorators/foreign-key-constraint.decorator';
export * from 'src/sql-tools/from-code/decorators/generated-column.decorator';
export * from 'src/sql-tools/from-code/decorators/index.decorator';
export * from 'src/sql-tools/from-code/decorators/primary-column.decorator';

View File

@@ -2,7 +2,7 @@ import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
@@ -265,5 +265,95 @@ describe(SyncRequestType.AlbumUsersV1, () => {
},
]);
});
it('should backfill album users when a user shares an album with you', async () => {
const { auth, sut, testSync, getRepository } = await setup();
const albumRepo = getRepository('album');
const albumUserRepo = getRepository('albumUser');
const userRepo = getRepository('user');
const user1 = mediumFactory.userInsert();
const user2 = mediumFactory.userInsert();
await userRepo.create(user1);
await userRepo.create(user2);
const album1 = mediumFactory.albumInsert({ ownerId: user1.id });
const album2 = mediumFactory.albumInsert({ ownerId: user1.id });
await albumRepo.create(album1, [], []);
await albumRepo.create(album2, [], []);
// backfill album user
await albumUserRepo.create({ albumsId: album1.id, usersId: user1.id, role: AlbumUserRole.EDITOR });
await wait(2);
// initial album user
await albumUserRepo.create({ albumsId: album2.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR });
await wait(2);
// post checkpoint album user
await albumUserRepo.create({ albumsId: album1.id, usersId: user2.id, role: AlbumUserRole.EDITOR });
const response = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album2.id,
role: AlbumUserRole.EDITOR,
userId: auth.user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
// ack initial user
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
// get access to the backfill album user
await albumUserRepo.create({ albumsId: album1.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album user
const backfillResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(backfillResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.EDITOR,
userId: user1.id,
}),
type: SyncEntityType.AlbumUserBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumUserBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.EDITOR,
userId: user2.id,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.EDITOR,
userId: auth.user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
]);
await sut.setAcks(auth, { acks: [backfillResponse[1].ack, backfillResponse.at(-1).ack] });
const finalResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
expect(finalResponse).toEqual([]);
});
});
});

View File

@@ -0,0 +1,124 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id1!: string;
@PrimaryColumn({ type: 'uuid' })
id2!: string;
}
@Table()
@ForeignKeyConstraint({
columns: ['parentId1', 'parentId2'],
referenceTable: () => Table1,
referenceColumns: ['id2', 'id1'],
})
export class Table2 {
@Column({ type: 'uuid' })
parentId1!: string;
@Column({ type: 'uuid' })
parentId2!: string;
}
export const description = 'should create a foreign key constraint to the target table';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id1',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
{
name: 'id2',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_e457e8b1301b7bc06ef78188ee4',
tableName: 'table1',
columnNames: ['id1', 'id2'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId1',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
{
name: 'parentId2',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [
{
name: 'IDX_aed36d04470eba20161aa8b1dc',
tableName: 'table2',
columnNames: ['parentId1', 'parentId2'],
unique: false,
synchronize: true,
},
],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_aed36d04470eba20161aa8b1dc6',
tableName: 'table2',
columnNames: ['parentId1', 'parentId2'],
referenceColumnNames: ['id2', 'id1'],
referenceTableName: 'table1',
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

View File

@@ -0,0 +1,78 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId2'], referenceTable: () => Table1 })
export class Table2 {
@Column({ type: 'uuid' })
parentId!: string;
}
export const description = 'should warn against missing column in foreign key constraint';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_b249cc64cf63b8a22557cdc8537',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
],
warnings: ['[@ForeignKeyConstraint.columns] Unable to find column (Table2.parentId2)'],
};

View File

@@ -0,0 +1,78 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, referenceColumns: ['foo'] })
export class Table2 {
@Column({ type: 'uuid' })
parentId!: string;
}
export const description = 'should warn against missing reference column in foreign key constraint';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_b249cc64cf63b8a22557cdc8537',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
],
warnings: ['[@ForeignKeyConstraint.referenceColumns] Unable to find column (Table1.foo)'],
};

View File

@@ -0,0 +1,44 @@
import { Column, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools';
class Foo {}
@Table()
@ForeignKeyConstraint({
columns: ['parentId'],
referenceTable: () => Foo,
})
export class Table1 {
@Column()
parentId!: string;
}
export const description = 'should warn against missing reference table';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'parentId',
tableName: 'table1',
type: 'character varying',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
],
warnings: ['[@ForeignKeyConstraint.referenceTable] Unable to find table (Foo)'],
};

View File

@@ -0,0 +1,120 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id1!: string;
@PrimaryColumn({ type: 'uuid' })
id2!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId1', 'parentId2'], referenceTable: () => Table1 })
export class Table2 {
@Column({ type: 'uuid' })
parentId1!: string;
@Column({ type: 'uuid' })
parentId2!: string;
}
export const description = 'should create a foreign key constraint to the target table';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id1',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
{
name: 'id2',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_e457e8b1301b7bc06ef78188ee4',
tableName: 'table1',
columnNames: ['id1', 'id2'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId1',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
{
name: 'parentId2',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [
{
name: 'IDX_aed36d04470eba20161aa8b1dc',
tableName: 'table2',
columnNames: ['parentId1', 'parentId2'],
unique: false,
synchronize: true,
},
],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_aed36d04470eba20161aa8b1dc6',
tableName: 'table2',
columnNames: ['parentId1', 'parentId2'],
referenceColumnNames: ['id1', 'id2'],
referenceTableName: 'table1',
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

View File

@@ -0,0 +1,88 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1, index: false })
export class Table2 {
@Column({ type: 'uuid' })
parentId!: string;
}
export const description = 'should create a foreign key constraint to the target table without an index';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_b249cc64cf63b8a22557cdc8537',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_3fcca5cc563abf256fc346e3ff4',
tableName: 'table2',
columnNames: ['parentId'],
referenceColumnNames: ['id'],
referenceTableName: 'table1',
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

View File

@@ -0,0 +1,85 @@
import { Column, DatabaseConstraintType, DatabaseSchema, ForeignKeyConstraint, Table } from 'src/sql-tools';
@Table()
export class Table1 {
@Column()
foo!: string;
}
@Table()
@ForeignKeyConstraint({
columns: ['bar'],
referenceTable: () => Table1,
referenceColumns: ['foo'],
})
export class Table2 {
@Column()
bar!: string;
}
export const description = 'should create a foreign key constraint to the target table without a primary key';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'foo',
tableName: 'table1',
type: 'character varying',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'bar',
tableName: 'table2',
type: 'character varying',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [
{
name: 'IDX_7d9c784c98d12365d198d52e4e',
tableName: 'table2',
columnNames: ['bar'],
unique: false,
synchronize: true,
},
],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_7d9c784c98d12365d198d52e4e6',
tableName: 'table2',
columnNames: ['bar'],
referenceTableName: 'table1',
referenceColumnNames: ['foo'],
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

View File

@@ -0,0 +1,96 @@
import {
Column,
DatabaseConstraintType,
DatabaseSchema,
ForeignKeyConstraint,
PrimaryColumn,
Table,
} from 'src/sql-tools';
@Table()
export class Table1 {
@PrimaryColumn({ type: 'uuid' })
id!: string;
}
@Table()
@ForeignKeyConstraint({ columns: ['parentId'], referenceTable: () => Table1 })
export class Table2 {
@Column({ type: 'uuid' })
parentId!: string;
}
export const description = 'should create a foreign key constraint to the target table';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',
functions: [],
enums: [],
extensions: [],
parameters: [],
tables: [
{
name: 'table1',
columns: [
{
name: 'id',
tableName: 'table1',
type: 'uuid',
nullable: false,
isArray: false,
primary: true,
synchronize: true,
},
],
indexes: [],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_b249cc64cf63b8a22557cdc8537',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
],
synchronize: true,
},
{
name: 'table2',
columns: [
{
name: 'parentId',
tableName: 'table2',
type: 'uuid',
nullable: false,
isArray: false,
primary: false,
synchronize: true,
},
],
indexes: [
{
name: 'IDX_3fcca5cc563abf256fc346e3ff',
tableName: 'table2',
columnNames: ['parentId'],
unique: false,
synchronize: true,
},
],
triggers: [],
constraints: [
{
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_3fcca5cc563abf256fc346e3ff4',
tableName: 'table2',
columnNames: ['parentId'],
referenceColumnNames: ['id'],
referenceTableName: 'table1',
synchronize: true,
},
],
synchronize: true,
},
],
warnings: [],
};

6
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.135.0",
"version": "1.135.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.135.0",
"version": "1.135.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -87,7 +87,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.135.0",
"version": "1.135.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.135.0",
"version": "1.135.1",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {

View File

@@ -1,10 +1,7 @@
<script lang="ts">
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiMapOutline } from '@mdi/js';
@@ -17,9 +14,7 @@
let { album }: Props = $props();
let abortController: AbortController;
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
let { setAssetId } = assetViewingStore;
let mapMarkers: MapMarkerResponseDto[] = $state([]);
@@ -61,37 +56,9 @@
const assetIds = await modalManager.show(MapModal, { mapMarkers });
if (assetIds) {
viewingAssets = assetIds;
viewingAssetCursor = 0;
await setAssetId(assetIds[0]);
}
}
async function navigateNext() {
if (viewingAssetCursor < viewingAssets.length - 1) {
await setAssetId(viewingAssets[++viewingAssetCursor]);
return true;
}
return false;
}
async function navigatePrevious() {
if (viewingAssetCursor > 0) {
await setAssetId(viewingAssets[--viewingAssetCursor]);
return true;
}
return false;
}
async function navigateRandom() {
if (viewingAssets.length <= 0) {
return undefined;
}
const index = Math.floor(Math.random() * viewingAssets.length);
const asset = await setAssetId(viewingAssets[index]);
return asset;
}
</script>
<IconButton
@@ -102,22 +69,3 @@
onclick={openMap}
aria-label={$t('map')}
/>
<Portal target="body">
{#if $showAssetViewer}
{#await import('../../../lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onRandom={navigateRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
isShared={false}
/>
{/await}
{/if}
</Portal>

View File

@@ -45,7 +45,7 @@
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"
>
<tr
class="flex w-full place-items-center p-2 md:ps-5 md:pe-5 md:pt-3 md:pb-3"

View File

@@ -546,11 +546,8 @@
{#if stack && withStacked}
{@const stackedAssets = stack.assets}
<div
id="stack-slideshow"
class="flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto overflow-y-hidden horizontal-scrollbar"
>
<div class="relative flex flex-row no-wrap">
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
class={['inline-block px-1 relative transition-all pb-2']}

View File

@@ -108,6 +108,30 @@
}
await modalManager.show(SlideshowSettingsModal);
};
onMount(() => {
function exitFullscreenHandler() {
const doc = document as Document & {
webkitIsFullScreen?: boolean;
};
if (
// eslint-disable-next-line tscompat/tscompat
!document.fullscreenElement &&
!doc.webkitIsFullScreen
) {
onClose();
}
}
document.addEventListener('fullscreenchange', exitFullscreenHandler);
document.addEventListener('webkitfullscreenchange', exitFullscreenHandler);
return () => {
document.removeEventListener('fullscreenchange', exitFullscreenHandler);
document.removeEventListener('webkitfullscreenchange', exitFullscreenHandler);
};
});
</script>
<svelte:document

View File

@@ -12,7 +12,6 @@
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
@@ -911,28 +910,26 @@
</section>
</section>
{#if !albumMapViewManager.isInMapView}
<Portal target="body">
{#if $showAssetViewer}
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/await}
{/if}
</Portal>
{/if}
<Portal target="body">
{#if $showAssetViewer}
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/await}
{/if}
</Portal>
<style>
#asset-grid {

View File

@@ -22,7 +22,7 @@
import { isEqual, omit } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import {
AttributionControl,
@@ -251,7 +251,11 @@
});
$effect(() => {
map?.jumpTo({ center, zoom });
if (!center || !zoom) {
return;
}
untrack(() => map?.jumpTo({ center, zoom }));
});
</script>

View File

@@ -1,13 +0,0 @@
class AlbumMapViewManager {
#isInMapView = $state(false);
get isInMapView() {
return this.#isInMapView;
}
set isInMapView(isInMapView: boolean) {
this.#isInMapView = isInMapView;
}
}
export const albumMapViewManager = new AlbumMapViewManager();

View File

@@ -343,7 +343,11 @@ export class MonthGroup {
}
findAssetById(assetDescriptor: AssetDescriptor) {
return this.assetsIterator().find((asset) => asset.id === assetDescriptor.id);
for (const asset of this.assetsIterator()) {
if (asset.id === assetDescriptor.id) {
return asset;
}
}
}
findClosest(target: TimelinePlainDateTime) {

View File

@@ -171,7 +171,7 @@
{#if sharedLinks.length > 0}
<div class="flex justify-between items-center">
<Text>{$t('shared_links')}</Text>
<Link href={AppRoute.SHARED_LINKS} class="text-sm">{$t('view_all')}</Link>
<Link href={AppRoute.SHARED_LINKS} onclick={() => onClose()} class="text-sm">{$t('view_all')}</Link>
</div>
<Stack gap={4}>