diff --git a/CHANGELOG.md b/CHANGELOG.md index 548bce6..4114143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ Notable changes to Parenthèse. Dates are in YYYY-MM-DD format. +## Unreleased + +### First photos + +- Tree creation offers "I already have a tree": the .ged file is imported right away, without creating a "you" entry that would end up duplicated. +- GEDCOM import reads the photos each person had in the original software (`OBJE` / `FILE`, inline or by reference, primary photo first). After an import, the owner picks the folder where those photos live and Parenthèse places each portrait by file name. Paths stay on the device, nothing is stored on the server. +- After an import, a window suggests five faces to start with: the close family of the person marked "Me", otherwise the most recent generations. +- For the owner, an empty portrait invites a photo: the empty frame on a profile is a button outside edit mode, and hovering an empty portrait in the tree shows "+ photo". +- Phone photos over 5 MB are scaled down to 2000 px before upload instead of being refused. +- Owner portrait uploads are limited to 120 per minute (was 30), still within the tree's storage quota. +- Anonymous events: `gedcom_imported`, `portraits_bulk_placed`, `portrait_added` (rounded counts, no names, paths or tree address). + +### Fixes + +- Children of a family with only one known parent (common in GEDCOM files) are linked to that parent, under a single "Unknown" partner, instead of floating in the tree without a link. +- Importing a GEDCOM file into a tree that already holds it no longer duplicates everyone: people with the same first name, last name, birth and death dates are reused, and existing unions and parent links are kept. + ## 1.0.3 (2026-10-10) ### Self-hosting diff --git a/backend/src/lib/gedcom-photos.ts b/backend/src/lib/gedcom-photos.ts new file mode 100644 index 0000000..91ee298 --- /dev/null +++ b/backend/src/lib/gedcom-photos.ts @@ -0,0 +1,56 @@ +// Photos citées par un fichier GEDCOM. Le fichier ne contient pas les images, seulement leur chemin sur +// l'ordinateur de la personne (« C:\Généalogie\Photos\jean.jpg »). L'import renvoie ce chemin pour chaque +// personne, et l'app les relie aux fichiers du dossier que la personne choisit, sans rien garder en base. + +export type GedcomPhotoNode = { + xref?: string + tag: string + value: string + children: GedcomPhotoNode[] +} + +const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tif', 'tiff', 'heic', 'heif']) +const MAX_PATH_LENGTH = 500 + +function child(node: GedcomPhotoNode | undefined, tag: string): GedcomPhotoNode | undefined { + return node?.children.find((c) => c.tag === tag) +} + +function isImagePath(value: string): boolean { + if (!value || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false + const name = value.split(/[\\/]/).pop() || '' + const dot = name.lastIndexOf('.') + return dot > 0 && IMAGE_EXTENSIONS.has(name.slice(dot + 1).toLowerCase()) +} + +// Un OBJE est soit écrit sous la personne (« 1 OBJE » puis « 2 FILE »), soit un renvoi (« 1 OBJE @M1@ ») +// vers un enregistrement « 0 @M1@ OBJE » en fin de fichier. Les logiciels marquent la photo principale +// par _PRIM Y (sous OBJE ou sous FILE) : elle passe devant, sinon la première image citée. +export function photoPathForPerson( + indi: GedcomPhotoNode, + objeByXref: Map, +): string | null { + let first: string | null = null + for (const ref of indi.children) { + if (ref.tag !== 'OBJE') continue + const pointer = ref.value.match(/^@([^@]+)@$/) + const obje = pointer ? objeByXref.get(pointer[1]) : ref + if (!obje) continue + const isPrimary = (node: GedcomPhotoNode | undefined) => child(node, '_PRIM')?.value?.toUpperCase().startsWith('Y') + for (const file of obje.children.filter((c) => c.tag === 'FILE')) { + const filePath = file.value.trim() + if (!isImagePath(filePath) || filePath.length > MAX_PATH_LENGTH) continue + if (isPrimary(ref) || isPrimary(obje) || isPrimary(file)) return filePath + first ??= filePath + } + } + return first +} + +export function indexObjeRecords(roots: GedcomPhotoNode[]): Map { + const map = new Map() + for (const node of roots) { + if (node.tag === 'OBJE' && node.xref) map.set(node.xref, node) + } + return map +} diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 8795b55..d5789a4 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify' import { z } from 'zod' import { UPLOAD_BODY_LIMIT } from '../config/env.js' +import { indexObjeRecords, photoPathForPerson } from '../lib/gedcom-photos.js' import type { MembershipRole } from '../types/auth.js' const treeIdParamSchema = z.object({ @@ -52,7 +53,7 @@ type GedcomNode = { children: GedcomNode[] } -function parseGedcom(text: string): GedcomNode[] { +export function parseGedcom(text: string): GedcomNode[] { const lines = text.split(/\r\n|\r|\n/) const roots: GedcomNode[] = [] const stack: GedcomNode[] = [] @@ -176,6 +177,18 @@ function parseGedcomName(value: string): { firstName: string; lastName: string } return { firstName: value.trim(), lastName: '' } } +// Réimport : une personne du fichier déjà présente dans l'arbre (même prénom, nom, naissance et décès) +// est reprise au lieu d'être créée une seconde fois +function personKey(firstName: string, lastName: string, birthDate: Date | null, deathDate: Date | null): string { + const norm = (value: string) => value.normalize('NFC').trim().toLowerCase() + const day = (value: Date | null) => (value ? value.toISOString().slice(0, 10) : '') + return [norm(firstName), norm(lastName), day(birthDate), day(deathDate)].join('|') +} + +function pairKey(a: string, b: string | null): string { + return b ? [a, b].sort().join('|') : `${a}|` +} + function sanitizeXref(id: string): string { return id.replace(/[^A-Za-z0-9_]/g, '_') } @@ -466,7 +479,30 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { } const xrefToPersonId = new Map() + const objeByXref = indexObjeRecords(roots) + const photoRefs: Array<{ personId: string; file: string }> = [] + + // Ce que l'arbre contient déjà : un second import du même fichier complète l'arbre sans rien doubler. + // Chaque personne existante ne sert qu'une fois, deux fiches identiques du fichier restent deux personnes. + const [existingPersons, existingUnions, existingLinks] = await Promise.all([ + app.prisma.person.findMany({ + where: { treeId, deletedAt: null }, + select: { id: true, firstName: true, lastName: true, birthDate: true, deathDate: true, avatarPath: true }, + }), + app.prisma.union.findMany({ where: { treeId, deletedAt: null }, select: { id: true, partner1PersonId: true, partner2PersonId: true } }), + app.prisma.parentChildLink.findMany({ where: { treeId, deletedAt: null }, select: { parentPersonId: true, childPersonId: true } }), + ]) + const reusablePersons = new Map>() + for (const person of existingPersons) { + const key = personKey(person.firstName, person.lastName, person.birthDate, person.deathDate) + if (!reusablePersons.has(key)) reusablePersons.set(key, []) + reusablePersons.get(key)!.push({ id: person.id, hasAvatar: Boolean(person.avatarPath) }) + } + const unionByPair = new Map(existingUnions.map((u) => [pairKey(u.partner1PersonId, u.partner2PersonId), u.id])) + const linkKeys = new Set(existingLinks.map((l) => `${l.parentPersonId}:${l.childPersonId}`)) + let personsCreated = 0 + let personsReused = 0 let unionsCreated = 0 let linksCreated = 0 @@ -498,14 +534,29 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { notes = clip([noteNode.value, ...contLines].join('\n'), 2000) } + const personFirstName = (firstName || '?').slice(0, 120) + const personLastName = (lastName || '').slice(0, 120) + const birthDate = isoDateToDbDate(birthDateIso) + const deathDate = isoDateToDbDate(deathDateIso) + const photoPath = photoPathForPerson(indi, objeByXref) + + const existing = reusablePersons.get(personKey(personFirstName, personLastName, birthDate, deathDate))?.shift() + if (existing) { + xrefToPersonId.set(indi.xref!, existing.id) + personsReused++ + // Une personne qui a déjà son portrait le garde + if (photoPath && !existing.hasAvatar) photoRefs.push({ personId: existing.id, file: photoPath }) + continue + } + const person = await tx.person.create({ data: { treeId, - firstName: (firstName || '?').slice(0, 120), - lastName: (lastName || '').slice(0, 120), + firstName: personFirstName, + lastName: personLastName, sex, - birthDate: isoDateToDbDate(birthDateIso), - deathDate: isoDateToDbDate(deathDateIso), + birthDate, + deathDate, birthPlace, profession, notes, @@ -516,6 +567,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { xrefToPersonId.set(indi.xref!, person.id) personsCreated++ + if (photoPath) photoRefs.push({ personId: person.id, file: photoPath }) } for (const fam of famNodes) { @@ -530,16 +582,22 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const marrNode = findChild(fam, 'MARR') const marriageDateIso = gedcomDateToIso(findChild(marrNode, 'DATE')?.value ?? '') - const union = await tx.union.create({ - data: { - treeId, - partner1PersonId: partner1Id ?? partner2Id!, - partner2PersonId: partner1Id ? (partner2Id ?? null) : null, - startDate: isoDateToDbDate(marriageDateIso), - }, - }) - - unionsCreated++ + const firstPartner = partner1Id ?? partner2Id! + const secondPartner = partner1Id ? (partner2Id ?? null) : null + let unionId = unionByPair.get(pairKey(firstPartner, secondPartner)) + if (!unionId) { + const union = await tx.union.create({ + data: { + treeId, + partner1PersonId: firstPartner, + partner2PersonId: secondPartner, + startDate: isoDateToDbDate(marriageDateIso), + }, + }) + unionId = union.id + unionByPair.set(pairKey(firstPartner, secondPartner), unionId) + unionsCreated++ + } const chilNodes = findAllChildren(fam, 'CHIL') const seenChildren = new Set() @@ -555,14 +613,17 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const parentIds = [partner1Id, partner2Id].filter((id): id is string => Boolean(id)) for (const parentId of parentIds) { + const linkKey = `${parentId}:${childId}` + if (linkKeys.has(linkKey)) continue await tx.parentChildLink.create({ data: { treeId, parentPersonId: parentId, childPersonId: childId, - viaUnionId: union.id, + viaUnionId: unionId, }, }) + linkKeys.add(linkKey) linksCreated++ } } @@ -576,7 +637,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { action: 'gedcom_imported', entityType: 'tree', entityId: treeId, - payloadJson: { personsCreated, unionsCreated, linksCreated }, + payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, photoRefs: photoRefs.length }, }, }) }, { timeout: 60000 }) @@ -584,6 +645,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { importInProgress = false } - return reply.send({ personsCreated, unionsCreated, linksCreated }) + // photoRefs : chemin de la photo que le logiciel d'origine associait à chaque personne (rien n'est gardé) + return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, photoRefs }) }) } diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 0488fef..e77c0d8 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -186,6 +186,9 @@ async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string // Seules les routes d'envoi acceptent un corps de 12 Mo (fichier en base64), le reste de l'API est à 1 Mo (app.ts) const UPLOAD_RATE_LIMIT = { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } } +// Portraits : réservés au propriétaire, mais l'app peut en placer quelques centaines d'un coup (photos +// retrouvées après un import GEDCOM), envoyées une par une. Le quota de stockage de l'arbre s'applique. +const AVATAR_RATE_LIMIT = { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 120, timeWindow: '1 minute' } } } async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) { const membership = await app.prisma.treeMembership.findUnique({ @@ -461,7 +464,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(201).send({ media, status: media.status }) }) - app.post('/trees/:id/persons/:personId/avatar', UPLOAD_RATE_LIMIT, async (request, reply) => { + app.post('/trees/:id/persons/:personId/avatar', AVATAR_RATE_LIMIT, async (request, reply) => { if (!request.actor || request.actor.kind !== 'user') { return reply.code(401).send({ error: 'authentication_required' }) } diff --git a/backend/test/gedcom-photos.test.ts b/backend/test/gedcom-photos.test.ts new file mode 100644 index 0000000..b639177 --- /dev/null +++ b/backend/test/gedcom-photos.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { indexObjeRecords, photoPathForPerson } from '../src/lib/gedcom-photos' +import { parseGedcom } from '../src/routes/gedcom' + +function photosOf(text: string): Record { + const roots = parseGedcom(text) + const objes = indexObjeRecords(roots) + const out: Record = {} + for (const indi of roots.filter((n) => n.tag === 'INDI' && n.xref)) { + out[indi.xref!] = photoPathForPerson(indi, objes) + } + return out +} + +describe('photos citées par un GEDCOM', () => { + it('lit un OBJE écrit sous la personne (GEDCOM 5.5)', () => { + const text = [ + '0 @I1@ INDI', + '1 NAME Jean /Dupont/', + '1 OBJE', + '2 FORM jpg', + '2 FILE C:\\Genealogie\\Photos\\jean.jpg', + ].join('\n') + expect(photosOf(text)).toEqual({ I1: 'C:\\Genealogie\\Photos\\jean.jpg' }) + }) + + it('suit un renvoi vers un enregistrement OBJE (Gramps, GEDCOM 5.5.1)', () => { + const text = [ + '0 @I1@ INDI', + '1 NAME Marie /Martin/', + '1 OBJE @M7@', + '0 @M7@ OBJE', + '1 FILE photos/marie 1920.png', + '2 FORM png', + ].join('\n') + expect(photosOf(text)).toEqual({ I1: 'photos/marie 1920.png' }) + }) + + it('préfère la photo marquée principale, sinon la première image', () => { + const text = [ + '0 @I1@ INDI', + '1 OBJE', + '2 FILE acte-naissance.pdf', + '1 OBJE', + '2 FILE groupe.jpg', + '1 OBJE', + '2 FILE portrait.jpg', + '2 _PRIM Y', + '0 @I2@ INDI', + '1 OBJE', + '2 FILE acte.pdf', + '1 OBJE', + '2 FILE premier.JPEG', + '1 OBJE', + '2 FILE second.jpg', + ].join('\n') + expect(photosOf(text)).toEqual({ I1: 'portrait.jpg', I2: 'premier.JPEG' }) + }) + + it('ignore les adresses web, les documents et les renvois cassés', () => { + const text = [ + '0 @I1@ INDI', + '1 OBJE', + '2 FILE https://exemple.org/photo.jpg', + '1 OBJE @M404@', + '1 OBJE', + '2 FILE registre.pdf', + '0 @I2@ INDI', + '1 NAME Sans /Photo/', + ].join('\n') + expect(photosOf(text)).toEqual({ I1: null, I2: null }) + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6d1fb8c..57b551c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import AccessGate from './components/AccessGate' import AccountDashboard from './components/AccountDashboard' import AccountPanel from './components/AccountPanel' import TreeCreationWizard from './components/TreeCreationWizard' +import PhotoStartPanel from './components/PhotoStartPanel.jsx' import ContributionPanel from './components/ContributionPanel' import EditModeOverlay from './components/EditSessionBar.jsx' import EditConfirmModal from './components/EditConfirmModal' @@ -33,6 +34,7 @@ import { batchAnnotations, deletePersonAvatar, getTree, + importGedcom, linkTreeAccess, updateTree, rotateTreePasswords, @@ -53,7 +55,8 @@ import { useEditMode } from './hooks/useEditMode' import { useAnnotations } from './hooks/useAnnotations' import AnnotationToolbar from './components/annotations/AnnotationToolbar' import TextInputOverlay from './components/annotations/TextInputOverlay' -import { filiations, medias, persons, unions } from './data/mockData' +import { filiations, getChildren, getParents, getPersonUnions, medias, persons, unions } from './data/mockData' +import { loadPhotoRefs, pickFirstFaces, prepareImageForUpload, savePhotoRefs, sizeBucket } from './utils/photoImport' import { fileToBase64, getAccountErrorMessage } from './utils/errorMessages' import { getSiblingParentLinks } from './utils/familyLinks' import { getUploadMimeType, validateMediaFile } from './utils/mediaUpload' @@ -476,6 +479,9 @@ function App() { }) const [adminPanelVisible, setAdminPanelVisible] = useState(false) + // Premiers visages : fenêtre ouverte après un import GEDCOM, ou depuis les paramètres de l'arbre + const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], faces: [] }) + const photoStartDirtyRef = useRef(false) const [showConfirmModal, setShowConfirmModal] = useState(false) const [showDraftBanner, setShowDraftBanner] = useState(false) const [accountPanelVisible, setAccountPanelVisible] = useState(false) @@ -494,6 +500,7 @@ function App() { const [treeWizardVisible, setTreeWizardVisible] = useState(false) const [treeWizardLoading, setTreeWizardLoading] = useState(false) const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false) + const [treeImportError, setTreeImportError] = useState('') const [contribInviteState, setContribInviteState] = useState({ shareUrl: '', knownPasswords: { @@ -553,6 +560,30 @@ function App() { persons.find((candidate) => String(candidate.id) === String(linkedSelfPersonId)) || null ), [linkedSelfPersonId, tree.graphRevision, tree.treeContext.treeId]) + // Les visages à demander en premier : la famille proche de « Moi » (ou de la personne de départ), + // sinon les générations les plus récentes + const computeFirstFaces = useCallback(() => pickFirstFaces({ + persons, + selfId: linkedSelfPersonId || tree.treeContext.rootPersonId || null, + getParents, + getChildren, + getPartners: (id) => getPersonUnions(id) + .map((union) => (union.partner1Id === id ? union.partner2Id : union.partner1Id)) + .map((partnerId) => persons.find((person) => person.id === partnerId)) + .filter(Boolean), + }), [linkedSelfPersonId, tree.treeContext.rootPersonId]) + + const openPhotoStart = useCallback(({ personsImported = null, refs = null, treeId = null } = {}) => { + photoStartDirtyRef.current = false + setPhotoStart({ + visible: true, + personsImported, + refs: refs ?? loadPhotoRefs(treeId || tree.treeContext.treeId), + faces: computeFirstFaces(), + }) + }, [computeFirstFaces, tree.treeContext.treeId]) + + const accountDisplayName = useMemo(() => { if (linkedSelfPerson) { const fullName = `${linkedSelfPerson.firstName || ''} ${linkedSelfPerson.lastName || ''}`.trim() @@ -1475,27 +1506,28 @@ function App() { const accessToken = tree.treeContext.accessToken || auth.userAuth.token if (!accessToken || !tree.treeContext.treeId) return - const validationError = validateMediaFile(file, 'photo') + setAvatarActionState({ loading: true, error: '' }) + const prepared = await prepareImageForUpload(file) + const validationError = validateMediaFile(prepared, 'photo') if (validationError) { setAvatarActionState({ loading: false, error: validationError }) return } - setAvatarActionState({ loading: true, error: '' }) - try { - const dataBase64 = await fileToBase64(file) + const dataBase64 = await fileToBase64(prepared) await uploadPersonAvatar( tree.treeContext.treeId, personId, { - fileName: file.name || 'avatar.jpg', - mimeType: getUploadMimeType(file, 'photo'), - sizeBytes: file.size, + fileName: prepared.name || 'avatar.jpg', + mimeType: getUploadMimeType(prepared, 'photo'), + sizeBytes: prepared.size, dataBase64, }, accessToken, ) + trackAppEvent('portrait_added', { via: 'card' }) await refreshTreeAndKeepSelection(personId, { relayout: true }) setAvatarActionState({ loading: false, error: '' }) } catch (error) { @@ -2285,6 +2317,49 @@ function App() { ) trackAppEvent('tree_created', { tree_id: toOpaqueTreeId(createdTree.id) }) + // Arbre déjà tenu dans un logiciel : on importe son fichier au lieu de commencer par « vous », + // sinon la personne saisie ici existerait deux fois après l'import + if (payload.gedcomFile) { + let imported = null + let importError = '' + try { + const content = await payload.gedcomFile.text() + imported = content.trim() + ? await importGedcom(createdTree.id, content, token) + : null + if (!imported) importError = t('Le fichier GEDCOM est vide.') + } catch (error) { + importError = getAccountErrorMessage(error) || t("Impossible d'importer ce fichier GEDCOM.") + } + + await auth.loadAccountTrees(token) + await tree.openTreeGraph(createdTree.id, token, { + treeName: createdTree.name || payload.treeName, + treeDescription: createdTree.description || '', + role: 'owner', + accessMode: 'member', + rootPersonId: null, + promptWizard: false, + }) + await auth.persistLastOpenedTree({ treeId: createdTree.id, accessMode: 'member' }) + setTreeWizardVisible(false) + + if (imported) { + const refs = Array.isArray(imported.photoRefs) ? imported.photoRefs : [] + savePhotoRefs(createdTree.id, refs) + trackAppEvent('gedcom_imported', { + persons: sizeBucket(imported.personsCreated || 0), + photo_refs: sizeBucket(refs.length), + via: 'wizard', + }) + openPhotoStart({ personsImported: (imported.personsCreated || 0) + (imported.personsReused || 0), refs }) + } else { + // L'arbre existe déjà : on l'ouvre vide et on dit où réessayer, plutôt que d'en créer un second + setTreeImportError(importError) + } + return null + } + const createdSelf = await createPerson( createdTree.id, { @@ -2435,8 +2510,71 @@ function App() { } } - const handleGedcomImported = useCallback(async () => { + const handleGedcomImported = useCallback(async (result, treeIdOverride = null) => { await refreshTreeAndKeepSelection(null, { relayout: true }) + const refs = Array.isArray(result?.photoRefs) ? result.photoRefs : [] + savePhotoRefs(treeIdOverride || tree.treeContext.treeId, refs) + trackAppEvent('gedcom_imported', { + persons: sizeBucket(result?.personsCreated || 0), + photo_refs: sizeBucket(refs.length), + }) + setAdminPanelVisible(false) + openPhotoStart({ personsImported: (result?.personsCreated || 0) + (result?.personsReused || 0), refs }) + }, [refreshTreeAndKeepSelection, openPhotoStart, tree.treeContext.treeId]) + + // Un portrait envoyé sans recharger l'arbre (rechargé une fois, à la fermeture de la fenêtre). + // Renvoie un message d'erreur, ou '' si c'est fait. + const sendPortrait = useCallback(async (personId, file) => { + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return t('Session expirée, reconnectez-vous.') + const prepared = await prepareImageForUpload(file) + const validationError = validateMediaFile(prepared, 'photo') + if (validationError) return validationError + const payload = { + fileName: prepared.name || 'avatar.jpg', + mimeType: getUploadMimeType(prepared, 'photo'), + sizeBytes: prepared.size, + dataBase64: await fileToBase64(prepared), + } + // Trop d'envois d'un coup (429) : on attend et on réessaie, deux fois au plus + for (let attempt = 0; ; attempt += 1) { + try { + await uploadPersonAvatar(tree.treeContext.treeId, personId, payload, accessToken) + photoStartDirtyRef.current = true + return '' + } catch (error) { + if (error?.status === 429 && attempt < 2) { + await new Promise((resolve) => window.setTimeout(resolve, 15000)) + continue + } + return getAccountErrorMessage(error) + } + } + }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId]) + + const handlePhotoStartUploadOne = useCallback(async (personId, file) => { + const error = await sendPortrait(personId, file) + if (!error) trackAppEvent('portrait_added', { via: 'first_faces' }) + return error + }, [sendPortrait]) + + const handlePhotoStartBulk = useCallback(async (matches, onProgress) => { + const placedIds = [] + for (let i = 0; i < matches.length; i += 1) { + const error = await sendPortrait(matches[i].personId, matches[i].file) + if (!error) placedIds.push(matches[i].personId) + onProgress?.(i + 1) + } + trackAppEvent('portraits_bulk_placed', { placed: sizeBucket(placedIds.length), failed: sizeBucket(matches.length - placedIds.length) }) + return { placedIds } + }, [sendPortrait]) + + const handlePhotoStartClose = useCallback(async () => { + setPhotoStart((current) => ({ ...current, visible: false })) + if (photoStartDirtyRef.current) { + photoStartDirtyRef.current = false + await refreshTreeAndKeepSelection(null, { relayout: true, avatarCacheBust: Date.now() }) + } }, [refreshTreeAndKeepSelection]) const handleSelectSelfPerson = (personId) => { @@ -2529,6 +2667,16 @@ function App() { )} + {treeImportError && ( +
+ {t("Votre arbre est créé, mais l'import n'a pas abouti.")} + {` ${treeImportError} `} + {t("Vous pouvez réessayer depuis les paramètres de l'arbre.")} + +
+ )} {/* Bouton premier ajout (arbre vide) */} {isEmptyTree && ( + + + )} +
{t('Télécharger en GEDCOM')} diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 816ce80..6d69962 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -29,12 +29,15 @@ const MAX_SCALE = 3.2 const FOCUS_MIN_READABLE_SCALE = 0.5 const FOCUS_SCALE = 0.95 -const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0 }) => { +const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false }) => { const canvasRef = useRef(null) // Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours const focusPersonIdRef = useRef(focusPersonId) // Mis à jour hors du rendu, avant l'effet de cadrage déclaré plus bas (même phase, ordre de déclaration) useEffect(() => { focusPersonIdRef.current = focusPersonId }, [focusPersonId]) + // Propriétaire : un portrait vide survolé montre « + photo » + const photoHintRef = useRef(photoHint) + useEffect(() => { photoHintRef.current = photoHint }, [photoHint]) const layoutDataRef = useRef(null) const [layoutReady, setLayoutReady] = useState(false) const transformRef = useRef({ x: 0, y: 0, scale: 1 }) @@ -571,6 +574,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig mediaHoverState: mediaHoverState.current, entrance, entranceActive, elapsed, view, + photoHint: photoHintRef.current, }) drawRenvoiPills(ctx) // Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index 9405517..e1cd883 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -44,6 +44,16 @@ } .pc-photo img { width: 100%; height: 100%; object-fit: cover; display: block; } .pc-photo-initials { font: 400 30px/1 var(--pz-serif); color: var(--pz-a700); } +.pc-photo--add { + border: 1.5px dashed var(--pz-a300); background: var(--pz-a50); box-shadow: none; cursor: pointer; + align-content: center; gap: 5px; padding: 6px; color: var(--pz-a700); + font: 500 11.5px/1.15 var(--pz-sans); text-align: center; + transition: background-color 160ms ease, border-color 160ms ease, transform 140ms var(--pz-ease); +} +@media (hover: hover) and (pointer: fine) { .pc-photo--add:hover { background: var(--pz-a100); border-color: var(--pz-a500); } } +.pc-photo--add:active:not(:disabled) { transform: scale(.97); } +.pc-photo--add:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 3px; } +.pc-photo--add:disabled { opacity: .6; cursor: default; } .pc-photo-actions { display: grid; gap: 4px; justify-items: center; } .pc-photo-btn { border: 0; background: none; padding: 2px 4px; cursor: pointer; diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index bf35fdb..80e678d 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, forwardRef } from 'react' -import { Save, Trash2, Plus, X, Search, Check, Play } from 'lucide-react' +import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus } from 'lucide-react' import { getParents, getChildren, getPersonUnions, getPersonMedias, filiations, persons, unions } from '../data/mockData' import GeoMediaThumbnail from './GeoMediaThumbnail' import './PersonCard.css' @@ -333,21 +333,36 @@ const PersonCard = ({ {/* ── En-tête : visage, nom, repères ── */}
-
- {person.photo - ? {`${person.firstName} - : {initials}} -
+ {onAvatarUpload && ( + + )} + {/* Portrait vide chez le propriétaire : le cadre lui-même invite à poser la photo, sans passer par l'édition */} + {!person.photo && onAvatarUpload && !editMode ? ( + + ) : ( +
+ {person.photo + ? {`${person.firstName} + : {initials}} +
+ )} {editMode && onAvatarUpload && (
- diff --git a/frontend/src/components/PhotoStartPanel.css b/frontend/src/components/PhotoStartPanel.css new file mode 100644 index 0000000..b557ca3 --- /dev/null +++ b/frontend/src/components/PhotoStartPanel.css @@ -0,0 +1,61 @@ +/* Premiers visages après un import : retrouver les photos du logiciel, puis cinq portraits à demander */ + +.psp { width: min(100%, 560px); gap: 22px; } +.psp .pz-title em { font-style: italic; color: var(--pz-a700); } + +.psp-block { display: grid; gap: 12px; } +.psp-block--folder { + padding: 18px; + background: var(--pz-a50); + border: 1px solid var(--pz-a100); + border-radius: var(--pz-r-m); +} +.psp-h { margin: 0; font: 500 16px/1.35 var(--pz-sans); color: var(--pz-ink); } +.psp-p { margin: 0; font: 400 15px/1.55 var(--pz-sans); color: var(--pz-ink-2); } +.psp-p strong { font-weight: 600; color: var(--pz-ink); } +.psp-block .pz-hint { overflow-wrap: anywhere; } +.psp-folder-btn { justify-self: start; } +.psp-row-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.psp-found { display: grid; gap: 10px; justify-items: start; } + +.psp-progress { display: grid; gap: 8px; } +.psp-bar { height: 8px; border-radius: 999px; background: var(--pz-surface); box-shadow: inset 0 0 0 1px var(--pz-a100); overflow: hidden; } +.psp-bar span { display: block; height: 100%; background: var(--pz-a500); border-radius: inherit; transition: width 240ms var(--pz-ease); } + +.psp-faces { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; } +.psp-face { + display: flex; + align-items: center; + gap: 14px; + min-height: 62px; + padding: 6px 4px; + border-bottom: 1px solid var(--pz-line-soft); +} +.psp-face:last-child { border-bottom: 0; } +.psp-face-photo { + flex: none; + width: 48px; + height: 48px; + border-radius: 50%; + display: grid; + place-items: center; + overflow: hidden; + background: var(--pz-bg); + border: 1.5px dashed var(--pz-a300); + font: 400 16px var(--pz-serif); + color: var(--pz-muted); +} +.psp-face-photo.is-set { border: 2px solid #fff; box-shadow: 0 0 0 1px var(--pz-line), var(--pz-shadow); } +.psp-face-photo img { width: 100%; height: 100%; object-fit: cover; display: block; } +.psp-face-id { flex: 1; min-width: 0; display: grid; gap: 1px; } +.psp-face-name { font: 400 17px/1.25 var(--pz-serif); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.psp-face-years { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); } +.psp-face-error { font: 400 12.5px/1.4 var(--pz-sans); color: var(--pz-danger); } +.psp-face-done { color: var(--pz-success); } +.psp-face .pz-btn { flex: none; gap: 6px; } + +@media (max-width: 560px) { + .psp { gap: 18px; } + .psp-block--folder { padding: 16px; } + .psp-face .pz-btn--soft { padding: 0 12px; } +} diff --git a/frontend/src/components/PhotoStartPanel.jsx b/frontend/src/components/PhotoStartPanel.jsx new file mode 100644 index 0000000..1a224bd --- /dev/null +++ b/frontend/src/components/PhotoStartPanel.jsx @@ -0,0 +1,283 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Check, FolderOpen, ImagePlus, X } from 'lucide-react' +import './PhotoStartPanel.css' +import PzBusy from './PzBusy.jsx' +import { t } from '../i18n/index.js' +import { matchPhotoFiles } from '../utils/photoImport.js' + +function withEmphasis(text) { + const [before, emphasized = '', after = ''] = text.split(/<\/?em>/) + return <>{before}{emphasized}{after} +} + +function lifeYears(person) { + if (!person.birthYear) return '' + return person.deathYear ? `${person.birthYear} – ${person.deathYear}` : String(person.birthYear) +} + +// Singulier ou pluriel, les deux formes passent par t() +function plural(n, one, many, vars = {}) { + return n === 1 ? t(one, vars) : t(many, { n, ...vars }) +} + +function initialsOf(person) { + return `${person.firstName?.[0] || ''}${person.lastName?.[0] || ''}`.toUpperCase() || '?' +} + +// Dossier où le logiciel d'origine rangeait la première photo citée, pour aider à le retrouver +function folderOf(filePath) { + const parts = String(filePath || '').split(/([\\/])/) + if (parts.length < 3) return '' + return parts.slice(0, -2).join('') +} + +// Après un import GEDCOM (ou depuis les paramètres) : retrouver les photos que le logiciel connaissait, +// et demander les premiers visages un par un. +export default function PhotoStartPanel({ + visible, + personsImported = null, + photoRefs = [], + faces = [], + onUploadOne, + onBulkPlace, + onRefsChange, + onClose, +}) { + const folderInputRef = useRef(null) + const faceInputRef = useRef(null) + const faceTargetRef = useRef(null) + const [remaining, setRemaining] = useState(photoRefs) + const [found, setFound] = useState(null) + const [bulk, setBulk] = useState({ running: false, done: 0, total: 0, placed: null, failed: 0 }) + const [faceState, setFaceState] = useState({}) + const [shownFaces, setShownFaces] = useState(faces) + const [placedTotal, setPlacedTotal] = useState(0) + + // La liste des visages et les chemins sont figés à l'ouverture : une ligne ne disparaît pas sous le doigt + useEffect(() => { + if (!visible) return + setRemaining(photoRefs) + setFound(null) + setBulk({ running: false, done: 0, total: 0, placed: null, failed: 0 }) + setFaceState({}) + setShownFaces(faces) + setPlacedTotal(0) + }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!visible) return undefined + const onKey = (e) => { if (e.key === 'Escape' && !bulk.running) onClose?.() } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [visible, onClose, bulk.running]) + + // Les aperçus locaux sont libérés quand la fenêtre se ferme + const previewsRef = useRef([]) + useEffect(() => { + if (visible) return undefined + previewsRef.current.forEach((url) => URL.revokeObjectURL(url)) + previewsRef.current = [] + return undefined + }, [visible]) + + const exampleFolder = useMemo(() => folderOf(remaining[0]?.file), [remaining]) + + if (!visible) return null + + const handleFolder = (event) => { + const files = Array.from(event.target.files || []) + event.target.value = '' + if (files.length === 0) return + setBulk((b) => ({ ...b, placed: null, failed: 0 })) + setFound(matchPhotoFiles(remaining, files)) + } + + const handlePlace = async () => { + if (!found?.matches.length || bulk.running) return + const total = found.matches.length + setBulk({ running: true, done: 0, total, placed: null, failed: 0 }) + const result = await onBulkPlace(found.matches, (done) => setBulk((b) => ({ ...b, done }))) + const placedIds = new Set(result.placedIds) + // Une personne de la liste des visages dont la photo vient d'être placée passe en « Ajoutée » + const shownIds = new Set(shownFaces.map((p) => String(p.id))) + const placedFaces = {} + for (const match of found.matches) { + if (!placedIds.has(match.personId) || !shownIds.has(String(match.personId))) continue + const preview = URL.createObjectURL(match.file) + previewsRef.current.push(preview) + placedFaces[match.personId] = { busy: false, error: '', preview, fromFolder: true } + } + setFaceState((s) => ({ ...s, ...placedFaces })) + const nextRemaining = remaining.filter((ref) => !placedIds.has(ref.personId)) + setRemaining(nextRemaining) + onRefsChange?.(nextRemaining) + setFound(null) + setPlacedTotal((n) => n + placedIds.size) + setBulk({ running: false, done: total, total, placed: placedIds.size, failed: total - placedIds.size }) + } + + const pickFace = (personId) => { + faceTargetRef.current = personId + faceInputRef.current?.click() + } + + const handleFaceFile = async (event) => { + const file = event.target.files?.[0] + event.target.value = '' + const personId = faceTargetRef.current + if (!file || !personId) return + setFaceState((s) => ({ ...s, [personId]: { ...s[personId], busy: true, error: '' } })) + const error = await onUploadOne(personId, file) + setFaceState((s) => { + const previous = s[personId] + if (error) return { ...s, [personId]: { ...previous, busy: false, error } } + const preview = URL.createObjectURL(file) + previewsRef.current.push(preview) + return { ...s, [personId]: { busy: false, error: '', preview } } + }) + } + + const refsKnown = photoRefs.length > 0 + const addedCount = Object.values(faceState).filter((s) => s?.preview && !s.fromFolder).length + placedTotal + + return ( +
+
+ +
+ {personsImported != null &&

{t('Import terminé')}

} +

{withEmphasis(t('Il manque les visages'))}

+

+ {personsImported != null + ? t('{n} personnes sont dans votre arbre. Avec une photo, la famille se reconnaît tout de suite.', { n: personsImported.toLocaleString() }) + : t('Avec une photo, la famille se reconnaît tout de suite.')} +

+
+ + {refsKnown && ( +
+

{t('Vos photos sont déjà sur votre ordinateur')}

+ {remaining.length > 0 ? ( + <> +

+ {plural(remaining.length, 'Votre logiciel associait une photo à 1 personne. Choisissez le dossier où il la range, Parenthèse place le portrait.', 'Votre logiciel associait une photo à {n} personnes. Choisissez le dossier où il les range, Parenthèse place chaque portrait.')} +

+ {exampleFolder && ( +

{t('Il les rangeait dans {folder}', { folder: exampleFolder })}

+ )} + + ) : ( +

{t('Toutes les photos de votre logiciel sont placées.')}

+ )} + + + + {bulk.running ? ( +
+
+

{t('Envoi de {done} sur {total}', { done: bulk.done, total: bulk.total })}

+
+ ) : found ? ( +
+ {found.matches.length > 0 ? ( + <> +

{plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length })}

+
+ + +
+ + ) : ( + <> +

{t("Aucune photo de ce dossier ne correspond. Essayez le dossier indiqué plus haut, ou celui qui le contient.")}

+ + + )} +
+ ) : remaining.length > 0 ? ( + + ) : null} + + {bulk.placed != null && !bulk.running && ( +

+ {plural(bulk.placed, '1 portrait placé.', '{n} portraits placés.')} + {bulk.failed > 0 && ` ${t("{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).", { f: bulk.failed })}`} +

+ )} + {!bulk.running && remaining.length > 0 && ( +

{t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}

+ )} +
+ )} + + {shownFaces.length > 0 && ( +
+

{refsKnown ? t('Ou commencez par ces visages') : t('Commencez par ces visages')}

+ +
    + {shownFaces.map((person) => { + const state = faceState[person.id] || {} + const years = lifeYears(person) + return ( +
  • + + + {`${person.firstName} ${person.lastName || ''}`.trim()} + {years && {years}} + {state.error && {state.error}} + + {state.preview && !state.busy ? ( + + ) : ( + + )} +
  • + ) + })} +
+
+ )} + +
+

+ {addedCount > 0 ? plural(addedCount, '1 photo ajoutée.', '{n} photos ajoutées.') : t('Vous pourrez en ajouter plus tard depuis chaque fiche.')} +

+ +
+
+
+ ) +} diff --git a/frontend/src/components/TreeCreationWizard.jsx b/frontend/src/components/TreeCreationWizard.jsx index 4310770..9d1407c 100644 --- a/frontend/src/components/TreeCreationWizard.jsx +++ b/frontend/src/components/TreeCreationWizard.jsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { ImagePlus } from 'lucide-react' +import { FileUp, ImagePlus } from 'lucide-react' import './TreeCreationWizard.css' import PzBusy from './PzBusy.jsx' import { t } from '../i18n/index.js' @@ -10,6 +10,9 @@ const INITIAL_STATE = { selfLastName: '', selfBirthYear: '', photoFile: null, + // « J'ai déjà un arbre » : fichier GEDCOM exporté d'un logiciel, importé à la création + start: 'self', + gedcomFile: null, } // Titre traduit d'un bloc : le passage entre et garde son italique @@ -46,8 +49,9 @@ function TreeCreationWizard({ const canContinue = useMemo(() => form.treeName.trim().length >= 2 && !loading, [form.treeName, loading]) const canCreate = useMemo(() => { if (loading) return false + if (form.start === 'import') return Boolean(form.gedcomFile) return form.selfFirstName.trim().length > 0 && form.selfLastName.trim().length > 0 - }, [form.selfFirstName, form.selfLastName, loading]) + }, [form.selfFirstName, form.selfLastName, form.start, form.gedcomFile, loading]) if (!visible) { return null @@ -68,6 +72,11 @@ function TreeCreationWizard({ if (!canCreate) return setError('') + if (form.start === 'import') { + const submitError = await onSubmit({ treeName: form.treeName.trim(), gedcomFile: form.gedcomFile }) + if (submitError) setError(submitError) + return + } const submitError = await onSubmit({ treeName: form.treeName.trim(), selfFirstName: form.selfFirstName.trim(), @@ -123,6 +132,40 @@ function TreeCreationWizard({ ) : (
+
+ + +
+ + {form.start === 'import' ? ( + <> +
+

{withEmphasis(t('Importez votre arbre'))}

+

{t('Heredis, Geneanet, Gramps, MyHeritage ou Ancestry savent exporter un fichier .ged. Parenthèse reprend les personnes, les liens et les dates, puis vous aide à retrouver les photos.')}

+
+
+ + updateField('gedcomFile', event.target.files?.[0] || null)} + /> +
+ + ) : ( + <>

{withEmphasis(t('Commencez par vous'))}

{t("L'arbre « {name} » démarre avec votre fiche. Vous ajouterez les autres ensuite.", { name: form.treeName.trim() })}

@@ -186,6 +229,8 @@ function TreeCreationWizard({ onChange={(event) => updateField('photoFile', event.target.files?.[0] || null)} />
+ + )} {error &&
{error}
} @@ -194,7 +239,9 @@ function TreeCreationWizard({ {t('Retour')}
diff --git a/frontend/src/components/galaxy/elkLayout.js b/frontend/src/components/galaxy/elkLayout.js index ba2ce5a..6f32e97 100644 --- a/frontend/src/components/galaxy/elkLayout.js +++ b/frontend/src/components/galaxy/elkLayout.js @@ -311,13 +311,16 @@ export function computeLayout(graphData = null) { allNodes.push(node) } - // Unions virtuelles (parent unique → inconnu) + // Unions virtuelles (parent unique → inconnu) : une par parent, ses enfants sans autre parent connu + // partagent le même « Inconnu » (frères et sœurs d'un GEDCOM où un seul parent est renseigné) const virtualUnions = [] + const virtualByParent = new Set() normalizedFiliations.forEach(f => { - if (!f.unionId && f.parentId) { + if (!f.unionId && f.parentId && !virtualByParent.has(f.parentId)) { + virtualByParent.add(f.parentId) virtualUnions.push({ - elkId: `vu-${f.id}`, - unknownElkId: `unknown-${f.id}`, + elkId: `vu-${f.parentId}`, + unknownElkId: `unknown-${f.parentId}`, parentId: f.parentId, filiation: f, }) @@ -382,7 +385,7 @@ export function computeLayout(graphData = null) { // union/vu elkId → [child elkId] const childrenBySourceId = new Map() normalizedFiliations.forEach(f => { - const sourceId = f.unionId ? `u-${f.unionId}` : `vu-${f.id}` + const sourceId = f.unionId ? `u-${f.unionId}` : `vu-${f.parentId}` const targetId = `p-${f.childId}` if (!childrenBySourceId.has(sourceId)) childrenBySourceId.set(sourceId, []) childrenBySourceId.get(sourceId).push(targetId) @@ -515,7 +518,7 @@ export function computeLayout(graphData = null) { .map(e => ({ ...e })) normalizedFiliations.forEach(f => { - const sourceId = f.unionId ? `u-${f.unionId}` : `vu-${f.id}` + const sourceId = f.unionId ? `u-${f.unionId}` : `vu-${f.parentId}` const targetId = `p-${f.childId}` if (!validNodeIds.has(sourceId) || !validNodeIds.has(targetId)) return allEdges.push({ diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index b278f23..00fcbff 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -426,7 +426,7 @@ function measureTextCached(ctx, text, fontKey) { // ============================================================ function drawPersonNode(ctx, node, pos, nodeProgress, { nodeRandomData, imageCache, hoverScales, selectedGlowScales, searchGlowScales, - mediaHoverState, entrance, entranceActive, elapsed, view = null, + mediaHoverState, entrance, entranceActive, elapsed, view = null, photoHint = false, }) { const { cx, cy } = pos const person = getPersonById(node._data.id) || node._data @@ -562,7 +562,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, { ctx.shadowOffsetY = shadowY } - drawFrame(ctx, frameType, person, img) + drawFrame(ctx, frameType, person, img, photoHint && !img ? hs : 0) ctx.restore() // --- Labels (estompés puis absents en dézoom) --- @@ -880,7 +880,7 @@ function resolveFrameType(person, img) { return fallback } -function drawFrame(ctx, frameType, person, img) { +function drawFrame(ctx, frameType, person, img, hint = 0) { if (frameType === 'polaroid') { const pw = PERSON_R + 4 const ph = PERSON_R + 4 @@ -898,7 +898,7 @@ function drawFrame(ctx, frameType, person, img) { ctx.beginPath() ctx.roundRect(-pw, -ph, pw * 2, ph * 2, 2) ctx.clip() - drawPersonImage(ctx, img, person, -pw, -ph, pw * 2, ph * 2) + drawPersonImage(ctx, img, person, -pw, -ph, pw * 2, ph * 2, hint) } else if (frameType === 'square') { const s = PERSON_R + 4 const pad = 3 @@ -914,7 +914,7 @@ function drawFrame(ctx, frameType, person, img) { ctx.beginPath() ctx.roundRect(-s, -s, s * 2, s * 2, 2) ctx.clip() - drawPersonImage(ctx, img, person, -s, -s, s * 2, s * 2) + drawPersonImage(ctx, img, person, -s, -s, s * 2, s * 2, hint) } else if (frameType === 'rect') { const ratio = img?.naturalWidth && img?.naturalHeight ? img.naturalWidth / img.naturalHeight @@ -936,7 +936,7 @@ function drawFrame(ctx, frameType, person, img) { ctx.beginPath() ctx.roundRect(-rw, -rh, rw * 2, rh * 2, 3) ctx.clip() - drawPersonImage(ctx, img, person, -rw, -rh, rw * 2, rh * 2) + drawPersonImage(ctx, img, person, -rw, -rh, rw * 2, rh * 2, hint) } else { ctx.fillStyle = '#FFFFFF' ctx.beginPath() @@ -951,14 +951,14 @@ function drawFrame(ctx, frameType, person, img) { ctx.beginPath() ctx.arc(0, 0, PERSON_R, 0, Math.PI * 2) ctx.clip() - drawPersonImage(ctx, img, person, -PERSON_R, -PERSON_R, PERSON_R * 2, PERSON_R * 2) + drawPersonImage(ctx, img, person, -PERSON_R, -PERSON_R, PERSON_R * 2, PERSON_R * 2, hint) } } // ============================================================ // Dessiner l'image ou le placeholder initiales // ============================================================ -function drawPersonImage(ctx, img, person, x, y, w, h) { +function drawPersonImage(ctx, img, person, x, y, w, h, hint = 0) { if (img) { drawImageCover(ctx, img, x, y, w, h) if (!person.isAlive) { @@ -968,11 +968,27 @@ function drawPersonImage(ctx, img, person, x, y, w, h) { } else { ctx.fillStyle = person.isAlive ? COLORS.nodeAlive : COLORS.nodeDead ctx.fillRect(x, y, w, h) - ctx.fillStyle = COLORS.text - ctx.font = '400 16px "Newsreader", Georgia, serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' + // Au survol, chez le propriétaire, les initiales cèdent la place à « + photo » + const baseAlpha = ctx.globalAlpha + if (hint > 0.01) { + ctx.fillStyle = `rgba(248, 228, 219, ${hint})` + ctx.fillRect(x, y, w, h) + } + ctx.globalAlpha = baseAlpha * (1 - hint) + ctx.fillStyle = COLORS.text + ctx.font = '400 16px "Newsreader", Georgia, serif' ctx.fillText((person.firstName[0] || '') + (person.lastName[0] || ''), 0, 0) + if (hint > 0.01) { + ctx.globalAlpha = baseAlpha * hint + ctx.fillStyle = '#93402A' + ctx.font = '300 22px "DM Sans", system-ui, sans-serif' + ctx.fillText('+', 0, -6) + ctx.font = '500 9.5px "DM Sans", system-ui, sans-serif' + ctx.fillText(t('photo'), 0, 10) + } + ctx.globalAlpha = baseAlpha } } diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index b57a489..7a1084d 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -162,22 +162,32 @@ export function loadGraphData(graph, options = {}) { const unionIdSet = new Set(unions.map((union) => union.id)) const seenUnionChildKey = new Set() + const seenDirectKey = new Set() filiations = remoteFiliations - .map((filiation, index) => ({ - id: filiation.id, - childId: filiation.childPersonId, - unionId: filiation.viaUnionId || null, - parentId: filiation.viaUnionId ? null : filiation.parentPersonId, - parentageType: filiation.parentageType || 'biologique', - displayOrder: Number.isFinite(filiation.displayOrder) ? filiation.displayOrder : index + 1, - })) + .map((filiation, index) => { + // Union à un seul parent connu (fréquente dans un GEDCOM) : l'union est écartée plus haut, l'enfant + // est relié directement à ce parent, sinon il flotterait dans l'arbre sans aucun lien + const viaKeptUnion = filiation.viaUnionId && unionIdSet.has(filiation.viaUnionId) + return { + id: filiation.id, + childId: filiation.childPersonId, + unionId: viaKeptUnion ? filiation.viaUnionId : null, + parentId: viaKeptUnion ? null : filiation.parentPersonId, + parentageType: filiation.parentageType || 'biologique', + displayOrder: Number.isFinite(filiation.displayOrder) ? filiation.displayOrder : index + 1, + } + }) .filter((filiation) => { if (!personIdSet.has(filiation.childId)) { return false } if (!filiation.unionId) { - return filiation.parentId ? personIdSet.has(filiation.parentId) : false + if (!filiation.parentId || !personIdSet.has(filiation.parentId)) return false + const key = `${filiation.parentId}:${filiation.childId}` + if (seenDirectKey.has(key)) return false + seenDirectKey.add(key) + return true } if (!unionIdSet.has(filiation.unionId)) { diff --git a/frontend/src/data/mockData.test.js b/frontend/src/data/mockData.test.js index 5caa7fe..91ee070 100644 --- a/frontend/src/data/mockData.test.js +++ b/frontend/src/data/mockData.test.js @@ -50,3 +50,24 @@ describe('loadGraphData avatar mapping', () => { expect(getPersonMedias('p-2')[0]?.source).toBe('Album familial') }) }) + +describe('loadGraphData, union à un seul parent', () => { + it('relie les enfants directement au parent connu au lieu de les perdre', async () => { + const { getParents, getChildren } = await import('./mockData') + const person = (id, firstName) => ({ id, firstName, lastName: 'Lemaire', birthDate: null, deathDate: null, notes: null, avatarUrlPath: null }) + loadGraphData({ + persons: [person('julien', 'Julien'), person('hugo', 'Hugo'), person('lea', 'Léa')], + unions: [{ id: 'u-seul', partner1PersonId: 'julien', partner2PersonId: null }], + filiations: [ + { id: 'f-1', parentPersonId: 'julien', childPersonId: 'hugo', viaUnionId: 'u-seul' }, + { id: 'f-2', parentPersonId: 'julien', childPersonId: 'lea', viaUnionId: 'u-seul' }, + { id: 'f-3', parentPersonId: 'julien', childPersonId: 'lea', viaUnionId: 'u-seul' }, + ], + medias: [], + annotations: [], + }) + + expect(getParents('hugo').map((p) => p.id)).toEqual(['julien']) + expect(getChildren('julien').map((p) => p.id).sort()).toEqual(['hugo', 'lea']) + }) +}) diff --git a/frontend/src/i18n/en/index.js b/frontend/src/i18n/en/index.js index 175e2c1..a6c7d4e 100644 --- a/frontend/src/i18n/en/index.js +++ b/frontend/src/i18n/en/index.js @@ -4,6 +4,7 @@ import arbre from './arbre.js' import fiche from './fiche.js' import contribution from './contribution.js' import compte from './compte.js' +import photos from './photos.js' export default { ...app, @@ -11,5 +12,6 @@ export default { ...fiche, ...contribution, ...compte, + ...photos, } diff --git a/frontend/src/i18n/en/photos.js b/frontend/src/i18n/en/photos.js new file mode 100644 index 0000000..c4b462c --- /dev/null +++ b/frontend/src/i18n/en/photos.js @@ -0,0 +1,56 @@ +// Traductions anglaises, clé = texte français exact passé à t(). +// Lot « photos » : premiers visages après un import, photos retrouvées, import dès la création de l'arbre. +export default { + // Fenêtre des premiers visages (PhotoStartPanel) + "Import terminé": "Import complete", + "Il manque les visages": "The faces are missing", + "{n} personnes sont dans votre arbre. Avec une photo, la famille se reconnaît tout de suite.": "{n} people are in your tree. With a photo, the family recognises each other straight away.", + "Avec une photo, la famille se reconnaît tout de suite.": "With a photo, the family recognises each other straight away.", + "Vos photos sont déjà sur votre ordinateur": "Your photos are already on your computer", + "Votre logiciel associait une photo à 1 personne. Choisissez le dossier où il la range, Parenthèse place le portrait.": "Your software had a photo for 1 person. Choose the folder where it keeps it, and Parenthèse places the portrait.", + "Votre logiciel associait une photo à {n} personnes. Choisissez le dossier où il les range, Parenthèse place chaque portrait.": "Your software had a photo for {n} people. Choose the folder where it keeps them, and Parenthèse places each portrait.", + "Il les rangeait dans {folder}": "It kept them in {folder}", + "Toutes les photos de votre logiciel sont placées.": "All the photos from your software are in place.", + "Envoi de {done} sur {total}": "Sending {done} of {total}", + "1 photo retrouvée sur {k}.": "1 photo found out of {k}.", + "{n} photos retrouvées sur {k}.": "{n} photos found out of {k}.", + "Placer ce portrait": "Place this portrait", + "Placer les {n} portraits": "Place the {n} portraits", + "Un autre dossier": "Another folder", + "Aucune photo de ce dossier ne correspond. Essayez le dossier indiqué plus haut, ou celui qui le contient.": "No photo in this folder matches. Try the folder shown above, or the one that contains it.", + "Choisir un autre dossier": "Choose another folder", + "Choisir le dossier des photos": "Choose the photo folder", + "1 portrait placé.": "1 portrait placed.", + "{n} portraits placés.": "{n} portraits placed.", + "{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).": "{f} couldn't be sent (unsupported format or file too large).", + "Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.": "Only the photos that match a person are sent, and only to your tree.", + "Ou commencez par ces visages": "Or start with these faces", + "Commencez par ces visages": "Start with these faces", + "Envoi": "Sending", + "Ajoutée": "Added", + "1 photo ajoutée.": "1 photo added.", + "{n} photos ajoutées.": "{n} photos added.", + "Vous pourrez en ajouter plus tard depuis chaque fiche.": "You can add more later from each profile.", + "Voir l'arbre": "See the tree", + "Session expirée, reconnectez-vous.": "Session expired, please sign in again.", + + // Portrait vide sur l'arbre, au survol + "photo": "photo", + + // Paramètres de l'arbre + "Ajouter des portraits": "Add portraits", + "Retrouver les photos de votre ancien logiciel, ou ajouter les premiers visages un par un.": "Bring back the photos from your old software, or add the first faces one by one.", + + // Création de l'arbre, « J'ai déjà un arbre » + "Point de départ": "Starting point", + "Je pars de moi": "Start with me", + "J'ai déjà un arbre": "I already have a tree", + "Importez votre arbre": "Import your tree", + "Heredis, Geneanet, Gramps, MyHeritage ou Ancestry savent exporter un fichier .ged. Parenthèse reprend les personnes, les liens et les dates, puis vous aide à retrouver les photos.": "Heredis, Geneanet, Gramps, MyHeritage and Ancestry can all export a .ged file. Parenthèse brings in the people, links and dates, then helps you find the photos.", + "Choisir le fichier .ged": "Choose the .ged file", + "Cliquez pour en choisir un autre": "Click to choose another one", + "Exporté depuis le logiciel où vous tenez votre arbre": "Exported from the software where you keep your tree", + "Importer mon arbre": "Import my tree", + "Votre arbre est créé, mais l'import n'a pas abouti.": "Your tree is created, but the import didn't go through.", + "Vous pouvez réessayer depuis les paramètres de l'arbre.": "You can try again from the tree settings.", +} diff --git a/frontend/src/utils/photoImport.js b/frontend/src/utils/photoImport.js new file mode 100644 index 0000000..644540f --- /dev/null +++ b/frontend/src/utils/photoImport.js @@ -0,0 +1,179 @@ +// Photos après un import GEDCOM : relier les chemins cités par le logiciel d'origine aux fichiers du dossier +// choisi par la personne, et choisir les premiers visages à demander. + +import { MAX_IMAGE_BYTES } from './mediaUpload.js' + +const MAX_SIDE = 2000 +const JPEG_QUALITY = 0.88 + +function normalizeName(value) { + return String(value || '').normalize('NFC').toLowerCase() +} + +// Dernier élément d'un chemin Windows, macOS ou Linux +export function baseNameOf(filePath) { + return String(filePath || '').split(/[\\/]/).filter(Boolean).pop() || '' +} + +function pathParts(filePath) { + return normalizeName(filePath).split(/[\\/]/).filter(Boolean) +} + +// Nombre de dossiers communs en partant du nom de fichier : départage deux « portrait.jpg » +function commonSuffixLength(a, b) { + const pa = pathParts(a) + const pb = pathParts(b) + let n = 0 + while (n < pa.length && n < pb.length && pa[pa.length - 1 - n] === pb[pb.length - 1 - n]) n += 1 + return n +} + +// photoRefs : [{ personId, file }] renvoyé par l'import. files : les File du dossier choisi +// (webkitRelativePath renseigné). Renvoie les paires trouvées et les références sans fichier. +export function matchPhotoFiles(photoRefs, files) { + const byName = new Map() + for (const file of files || []) { + const key = normalizeName(file.name) + if (!key) continue + if (!byName.has(key)) byName.set(key, []) + byName.get(key).push(file) + } + + const matches = [] + const missing = [] + for (const ref of photoRefs || []) { + const candidates = byName.get(normalizeName(baseNameOf(ref.file))) || [] + if (candidates.length === 0) { + missing.push(ref) + continue + } + let best = candidates[0] + let bestScore = -1 + for (const candidate of candidates) { + const score = commonSuffixLength(ref.file, candidate.webkitRelativePath || candidate.name) + if (score > bestScore) { + best = candidate + bestScore = score + } + } + matches.push({ personId: ref.personId, file: best }) + } + return { matches, missing } +} + +// Les premiers visages à demander : la famille proche de « Moi » si on la connaît, sinon les générations +// les plus récentes, dont les photos sont sous la main. Seulement des personnes sans portrait. +export function pickFirstFaces({ persons, selfId = null, getParents, getChildren, getPartners, limit = 5 }) { + const withoutPhoto = (persons || []).filter((p) => !p.photo) + if (withoutPhoto.length === 0) return [] + const wanted = new Set(withoutPhoto.map((p) => String(p.id))) + const byId = new Map((persons || []).map((p) => [String(p.id), p])) + + const picked = [] + if (selfId && byId.has(String(selfId))) { + const seen = new Set([String(selfId)]) + let frontier = [String(selfId)] + if (wanted.has(String(selfId))) picked.push(byId.get(String(selfId))) + while (frontier.length > 0 && picked.length < limit) { + const next = [] + for (const id of frontier) { + const around = [ + ...(getParents?.(id) || []), + ...(getPartners?.(id) || []), + ...(getChildren?.(id) || []), + ] + for (const person of around) { + const key = String(person?.id ?? '') + if (!key || seen.has(key)) continue + seen.add(key) + next.push(key) + } + } + const ring = next + .filter((key) => wanted.has(key)) + .map((key) => byId.get(key)) + .sort(byRecentBirth) + for (const person of ring) { + if (picked.length >= limit) break + picked.push(person) + } + frontier = next + } + } + + if (picked.length < limit) { + const already = new Set(picked.map((p) => String(p.id))) + const rest = withoutPhoto.filter((p) => !already.has(String(p.id))).sort(byRecentBirth) + picked.push(...rest.slice(0, limit - picked.length)) + } + return picked +} + +function byRecentBirth(a, b) { + const ya = Number.isFinite(a?.birthYear) ? a.birthYear : -Infinity + const yb = Number.isFinite(b?.birthYear) ? b.birthYear : -Infinity + if (ya !== yb) return yb - ya + if (Boolean(a?.isAlive) !== Boolean(b?.isAlive)) return a?.isAlive ? -1 : 1 + return 0 +} + +// Une photo de téléphone dépasse souvent 5 Mo : elle est réduite à 2000 px en JPEG avant l'envoi. +// Un format que le navigateur ne sait pas lire (HEIC sur Chrome) part tel quel, le serveur tranche. +export async function prepareImageForUpload(file) { + if (!file || !String(file.type || '').startsWith('image/') || file.type === 'image/gif') return file + let bitmap + try { + bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }) + } catch { + return file + } + const largest = Math.max(bitmap.width, bitmap.height) + const isSimpleFormat = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type) + if (largest <= MAX_SIDE && file.size <= MAX_IMAGE_BYTES && isSimpleFormat) { + bitmap.close?.() + return file + } + const ratio = Math.min(1, MAX_SIDE / largest) + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(bitmap.width * ratio)) + canvas.height = Math.max(1, Math.round(bitmap.height * ratio)) + canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height) + bitmap.close?.() + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', JPEG_QUALITY)) + if (!blob) return file + const name = baseNameOf(file.name).replace(/\.[^.]+$/, '') || 'photo' + return new File([blob], `${name}.jpg`, { type: 'image/jpeg' }) +} + +// Les chemins de l'import, gardés sur l'appareil pour rouvrir l'étape plus tard (rien côté serveur) +const REFS_KEY = (treeId) => `parenthese_photo_refs:${treeId}` + +export function savePhotoRefs(treeId, refs) { + if (!treeId) return + try { + if (refs?.length) localStorage.setItem(REFS_KEY(treeId), JSON.stringify(refs)) + else localStorage.removeItem(REFS_KEY(treeId)) + } catch { + // Stockage plein ou indisponible : l'étape ne se rouvrira pas, l'import reste fait + } +} + +export function loadPhotoRefs(treeId) { + if (!treeId) return [] + try { + const parsed = JSON.parse(localStorage.getItem(REFS_KEY(treeId)) || '[]') + return Array.isArray(parsed) ? parsed.filter((r) => r && r.personId && r.file) : [] + } catch { + return [] + } +} + +// Effectifs arrondis pour PostHog : un ordre de grandeur, jamais un compte exact d'un arbre +export function sizeBucket(n) { + if (!n) return '0' + if (n < 10) return '1-9' + if (n < 50) return '10-49' + if (n < 200) return '50-199' + if (n < 1000) return '200-999' + return '1000+' +} diff --git a/frontend/src/utils/photoImport.test.js b/frontend/src/utils/photoImport.test.js new file mode 100644 index 0000000..c0185be --- /dev/null +++ b/frontend/src/utils/photoImport.test.js @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { baseNameOf, matchPhotoFiles, pickFirstFaces, sizeBucket } from './photoImport.js' + +const file = (name, webkitRelativePath = name) => ({ name, webkitRelativePath }) + +describe('baseNameOf', () => { + it('lit le nom de fichier d\'un chemin Windows, macOS ou relatif', () => { + expect(baseNameOf('C:\\Genealogie\\Photos\\jean.jpg')).toBe('jean.jpg') + expect(baseNameOf('/Users/anne/Heredis/marie.png')).toBe('marie.png') + expect(baseNameOf('photos/robert 1920.jpg')).toBe('robert 1920.jpg') + }) +}) + +describe('matchPhotoFiles', () => { + it('relie chaque chemin du GEDCOM au fichier du même nom, sans tenir compte de la casse', () => { + const refs = [ + { personId: 'a', file: 'C:\\Photos\\Jean.JPG' }, + { personId: 'b', file: 'C:\\Photos\\marie.jpg' }, + { personId: 'c', file: 'C:\\Photos\\absente.jpg' }, + ] + const { matches, missing } = matchPhotoFiles(refs, [file('jean.jpg', 'Photos/jean.jpg'), file('marie.jpg', 'Photos/marie.jpg')]) + expect(matches.map((m) => [m.personId, m.file.name])).toEqual([['a', 'jean.jpg'], ['b', 'marie.jpg']]) + expect(missing.map((m) => m.personId)).toEqual(['c']) + }) + + it('départage deux fichiers du même nom par les dossiers du chemin', () => { + const refs = [{ personId: 'a', file: 'D:\\Arbre\\Dupont\\portrait.jpg' }] + const files = [file('portrait.jpg', 'Arbre/Martin/portrait.jpg'), file('portrait.jpg', 'Arbre/Dupont/portrait.jpg')] + expect(matchPhotoFiles(refs, files).matches[0].file.webkitRelativePath).toBe('Arbre/Dupont/portrait.jpg') + }) + + it('rapproche un nom accentué écrit sous deux formes Unicode', () => { + const refs = [{ personId: 'a', file: 'Hélène.jpg' }] + const decomposed = 'He\u0301le\u0300ne.jpg' + expect(matchPhotoFiles(refs, [file(decomposed)]).matches).toHaveLength(1) + }) +}) + +describe('pickFirstFaces', () => { + const people = [ + { id: 'moi', firstName: 'Lou', birthYear: 1990, isAlive: true }, + { id: 'mere', firstName: 'Anne', birthYear: 1962, isAlive: true }, + { id: 'pere', firstName: 'Marc', birthYear: 1960, isAlive: true, photo: '/p.jpg' }, + { id: 'fils', firstName: 'Tom', birthYear: 2020, isAlive: true }, + { id: 'aieul', firstName: 'Jules', birthYear: 1880, isAlive: false }, + { id: 'cousin', firstName: 'Paul', birthYear: 1995, isAlive: true }, + ] + const byId = new Map(people.map((p) => [p.id, p])) + const parents = { moi: ['mere', 'pere'], mere: ['aieul'] } + const children = { moi: ['fils'], mere: ['moi'], pere: ['moi'], aieul: ['mere'] } + const helpers = { + getParents: (id) => (parents[id] || []).map((x) => byId.get(x)), + getChildren: (id) => (children[id] || []).map((x) => byId.get(x)), + getPartners: () => [], + } + + it('part de « Moi » puis de ses proches, sans les personnes qui ont déjà un portrait', () => { + const picked = pickFirstFaces({ persons: people, selfId: 'moi', ...helpers, limit: 3 }) + expect(picked.map((p) => p.id)).toEqual(['moi', 'fils', 'mere']) + }) + + it('sans « Moi », prend les naissances les plus récentes', () => { + const picked = pickFirstFaces({ persons: people, ...helpers, limit: 3 }) + expect(picked.map((p) => p.id)).toEqual(['fils', 'cousin', 'moi']) + }) + + it('complète avec les plus récents quand la famille proche ne suffit pas', () => { + const picked = pickFirstFaces({ persons: people, selfId: 'aieul', ...helpers, limit: 3 }) + expect(picked.map((p) => p.id)).toEqual(['aieul', 'mere', 'moi']) + }) +}) + +describe('sizeBucket', () => { + it('arrondit les effectifs envoyés à PostHog', () => { + expect([0, 3, 12, 80, 450, 2697].map(sizeBucket)).toEqual(['0', '1-9', '10-49', '50-199', '200-999', '1000+']) + }) +})