diff --git a/mobile/openapi/doc/UpdateLibraryDto.md b/mobile/openapi/doc/UpdateLibraryDto.md index 0f0e2652b81c0..737d113920a31 100644 --- a/mobile/openapi/doc/UpdateLibraryDto.md +++ b/mobile/openapi/doc/UpdateLibraryDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **exclusionPatterns** | **List** | | [optional] [default to const []] **importPaths** | **List** | | [optional] [default to const []] +**isReadOnly** | **bool** | | [optional] **isVisible** | **bool** | | [optional] **name** | **String** | | [optional] diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart index b870f240fe08d..7bf571872d687 100644 --- a/mobile/openapi/lib/model/update_library_dto.dart +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -15,6 +15,7 @@ class UpdateLibraryDto { UpdateLibraryDto({ this.exclusionPatterns = const [], this.importPaths = const [], + this.isReadOnly, this.isVisible, this.name, }); @@ -23,6 +24,14 @@ class UpdateLibraryDto { List importPaths; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isReadOnly; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -43,6 +52,7 @@ class UpdateLibraryDto { bool operator ==(Object other) => identical(this, other) || other is UpdateLibraryDto && _deepEquality.equals(other.exclusionPatterns, exclusionPatterns) && _deepEquality.equals(other.importPaths, importPaths) && + other.isReadOnly == isReadOnly && other.isVisible == isVisible && other.name == name; @@ -51,16 +61,22 @@ class UpdateLibraryDto { // ignore: unnecessary_parenthesis (exclusionPatterns.hashCode) + (importPaths.hashCode) + + (isReadOnly == null ? 0 : isReadOnly!.hashCode) + (isVisible == null ? 0 : isVisible!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'UpdateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isVisible=$isVisible, name=$name]'; + String toString() => 'UpdateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isReadOnly=$isReadOnly, isVisible=$isVisible, name=$name]'; Map toJson() { final json = {}; json[r'exclusionPatterns'] = this.exclusionPatterns; json[r'importPaths'] = this.importPaths; + if (this.isReadOnly != null) { + json[r'isReadOnly'] = this.isReadOnly; + } else { + // json[r'isReadOnly'] = null; + } if (this.isVisible != null) { json[r'isVisible'] = this.isVisible; } else { @@ -88,6 +104,7 @@ class UpdateLibraryDto { importPaths: json[r'importPaths'] is Iterable ? (json[r'importPaths'] as Iterable).cast().toList(growable: false) : const [], + isReadOnly: mapValueOfType(json, r'isReadOnly'), isVisible: mapValueOfType(json, r'isVisible'), name: mapValueOfType(json, r'name'), ); diff --git a/mobile/openapi/test/update_library_dto_test.dart b/mobile/openapi/test/update_library_dto_test.dart index 222eb333bcf1f..4d4cd6a4bab18 100644 --- a/mobile/openapi/test/update_library_dto_test.dart +++ b/mobile/openapi/test/update_library_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // bool isReadOnly + test('to test the property `isReadOnly`', () async { + // TODO + }); + // bool isVisible test('to test the property `isVisible`', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4f666b303cf1e..2d13f586cdd17 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8778,6 +8778,10 @@ }, "type": "array" }, + "isReadOnly": { + "nullable": true, + "type": "boolean" + }, "name": { "type": "string" }, @@ -8803,6 +8807,7 @@ "exclusionPatterns", "id", "importPaths", + "isReadOnly", "name", "ownerId", "refreshedAt", @@ -11183,6 +11188,9 @@ }, "type": "array" }, + "isReadOnly": { + "type": "boolean" + }, "isVisible": { "type": "boolean" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1bf219162dd8b..36963074471ce 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -439,6 +439,7 @@ export type CreateLibraryDto = { export type UpdateLibraryDto = { exclusionPatterns?: string[]; importPaths?: string[]; + isReadOnly?: boolean; isVisible?: boolean; name?: string; }; diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index b693d35adf854..83d88c22c8992 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -43,6 +43,9 @@ export class UpdateLibraryDto { @ValidateBoolean({ optional: true }) isVisible?: boolean; + @ValidateBoolean({ optional: true }) + isReadOnly?: boolean; + @Optional() @IsString({ each: true }) @IsNotEmpty({ each: true }) @@ -128,6 +131,8 @@ export class LibraryResponseDto { createdAt!: Date; updatedAt!: Date; refreshedAt!: Date | null; + + isReadOnly!: boolean | null; } export class LibraryStatsResponseDto { @@ -160,5 +165,6 @@ export function mapLibrary(entity: LibraryEntity): LibraryResponseDto { assetCount, importPaths: entity.importPaths, exclusionPatterns: entity.exclusionPatterns, + isReadOnly: entity.isReadOnly, }; } diff --git a/server/src/entities/library.entity.ts b/server/src/entities/library.entity.ts index 8be560a8893f2..12fc0a22a8acc 100644 --- a/server/src/entities/library.entity.ts +++ b/server/src/entities/library.entity.ts @@ -53,6 +53,9 @@ export class LibraryEntity { @Column({ type: 'boolean', default: true }) isVisible!: boolean; + + @Column({ type: 'boolean', nullable: true }) + isReadOnly!: boolean | null; } export enum LibraryType { diff --git a/server/src/migrations/1712293452361-AddLibraryReadOnly.ts b/server/src/migrations/1712293452361-AddLibraryReadOnly.ts new file mode 100644 index 0000000000000..d624f5bb9f74d --- /dev/null +++ b/server/src/migrations/1712293452361-AddLibraryReadOnly.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddLibraryReadOnly1712293452361 implements MigrationInterface { + name = 'AddLibraryReadOnly1712293452361' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "libraries" ADD "isReadOnly" boolean`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "libraries" DROP COLUMN "isReadOnly"`); + } + +} diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index 93a6fc97fb107..eb418ee745834 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -17,6 +17,7 @@ FROM "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible", + "LibraryEntity"."isReadOnly" AS "LibraryEntity_isReadOnly", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", @@ -90,7 +91,8 @@ SELECT "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", - "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible" + "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible", + "LibraryEntity"."isReadOnly" AS "LibraryEntity_isReadOnly" FROM "libraries" "LibraryEntity" WHERE @@ -133,6 +135,7 @@ SELECT "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible", + "LibraryEntity"."isReadOnly" AS "LibraryEntity_isReadOnly", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", @@ -179,6 +182,7 @@ SELECT "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible", + "LibraryEntity"."isReadOnly" AS "LibraryEntity_isReadOnly", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", @@ -219,6 +223,7 @@ SELECT "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible", + "LibraryEntity"."isReadOnly" AS "LibraryEntity_isReadOnly", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", @@ -259,6 +264,7 @@ SELECT "libraries"."deletedAt" AS "libraries_deletedAt", "libraries"."refreshedAt" AS "libraries_refreshedAt", "libraries"."isVisible" AS "libraries_isVisible", + "libraries"."isReadOnly" AS "libraries_isReadOnly", COUNT("assets"."id") FILTER ( WHERE "assets"."type" = 'IMAGE' diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index ddc666edd3daa..76b9c4086f679 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -254,7 +254,7 @@ export class AssetRepository implements IAssetRepository { @Chunked() async softDeleteAll(ids: string[]): Promise { - await this.repository.softDelete({ id: In(ids), isExternal: false }); + await this.repository.softDelete({ id: In(ids) }); } @Chunked() diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 1516ef89613a9..6334381fb0ec4 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -691,6 +691,39 @@ describe(AssetService.name, () => { expect(assetMock.remove).not.toHaveBeenCalled(); }); + it('should process assets from external non-read-only library without fromExternal flag', async () => { + when(assetMock.getById) + .calledWith(assetStub.externalDeletable.id, { + faces: { + person: true, + }, + library: true, + stack: { assets: true }, + exifInfo: true, + }) + .mockResolvedValue(assetStub.externalDeletable); + + await sut.handleAssetDeletion({ id: assetStub.externalDeletable.id }); + + expect(assetMock.remove).toHaveBeenCalledWith(assetStub.externalDeletable); + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.DELETE_FILES, + data: { + files: [ + assetStub.externalDeletable.thumbnailPath, + assetStub.externalDeletable.previewPath, + assetStub.externalDeletable.encodedVideoPath, + assetStub.externalDeletable.sidecarPath, + assetStub.externalDeletable.originalPath, + ], + }, + }, + ], + ]); + }); + it('should process assets from external library with fromExternal flag', async () => { assetMock.getById.mockResolvedValue(assetStub.external); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index a26b7f5a73096..441f75c5a9bb7 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -372,8 +372,10 @@ export class AssetService { return JobStatus.FAILED; } - // Ignore requests that are not from external library job but is for an external asset - if (!fromExternal && (!asset.library || asset.library.type === LibraryType.EXTERNAL)) { + // Ignore requests that are not from external library job but is for an external read-only asset + const isReadOnlyLibrary = !asset.library || asset.library.isReadOnly || + (asset.library.isReadOnly === null && asset.library.type === LibraryType.EXTERNAL); + if (!fromExternal && isReadOnlyLibrary) { return JobStatus.SKIPPED; } @@ -406,7 +408,7 @@ export class AssetService { } const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath]; - if (!(asset.isExternal || asset.isReadOnly)) { + if (!isReadOnlyLibrary) { files.push(asset.sidecarPath, asset.originalPath); } diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index ce2b07067215e..f33a40570e482 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -255,6 +255,46 @@ export const assetStub = { } as ExifEntity, }), + externalDeletable: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/ext/photo.jpg', + previewPath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + thumbnailPath: '/uploads/user-id/webp/path.ext', + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, + isReadOnly: false, + isExternal: true, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.externalLibraryNotReadOnly, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + } as ExifEntity, + }), + offline: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts index dde250a7a16aa..1db359b1731cd 100644 --- a/server/test/fixtures/library.stub.ts +++ b/server/test/fixtures/library.stub.ts @@ -17,6 +17,7 @@ export const libraryStub = { updatedAt: new Date('2022-01-01'), refreshedAt: null, isVisible: true, + isReadOnly: null, exclusionPatterns: [], }), externalLibrary1: Object.freeze({ @@ -31,6 +32,7 @@ export const libraryStub = { updatedAt: new Date('2023-01-01'), refreshedAt: null, isVisible: true, + isReadOnly: null, exclusionPatterns: [], }), externalLibrary2: Object.freeze({ @@ -45,6 +47,22 @@ export const libraryStub = { updatedAt: new Date('2022-01-01'), refreshedAt: null, isVisible: true, + isReadOnly: null, + exclusionPatterns: [], + }), + externalLibraryNotReadOnly: Object.freeze({ + id: 'library-id-not-read-only', + name: 'library-external-not-read-only', + assets: [], + owner: userStub.admin, + ownerId: 'admin_id', + type: LibraryType.EXTERNAL, + importPaths: [], + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2022-01-01'), + refreshedAt: null, + isVisible: true, + isReadOnly: false, exclusionPatterns: [], }), externalLibraryWithImportPaths1: Object.freeze({ @@ -59,6 +77,7 @@ export const libraryStub = { updatedAt: new Date('2023-01-01'), refreshedAt: null, isVisible: true, + isReadOnly: null, exclusionPatterns: [], }), externalLibraryWithImportPaths2: Object.freeze({ @@ -73,6 +92,7 @@ export const libraryStub = { updatedAt: new Date('2023-01-01'), refreshedAt: null, isVisible: true, + isReadOnly: null, exclusionPatterns: [], }), externalLibraryWithExclusionPattern: Object.freeze({ @@ -87,6 +107,7 @@ export const libraryStub = { updatedAt: new Date('2023-01-01'), refreshedAt: null, isVisible: true, + isReadOnly: null, exclusionPatterns: ['**/dir1/**'], }), patternPath: Object.freeze({ @@ -101,6 +122,7 @@ export const libraryStub = { updatedAt: new Date('2023-01-01'), refreshedAt: null, isVisible: true, + isReadOnly: null, exclusionPatterns: ['**/dir1/**'], }), hasImmichPaths: Object.freeze({ @@ -115,6 +137,7 @@ export const libraryStub = { updatedAt: new Date('2023-01-01'), refreshedAt: null, isVisible: true, + isReadOnly: null, exclusionPatterns: ['**/dir1/**'], }), }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 6772ff5db00fe..643cd028beed6 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -193,7 +193,7 @@ {/if} {#if isOwner} - {#if !asset.isReadOnly || !asset.isExternal} + {#if !asset.isReadOnly || !asset.isExternal || (true /* XXX */)} dispatch('delete')} title="Delete" /> {/if}
{ loading = true; - const ids = [...getOwnedAssets()].filter((a) => !a.isExternal).map((a) => a.id); + const ids = [...getOwnedAssets()].map((a) => a.id); await deleteAssets(force, onAssetDelete, ids); clearSelect(); isShowConfirmation = false; diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 5bd3cfb09e86d..b213d9e6ee547 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -243,6 +243,19 @@ updateLibraryIndex = selectedLibraryIndex; }; + const onToggleReadOnlyClicked = async () => { + const library = libraries[selectedLibraryIndex]; + const prevValue = library.isReadOnly == null ? (library.type === LibraryType.External) : library.isReadOnly; + + try { + await updateLibrary({ id: library.id, updateLibraryDto: { ...library, isReadOnly: !prevValue } }); + closeAll(); + await readLibraryList(); + } catch (error) { + handleError(error, 'Unable to update library'); + } + }; + const onEditImportPathClicked = () => { closeAll(); editImportPaths = selectedLibraryIndex; @@ -397,6 +410,12 @@ onMenuExit()}> onRenameClicked()} text={`Rename`} /> + onToggleReadOnlyClicked()} + text={(selectedLibrary?.isReadOnly || + (selectedLibrary?.isReadOnly == null && selectedLibrary?.type === LibraryType.External)) + ? 'Allow file deletion' : 'Disallow file deletion'} + /> {#if selectedLibrary && selectedLibrary.type === LibraryType.External} onEditImportPathClicked()} text="Edit Import Paths" />