Juho Mäkinen 2025-12-18 10:15:36 +02:00 committed by GitHub
commit 3fa65000b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 130 additions and 2 deletions

View File

@ -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",

View File

@ -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"
}, },

View File

@ -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,
}; };
}; };

View File

@ -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)

View File

@ -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) =>

View File

@ -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);
}

View File

@ -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>;
} }

View File

@ -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 });

View File

@ -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();

View File

@ -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">