mirror of
https://github.com/immich-app/immich.git
synced 2026-09-30 13:23:21 +08:00
feat: auto-sync name changes to connected users (#31923)
* feat: auto-sync name changes to connected users * fix: rename to faceId * fix: pr feedback * fix: mobile build
This commit is contained in:
@@ -1679,6 +1679,10 @@
|
||||
"person_read_access_message": "You only have read access to this person.",
|
||||
"person_recognized": "Person recognized",
|
||||
"person_share_no_users": "Every user in your cluster group already has access to this person.",
|
||||
"person_update_strategy": "Apply changes to",
|
||||
"person_update_strategy_description": "Which people to update when you change a name or date of birth",
|
||||
"person_update_strategy_everyone": "Everyone with shared access",
|
||||
"person_update_strategy_self": "Only me",
|
||||
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
|
||||
"photos": "Photos",
|
||||
"photos_and_videos": "Photos & Videos",
|
||||
|
||||
@@ -21,7 +21,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'folders': FoldersResponse(enabled: false, sidebarWeb: false).toJson(),
|
||||
'memories': MemoriesResponse(enabled: true, duration: 5, sidebarWeb: false).toJson(),
|
||||
'ratings': RatingsResponse(enabled: false).toJson(),
|
||||
'people': PeopleResponse(enabled: true, sidebarWeb: false).toJson(),
|
||||
'people': PeopleResponse(enabled: true, sidebarWeb: false, updateStrategy: PersonUpdateStrategy.everyone).toJson(),
|
||||
'tags': TagsResponse(enabled: false, sidebarWeb: false).toJson(),
|
||||
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
|
||||
'cast': CastResponse(gCastEnabled: false).toJson(),
|
||||
@@ -42,6 +42,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
|
||||
'SearchAssetResponseDto': {'nextCursor': null},
|
||||
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
|
||||
'PeopleResponse': {'updateStrategy': 'everyone'},
|
||||
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
};
|
||||
|
||||
@@ -24944,11 +24944,15 @@
|
||||
"sidebarWeb": {
|
||||
"description": "Whether people appear in web sidebar",
|
||||
"type": "boolean"
|
||||
},
|
||||
"updateStrategy": {
|
||||
"$ref": "#/components/schemas/PersonUpdateStrategy"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"sidebarWeb"
|
||||
"sidebarWeb",
|
||||
"updateStrategy"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -25011,6 +25015,9 @@
|
||||
"sidebarWeb": {
|
||||
"description": "Whether people appear in web sidebar",
|
||||
"type": "boolean"
|
||||
},
|
||||
"updateStrategy": {
|
||||
"$ref": "#/components/schemas/PersonUpdateStrategy"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -25069,7 +25076,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"userId": {
|
||||
"description": "User ID",
|
||||
"description": "Restrict the update to the person record of this User ID",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
@@ -25527,7 +25534,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"userId": {
|
||||
"description": "User ID",
|
||||
"description": "Restrict the update to the person record of this User ID",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
@@ -25535,6 +25542,14 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PersonUpdateStrategy": {
|
||||
"description": "Which person records to update when editing a person",
|
||||
"enum": [
|
||||
"self",
|
||||
"everyone"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PersonUserRole": {
|
||||
"description": "Levels of access for managing people resources on behalf of another user.",
|
||||
"enum": [
|
||||
|
||||
@@ -688,6 +688,7 @@ export type PeopleResponse = {
|
||||
minimumFaces?: number;
|
||||
/** Whether people appear in web sidebar */
|
||||
sidebarWeb: boolean;
|
||||
updateStrategy: PersonUpdateStrategy;
|
||||
};
|
||||
export type PurchaseResponse = {
|
||||
/** Date until which to hide buy button */
|
||||
@@ -774,6 +775,7 @@ export type PeopleUpdate = {
|
||||
minimumFaces?: number;
|
||||
/** Whether people appear in web sidebar */
|
||||
sidebarWeb?: boolean;
|
||||
updateStrategy?: PersonUpdateStrategy;
|
||||
};
|
||||
export type PurchaseUpdate = {
|
||||
/** Date until which to hide buy button */
|
||||
@@ -2033,7 +2035,7 @@ export type PeopleUpdateItem = {
|
||||
isHidden?: boolean;
|
||||
/** Person name */
|
||||
name?: string;
|
||||
/** User ID */
|
||||
/** Restrict the update to the person record of this User ID */
|
||||
userId?: string;
|
||||
};
|
||||
export type PeopleUpdateDto = {
|
||||
@@ -2090,7 +2092,7 @@ export type PersonUpdateDto = {
|
||||
isHidden?: boolean;
|
||||
/** Person name */
|
||||
name?: string;
|
||||
/** User ID */
|
||||
/** Restrict the update to the person record of this User ID */
|
||||
userId?: string;
|
||||
};
|
||||
export type AssetFaceUpdateItem = {
|
||||
@@ -8061,6 +8063,10 @@ export enum AssetOrder {
|
||||
Asc = "asc",
|
||||
Desc = "desc"
|
||||
}
|
||||
export enum PersonUpdateStrategy {
|
||||
Self = "self",
|
||||
Everyone = "everyone"
|
||||
}
|
||||
export enum AssetVisibility {
|
||||
Archive = "archive",
|
||||
Timeline = "timeline",
|
||||
|
||||
@@ -91,6 +91,23 @@ describe(PersonController.name, () => {
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should require at least one property to update', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ userId: factory.uuid() })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{
|
||||
path: [],
|
||||
message:
|
||||
'At least one of the following fields is required: name, birthDate, isHidden, isFavorite, color, featureFaceAssetId',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should require isFavorite to be a boolean`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
|
||||
@@ -29,10 +29,17 @@ const PersonCreateSchema = z
|
||||
})
|
||||
.meta({ id: 'PersonCreateDto' });
|
||||
|
||||
const PersonUpdateSchema = PersonCreateSchema.extend({
|
||||
const PersonUpdateBaseSchema = PersonCreateSchema.extend({
|
||||
featureFaceAssetId: z.uuidv4().optional().describe('Asset ID used for feature face thumbnail'),
|
||||
userId: z.uuid().optional().describe('User ID'),
|
||||
}).meta({ id: 'PersonUpdateDto' });
|
||||
});
|
||||
|
||||
const PersonUpdateSchema = PersonUpdateBaseSchema.extend({
|
||||
userId: z.uuid().optional().describe('Restrict the update to the person record of this User ID'),
|
||||
})
|
||||
.refine((dto) => Object.entries(dto).some(([key, value]) => key !== 'userId' && value !== undefined), {
|
||||
message: `At least one of the following fields is required: ${Object.keys(PersonUpdateBaseSchema.shape).join(', ')}`,
|
||||
})
|
||||
.meta({ id: 'PersonUpdateDto' });
|
||||
|
||||
const PeopleUpdateItemSchema = PersonUpdateSchema.extend({
|
||||
id: z.uuidv4().describe('Person ID'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import z from 'zod';
|
||||
import type { UserPreferences } from 'src/types.js';
|
||||
import { AssetOrderSchema, UserAvatarColorSchema } from 'src/enum.js';
|
||||
import { AssetOrderSchema, PersonUpdateStrategySchema, UserAvatarColorSchema } from 'src/enum.js';
|
||||
|
||||
const AlbumsUpdateSchema = z
|
||||
.object({
|
||||
@@ -47,6 +47,7 @@ const PeopleUpdateSchema = z
|
||||
enabled: z.boolean().optional().describe('Whether people are enabled'),
|
||||
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
|
||||
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||
updateStrategy: PersonUpdateStrategySchema.optional(),
|
||||
})
|
||||
.optional()
|
||||
.meta({ id: 'PeopleUpdate' });
|
||||
@@ -150,6 +151,7 @@ const PeopleResponseSchema = z
|
||||
enabled: z.boolean().describe('Whether people are enabled'),
|
||||
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
|
||||
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||
updateStrategy: PersonUpdateStrategySchema,
|
||||
})
|
||||
.meta({ id: 'PeopleResponse' });
|
||||
|
||||
|
||||
@@ -417,6 +417,16 @@ export const UserAvatarColorSchema = z
|
||||
.describe('User avatar color')
|
||||
.meta({ id: 'UserAvatarColor' });
|
||||
|
||||
export enum PersonUpdateStrategy {
|
||||
Self = 'self',
|
||||
Everyone = 'everyone',
|
||||
}
|
||||
|
||||
export const PersonUpdateStrategySchema = z
|
||||
.enum(PersonUpdateStrategy)
|
||||
.describe('Which person records to update when editing a person')
|
||||
.meta({ id: 'PersonUpdateStrategy' });
|
||||
|
||||
export enum UserStatus {
|
||||
Active = 'active',
|
||||
Removing = 'removing',
|
||||
|
||||
@@ -1116,6 +1116,23 @@ from
|
||||
1
|
||||
) as "dummy"
|
||||
|
||||
-- PersonRepository.updateForWritableOwners
|
||||
update "person"
|
||||
set
|
||||
"name" = $1
|
||||
where
|
||||
"person"."personGroupId" = $2
|
||||
and "person"."ownerId" in (
|
||||
select
|
||||
"person_user"."sharedById"
|
||||
from
|
||||
"person_user"
|
||||
where
|
||||
"person_user"."personGroupId" = $3
|
||||
and "person_user"."sharedWithId" = $4
|
||||
and "person_user"."role" in ($5, $6)
|
||||
)
|
||||
|
||||
-- PersonRepository.getFacesByIds
|
||||
select
|
||||
"asset_face".*,
|
||||
|
||||
@@ -933,6 +933,26 @@ export class PersonRepository {
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, personGroupId: DummyValue.UUID }, { name: DummyValue.STRING }] })
|
||||
async updateForWritableOwners(
|
||||
{ userId, personGroupId }: { userId: string; personGroupId: string },
|
||||
person: Pick<Updateable<PersonTable>, 'name' | 'birthDate'>,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('person')
|
||||
.set(person)
|
||||
.where('person.personGroupId', '=', personGroupId)
|
||||
.where('person.ownerId', 'in', (eb) =>
|
||||
eb
|
||||
.selectFrom('person_user')
|
||||
.select('person_user.sharedById')
|
||||
.where('person_user.personGroupId', '=', personGroupId)
|
||||
.where('person_user.sharedWithId', '=', userId)
|
||||
.where('person_user.role', 'in', [PersonUserRole.Write, PersonUserRole.Admin]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async updateAll(people: Insertable<PersonTable>[]): Promise<void> {
|
||||
if (people.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -122,8 +122,8 @@ describe(PersonService.name, () => {
|
||||
const person = PersonFactory.create();
|
||||
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
|
||||
|
||||
mocks.person.getForUser.mockResolvedValue(getDehydrated(person));
|
||||
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
|
||||
mocks.person.getForUser.mockResolvedValue(getDehydrated(person));
|
||||
await expect(sut.getById(auth, person.personGroupId)).resolves.toEqual(
|
||||
expect.objectContaining({ id: person.personGroupId }),
|
||||
);
|
||||
@@ -221,7 +221,7 @@ describe(PersonService.name, () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ ownerId: auth.user.id });
|
||||
|
||||
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||
await expect(sut.update(auth, person.personGroupId, { isHidden: true })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
@@ -237,28 +237,46 @@ describe(PersonService.name, () => {
|
||||
|
||||
mocks.access.person.checkAccess.mockResolvedValue(new Set());
|
||||
await expect(sut.update(auth, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
|
||||
auth.user.id,
|
||||
new Set([{ personGroupId: 'person-1', ownerId: auth.user.id }]),
|
||||
PERSON_WRITE_ROLES,
|
||||
);
|
||||
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update a person's name", async () => {
|
||||
it('should throw an error when personal properties are updated for another user', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
await expect(
|
||||
sut.update(auth, person.personGroupId, { userId: person.ownerId, name: 'Person 1', isFavorite: true }),
|
||||
).rejects.toThrow('Only name and birthDate can be updated for other users');
|
||||
expect(mocks.access.person.checkAccess).not.toHaveBeenCalled();
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update a person's name for every user with write access", async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1' });
|
||||
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
|
||||
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
|
||||
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).resolves.toEqual(
|
||||
expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }),
|
||||
);
|
||||
|
||||
expect(mocks.person.updateForWritableOwners).toHaveBeenCalledWith(
|
||||
{ userId: auth.user.id, personGroupId: person.personGroupId },
|
||||
{
|
||||
name: 'Person 1',
|
||||
},
|
||||
);
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
ownerId: person.ownerId,
|
||||
ownerId: auth.user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
name: 'Person 1',
|
||||
});
|
||||
@@ -282,17 +300,41 @@ describe(PersonService.name, () => {
|
||||
personGroupId: person.personGroupId,
|
||||
name: 'Person 1',
|
||||
});
|
||||
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
|
||||
});
|
||||
|
||||
it("should update a person's date of birth", async () => {
|
||||
it("should only update the user's own person when userId is the current user", async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ ownerId: auth.user.id, birthDate: new Date('1976-06-30') });
|
||||
const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1' });
|
||||
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
|
||||
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
|
||||
|
||||
await expect(
|
||||
sut.update(auth, person.personGroupId, { name: 'Person 1', isFavorite: true, userId: auth.user.id }),
|
||||
).resolves.toEqual(expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }));
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
ownerId: auth.user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
name: 'Person 1',
|
||||
isFavorite: true,
|
||||
});
|
||||
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
|
||||
});
|
||||
|
||||
it("should update a person's date of birth for every user with write access", async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ ownerId: auth.user.id, birthDate: new Date('1976-06-30') });
|
||||
|
||||
mocks.access.person.checkAccess.mockResolvedValue(
|
||||
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
|
||||
);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
|
||||
await expect(sut.update(auth, person.personGroupId, { birthDate: '1976-06-30' })).resolves.toEqual({
|
||||
id: person.personGroupId,
|
||||
name: person.name,
|
||||
@@ -305,14 +347,19 @@ describe(PersonService.name, () => {
|
||||
sharedWith: [],
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(mocks.person.updateForWritableOwners).toHaveBeenCalledWith(
|
||||
{ userId: auth.user.id, personGroupId: person.personGroupId },
|
||||
{
|
||||
birthDate: '1976-06-30',
|
||||
},
|
||||
);
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
ownerId: person.ownerId,
|
||||
ownerId: auth.user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
birthDate: '1976-06-30',
|
||||
});
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
|
||||
});
|
||||
|
||||
it('should update a person visibility', async () => {
|
||||
@@ -332,6 +379,7 @@ describe(PersonService.name, () => {
|
||||
personGroupId: person.personGroupId,
|
||||
isHidden: true,
|
||||
});
|
||||
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
|
||||
});
|
||||
|
||||
@@ -352,9 +400,42 @@ describe(PersonService.name, () => {
|
||||
personGroupId: person.personGroupId,
|
||||
isFavorite: true,
|
||||
});
|
||||
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
|
||||
});
|
||||
|
||||
it('should update shared and personal properties together', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ ownerId: auth.user.id, name: 'Person 1', isFavorite: true });
|
||||
|
||||
mocks.access.person.checkAccess.mockResolvedValue(
|
||||
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
|
||||
);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
|
||||
await expect(sut.update(auth, person.personGroupId, { name: 'Person 1', isFavorite: true })).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'Person 1', isFavorite: true }),
|
||||
);
|
||||
|
||||
expect(mocks.person.updateForWritableOwners).toHaveBeenCalledWith(
|
||||
{ userId: auth.user.id, personGroupId: person.personGroupId },
|
||||
{
|
||||
name: 'Person 1',
|
||||
},
|
||||
);
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
ownerId: auth.user.id,
|
||||
personGroupId: person.personGroupId,
|
||||
name: 'Person 1',
|
||||
isFavorite: true,
|
||||
});
|
||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
|
||||
auth.user.id,
|
||||
new Set([{ personGroupId: person.personGroupId, ownerId: auth.user.id }]),
|
||||
PERSON_WRITE_ROLES,
|
||||
);
|
||||
});
|
||||
|
||||
it("should update a person's thumbnailPath", async () => {
|
||||
const face = AssetFaceFactory.create();
|
||||
const auth = AuthFactory.create();
|
||||
@@ -389,9 +470,7 @@ describe(PersonService.name, () => {
|
||||
it('should throw an error when the face feature assetId is invalid', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ ownerId: auth.user.id });
|
||||
const ids = [{ personGroupId: person.personGroupId, ownerId: auth.user.id }];
|
||||
|
||||
mocks.access.person.checkAccess.mockResolvedValue(new Set(ids));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['-1']));
|
||||
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(undefined);
|
||||
|
||||
@@ -399,7 +478,7 @@ describe(PersonService.name, () => {
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(auth.user.id, new Set(ids), PERSON_WRITE_ROLES);
|
||||
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -411,6 +490,7 @@ describe(PersonService.name, () => {
|
||||
{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false },
|
||||
]);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.person.updateForWritableOwners).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set([{ personGroupId: 'person-1', ownerId: authStub.admin.user.id }]),
|
||||
|
||||
@@ -52,7 +52,7 @@ import { getDimensions, getMyPartnerIds } from 'src/utils/asset.util.js';
|
||||
import { ImmichFileResponse } from 'src/utils/file.js';
|
||||
import { isHttpException } from 'src/utils/logger.js';
|
||||
import { mimeTypes } from 'src/utils/mime-types.js';
|
||||
import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc.js';
|
||||
import { batched, findOrFail, hasSomeDefined, isFacialRecognitionEnabled } from 'src/utils/misc.js';
|
||||
import { Point, transformPoints } from 'src/utils/transform.js';
|
||||
|
||||
const personKey = ({ ownerId, personGroupId }: PersonId) => `${ownerId}/${personGroupId}`;
|
||||
@@ -247,17 +247,27 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, personGroupId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
const ownerId = dto.userId ?? auth.user.id;
|
||||
const { userId: userIdOverride, name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
|
||||
const targetOwnerId = userIdOverride ?? auth.user.id;
|
||||
const hasSharedProperties = hasSomeDefined([name, birthDate]);
|
||||
const hasPersonalProperties = hasSomeDefined([isHidden, isFavorite, color, assetId]);
|
||||
|
||||
if (targetOwnerId !== auth.user.id && hasPersonalProperties) {
|
||||
throw new BadRequestException('Only name and birthDate can be updated for other users');
|
||||
}
|
||||
|
||||
await this.requirePersonAccess({
|
||||
auth,
|
||||
permission: Permission.PersonUpdate,
|
||||
ids: [{ personGroupId, ownerId }],
|
||||
ids: [{ personGroupId, ownerId: targetOwnerId }],
|
||||
});
|
||||
|
||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
|
||||
// TODO: set by faceId directly
|
||||
if (!userIdOverride && hasSharedProperties) {
|
||||
await this.personRepository.updateForWritableOwners({ userId: auth.user.id, personGroupId }, { name, birthDate });
|
||||
}
|
||||
|
||||
let faceId: string | undefined;
|
||||
|
||||
if (assetId) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] });
|
||||
const face = await this.personRepository.getForFeatureFaceUpdate({ personGroupId, assetId });
|
||||
@@ -269,7 +279,7 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
|
||||
const person = await this.personRepository.update({
|
||||
ownerId,
|
||||
ownerId: targetOwnerId,
|
||||
personGroupId,
|
||||
faceAssetId: faceId,
|
||||
name,
|
||||
@@ -280,7 +290,10 @@ export class PersonService extends BaseService {
|
||||
});
|
||||
|
||||
if (assetId) {
|
||||
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } });
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: { ownerId: targetOwnerId, personGroupId },
|
||||
});
|
||||
}
|
||||
|
||||
return mapPerson(person);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
IntegrityReport,
|
||||
JobName,
|
||||
MemoryType,
|
||||
PersonUpdateStrategy,
|
||||
QueueName,
|
||||
StorageFolder,
|
||||
SyncEntityType,
|
||||
@@ -580,6 +581,7 @@ export type UserPreferences = {
|
||||
enabled: boolean;
|
||||
sidebarWeb: boolean;
|
||||
minimumFaces: number;
|
||||
updateStrategy: PersonUpdateStrategy;
|
||||
};
|
||||
ratings: {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -108,6 +108,8 @@ export const handlePromiseError = <T>(promise: Promise<T>, logger: LoggingReposi
|
||||
promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack));
|
||||
};
|
||||
|
||||
export const hasSomeDefined = (values: unknown[]) => values.some((value) => value !== undefined);
|
||||
|
||||
export const findOrFail = async <T>(find: () => Promise<T>, entity: string): Promise<NonNullable<T>> => {
|
||||
const value = await find();
|
||||
if (!value) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { get, isEqual, set } from 'lodash-es';
|
||||
import type { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types.js';
|
||||
import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto.js';
|
||||
import { AssetOrder, UserMetadataKey } from 'src/enum.js';
|
||||
import { AssetOrder, PersonUpdateStrategy, UserMetadataKey } from 'src/enum.js';
|
||||
import { HumanReadableSize } from 'src/utils/bytes.js';
|
||||
import { getKeysDeep } from 'src/utils/misc.js';
|
||||
|
||||
@@ -23,6 +23,7 @@ const getDefaultPreferences = (): UserPreferences => {
|
||||
enabled: true,
|
||||
sidebarWeb: false,
|
||||
minimumFaces: 3,
|
||||
updateStrategy: PersonUpdateStrategy.Everyone,
|
||||
},
|
||||
sharedLinks: {
|
||||
enabled: true,
|
||||
|
||||
@@ -303,6 +303,123 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should throw an error when there is no access', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user2.id });
|
||||
|
||||
await expect(sut.update(factory.auth({ user }), person.personGroupId, { name: 'New name' })).rejects.toThrow(
|
||||
'Not found or no person.update access',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update the name and birth date for every user with write access', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const personRepo = ctx.get(PersonRepository);
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: writer } = await ctx.newUser();
|
||||
const { user: reader } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: writer.id, name: 'Old name' });
|
||||
const { personGroupId } = person;
|
||||
await ctx.newPerson({ ownerId: reader.id, personGroupId, name: 'Old name' });
|
||||
await ctx.newPersonUser({
|
||||
personGroupId,
|
||||
sharedById: writer.id,
|
||||
sharedWithId: user.id,
|
||||
role: PersonUserRole.Write,
|
||||
});
|
||||
await ctx.newPersonUser({
|
||||
personGroupId,
|
||||
sharedById: reader.id,
|
||||
sharedWithId: user.id,
|
||||
role: PersonUserRole.Read,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.update(factory.auth({ user }), personGroupId, { name: 'New name', birthDate: '2000-01-01' }),
|
||||
).resolves.toEqual(expect.objectContaining({ name: 'New name', birthDate: '2000-01-01' }));
|
||||
|
||||
await expect(personRepo.getForUser({ userId: user.id, personGroupId })).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'New name', birthDate: '2000-01-01' }),
|
||||
);
|
||||
await expect(personRepo.getForUser({ userId: writer.id, personGroupId })).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'New name', birthDate: '2000-01-01' }),
|
||||
);
|
||||
await expect(personRepo.getForUser({ userId: reader.id, personGroupId })).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'Old name', birthDate: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should only update the specified user when userId is provided', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const personRepo = ctx.get(PersonRepository);
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: writer } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: writer.id, name: 'Old name' });
|
||||
const { personGroupId } = person;
|
||||
await ctx.newPersonUser({
|
||||
personGroupId,
|
||||
sharedById: writer.id,
|
||||
sharedWithId: user.id,
|
||||
role: PersonUserRole.Write,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.update(factory.auth({ user }), personGroupId, { name: 'New name', userId: writer.id }),
|
||||
).resolves.toEqual(expect.objectContaining({ name: 'New name' }));
|
||||
|
||||
await expect(personRepo.getForUser({ userId: writer.id, personGroupId })).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'New name' }),
|
||||
);
|
||||
await expect(personRepo.getForUser({ userId: user.id, personGroupId })).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'Old name' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should only update personal properties for the current user', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const personRepo = ctx.get(PersonRepository);
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: writer } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: writer.id, name: 'Old name' });
|
||||
const { personGroupId } = person;
|
||||
await ctx.newPersonUser({
|
||||
personGroupId,
|
||||
sharedById: writer.id,
|
||||
sharedWithId: user.id,
|
||||
role: PersonUserRole.Write,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.update(factory.auth({ user }), personGroupId, { name: 'New name', isFavorite: true, isHidden: true }),
|
||||
).resolves.toEqual(expect.objectContaining({ name: 'New name', isFavorite: true, isHidden: true }));
|
||||
|
||||
await expect(personRepo.getForUser({ userId: writer.id, personGroupId })).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'New name', isFavorite: false, isHidden: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow personal properties to be updated for another user', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: writer } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: writer.id });
|
||||
const { personGroupId } = person;
|
||||
await ctx.newPersonUser({
|
||||
personGroupId,
|
||||
sharedById: writer.id,
|
||||
sharedWithId: user.id,
|
||||
role: PersonUserRole.Write,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.update(factory.auth({ user }), personGroupId, { isFavorite: true, userId: writer.id }),
|
||||
).rejects.toThrow('Only name and birthDate can be updated for other users');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw an error when there is no access', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum.js';
|
||||
import { ImmichEnvironment, JobName, JobStatus, PersonUpdateStrategy, UserAvatarColor } from 'src/enum.js';
|
||||
import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository.js';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository.js';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository.js';
|
||||
@@ -263,6 +263,20 @@ describe(UserService.name, () => {
|
||||
await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto);
|
||||
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto);
|
||||
});
|
||||
|
||||
it('should update the person update strategy', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const dto = { people: { updateStrategy: PersonUpdateStrategy.Self } };
|
||||
|
||||
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject({
|
||||
people: { updateStrategy: PersonUpdateStrategy.Everyone },
|
||||
});
|
||||
await expect(sut.updateMyPreferences(auth, dto)).resolves.toMatchObject(dto);
|
||||
await expect(sut.getMyPreferences(auth)).resolves.toMatchObject(dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLicense', () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import PeopleFilterUserPicker from '$lib/modals/PeopleFilterUserPicker.svelte';
|
||||
import { handleUpdatePeople } from '$lib/services/person.service';
|
||||
import { handleUpdatePerson } from '$lib/services/person.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { PersonUserRole, type PersonResponseDto } from '@immich/sdk';
|
||||
import { PersonUpdateStrategy, PersonUserRole, type PersonResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
let targetUserId = $state(initialTargetUserId ?? authManager.user.id);
|
||||
let targetPerson = $state(candidates[0]);
|
||||
let applyToEveryone = $state(false);
|
||||
let applyToEveryone = $state(authManager.preferences.people?.updateStrategy === PersonUpdateStrategy.Everyone);
|
||||
|
||||
const isWritable = $derived.by(() => {
|
||||
if (!targetUserId) {
|
||||
@@ -68,20 +68,14 @@
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const userIdsToUpdate = applyToEveryone
|
||||
? candidates.map(({ sharedById }) => sharedById)
|
||||
: [targetPerson.sharedById];
|
||||
|
||||
const success = await handleUpdatePeople({
|
||||
people: userIdsToUpdate.map((userId) => ({
|
||||
id: person.id,
|
||||
name: targetPerson.name,
|
||||
birthDate: targetPerson.birthDate,
|
||||
userId,
|
||||
})),
|
||||
const response = await handleUpdatePerson({
|
||||
id: person.id,
|
||||
name: targetPerson.name,
|
||||
birthDate: targetPerson.birthDate,
|
||||
userId: applyToEveryone ? undefined : targetPerson.sharedById,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
if (response) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
getPerson,
|
||||
updatePeople,
|
||||
PersonUpdateStrategy,
|
||||
updatePerson,
|
||||
type AssetResponseDto,
|
||||
type PeopleUpdateDto,
|
||||
type PersonResponseDto,
|
||||
type PersonUpdateDto,
|
||||
} from '@immich/sdk';
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
mdiPencilOutline,
|
||||
} from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import PersonEditAccessModal from '$lib/modals/PersonEditAccessModal.svelte';
|
||||
import PersonEditModal from '$lib/modals/PersonEditModal.svelte';
|
||||
@@ -114,41 +114,19 @@ const handleHidePerson = async (person: { id: string }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUpdatePerson = async (id: string, personUpdateDto: PersonUpdateDto) => {
|
||||
export const handleUpdatePerson = async ({ id, ...personUpdateDto }: { id: string } & PersonUpdateDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await updatePerson({ id, personUpdateDto });
|
||||
return true;
|
||||
const response = await updatePerson({ id, personUpdateDto });
|
||||
const isOtherUser = !!personUpdateDto.userId && personUpdateDto.userId !== authManager.user.id;
|
||||
eventManager.emit('PersonUpdate', isOtherUser ? await getPerson({ id }) : response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUpdatePeople = async (peopleUpdateDto: PeopleUpdateDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const bulkResponse = await updatePeople({ peopleUpdateDto });
|
||||
|
||||
const ids = new Set(peopleUpdateDto.people.map(({ id }) => id));
|
||||
const responses = await Promise.all([...ids].map((id) => getPerson({ id })));
|
||||
for (const response of responses) {
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
}
|
||||
|
||||
if (bulkResponse.some((response) => !response.success)) {
|
||||
toastManager.danger($t('errors.something_went_wrong'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('uh oh');
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowPerson = async (person: { id: string }) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
@@ -161,17 +139,22 @@ const handleShowPerson = async (person: { id: string }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, birthDate: string | null) => {
|
||||
const $t = await getFormatter();
|
||||
export const withUpdateStrategy = (dto: PersonUpdateDto): PersonUpdateDto =>
|
||||
authManager.preferences.people?.updateStrategy === PersonUpdateStrategy.Self
|
||||
? { ...dto, userId: authManager.user.id }
|
||||
: dto;
|
||||
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { birthDate } });
|
||||
toastManager.primary($t('date_of_birth_saved'));
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_date_of_birth'));
|
||||
export const handleUpdatePersonName = async (
|
||||
{ id, name }: { id: string; name: string },
|
||||
options?: { notify: boolean },
|
||||
) => {
|
||||
const response = await handleUpdatePerson({ id, ...withUpdateStrategy({ name }) });
|
||||
if (response && options?.notify) {
|
||||
const $t = await getFormatter();
|
||||
toastManager.primary($t('change_name_successfully'));
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const handleSetFeaturedPhoto = async (person: PersonResponseDto, featureFaceAssetId: string) => {
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { clearQueryParam } from '$lib/utils/navigation';
|
||||
import { getAllPeople, getPerson, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import { handleUpdatePersonName } from '$lib/services/person.service';
|
||||
import { getAllPeople, getPerson, searchPerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Icon, IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiAccountOff, mdiEyeOutline, mdiTune } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -150,19 +151,7 @@
|
||||
* the person he's editing
|
||||
*
|
||||
*/
|
||||
try {
|
||||
await updatePerson({ id: personToBeMergedInto.id, personUpdateDto: { name: newName } });
|
||||
|
||||
for (const person of people) {
|
||||
if (person.id === personToBeMergedInto.id) {
|
||||
person.name = newName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
toastManager.primary($t('change_name_successfully'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_name'));
|
||||
}
|
||||
await handleUpdatePersonName({ id: personToBeMergedInto.id, name: newName }, { notify: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -255,11 +244,7 @@
|
||||
};
|
||||
|
||||
const updateName = async (id: string, name: string) => {
|
||||
await updatePerson({
|
||||
id,
|
||||
personUpdateDto: { name },
|
||||
});
|
||||
|
||||
await handleUpdatePersonName({ id, name });
|
||||
newName = '';
|
||||
};
|
||||
|
||||
|
||||
+2
-7
@@ -31,7 +31,7 @@
|
||||
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { getPersonActions } from '$lib/services/person.service';
|
||||
import { getPersonActions, handleUpdatePersonName } from '$lib/services/person.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
@@ -217,12 +217,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } });
|
||||
toastManager.primary($t('change_name_successfully'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_name'));
|
||||
}
|
||||
person = (await handleUpdatePersonName({ id: person.id, name: personName }, { notify: true })) ?? person;
|
||||
};
|
||||
|
||||
const handleCancelEditName = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetOrder, updateMyPreferences } from '@immich/sdk';
|
||||
import { AssetOrder, PersonUpdateStrategy, updateMyPreferences } from '@immich/sdk';
|
||||
import { Button, Field, NumberInput, Select, Switch, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -24,6 +24,7 @@
|
||||
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
||||
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
|
||||
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
|
||||
let peopleUpdateStrategy = $state(authManager.preferences.people?.updateStrategy ?? PersonUpdateStrategy.Everyone);
|
||||
|
||||
// Ratings
|
||||
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
|
||||
@@ -49,7 +50,12 @@
|
||||
albums: { defaultAssetOrder },
|
||||
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
||||
memories: { enabled: memoriesEnabled, duration: memoriesDuration, sidebarWeb: memoriesSidebar },
|
||||
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces },
|
||||
people: {
|
||||
enabled: peopleEnabled,
|
||||
sidebarWeb: peopleSidebar,
|
||||
minimumFaces: peopleMinFaces,
|
||||
updateStrategy: peopleUpdateStrategy,
|
||||
},
|
||||
ratings: { enabled: ratingsEnabled },
|
||||
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
||||
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
||||
@@ -133,6 +139,15 @@
|
||||
<Field label={$t('minFaces')} description={$t('minFaces_description')}>
|
||||
<NumberInput bind:value={peopleMinFaces} />
|
||||
</Field>
|
||||
<Field label={$t('person_update_strategy')} description={$t('person_update_strategy_description')}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: $t('person_update_strategy_everyone'), value: PersonUpdateStrategy.Everyone },
|
||||
{ label: $t('person_update_strategy_self'), value: PersonUpdateStrategy.Self },
|
||||
]}
|
||||
bind:value={peopleUpdateStrategy}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetOrder, type UserPreferencesResponseDto } from '@immich/sdk';
|
||||
import { AssetOrder, PersonUpdateStrategy, type UserPreferencesResponseDto } from '@immich/sdk';
|
||||
import { Sync } from 'factory.ts';
|
||||
|
||||
export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
||||
@@ -29,6 +29,7 @@ export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
||||
people: {
|
||||
enabled: false,
|
||||
sidebarWeb: false,
|
||||
updateStrategy: PersonUpdateStrategy.Everyone,
|
||||
},
|
||||
purchase: {
|
||||
hideBuyButtonUntil: '',
|
||||
|
||||
Reference in New Issue
Block a user