Merge pull request #56 from IliesAllali/produit/premiere-photo

Premières photos : import dès la création, photos du logiciel retrouvées, premiers visages
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-11 18:59:12 +02:00
commit 8103154e02
21 files changed
+1244 -72

No files matched your search

+17
View File
@@ -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
+56
View File
@@ -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, GedcomPhotoNode>,
): 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<string, GedcomPhotoNode> {
const map = new Map<string, GedcomPhotoNode>()
for (const node of roots) {
if (node.tag === 'OBJE' && node.xref) map.set(node.xref, node)
}
return map
}
+80 -18
View File
@@ -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<string, string>()
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<string, Array<{ id: string; hasAvatar: boolean }>>()
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<string>()
@@ -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 })
})
}
+4 -1
View File
@@ -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' })
}
+74
View File
@@ -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<string, string | null> {
const roots = parseGedcom(text)
const objes = indexObjeRecords(roots)
const out: Record<string, string | null> = {}
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 })
})
})
+169 -9
View File
@@ -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() {
</button>
</div>
)}
{treeImportError && (
<div className="tree-created-prompt tree-created-prompt--error" role="alert">
<strong>{t("Votre arbre est créé, mais l'import n'a pas abouti.")}</strong>
{` ${treeImportError} `}
{t("Vous pouvez réessayer depuis les paramètres de l'arbre.")}
<button type="button" onClick={() => setTreeImportError('')} aria-label={t('Fermer')}>
{t('Fermer')}
</button>
</div>
)}
{/* Bouton premier ajout (arbre vide) */}
{isEmptyTree && (
<button
@@ -2686,6 +2834,7 @@ function App() {
zoomApiRef={galaxyZoomApiRef}
focusPersonId={tree.treeContext.rootPersonId}
assetRevision={tree.assetRevision}
photoHint={tree.canEditCurrentTree}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{tree.selectedPerson && (
@@ -2766,8 +2915,19 @@ function App() {
authToken={tree.treeContext.accessToken}
onTreeDeleted={handleTreeDeleted}
onGedcomImported={handleGedcomImported}
onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }}
onClose={() => setAdminPanelVisible(false)}
/>
<PhotoStartPanel
visible={photoStart.visible}
personsImported={photoStart.personsImported}
photoRefs={photoStart.refs}
faces={photoStart.faces}
onUploadOne={handlePhotoStartUploadOne}
onBulkPlace={handlePhotoStartBulk}
onRefsChange={(refs) => savePhotoRefs(tree.treeContext.treeId, refs)}
onClose={handlePhotoStartClose}
/>
{/* Annotation Toolbar (visible uniquement en mode édition) */}
{editMode.isActive && (
<AnnotationToolbar
+17 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Download, Trash2, X } from 'lucide-react'
import { Download, ImagePlus, Trash2, X } from 'lucide-react'
import './SettingsPanels.css'
import {
deleteTree,
@@ -30,6 +30,7 @@ const AdminPanel = ({
onClose,
onTreeDeleted,
onGedcomImported,
onOpenPhotoStart,
}) => {
const [section, setSection] = useState('tree')
const [loading, setLoading] = useState(false)
@@ -383,6 +384,21 @@ const AdminPanel = ({
</div>
</div>
{onOpenPhotoStart && (
<div className="ts-row">
<div className="ts-row-text">
<strong>{t('Ajouter des portraits')}</strong>
<p className="pz-small">{t("Retrouver les photos de votre ancien logiciel, ou ajouter les premiers visages un par un.")}</p>
</div>
<div className="ts-row-actions">
<button type="button" className="pz-btn pz-btn--secondary pz-btn--sm" onClick={onOpenPhotoStart} disabled={saving}>
<ImagePlus size={15} aria-hidden="true" />
{t('Ajouter des portraits')}
</button>
</div>
</div>
)}
<div className="ts-row">
<div className="ts-row-text">
<strong>{t('Télécharger en GEDCOM')}</strong>
+5 -1
View File
@@ -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
+10
View File
@@ -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;
+29 -14
View File
@@ -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 ── */}
<header className="pc-head">
<div className="pc-photo-wrap">
<div className="pc-photo">
{person.photo
? <img src={person.photo} alt={`${person.firstName} ${person.lastName || ''}`} />
: <span className="pc-photo-initials">{initials}</span>}
</div>
{onAvatarUpload && (
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="pc-hidden-input"
onChange={handleAvatarFileChange}
disabled={avatarActionLoading}
/>
)}
{/* Portrait vide chez le propriétaire : le cadre lui-même invite à poser la photo, sans passer par l'édition */}
{!person.photo && onAvatarUpload && !editMode ? (
<button
type="button"
className="pc-photo pc-photo--add"
onClick={() => avatarInputRef.current?.click()}
disabled={avatarActionLoading}
>
<ImagePlus size={22} strokeWidth={1.8} aria-hidden="true" />
<span>{avatarActionLoading ? t('Envoi') : t('Ajouter une photo')}</span>
</button>
) : (
<div className="pc-photo">
{person.photo
? <img src={person.photo} alt={`${person.firstName} ${person.lastName || ''}`} />
: <span className="pc-photo-initials">{initials}</span>}
</div>
)}
{editMode && onAvatarUpload && (
<div className="pc-photo-actions">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="pc-hidden-input"
onChange={handleAvatarFileChange}
disabled={avatarActionLoading}
/>
<button type="button" className="pc-photo-btn" onClick={() => avatarInputRef.current?.click()} disabled={avatarActionLoading}>
{person.photo ? t('Changer') : t('Ajouter une photo')}
</button>
@@ -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; }
}
+283
View File
@@ -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}<em>{emphasized}</em>{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 (
<div className="pz-overlay psp-overlay">
<div className="pz-modal psp" role="dialog" aria-modal="true" aria-labelledby="pspTitle">
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} disabled={bulk.running} aria-label={t('Fermer')}>
<X size={18} strokeWidth={2} />
</button>
<div className="pz-modal-head">
{personsImported != null && <p className="pz-eyebrow">{t('Import terminé')}</p>}
<h2 id="pspTitle" className="pz-title">{withEmphasis(t('Il manque les <em>visages</em>'))}</h2>
<p className="pz-sub">
{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.')}
</p>
</div>
{refsKnown && (
<section className="psp-block psp-block--folder" aria-labelledby="pspFolderTitle">
<h3 id="pspFolderTitle" className="psp-h">{t('Vos photos sont déjà sur votre ordinateur')}</h3>
{remaining.length > 0 ? (
<>
<p className="psp-p">
{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.')}
</p>
{exampleFolder && (
<p className="pz-hint">{t('Il les rangeait dans {folder}', { folder: exampleFolder })}</p>
)}
</>
) : (
<p className="psp-p">{t('Toutes les photos de votre logiciel sont placées.')}</p>
)}
<input
ref={folderInputRef}
type="file"
className="pz-sr"
tabIndex={-1}
webkitdirectory=""
directory=""
multiple
onChange={handleFolder}
/>
{bulk.running ? (
<div className="psp-progress" role="status">
<div className="psp-bar"><span style={{ width: `${Math.round((bulk.done / Math.max(1, bulk.total)) * 100)}%` }} /></div>
<p className="pz-small">{t('Envoi de {done} sur {total}', { done: bulk.done, total: bulk.total })}</p>
</div>
) : found ? (
<div className="psp-found">
{found.matches.length > 0 ? (
<>
<p className="psp-p"><strong>{plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length })}</strong></p>
<div className="psp-row-actions">
<button type="button" className="pz-btn pz-btn--accent" onClick={handlePlace}>
{plural(found.matches.length, 'Placer ce portrait', 'Placer les {n} portraits')}
</button>
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => folderInputRef.current?.click()}>
{t('Un autre dossier')}
</button>
</div>
</>
) : (
<>
<p className="pz-error">{t("Aucune photo de ce dossier ne correspond. Essayez le dossier indiqué plus haut, ou celui qui le contient.")}</p>
<button type="button" className="pz-btn pz-btn--secondary" onClick={() => folderInputRef.current?.click()}>
<FolderOpen size={18} strokeWidth={2} aria-hidden="true" />
{t('Choisir un autre dossier')}
</button>
</>
)}
</div>
) : remaining.length > 0 ? (
<button type="button" className="pz-btn pz-btn--accent psp-folder-btn" onClick={() => folderInputRef.current?.click()}>
<FolderOpen size={18} strokeWidth={2} aria-hidden="true" />
{bulk.placed != null ? t('Choisir un autre dossier') : t('Choisir le dossier des photos')}
</button>
) : null}
{bulk.placed != null && !bulk.running && (
<p className="pz-success" role="status">
{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 })}`}
</p>
)}
{!bulk.running && remaining.length > 0 && (
<p className="pz-hint">{t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}</p>
)}
</section>
)}
{shownFaces.length > 0 && (
<section className="psp-block" aria-labelledby="pspFacesTitle">
<h3 id="pspFacesTitle" className="psp-h">{refsKnown ? t('Ou commencez par ces visages') : t('Commencez par ces visages')}</h3>
<input ref={faceInputRef} type="file" accept="image/*" className="pz-sr" tabIndex={-1} onChange={handleFaceFile} />
<ul className="psp-faces">
{shownFaces.map((person) => {
const state = faceState[person.id] || {}
const years = lifeYears(person)
return (
<li key={person.id} className="psp-face">
<span className={`psp-face-photo ${state.preview ? 'is-set' : ''}`} aria-hidden="true">
{state.preview ? <img src={state.preview} alt="" /> : initialsOf(person)}
</span>
<span className="psp-face-id">
<span className="psp-face-name">{`${person.firstName} ${person.lastName || ''}`.trim()}</span>
{years && <span className="psp-face-years">{years}</span>}
{state.error && <span className="psp-face-error">{state.error}</span>}
</span>
{state.preview && !state.busy ? (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm psp-face-done" onClick={() => pickFace(person.id)}>
<Check size={16} strokeWidth={2.2} aria-hidden="true" />
{t('Ajoutée')}
</button>
) : (
<button type="button" className="pz-btn pz-btn--soft pz-btn--sm" onClick={() => pickFace(person.id)} disabled={state.busy || bulk.running}>
<PzBusy busy={Boolean(state.busy)} busyLabel={t('Envoi')}>
<ImagePlus size={16} strokeWidth={2} aria-hidden="true" />
{t('Ajouter une photo')}
</PzBusy>
</button>
)}
</li>
)
})}
</ul>
</section>
)}
<div className="pz-modal-actions pz-modal-actions--split">
<p className="pz-small">
{addedCount > 0 ? plural(addedCount, '1 photo ajoutée.', '{n} photos ajoutées.') : t('Vous pourrez en ajouter plus tard depuis chaque fiche.')}
</p>
<button type="button" className="pz-btn pz-btn--primary" onClick={onClose} disabled={bulk.running}>
{t("Voir l'arbre")}
</button>
</div>
</div>
</div>
)
}
+50 -3
View File
@@ -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 <em> et </em> 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 onSubmit={handleCreate} className="tw-form">
<div className="pz-tabs tw-start" role="tablist" aria-label={t('Point de départ')}>
<button type="button" role="tab" aria-selected={form.start === 'self'} className={`pz-tab ${form.start === 'self' ? 'is-active' : ''}`} onClick={() => updateField('start', 'self')}>
{t('Je pars de moi')}
</button>
<button type="button" role="tab" aria-selected={form.start === 'import'} className={`pz-tab ${form.start === 'import' ? 'is-active' : ''}`} onClick={() => updateField('start', 'import')}>
{t("J'ai déjà un arbre")}
</button>
</div>
{form.start === 'import' ? (
<>
<div className="pz-modal-head">
<h2 id="treeWizardTitle" className="pz-title">{withEmphasis(t('Importez <em>votre arbre</em>'))}</h2>
<p className="pz-sub">{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.')}</p>
</div>
<div className="pz-field">
<label htmlFor="wizardGedcom" className="tw-photo">
<span className="tw-photo-icon" aria-hidden="true"><FileUp size={20} strokeWidth={1.8} /></span>
<span className="tw-photo-text">
<strong>{form.gedcomFile ? form.gedcomFile.name : t('Choisir le fichier .ged')}</strong>
<span>{form.gedcomFile ? t('Cliquez pour en choisir un autre') : t('Exporté depuis le logiciel où vous tenez votre arbre')}</span>
</span>
</label>
<input
id="wizardGedcom"
className="tw-photo-input"
type="file"
accept=".ged,text/x-gedcom,text/plain"
onChange={(event) => updateField('gedcomFile', event.target.files?.[0] || null)}
/>
</div>
</>
) : (
<>
<div className="pz-modal-head">
<h2 id="treeWizardTitle" className="pz-title">{withEmphasis(t('Commencez <em>par vous</em>'))}</h2>
<p className="pz-sub">{t("L'arbre « {name} » démarre avec votre fiche. Vous ajouterez les autres ensuite.", { name: form.treeName.trim() })}</p>
@@ -186,6 +229,8 @@ function TreeCreationWizard({
onChange={(event) => updateField('photoFile', event.target.files?.[0] || null)}
/>
</div>
</>
)}
{error && <div className="pz-error tree-wizard-error" role="alert">{error}</div>}
@@ -194,7 +239,9 @@ function TreeCreationWizard({
{t('Retour')}
</button>
<button type="submit" className="pz-btn pz-btn--primary" disabled={!canCreate}>
<PzBusy busy={loading} busyLabel={t("Création de l'arbre")}>{t('Créer mon arbre')}</PzBusy>
<PzBusy busy={loading} busyLabel={form.start === 'import' ? t('Import en cours') : t("Création de l'arbre")}>
{form.start === 'import' ? t('Importer mon arbre') : t('Créer mon arbre')}
</PzBusy>
</button>
</div>
</form>
+9 -6
View File
@@ -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({
+26 -10
View File
@@ -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
}
}
+19 -9
View File
@@ -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)) {
+21
View File
@@ -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'])
})
})
+2
View File
@@ -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,
}
+56
View File
@@ -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 <em>visages</em>": "The <em>faces</em> 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 <em>votre arbre</em>": "Import <em>your tree</em>",
"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.",
}
+179
View File
@@ -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+'
}
+77
View File
@@ -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+'])
})
})