Immich : publier l'origine du portrait dans le graphe, pas l'identifiant

- avatarFromImmich (fichier préfixé « immich- ») remplace immichPersonId
  côté front : le panneau ne propose le retrait que si le portrait vient
  d'Immich, et le bouton de la fiche retrouve son libellé distinctif
- helper isImmichAvatarPath partagé entre le payload du graphe et la route
  unassign, qui testaient le préfixe chacun de son côté
- tests : origine du portrait dans le payload (vrai/faux/null)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mistral Vibe committed 2026-10-13 17:47:37 +02:00
1 parent 8d36a08b11
commit 310f2fb597
10 files changed
+50 -12

No files matched your search

+6
View File
@@ -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<void> {
if (!relativePath || /^https?:\/\//.test(relativePath)) return
+2 -2
View File
@@ -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
+3
View File
@@ -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,
+23
View File
@@ -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')
+6 -7
View File
@@ -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 && (
<div className="immich-current">
<p>{t('{name} peut être détachée d’une personne Immich, même si Immich est injoignable.', { name: person.firstName || person.lastName })}</p>
<p>{t('{name} est déjà associée à une personne Immich.', { name: person.firstName || person.lastName })}</p>
<button type="button" className="pz-btn pz-btn--danger-ghost" onClick={handleUnassign} disabled={actionLoading}>
<Unlink size={16} aria-hidden="true" /> {t('Retirer l’association')}
</button>
+1 -1
View File
@@ -378,7 +378,7 @@ const PersonCard = ({
)}
{treeId && onImmichChange && (
<button type="button" className="pc-photo-btn" onClick={() => setShowImmichPanel(true)} disabled={avatarActionLoading}>
<Sparkles size={14} aria-hidden="true" /> {t('Depuis Immich')}
<Sparkles size={14} aria-hidden="true" /> {person.avatarFromImmich ? t('Visage Immich') : t('Depuis Immich')}
</button>
)}
</div>
+1
View File
@@ -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,
}
}
+5
View File
@@ -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)
})
})
+2 -2
View File
@@ -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',
+1
View File
@@ -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
}))