Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,9 @@ jobs:
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies
run: pnpm --filter=immich-web install --frozen-lockfile
run: pnpm --filter=immich-i18n install --frozen-lockfile
- name: Format
run: pnpm --filter=immich-web format:i18n
run: pnpm --filter=immich-i18n format:fix
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
Expand Down
31 changes: 31 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Contributing to Immich

We appreciate every contribution, and we're happy about every new contributor. So please feel invited to help make Immich a better product!

## Getting started

To get you started quickly we have detailed guides for the dev setup on our [website](https://docs.immich.app/developer/setup). If you prefer, you can also use [Devcontainers](https://docs.immich.app/developer/devcontainers).
There are also additional resources about Immich's architecture, database migrations, the use of OpenAPI, and more in our [developer documentation](https://docs.immich.app/developer/architecture).

## General

Please try to keep pull requests as focused as possible. A PR should do exactly one thing and not bleed into other, unrelated areas. The smaller a PR, the fewer changes are likely needed, and the quicker it will likely be merged. For larger/more impactful PRs, please reach out to us first to discuss your plans. The best way to do this is through our [Discord](https://discord.immich.app). We have a dedicated `#contributing` channel there. Additionally, please fill out the entire template when opening a PR.

## Finding work

If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!

## Use of generative AI

We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template.

## Feature freezes

From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on:

* Sharing/Asset ownership
* (External) libraries

## Non-code contributions

If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.
4 changes: 4 additions & 0 deletions docs/docs/developer/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ sidebar_position: 2

# Setup

:::warning
Make sure to read the [`CONTRIBUTING.md`](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before you dive into the code.
:::

:::note
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:

Expand Down
5 changes: 5 additions & 0 deletions i18n/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json"]
}
4 changes: 4 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,7 @@
"unable_to_scan_library": "Unable to scan library",
"unable_to_set_feature_photo": "Unable to set feature photo",
"unable_to_set_profile_picture": "Unable to set profile picture",
"unable_to_set_rating": "Unable to set rating",
"unable_to_submit_job": "Unable to submit job",
"unable_to_trash_asset": "Unable to trash asset",
"unable_to_unlink_account": "Unable to unlink account",
Expand Down Expand Up @@ -1702,10 +1703,12 @@
"purchase_settings_server_activated": "The server product key is managed by the admin",
"query_asset_id": "Query Asset ID",
"queue_status": "Queuing {count}/{total}",
"rate_asset": "Rate Asset",
"rating": "Star rating",
"rating_clear": "Clear rating",
"rating_count": "{count, plural, one {# star} other {# stars}}",
"rating_description": "Display the EXIF rating in the info panel",
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
"reaction_options": "Reaction options",
"read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled",
Expand Down Expand Up @@ -2296,6 +2299,7 @@
"yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links",
"your_wifi_name": "Your Wi-Fi name",
"zero_to_clear_rating": "press 0 to clear asset rating",
"zoom_image": "Zoom Image",
"zoom_to_bounds": "Zoom to bounds"
}
13 changes: 13 additions & 0 deletions i18n/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "immich-i18n",
"version": "1.0.0",
"private": true,
"scripts": {
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.7.4",
"prettier-plugin-sort-json": "^4.1.1"
}
}
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ run = { task = ":i18n:format-fix" }

[tasks."i18n:format-fix"]
dir = "i18n"
run = "pnpm dlx sort-json *.json"
run = "pnpm run format:fix"
2 changes: 1 addition & 1 deletion mobile/makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ migration:
dart run drift_dev make-migrations

translation:
npm --prefix ../web run format:i18n
npm --prefix ../i18n run format:fix
dart run easy_localization:generate -S ../i18n
dart run bin/generate_keys.dart
dart format lib/generated/codegen_loader.g.dart
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ packages:
- cli
- docs
- e2e
- i18n
- open-api/typescript-sdk
- server
- plugins
Expand Down
2 changes: 1 addition & 1 deletion server/src/utils/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
)
.$if(!!options.withExif, withExifInner)
.$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople))
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
}

Expand Down
38 changes: 37 additions & 1 deletion server/test/medium/specs/services/search.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Kysely } from 'kysely';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
Expand All @@ -16,7 +17,14 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(SearchService, {
database: db || defaultDatabase,
real: [AccessRepository, DatabaseRepository, SearchRepository, PartnerRepository, PersonRepository],
real: [
AccessRepository,
AssetRepository,
DatabaseRepository,
SearchRepository,
PartnerRepository,
PersonRepository,
],
mock: [LoggingRepository],
});
};
Expand Down Expand Up @@ -52,4 +60,32 @@ describe(SearchService.name, () => {
expect.objectContaining({ id: assets[1].id }),
]);
});

describe('searchStatistics', () => {
it('should return statistics when filtering by personIds', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { person } = await ctx.newPerson({ ownerId: user.id });
await ctx.newAssetFace({ assetId: asset.id, personId: person.id });

const auth = factory.auth({ user: { id: user.id } });

const result = await sut.searchStatistics(auth, { personIds: [person.id] });

expect(result).toEqual({ total: 1 });
});

it('should return zero when no assets match the personIds filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });

const auth = factory.auth({ user: { id: user.id } });

const result = await sut.searchStatistics(auth, { personIds: [person.id] });

expect(result).toEqual({ total: 0 });
});
});
});
3 changes: 1 addition & 2 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
"lint": "eslint . --max-warnings 0 --concurrency 4",
"lint:fix": "pnpm run lint --fix",
"format": "prettier --check .",
"format:fix": "prettier --write . && pnpm run format:i18n",
"format:i18n": "pnpm dlx sort-json ../i18n/*.json",
"format:fix": "prettier --write .",
"test": "vitest",
"test:cov": "vitest --coverage",
"test:watch": "vitest dev",
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/components/asset-viewer/actions/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type ActionMap = {
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
[AssetAction.RATING]: { asset: TimelineAsset; rating: number | null };
};

export type Action = {
Expand Down
55 changes: 55 additions & 0 deletions web/src/lib/components/asset-viewer/actions/rating-action.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants';
import { preferences } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';

type Props = {
asset: AssetResponseDto;
onAction: OnAction;
};

let { asset, onAction }: Props = $props();

const rateAsset = async (rating: number | null) => {
try {
const updateAssetDto = rating === null ? {} : { rating };
await updateAsset({
id: asset.id,
updateAssetDto,
});

asset = {
...asset,
exifInfo: {
...asset.exifInfo,
rating,
},
};

onAction({
type: AssetAction.RATING,
asset: toTimelineAsset(asset),
rating,
});
} catch (error) {
handleError(error, $t('errors.unable_to_set_rating'));
}
};
</script>

<svelte:document
use:shortcuts={$preferences?.ratings.enabled
? [
{ shortcut: { key: '0' }, onShortcut: () => rateAsset(null) },
...[1, 2, 3, 4, 5].map((rating) => ({
shortcut: { key: String(rating) },
onShortcut: () => rateAsset(rating),
})),
]
: []}
/>
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
Expand Down Expand Up @@ -68,7 +69,6 @@
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
showCloseButton?: boolean;
showSlideshow?: boolean;
onZoomImage: () => void;
onCopyImage?: () => Promise<void>;
Expand All @@ -78,7 +78,7 @@
onRunJob: (name: AssetJobName) => void;
onPlaySlideshow: () => void;
// export let showEditorHandler: () => void;
onClose: () => void;
onClose?: () => void;
motionPhoto?: Snippet;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
Expand All @@ -89,7 +89,6 @@
album = null,
person = null,
stack = null,
showCloseButton = true,
showSlideshow = false,
onZoomImage,
onCopyImage,
Expand Down Expand Up @@ -127,7 +126,7 @@
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200"
>
<div class="dark">
{#if showCloseButton}
{#if onClose}
<CloseAction {onClose} />
{/if}
</div>
Expand Down Expand Up @@ -179,6 +178,7 @@

{#if isOwner}
<FavoriteAction {asset} {onAction} />
<RatingAction {asset} {onAction} />
{/if}

{#if isOwner}
Expand Down
19 changes: 13 additions & 6 deletions web/src/lib/components/asset-viewer/asset-viewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
onUndoDelete?: OnUndoDelete | undefined;
showCloseButton?: boolean;
onClose: (asset: AssetResponseDto) => void;
onClose?: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
Expand All @@ -84,7 +83,6 @@
preAction = undefined,
onAction = undefined,
onUndoDelete = undefined,
showCloseButton,
onClose,
onNext,
onPrevious,
Expand Down Expand Up @@ -203,7 +201,7 @@
};

const closeViewer = () => {
onClose(asset);
onClose?.(asset);
};

const closeEditor = () => {
Expand Down Expand Up @@ -331,6 +329,16 @@
asset = { ...asset, people: assetInfo.people };
break;
}
case AssetAction.RATING: {
asset = {
...asset,
exifInfo: {
...asset.exifInfo,
rating: action.rating,
},
};
break;
}
case AssetAction.KEEP_THIS_DELETE_OTHERS:
case AssetAction.UNSTACK: {
closeViewer();
Expand Down Expand Up @@ -401,7 +409,6 @@
{album}
{person}
{stack}
{showCloseButton}
showSlideshow={true}
onZoomImage={zoomToggle}
onCopyImage={copyImage}
Expand All @@ -410,7 +417,7 @@
{onUndoDelete}
onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={closeViewer}
onClose={onClose ? () => onClose(asset) : undefined}
{playOriginalVideo}
{setPlayOriginalVideo}
>
Expand Down
Loading
Loading