Merge f17d8c4836 into 5ade152bc5
commit
3fa65000b2
|
|
@ -962,6 +962,7 @@
|
||||||
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
|
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
|
||||||
"cant_apply_changes": "Can't apply changes",
|
"cant_apply_changes": "Can't apply changes",
|
||||||
"cant_change_activity": "Can't {enabled, select, true {disable} other {enable}} activity",
|
"cant_change_activity": "Can't {enabled, select, true {disable} other {enable}} activity",
|
||||||
|
"cant_change_timeline_visibility": "Unable to change timeline visibility",
|
||||||
"cant_change_asset_favorite": "Can't change favorite for asset",
|
"cant_change_asset_favorite": "Can't change favorite for asset",
|
||||||
"cant_change_metadata_assets_count": "Can't change metadata of {count, plural, one {# asset} other {# assets}}",
|
"cant_change_metadata_assets_count": "Can't change metadata of {count, plural, one {# asset} other {# assets}}",
|
||||||
"cant_get_faces": "Can't get faces",
|
"cant_get_faces": "Can't get faces",
|
||||||
|
|
@ -1177,6 +1178,8 @@
|
||||||
"hide_person": "Hide person",
|
"hide_person": "Hide person",
|
||||||
"hide_text_recognition": "Hide text recognition",
|
"hide_text_recognition": "Hide text recognition",
|
||||||
"hide_unnamed_people": "Hide unnamed people",
|
"hide_unnamed_people": "Hide unnamed people",
|
||||||
|
"hide_from_timeline": "Hide from timeline",
|
||||||
|
"hide_from_timeline_description": "Photos in this album will not appear in your main timeline",
|
||||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||||
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
|
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
|
||||||
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
||||||
|
|
@ -2081,6 +2084,7 @@
|
||||||
"time_based_memories": "Time-based memories",
|
"time_based_memories": "Time-based memories",
|
||||||
"time_based_memories_duration": "Number of seconds to display each image.",
|
"time_based_memories_duration": "Number of seconds to display each image.",
|
||||||
"timeline": "Timeline",
|
"timeline": "Timeline",
|
||||||
|
"timeline_visibility_changed": "Album is now {hidden, select, true {hidden from} other {visible in}} timeline",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
"to_archive": "Archive",
|
"to_archive": "Archive",
|
||||||
"to_change_password": "Change password",
|
"to_change_password": "Change password",
|
||||||
|
|
|
||||||
|
|
@ -14671,6 +14671,9 @@
|
||||||
"hasSharedLink": {
|
"hasSharedLink": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"hideFromTimeline": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -14715,6 +14718,7 @@
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"description",
|
"description",
|
||||||
"hasSharedLink",
|
"hasSharedLink",
|
||||||
|
"hideFromTimeline",
|
||||||
"id",
|
"id",
|
||||||
"isActivityEnabled",
|
"isActivityEnabled",
|
||||||
"owner",
|
"owner",
|
||||||
|
|
@ -16143,6 +16147,9 @@
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hideFromTimeline": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|
@ -22528,6 +22535,9 @@
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"hideFromTimeline": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"isActivityEnabled": {
|
"isActivityEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ export class CreateAlbumDto {
|
||||||
|
|
||||||
@ValidateUUID({ optional: true, each: true })
|
@ValidateUUID({ optional: true, each: true })
|
||||||
assetIds?: string[];
|
assetIds?: string[];
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
hideFromTimeline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AlbumsAddAssetsDto {
|
export class AlbumsAddAssetsDto {
|
||||||
|
|
@ -86,6 +89,9 @@ export class UpdateAlbumDto {
|
||||||
|
|
||||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true })
|
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true })
|
||||||
order?: AssetOrder;
|
order?: AssetOrder;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
hideFromTimeline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetAlbumsDto {
|
export class GetAlbumsDto {
|
||||||
|
|
@ -157,6 +163,7 @@ export class AlbumResponseDto {
|
||||||
isActivityEnabled!: boolean;
|
isActivityEnabled!: boolean;
|
||||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true })
|
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true })
|
||||||
order?: AssetOrder;
|
order?: AssetOrder;
|
||||||
|
hideFromTimeline!: boolean;
|
||||||
|
|
||||||
// Optional per-user contribution counts for shared albums
|
// Optional per-user contribution counts for shared albums
|
||||||
@Type(() => ContributorCountResponseDto)
|
@Type(() => ContributorCountResponseDto)
|
||||||
|
|
@ -178,6 +185,7 @@ export type MapAlbumDto = {
|
||||||
owner: User;
|
owner: User;
|
||||||
isActivityEnabled: boolean;
|
isActivityEnabled: boolean;
|
||||||
order: AssetOrder;
|
order: AssetOrder;
|
||||||
|
hideFromTimeline: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
|
export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
|
||||||
|
|
@ -225,6 +233,7 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt
|
||||||
assetCount: entity.assets?.length || 0,
|
assetCount: entity.assets?.length || 0,
|
||||||
isActivityEnabled: entity.isActivityEnabled,
|
isActivityEnabled: entity.isActivityEnabled,
|
||||||
order: entity.order,
|
order: entity.order,
|
||||||
|
hideFromTimeline: entity.hideFromTimeline,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
|
import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
|
||||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
|
import { isUndefined, omitBy } from 'lodash';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { columns, Exif } from 'src/database';
|
import { columns, Exif } from 'src/database';
|
||||||
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
|
|
@ -294,9 +295,10 @@ export class AlbumRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, album: Updateable<AlbumTable>) {
|
update(id: string, album: Updateable<AlbumTable>) {
|
||||||
|
const value = omitBy(album, isUndefined);
|
||||||
return this.db
|
return this.db
|
||||||
.updateTable('album')
|
.updateTable('album')
|
||||||
.set(album)
|
.set(value)
|
||||||
.where('id', '=', id)
|
.where('id', '=', id)
|
||||||
.returningAll('album')
|
.returningAll('album')
|
||||||
.returning(withOwner)
|
.returning(withOwner)
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,18 @@ export class AssetRepository {
|
||||||
.where('asset_file.type', '=', AssetFileType.Preview),
|
.where('asset_file.type', '=', AssetFileType.Preview),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('album_asset')
|
||||||
|
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||||
|
.whereRef('album_asset.assetId', '=', 'asset.id')
|
||||||
|
.where('album.hideFromTimeline', '=', true)
|
||||||
|
.where('album.deletedAt', 'is', null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.where('asset.deletedAt', 'is', null)
|
.where('asset.deletedAt', 'is', null)
|
||||||
.orderBy(sql`(asset."localDateTime" at time zone 'UTC')::date`, 'desc')
|
.orderBy(sql`(asset."localDateTime" at time zone 'UTC')::date`, 'desc')
|
||||||
.limit(20)
|
.limit(20)
|
||||||
|
|
@ -624,7 +636,21 @@ export class AssetRepository {
|
||||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
.$if(options.isDuplicate !== undefined, (qb) =>
|
||||||
qb.where('asset.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
|
qb.where('asset.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
|
||||||
)
|
)
|
||||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)),
|
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||||
|
.$if(!options.albumId, (qb) =>
|
||||||
|
qb.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('album_asset')
|
||||||
|
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||||
|
.whereRef('album_asset.assetId', '=', 'asset.id')
|
||||||
|
.where('album.hideFromTimeline', '=', true)
|
||||||
|
.where('album.deletedAt', 'is', null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.select(sql<string>`("timeBucket" AT TIME ZONE 'UTC')::date::text`.as('timeBucket'))
|
.select(sql<string>`("timeBucket" AT TIME ZONE 'UTC')::date::text`.as('timeBucket'))
|
||||||
|
|
@ -725,6 +751,20 @@ export class AssetRepository {
|
||||||
)
|
)
|
||||||
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
||||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||||
|
.$if(!options.albumId, (qb) =>
|
||||||
|
qb.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('album_asset')
|
||||||
|
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||||
|
.whereRef('album_asset.assetId', '=', 'asset.id')
|
||||||
|
.where('album.hideFromTimeline', '=', true)
|
||||||
|
.where('album.deletedAt', 'is', null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy('asset.fileCreatedAt', options.order ?? 'desc'),
|
.orderBy('asset.fileCreatedAt', options.order ?? 'desc'),
|
||||||
)
|
)
|
||||||
.with('agg', (qb) =>
|
.with('agg', (qb) =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "album" ADD "hideFromTimeline" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "album" DROP COLUMN "hideFromTimeline";`.execute(db);
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,9 @@ export class AlbumTable {
|
||||||
@Column({ default: AssetOrder.Desc })
|
@Column({ default: AssetOrder.Desc })
|
||||||
order!: Generated<AssetOrder>;
|
order!: Generated<AssetOrder>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
hideFromTimeline!: Generated<boolean>;
|
||||||
|
|
||||||
@UpdateIdColumn({ index: true })
|
@UpdateIdColumn({ index: true })
|
||||||
updateId!: Generated<string>;
|
updateId!: Generated<string>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ export class AlbumService extends BaseService {
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
albumThumbnailAssetId: assetIds[0] || null,
|
albumThumbnailAssetId: assetIds[0] || null,
|
||||||
order: getPreferences(userMetadata).albums.defaultAssetOrder,
|
order: getPreferences(userMetadata).albums.defaultAssetOrder,
|
||||||
|
hideFromTimeline: dto.hideFromTimeline,
|
||||||
},
|
},
|
||||||
assetIds,
|
assetIds,
|
||||||
albumUsers,
|
albumUsers,
|
||||||
|
|
@ -153,6 +154,7 @@ export class AlbumService extends BaseService {
|
||||||
albumThumbnailAssetId: dto.albumThumbnailAssetId,
|
albumThumbnailAssetId: dto.albumThumbnailAssetId,
|
||||||
isActivityEnabled: dto.isActivityEnabled,
|
isActivityEnabled: dto.isActivityEnabled,
|
||||||
order: dto.order,
|
order: dto.order,
|
||||||
|
hideFromTimeline: dto.hideFromTimeline,
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });
|
return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,34 @@ describe(TimelineService.name, () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should exclude assets from albums with hideFromTimeline=true', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
|
||||||
|
// Create two assets
|
||||||
|
const { asset: asset1 } = await ctx.newAsset({
|
||||||
|
ownerId: user.id,
|
||||||
|
localDateTime: new Date('1970-01-01'),
|
||||||
|
});
|
||||||
|
const { asset: asset2 } = await ctx.newAsset({
|
||||||
|
ownerId: user.id,
|
||||||
|
localDateTime: new Date('1970-01-01'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an album with hideFromTimeline=true and add asset1
|
||||||
|
const { album } = await ctx.newAlbum({
|
||||||
|
ownerId: user.id,
|
||||||
|
hideFromTimeline: true,
|
||||||
|
});
|
||||||
|
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset1.id });
|
||||||
|
|
||||||
|
// Get time buckets - should only include asset2, not asset1
|
||||||
|
const response = await sut.getTimeBuckets(auth, {});
|
||||||
|
|
||||||
|
expect(response).toEqual([{ count: 1, timeBucket: '1970-01-01' }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return error if time bucket is requested with partners asset and archived', async () => {
|
it('should return error if time bucket is requested with partners asset and archived', async () => {
|
||||||
const { sut } = setup();
|
const { sut } = setup();
|
||||||
const auth = factory.auth();
|
const auth = factory.auth();
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,21 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleHideFromTimeline = async () => {
|
||||||
|
try {
|
||||||
|
album = await updateAlbumInfo({
|
||||||
|
id: album.id,
|
||||||
|
updateAlbumDto: {
|
||||||
|
hideFromTimeline: !album.hideFromTimeline,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toastManager.success($t('timeline_visibility_changed', { values: { hidden: album.hideFromTimeline } }));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.cant_change_timeline_visibility'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveUser = async (user: UserResponseDto): Promise<void> => {
|
const handleRemoveUser = async (user: UserResponseDto): Promise<void> => {
|
||||||
const confirmed = await modalManager.showDialog({
|
const confirmed = await modalManager.showDialog({
|
||||||
title: $t('album_remove_user'),
|
title: $t('album_remove_user'),
|
||||||
|
|
@ -127,6 +142,12 @@
|
||||||
checked={album.isActivityEnabled}
|
checked={album.isActivityEnabled}
|
||||||
onToggle={handleToggleActivity}
|
onToggle={handleToggleActivity}
|
||||||
/>
|
/>
|
||||||
|
<SettingSwitch
|
||||||
|
title={$t('hide_from_timeline')}
|
||||||
|
subtitle={$t('hide_from_timeline_description')}
|
||||||
|
checked={album.hideFromTimeline}
|
||||||
|
onToggle={handleToggleHideFromTimeline}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue