diff --git a/backend/src/lib/media-files.ts b/backend/src/lib/media-files.ts index ced93aa..70ce049 100644 --- a/backend/src/lib/media-files.ts +++ b/backend/src/lib/media-files.ts @@ -211,6 +211,12 @@ export function applyMediaResponseHeaders(reply: FastifyReply, storedMimeType: s reply.header('Content-Disposition', `attachment; filename="${safeName}"`) } +// Portrait venu d'une association Immich : son fichier porte le préfixe « immich- ». Le graphe +// ne publie jamais l'identifiant Immich, seulement cette origine du portrait. +export function isImmichAvatarPath(avatarPath: string | null | undefined): boolean { + return avatarPath != null && path.basename(avatarPath).startsWith('immich-') +} + // Efface un fichier du stockage, sans jamais sortir du dossier racine export async function removeStoredFile(storageRoot: string, relativePath: string | null | undefined): Promise { if (!relativePath || /^https?:\/\//.test(relativePath)) return diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 3ff6609..024ed46 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -16,7 +16,7 @@ import { type ImmichPersonSummary, } from '../lib/immich.js' import { encryptImmichApiKey } from '../lib/immich-secret.js' -import { InvalidMediaError, removeStoredFile, sniffMimeType } from '../lib/media-files.js' +import { InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js' import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js' // Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la @@ -503,7 +503,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { // Le portrait vient d'Immich uniquement si son fichier porte notre préfixe. Un portrait // envoyé à la main reste en place : seule l'association est retirée. - const avatarFromImmich = person.avatarPath !== null && path.basename(person.avatarPath).startsWith('immich-') + const avatarFromImmich = isImmichAvatarPath(person.avatarPath) const updateData: { immichPersonId: null diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index ea847dc..4ae8370 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -1,6 +1,7 @@ import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client' import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js' +import { isImmichAvatarPath } from '../lib/media-files.js' export type GraphPayload = { persons: Array<{ @@ -17,6 +18,7 @@ export type GraphPayload = { avatarUrlPath: string | null sex: string | null notes: string | null + avatarFromImmich: boolean }> unions: Array<{ id: string @@ -90,6 +92,7 @@ export function buildGraphPayload( avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null, sex: person.sex, notes: person.notes, + avatarFromImmich: isImmichAvatarPath(person.avatarPath), })), unions: unions.map((union) => ({ id: union.id, diff --git a/backend/test/graph-payload.test.ts b/backend/test/graph-payload.test.ts index 8b1e272..bb18e36 100644 --- a/backend/test/graph-payload.test.ts +++ b/backend/test/graph-payload.test.ts @@ -42,6 +42,24 @@ describe('buildGraphPayload', () => { updatedAt: new Date(), deletedAt: null, }, + { + id: 'p-3', + treeId: 't-1', + firstName: 'Paul', + lastName: 'Durand', + birthName: null, + birthDate: null, + deathDate: null, + sex: 'male', + notes: null, + avatarPath: 't-1/p-3/immich-abc.jpg', + immichPersonId: '123e4567-e89b-42d3-a456-426614174999', + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }, ] as any, [ { @@ -99,8 +117,13 @@ describe('buildGraphPayload', () => { expect(graph.persons[0].birthName).toBe('Durand') expect(graph.persons[0].avatarUrlPath).toBe('/trees/t-1/persons/p-2/avatar') // L'identifiant Immich ne part jamais dans le graphe : il ne sert qu'aux administrateurs. + // Seule l'origine du portrait (fichier préfixé « immich- ») est publiée. expect(graph.persons[0]).not.toHaveProperty('immichPersonId') expect(graph.persons[1]).not.toHaveProperty('immichPersonId') + expect(graph.persons[2]).not.toHaveProperty('immichPersonId') + expect(graph.persons[0].avatarFromImmich).toBe(false) + expect(graph.persons[1].avatarFromImmich).toBe(false) + expect(graph.persons[2].avatarFromImmich).toBe(true) expect(graph.persons[1].avatarUrlPath).toBeNull() expect(graph.unions[0].id).toBe('u-1') expect(graph.filiations[0].viaUnionId).toBe('u-1') diff --git a/frontend/src/components/ImmichFacePanel.jsx b/frontend/src/components/ImmichFacePanel.jsx index b0e9e99..e029727 100644 --- a/frontend/src/components/ImmichFacePanel.jsx +++ b/frontend/src/components/ImmichFacePanel.jsx @@ -113,7 +113,7 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => }, [selected, actionLoading, treeId, person, token, onImmichChange, onClose]) const handleUnassign = useCallback(async () => { - if (actionLoading) return + if (!person.avatarFromImmich || actionLoading) return setActionLoading(true) setError('') try { @@ -137,13 +137,12 @@ const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => } } - // Le retrait se propose toujours : le graphe ne dit plus qui est associé. Une association en - // place se retire aussi bien sans connexion : changer ou retirer le serveur Immich ne laisse - // jamais une ancienne image impossible à nettoyer manuellement. Sans association, le retrait - // ne fait rien côté serveur. - const unassignBlock = ( + // L'association en place se retire aussi bien sans connexion : changer ou retirer le serveur + // Immich ne laisse jamais une ancienne image impossible à nettoyer manuellement. Le graphe + // ne dit que l'origine du portrait (avatarFromImmich), jamais l'identifiant Immich. + const unassignBlock = person.avatarFromImmich && (
-

{t('{name} peut être détachée d’une personne Immich, même si Immich est injoignable.', { name: person.firstName || person.lastName })}

+

{t('{name} est déjà associée à une personne Immich.', { name: person.firstName || person.lastName })}

diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index fed617a..3642112 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -378,7 +378,7 @@ const PersonCard = ({ )} {treeId && onImmichChange && ( )}
diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index 7a1084d..6c3d1ba 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -115,6 +115,7 @@ function mapRemotePersonToLocal(person, current = null) { profession: person?.profession || null, nationality: person?.nationality || null, photo: current?.photo || null, + avatarFromImmich: person?.avatarFromImmich ?? false, } } diff --git a/frontend/src/data/mockData.test.js b/frontend/src/data/mockData.test.js index 91ee070..ee6caf9 100644 --- a/frontend/src/data/mockData.test.js +++ b/frontend/src/data/mockData.test.js @@ -15,6 +15,7 @@ describe('loadGraphData avatar mapping', () => { deathDate: null, notes: null, avatarUrlPath: '/trees/t-1/persons/p-1/avatar', + avatarFromImmich: true, }, { id: 'p-2', @@ -24,6 +25,7 @@ describe('loadGraphData avatar mapping', () => { deathDate: null, notes: null, avatarUrlPath: null, + avatarFromImmich: false, }, ], unions: [], @@ -48,6 +50,9 @@ describe('loadGraphData avatar mapping', () => { expect(getPersonById('p-1')?.photo).toBe('/api/trees/t-1/persons/p-1/avatar?token=abc') expect(getPersonById('p-2')?.photo).toBeNull() expect(getPersonMedias('p-2')[0]?.source).toBe('Album familial') + // Origine du portrait portée par la personne (faux quand il ne vient pas d'Immich) + expect(getPersonById('p-1')?.avatarFromImmich).toBe(true) + expect(getPersonById('p-2')?.avatarFromImmich).toBe(false) }) }) diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index f54142c..8236c81 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -261,6 +261,7 @@ export default { 'Inconnu': 'Unknown', // Visages venus d'Immich (PersonCard, ImmichFacePanel, immichApi) 'Depuis Immich': 'From Immich', + 'Visage Immich': 'Immich face', 'Reconnaissance des visages': 'Face recognition', 'Immich n’est pas configuré pour cet arbre.': 'Immich is not configured for this tree.', 'Immich n’est pas configuré pour cet arbre. Ses administrateurs le règlent dans Paramètres de l’arbre.': 'Immich is not configured for this tree. Its administrators set it up in Tree settings.', @@ -274,8 +275,7 @@ export default { 'Cette adresse Immich n’est pas autorisée par l’opérateur du serveur.': 'This Immich address is not authorized by the server operator.', 'Seuls les administrateurs de l’arbre peuvent associer un visage Immich.': 'Only tree administrators can link an Immich face.', 'Erreur inattendue.': 'Unexpected error.', - '{name} peut être détachée d’une personne Immich, même si Immich est injoignable.': - '{name} can be detached from an Immich person, even when Immich cannot be reached.', + '{name} est déjà associée à une personne Immich.': '{name} is already linked to an Immich person.', 'Retirer l’association': 'Remove the link', 'Personne à associer dans Immich': 'Immich person to link', 'Rechercher par nom': 'Search by name', diff --git a/frontend/src/utils/graphAdapter.js b/frontend/src/utils/graphAdapter.js index 7fb09ca..c079b16 100644 --- a/frontend/src/utils/graphAdapter.js +++ b/frontend/src/utils/graphAdapter.js @@ -57,6 +57,7 @@ export function adaptGraphForGalaxy(apiGraph, _treeId) { birthDate: p.birthDate, deathDate: p.deathDate, photo: p.avatarUrlPath ? `/api${p.avatarUrlPath}` : null, + avatarFromImmich: Boolean(p.avatarFromImmich), frameType: 'round', // Default }))