Merge pull request #118 from IliesAllali/feat/sexe-et-liens

Sexe d'une personne et liens de parenté genrés (#114)
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-20 15:47:57 +02:00
commit 441693bf43
27 files changed
+644 -137

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
// Sexe d'une personne : 'male', 'female', 'other' (autre), ou null (non précisé). L'import GEDCOM écrit
// 'male'/'female'/'other' (SEX X de GEDCOM 7), d'anciens clients de l'API ont pu écrire 'M'/'F' : tout est
// ramené à ces valeurs, à l'écriture comme à la lecture, sans migration des lignes existantes.
export type PersonSex = 'male' | 'female' | 'other'
const MALE = new Set(['male', 'm', 'homme', 'h'])
const FEMALE = new Set(['female', 'f', 'femme'])
const OTHER = new Set(['other', 'x', 'autre'])
const UNSPECIFIED = new Set(['', 'u', 'unknown', 'unspecified'])
// undefined : valeur que l'on ne sait pas lire (refusée à l'écriture)
export function parseSex(value: string | null): PersonSex | null | undefined {
if (value === null) return null
const key = value.trim().toLowerCase()
if (MALE.has(key)) return 'male'
if (FEMALE.has(key)) return 'female'
if (OTHER.has(key)) return 'other'
if (UNSPECIFIED.has(key)) return null
return undefined
}
// Lecture d'une valeur déjà en base : ce qu'on ne sait pas lire vaut « non précisé »
export function sexOrNull(value: string | null | undefined): PersonSex | null {
return parseSex(value ?? null) ?? null
}
// Champ « sex » des schémas d'écriture : facultatif, null pour « non précisé », valeur ramenée à male/female/other
export const sexSchema = z
.string()
.max(20)
.nullable()
.refine((value) => parseSex(value) !== undefined, { message: 'invalid_sex' })
.transform((value) => parseSex(value) ?? null)
.optional()
+4 -2
View File
@@ -14,6 +14,7 @@ import { findParentChildValidationError, findUnionValidationError } from '../uti
import { nextSiblingOrder } from '../lib/sibling-order.js'
import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js'
import { hasAnyName } from '../lib/person-names.js'
import { sexOrNull, sexSchema } from '../lib/person-sex.js'
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
@@ -81,7 +82,7 @@ const personCreateSchema = z.object({
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
sex: sexSchema,
notes: z.string().max(2000).optional().nullable(),
}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] })
@@ -99,7 +100,7 @@ const personUpdateSchema = z
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
sex: sexSchema,
notes: z.string().max(2000).optional().nullable(),
})
.refine((value) => Object.keys(value).length > 0, {
@@ -869,6 +870,7 @@ async function snapshotEntity(
...current,
birthDate: current.birthDate?.toISOString().slice(0, 10) ?? null,
deathDate: current.deathDate?.toISOString().slice(0, 10) ?? null,
sex: sexOrNull(current.sex),
}
}
+9 -6
View File
@@ -8,6 +8,7 @@ import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson }
import type { GedcomRecords } from '../lib/gedcom-photos.js'
import type { MembershipRole } from '../types/auth.js'
import { nextSiblingOrder } from '../lib/sibling-order.js'
import { sexOrNull } from '../lib/person-sex.js'
const treeIdParamSchema = z.object({
id: z.string().min(1),
@@ -455,8 +456,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
lines.push(`1 NAME ${gedcomNameValue(person.firstName, person.lastName)}`)
if (person.nickname) lines.push(`2 NICK ${oneLine(person.nickname)}`)
if (person.sex === 'male' || person.sex === 'M') lines.push('1 SEX M')
else if (person.sex === 'female' || person.sex === 'F') lines.push('1 SEX F')
const sex = sexOrNull(person.sex)
if (sex === 'male') lines.push('1 SEX M')
else if (sex === 'female') lines.push('1 SEX F')
else if (sex === 'other') lines.push('1 SEX X')
const personEvents = eventsByPerson.get(person.id) ?? []
const birthDetails = personEvents.find((event) => event.type === 'birth')
@@ -518,9 +521,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const p1 = union.partner1PersonId ? personById.get(union.partner1PersonId) : null
const p2 = union.partner2PersonId ? personById.get(union.partner2PersonId) : null
const p1IsFemale = p1?.sex === 'female' || p1?.sex === 'F'
const p1IsMale = p1?.sex === 'male' || p1?.sex === 'M'
const p2IsMale = p2?.sex === 'male' || p2?.sex === 'M'
const p1IsFemale = sexOrNull(p1?.sex) === 'female'
const p1IsMale = sexOrNull(p1?.sex) === 'male'
const p2IsMale = sexOrNull(p2?.sex) === 'male'
// Swap to HUSB=male if we know p1 is female and p2 is male (or unknown)
const shouldSwap = p1IsFemale && !p2IsMale === false ? false : p1IsFemale && (p2IsMale || !p1IsMale)
@@ -675,7 +678,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
: { firstName: '', lastName: '' }
const sexNode = findChild(indi, 'SEX')
const sex = sexNode?.value === 'M' ? 'male' : sexNode?.value === 'F' ? 'female' : null
const sex = sexOrNull(sexNode?.value)
const birtNode = findChild(indi, 'BIRT')
const birthDateIso = gedcomDateToIso(findChild(birtNode, 'DATE')?.value ?? '')
+3 -2
View File
@@ -17,6 +17,7 @@ import type { MembershipRole } from '../types/auth.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js'
import { hasAnyName } from '../lib/person-names.js'
import { sexSchema } from '../lib/person-sex.js'
import { ContributionRouteError, resolveSubmissionContext } from './contributions.js'
const MAX_TREES_PER_ACCOUNT = 10
@@ -97,7 +98,7 @@ const createPersonSchema = z.object({
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
sex: sexSchema,
notes: z.string().max(2000).optional().nullable(),
}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] })
@@ -115,7 +116,7 @@ const patchPersonSchema = z
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
sex: sexSchema,
notes: z.string().max(2000).optional().nullable(),
})
.refine((value) => Object.keys(value).length > 0, {
+2 -1
View File
@@ -3,6 +3,7 @@ import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@pri
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js'
import { isImmichAvatarPath } from '../lib/media-files.js'
import { sexOrNull } from '../lib/person-sex.js'
export type GraphPayload = {
persons: Array<{
@@ -98,7 +99,7 @@ export function buildGraphPayload(
region: person.region,
nationality: person.nationality,
avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null,
sex: person.sex,
sex: sexOrNull(person.sex),
notes: person.notes,
avatarFromImmich: isImmichAvatarPath(person.avatarPath),
avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null,
+83 -1
View File
@@ -491,7 +491,7 @@ describe('Graph routes', () => {
prismaMock.person.findMany.mockResolvedValue([
{ ...base, id: 'p-1', firstName: 'Louis', lastName: 'Ragot', sex: 'male', birthDate: new Date('1879-01-01T00:00:00Z'), birthDatePrecision: 'about', birthPlace: 'La Roche-sur-Yon', deathDate: new Date('1950-05-02T00:00:00Z'), deathPlace: 'Nantes', nickname: 'Loulou', profession: 'Instituteur' },
{ ...base, id: 'p-2', firstName: 'Marie', lastName: 'Bodin', sex: 'female', birthDate: null, birthDatePrecision: null, birthPlace: null, isDeceased: true },
{ ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'male', birthDate: null, birthDatePrecision: null, birthPlace: null },
{ ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'M', birthDate: null, birthDatePrecision: null, birthPlace: null },
])
prismaMock.union.findMany.mockResolvedValue([
{ id: 'u-1', partner1PersonId: 'p-1', partner2PersonId: 'p-2', startDate: new Date('1905-01-01T00:00:00Z'), startDatePrecision: 'estimated', startPlace: 'Limoges', endDate: null },
@@ -518,6 +518,8 @@ describe('Graph routes', () => {
expect(text).toContain('2 PEDI adopted')
// Décédée sans date (issue #77) : la forme courte de la norme
expect(text).toContain('1 NAME Marie /Bodin/\n1 SEX F\n1 DEAT Y\n')
// Ancienne écriture « M » en base : exportée comme un homme (issue #114)
expect(text).toContain('1 NAME Jean /Ragot/\n1 SEX M\n')
const roots = parseGedcom(text)
const records = indexGedcomRecords(roots)
@@ -891,6 +893,61 @@ describe('Import GEDCOM', () => {
})
})
describe('Sexe dans le GEDCOM (issue #114)', () => {
const importer = async (app: FastifyInstance, content: string) => {
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
prismaMock.person.findMany.mockResolvedValue([])
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` }))
prismaMock.person.create.mockClear()
const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } })
expect(response.statusCode).toBe(200)
return Object.fromEntries(prismaMock.person.create.mock.calls.map((call: [{ data: { firstName: string; sex: string | null } }]) => [call[0].data.firstName, call[0].data.sex]))
}
it('import : M, F, X (GEDCOM 7), U et absent donnent male, female, other, null, null', async () => {
const app = createApp()
const content = [
'0 HEAD',
'0 @I1@ INDI', '1 NAME Jean /Martin/', '1 SEX M',
'0 @I2@ INDI', '1 NAME Marie /Martin/', '1 SEX F',
'0 @I3@ INDI', '1 NAME Camille /Martin/', '1 SEX X',
'0 @I4@ INDI', '1 NAME Dominique /Martin/', '1 SEX U',
'0 @I5@ INDI', '1 NAME Claude /Martin/',
'0 TRLR',
].join('\n')
expect(await importer(app, content)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Dominique: null, Claude: null })
await app.close()
})
it('aller-retour : other sort en SEX X, non précisé sans ligne SEX, et tout se relit à l’identique', async () => {
const app = createApp()
await app.ready()
const token = (app as any).jwt.sign({ kind: 'tree_access', sub: 'tree:tree-1:visitor', treeId: 'tree-1', role: 'visitor', accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime() })
const base = { lastName: 'Martin', birthDate: null, birthDatePrecision: null, birthPlace: null, birthDateEnd: null, deathDateEnd: null, deathDatePrecision: null, deathPlace: null, nickname: null, notes: null, profession: null, deathDate: null }
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', name: 'Martin', rootPersonId: null })
prismaMock.person.findMany.mockResolvedValue([
{ ...base, id: 'p-1', firstName: 'Jean', sex: 'male' },
{ ...base, id: 'p-2', firstName: 'Marie', sex: 'female' },
{ ...base, id: 'p-3', firstName: 'Camille', sex: 'other' },
{ ...base, id: 'p-4', firstName: 'Claude', sex: null },
])
prismaMock.union.findMany.mockResolvedValue([])
prismaMock.parentChildLink.findMany.mockResolvedValue([])
prismaMock.lifeEvent.findMany.mockResolvedValue([])
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } })
expect(response.statusCode).toBe(200)
const text = response.body.replace(/\r\n/g, '\n')
expect(text).toContain('1 NAME Camille /Martin/\n1 SEX X\n')
expect(text).toMatch(/1 NAME Claude \/Martin\/\n(?!1 SEX)/)
expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Claude: null })
await app.close()
})
})
describe('Visit log', () => {
it('refuses the visit log to a non-admin member', async () => {
const app = createApp()
@@ -1147,6 +1204,31 @@ describe('Surnom (nickname) en écriture directe', () => {
})
})
describe('Sexe d’une personne en écriture directe (issue #114)', () => {
it('PATCH ramène le sexe à male / female / other, null = non précisé, refuse une valeur inconnue', async () => {
const app = createApp()
const token = await createUserToken(app)
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
expect((await patch({ sex: 'F' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' })
expect((await patch({ sex: 'male' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' })
expect((await patch({ sex: 'other' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'other' })
expect((await patch({ sex: null })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: null })
const calls = prismaMock.person.updateMany.mock.calls.length
expect((await patch({ sex: 'robot' })).statusCode).toBe(400)
expect(prismaMock.person.updateMany.mock.calls.length).toBe(calls)
await app.close()
})
})
describe('Lieu de décès et filiation adoptive (écriture)', () => {
it('PATCH accepte le lieu de décès, le coupe, et vide = null', async () => {
const app = createApp()
+53
View File
@@ -278,6 +278,7 @@ describe('Journey 1 — Owner creates account, tree, and adds family', () => {
},
})
expect(addSelfRes.statusCode).toBe(201)
expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' })
// Step 4: Add parent
prismaMock.person.create.mockResolvedValue({ id: 'person-2', firstName: 'Jean', lastName: 'Martin' })
@@ -806,3 +807,55 @@ describe('Surnom (nickname) proposé par un contributeur', () => {
await app.close()
})
})
describe('Sexe proposé par un contributeur (issue #114)', () => {
it('appliqué par le propriétaire avec la valeur normalisée', async () => {
const app = createApp()
await app.ready()
const adminToken = (app as any).jwt.sign(
{ kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' },
{ expiresIn: '1h' },
)
prismaMock.contributionSession.findFirst.mockResolvedValue({
id: 'session-s',
treeId: 'tree-1',
status: 'pending',
title: 'Sexe de Camille',
changes: [
{
id: 'change-s1',
entityType: 'person',
action: 'update',
entityId: 'person-1',
beforeJson: { sex: null },
afterJson: { sex: 'F' },
conflictState: 'none',
},
{
id: 'change-s2',
entityType: 'person',
action: 'create',
entityId: null,
beforeJson: null,
afterJson: { firstName: 'Léon', lastName: 'Martin', sex: 'male' },
conflictState: 'none',
},
],
})
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Camille', lastName: 'Martin', sex: null, deletedAt: null })
const reviewRes = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-s/review',
headers: { authorization: `Bearer ${adminToken}` },
payload: { decision: 'approved' },
})
expect(reviewRes.statusCode).toBe(200)
expect(reviewRes.json().approvedCount).toBe(2)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' })
expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' })
await app.close()
})
})
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { parseSex, sexOrNull, sexSchema } from '../src/lib/person-sex'
// Sexe d'une personne (issue #114) : male, female, other ou null, anciennes valeurs M/F relues
describe('sexe d’une personne', () => {
it('ramène les écritures connues à male / female / null', () => {
expect(parseSex('male')).toBe('male')
expect(parseSex('M')).toBe('male')
expect(parseSex(' homme ')).toBe('male')
expect(parseSex('female')).toBe('female')
expect(parseSex('F')).toBe('female')
expect(parseSex('Femme')).toBe('female')
expect(parseSex(null)).toBeNull()
expect(parseSex('')).toBeNull()
expect(parseSex('U')).toBeNull()
expect(parseSex('other')).toBe('other')
expect(parseSex('X')).toBe('other')
expect(parseSex('Autre')).toBe('other')
expect(parseSex('robot')).toBeUndefined()
})
it('une valeur illisible déjà en base se lit « non précisé »', () => {
expect(sexOrNull('M')).toBe('male')
expect(sexOrNull('robot')).toBeNull()
expect(sexOrNull(undefined)).toBeNull()
})
it('le schéma d’écriture normalise, garde l’absence et refuse l’inconnu', () => {
expect(sexSchema.parse('F')).toBe('female')
expect(sexSchema.parse(null)).toBeNull()
expect(sexSchema.parse(undefined)).toBeUndefined()
expect(sexSchema.safeParse('robot').success).toBe(false)
})
})
+8
View File
@@ -247,6 +247,7 @@ function buildContributionChangesFromDraft(draft = {}) {
lastName: String(person.lastName).trim(),
birthName: person.birthName ? String(person.birthName).trim() : null,
nickname: person.nickname ? String(person.nickname).trim() : null,
sex: person.sex || null,
birthDate: person.birthDate || null,
deathDate: person.deathDate || null,
isDeceased: !person.deathDate && Boolean(person.isDeceased),
@@ -302,6 +303,7 @@ function buildContributionChangesFromDraft(draft = {}) {
if (Object.prototype.hasOwnProperty.call(source, 'lastName')) after.lastName = source.lastName || ''
if (Object.prototype.hasOwnProperty.call(source, 'birthName')) after.birthName = (source.birthName || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'nickname')) after.nickname = (source.nickname || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'sex')) after.sex = source.sex || null
if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear)
if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear)
if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null
@@ -1077,6 +1079,7 @@ function App() {
lastName: formData.lastName || '',
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
birthYear,
@@ -1157,6 +1160,7 @@ function App() {
lastName: formData.lastName,
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
isDeceased: Boolean(formData.isDeceased),
@@ -1184,6 +1188,7 @@ function App() {
lastName: formData.lastName,
birthName: formData.birthName,
nickname: formData.nickname,
sex: formData.sex || null,
birthDate: formData.birthDate,
deathDate: formData.deathDate,
isDeceased: Boolean(formData.isDeceased),
@@ -1315,6 +1320,7 @@ function App() {
if (Object.prototype.hasOwnProperty.call(rawChanges, 'lastName')) payload.lastName = rawChanges.lastName || ''
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthName')) payload.birthName = (rawChanges.birthName || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'nickname')) payload.nickname = (rawChanges.nickname || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'sex')) payload.sex = rawChanges.sex || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear)
if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear)
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null
@@ -1591,6 +1597,7 @@ function App() {
if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) localPerson.lastName = changes.lastName || ''
if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) localPerson.birthName = changes.birthName || null
if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) localPerson.nickname = changes.nickname || null
if (Object.prototype.hasOwnProperty.call(changes, 'sex')) localPerson.sex = changes.sex || null
if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) {
const iso = yearToIsoDate(changes.birthYear)
localPerson.birthDate = iso
@@ -1629,6 +1636,7 @@ function App() {
if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) payload.lastName = changes.lastName || ''
if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) payload.birthName = (changes.birthName || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) payload.nickname = (changes.nickname || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'sex')) payload.sex = changes.sex || null
if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear)
if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear)
if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null
@@ -43,6 +43,8 @@
.apn-section { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--pz-line-soft); }
.apn-types .pz-tab { font-size: 13px; padding: 0 6px; }
/* Sexe : quatre choix, chacun sur une ligne même dans la colonne étroite du panneau */
.apn-sex .pz-tab { white-space: nowrap; padding: 0 4px; }
.apn-relation { position: relative; }
.apn-search { position: relative; display: flex; align-items: center; }
@@ -108,6 +110,10 @@
/* Décédé(e) sans date connue (issue #77) */
.apn-deceased { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; }
.apn-deceased input { width: 15px; height: 15px; accent-color: var(--pz-a500); }
/* Case sous une ligne de deux champs (« Je ne connais pas son prénom », « Décédé(e), date inconnue ») : sur une ligne,
sous les deux champs sur ordinateur, sous son champ sur téléphone (ordre du DOM) */
.apn-row > .apn-row-check { grid-column: 1 / -1; grid-row: 2; margin-top: -6px; white-space: nowrap; justify-self: start; }
@media (max-width: 480px) { .apn-row > .apn-row-check { grid-row: auto; } }
/* Colonnes alignées en haut : la case « décédé(e) » sous le décès ne décale pas la naissance */
.apn-row { align-items: start; }
+44 -21
View File
@@ -5,6 +5,7 @@ import './AddPersonPanel.css'
import PzBusy from './PzBusy.jsx'
import { t } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
import { SEX_CHOICES } from '../utils/personSex.js'
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
function withEmphasis(text) {
@@ -38,6 +39,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
const [lastName, setLastName] = useState('')
const [birthName, setBirthName] = useState('')
const [nickname, setNickname] = useState('')
const [sex, setSex] = useState(null)
const [birthDate, setBirthDate] = useState('')
const [deathDate, setDeathDate] = useState('')
// Décédé(e) sans date connue (issue #77)
@@ -101,6 +103,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
setLastName('')
setBirthName('')
setNickname('')
setSex(null)
setBirthDate('')
setDeathDate('')
setIsDeceased(false)
@@ -156,6 +159,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
lastName: lastName.trim(),
birthName: birthName.trim() || null,
nickname: nickname.trim() || null,
sex,
birthDate: birthDate || null,
deathDate: deathDate || null,
isDeceased: !deathDate && isDeceased,
@@ -211,21 +215,22 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
disabled={unknownFirstName}
aria-invalid={touched.firstName && !unknownFirstName && !firstName.trim()}
/>
{/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher) */}
{(!firstName.trim() || unknownFirstName) && (
<label className="apn-deceased">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setTouched({})
}}
/>
{t('Je ne connais pas son prénom')}
</label>
)}
</div>
{/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher).
Hors de la colonne Prénom pour tenir sur une ligne : sous les deux champs sur ordinateur, sous Prénom sur téléphone */}
{(!firstName.trim() || unknownFirstName) && (
<label className="apn-deceased apn-row-check">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setTouched({})
}}
/>
{t('Je ne connais pas son prénom')}
</label>
)}
<div className="pz-field">
<label htmlFor="ap-lastName">{t('Nom')}</label>
<input
@@ -271,6 +276,23 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
/>
</div>
<div className="pz-field">
<span className="pz-label" id="ap-sex-label">{t('Sexe')} <span className="apn-optional">{t('facultatif')}</span></span>
<div className="pz-tabs apn-types apn-sex" role="group" aria-labelledby="ap-sex-label">
{SEX_CHOICES.map(({ value, label }) => (
<button
key={value}
type="button"
aria-pressed={sex === value}
className={`pz-tab ${sex === value ? 'is-active' : ''}`}
onClick={() => setSex(sex === value ? null : value)}
>
{label}
</button>
))}
</div>
</div>
<div className="pz-row2 apn-row">
<div className="pz-field">
<label htmlFor="ap-birthDate">{t('Naissance')}</label>
@@ -279,13 +301,14 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
<div className="pz-field">
<label htmlFor="ap-deathDate">{t('Décès')}</label>
<DateField key={`d-${dateFieldsKey}`} id="ap-deathDate" value={deathDate} onChange={setDeathDate} />
{!deathDate && (
<label className="apn-deceased">
<input type="checkbox" checked={isDeceased} onChange={(e) => setIsDeceased(e.target.checked)} />
{t('Décédé(e), date inconnue')}
</label>
)}
</div>
{/* Hors de la colonne Décès pour tenir sur une ligne, comme la case du prénom */}
{!deathDate && (
<label className="apn-deceased apn-row-check">
<input type="checkbox" checked={isDeceased} onChange={(e) => setIsDeceased(e.target.checked)} />
{sex === 'male' ? t('Décédé, date inconnue') : sex === 'female' ? t('Décédée, date inconnue') : t('Décédé(e), date inconnue')}
</label>
)}
</div>
<section className="apn-section">
@@ -347,7 +370,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
{(selectedRelationType === 'child' || selectedRelationType === 'parent') && (
<label className="apn-deceased">
<input type="checkbox" checked={adopted} onChange={(e) => setAdopted(e.target.checked)} />
{t('Adopté(e)')}
{sex === 'male' ? t('Adopté') : sex === 'female' ? t('Adoptée') : t('Adopté(e)')}
</label>
)}
+13 -8
View File
@@ -7,6 +7,7 @@ import './SettingsPanels.css'
import PzBusy from './PzBusy.jsx'
import { HELP_URL } from '../utils/siteLinks.js'
import { t, dateLocale } from '../i18n/index.js'
import { sexLabel } from '../utils/personSex.js'
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -74,6 +75,7 @@ const PERSON_FIELD_LABELS = {
lastName: t('Nom'),
birthName: t('Nom de naissance'),
nickname: t('Surnom'),
sex: t('Sexe'),
birthDate: t('Date de naissance'),
deathDate: t('Date de décès'),
deathPlace: t('Lieu de décès'),
@@ -84,7 +86,9 @@ const PERSON_FIELD_LABELS = {
notes: t('Notes'),
}
function displayVal(v) {
function displayVal(v, key) {
// Sexe : « Non précisé » plutôt que « vide »
if (key === 'sex') return sexLabel(v)
if (v === null || v === undefined || v === '') return null
return String(v)
}
@@ -134,10 +138,11 @@ function getPersonDisplayName(change, sessionChanges = []) {
const data = change.afterJson || {}
return data.title || t('Moment')
}
const data = (change.afterJson && typeof change.afterJson === 'object')
? change.afterJson
: (change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null)
if (!data) return null
// Une modification ne porte que les champs changés : le nom vient alors de l'« avant » (lu en base)
const after = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null
const before = change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null
if (!after && !before) return null
const data = { ...before, ...after }
const name = `${data.firstName || ''} ${data.lastName || ''}`.trim()
return name || null
}
@@ -614,12 +619,12 @@ const ContributionPanel = ({
<span className="contrib-field-label">{label}</span>
{oldVal !== undefined ? (
<span className="contrib-field-diff">
<span className="contrib-field-old">{displayVal(oldVal) ?? t('vide')}</span>
<span className="contrib-field-old">{displayVal(oldVal, key) ?? t('vide')}</span>
<span className="contrib-field-arrow">→</span>
<span className="contrib-field-new">{displayVal(newVal) ?? t('vide')}</span>
<span className="contrib-field-new">{displayVal(newVal, key) ?? t('vide')}</span>
</span>
) : (
<span className="contrib-field-new">{displayVal(newVal) ?? t('vide')}</span>
<span className="contrib-field-new">{displayVal(newVal, key) ?? t('vide')}</span>
)}
</div>
))}
+1 -1
View File
@@ -798,7 +798,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
dirty = true
lastPathInputRef.current = { selected: selectedElkId, hovered: currentHovered }
if (selectedElkId && currentHovered && currentHovered !== selectedElkId && currentHovered.startsWith('p-')) {
const result = findPathBFS(selectedElkId, currentHovered, layoutData)
const result = findPathBFS(selectedElkId, currentHovered, layoutData, graphPersons)
pathNodesRef.current = result ? result.nodePath : []
pathEdgesRef.current = result ? result.edgePath : []
pathLabelRef.current = result ? result.label : ''
+3
View File
@@ -295,6 +295,9 @@
.pc-parentage { display: grid; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--pz-line-soft); }
.pc-seg { width: 100%; }
.pc-seg .pz-tab { flex: 1; }
/* Sexe (issue #114) : étiquette au-dessus, trois choix sur toute la largeur, chacun sur une ligne */
.pc-row--seg { flex-direction: column; align-items: stretch; gap: 6px; }
.pc-row--seg .pz-tab { white-space: nowrap; padding: 0 8px; }
/* Titres de section de l'onglet Infos et Liens : casse normale, Newsreader 15 px, comme la maquette (.sec) */
.pc-infos .pz-eyebrow { text-transform: none; letter-spacing: 0; font: 400 15px/1.3 var(--pz-serif); color: var(--pz-ink); }
+21 -1
View File
@@ -19,6 +19,7 @@ import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/pers
import { isYearOnly } from '../utils/dateInput.js'
import DateField from './DateField.jsx'
import { runOnce } from '../utils/saveOnLeave.js'
import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js'
// Aucune modification en cours (référence stable)
const NO_EDITS = Object.freeze({})
@@ -319,6 +320,7 @@ const PersonCard = ({
const birthFilled = isFilled(getEditValue('birthYear', person.birthDate || person.birthYear))
const deathFilled = isFilled(getEditValue('deathYear', person.deathDate || person.deathYear))
const deceasedNow = editMode ? !getEditValue('isAlive', person.isAlive) : !person.isAlive
const editSex = normalizeSex(getEditValue('sex', person.sex))
const notesShown = editMode && (isFilled(getEditValue('note', person.note)) || openedHas('note'))
// En lecture : toujours visible si demandé (naissance, décès d'une personne décédée), sinon si rempli
const infoRow = (field, label, filled, content, always = false) => {
@@ -921,6 +923,24 @@ const PersonCard = ({
{infoRow('birthPlace', t('Lieu'), isFilled(getEditValue('birthPlace', person.birthPlace)), renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace }))}
{infoRow('birthName', t('Nom de naissance'), isFilled(getEditValue('birthName', person.birthName)), renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') }))}
{infoRow('nickname', t('Surnom'), isFilled(getEditValue('nickname', person.nickname)), renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') }))}
{editMode && (
<div className="pc-row pc-row--seg" data-pc-field="sex">
<span className="pc-label" id="pc-sex-label">{t('Sexe')}</span>
<div className="pz-tabs pc-seg" role="group" aria-labelledby="pc-sex-label">
{SEX_CHOICES.map(({ value, label }) => (
<button
key={value}
type="button"
aria-pressed={editSex === value}
className={`pz-tab ${editSex === value ? 'is-active' : ''}`}
onClick={() => handleFieldChange('sex', editSex === value ? null : value)}
>
{label}
</button>
))}
</div>
</div>
)}
{editMode && (
<div className="pc-row">
<span className="pc-label">{t('Statut')}</span>
@@ -930,7 +950,7 @@ const PersonCard = ({
onClick={() => handleFieldChange('isAlive', !getEditValue('isAlive', person.isAlive))}
>
<span className="pc-alive-dot" />
{getEditValue('isAlive', person.isAlive) ? t('En vie') : t('Décédé(e)')}
{getEditValue('isAlive', person.isAlive) ? t('En vie') : editSex === 'male' ? t('Décédé') : editSex === 'female' ? t('Décédée') : t('Décédé(e)')}
</button>
</div>
)}
+29 -68
View File
@@ -1,5 +1,5 @@
import { coupleBarMeta } from './elkLayout'
import { t } from '../../i18n/index.js'
import { nameKinship, normalizeSex } from '../../utils/kinship/index.js'
// ============================================================
// BFS — chemin le plus court entre deux personnes
@@ -8,7 +8,8 @@ import { t } from '../../i18n/index.js'
// noeuds intermédiaires invisibles ; seuls les noeuds personne
// comptent comme "étapes".
// ============================================================
export function findPathBFS(fromElkId, toElkId, layoutData) {
// persons : les personnes à jour (sexe modifié sans recalcul du placement) ; à défaut, celles du placement
export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) {
if (!layoutData || fromElkId === toElkId) return null
// Construire l'adjacence
@@ -63,78 +64,38 @@ export function findPathBFS(fromElkId, toElkId, layoutData) {
}
nodePath.unshift(fromElkId)
const label = computeRelationshipLabel(edgePath)
return { nodePath, edgePath, label }
const personById = persons
? new Map(persons.map((person) => [`p-${person.id}`, person]))
: new Map((layoutData.children || []).filter((node) => node._type === 'person').map((node) => [node.id, node._data]))
const kinship = kinshipPath(edgePath, nodePath, (id) => personById.get(id))
return { nodePath, edgePath, kinship, label: nameKinship(kinship) }
}
// ============================================================
// Calcul du lien de parenté à partir du chemin BFS
// On analyse la direction à chaque union traversée :
// filiation→couple = UP (enfant → parent)
// couple→filiation = DOWN (parent → enfant)
// filiation→filiation = SIBLING (frères/sœurs, +1 up +1 down)
// couple→couple = SPOUSE (conjoints)
// Puis (ups, downs) donne le degré de parenté.
// Chemin de parenté complet, à partir du chemin BFS
// On lit la direction à chaque union traversée :
// filiation→couple = 'up' (enfant → parent)
// couple→filiation = 'down' (parent → enfant)
// filiation→filiation = 'sibling' (frère ou sœur)
// couple→couple = 'spouse' (conjoint)
// Chaque pas garde la personne atteinte (sexe, année de naissance) :
// le nommage, langue par langue, est dans utils/kinship.
// ============================================================
export function computeRelationshipLabel(edgePath) {
if (edgePath.length < 2) return ''
const MOVES = { 'filiation>couple': 'up', 'couple>filiation': 'down', 'filiation>filiation': 'sibling', 'couple>couple': 'spouse' }
let ups = 0, downs = 0, hasSpouse = false
const personFacts = (data, id) => ({ id: data?.id ?? id, sex: normalizeSex(data?.sex), birthYear: data?.birthYear ?? null })
for (let i = 0; i < edgePath.length; i += 2) {
if (i + 1 >= edgePath.length) break
const entering = edgePath[i].type
const leaving = edgePath[i + 1].type
if (entering === 'couple' && leaving === 'couple') hasSpouse = true
else if (entering === 'couple' && leaving === 'filiation') downs++
else if (entering === 'filiation' && leaving === 'couple') ups++
else if (entering === 'filiation' && leaving === 'filiation') { ups++; downs++ }
export function kinshipPath(edgePath, nodePath = [], personOf = () => null) {
if (edgePath.length < 2) return null
const steps = []
for (let i = 0; i + 1 < edgePath.length; i += 2) {
const move = MOVES[`${edgePath[i].type}>${edgePath[i + 1].type}`]
const id = nodePath[i + 2]
steps.push({ move, person: personFacts(personOf(id), id) })
}
return { from: personFacts(personOf(nodePath[0]), nodePath[0]), steps }
}
// Conjoint direct
if (hasSpouse && ups === 0 && downs === 0) return t('Conjoint(e)')
// In-laws courants
if (hasSpouse) {
if (ups === 1 && downs === 0) return t('Beau-parent')
if (ups === 0 && downs === 1) return t('Bel-enfant')
if (ups === 1 && downs === 1) return t('Beau-frère/sœur')
}
// Ascendants directs
let label
if (downs === 0 && ups > 0) {
if (ups === 1) label = t('Parent')
else if (ups === 2) label = t('Grand-parent')
else if (ups === 3) label = t('Arrière-grand-parent')
else label = t('Aïeul(e) {n}e gén.', { n: ups })
}
// Descendants directs
else if (ups === 0 && downs > 0) {
if (downs === 1) label = t('Enfant')
else if (downs === 2) label = t('Petit-enfant')
else if (downs === 3) label = t('Arrière-petit-enfant')
else label = t('Descendant(e) {n}e gén.', { n: downs })
}
// Fratrie
else if (ups === 1 && downs === 1) { label = t('Frère/Sœur') }
// Oncle / Tante
else if (ups === 2 && downs === 1) { label = t('Oncle/Tante') }
else if (ups === 3 && downs === 1) { label = t('Grand-oncle/tante') }
// Neveu / Nièce
else if (ups === 1 && downs === 2) { label = t('Neveu/Nièce') }
else if (ups === 1 && downs === 3) { label = t('Petit-neveu/nièce') }
// Cousins
else if (ups >= 2 && downs >= 2) {
const degree = Math.min(ups, downs) - 1
const removed = Math.abs(ups - downs)
if (degree === 1 && removed === 0) label = t('Cousin(e) germain(e)')
else if (degree === 1) label = t('Petit-cousin(e)')
else if (removed === 0) label = t('Cousin(e) {n}e degré', { n: degree })
else label = t('Cousin(e) éloigné(e)')
}
else { label = t('{n} liens', { n: ups + downs }) }
if (hasSpouse && label) label = t('{label} par alliance', { label })
return label
export function computeRelationshipLabel(edgePath, nodePath, personOf) {
return nameKinship(kinshipPath(edgePath, nodePath, personOf))
}
+3 -1
View File
@@ -5,6 +5,7 @@
import { t } from '../i18n/index.js'
import { buildMediaUrl } from '../utils/mediaToken.js'
import { normalizeSex } from '../utils/kinship/facts.js'
// Démo : noms et lieux restent français, les textes descriptifs sont traduits.
export let persons = [
@@ -119,7 +120,8 @@ function mapRemotePersonToLocal(person, current = null) {
region: person?.region || null,
profession: person?.profession || null,
nationality: person?.nationality || null,
sex: person?.sex ?? current?.sex ?? null,
// Le serveur renvoie déjà male/female/other/null ; relu ici aussi (anciennes valeurs M/F)
sex: normalizeSex(person?.sex ?? current?.sex),
photo: current?.photo || null,
avatarFromImmich: person?.avatarFromImmich ?? false,
avatarCrop: person ? (person.avatarCrop ?? null) : (current?.avatarCrop ?? null),
+1 -1
View File
@@ -66,7 +66,7 @@ export default {
"Ouverture de l'arbre": 'Opening the tree',
'Arbre prêt': 'Tree ready',
// utils/mediaUpload.js, utils/graphAdapter.js
// utils/mediaUpload.js
'Saisissez le texte de la citation.': 'Enter the text of the quote.',
'Sélectionnez un fichier.': 'Select a file.',
'Fichier invalide.': 'Invalid file.',
+1 -24
View File
@@ -12,30 +12,7 @@ export default {
'Voir l’enfant': 'See child',
'Inconnu': 'Unknown',
// Lien de parenté affiché au survol (components/galaxy/pathfinding.js)
'Conjoint(e)': 'Spouse',
'Beau-parent': 'Parent-in-law',
'Bel-enfant': 'Child-in-law',
'Beau-frère/sœur': 'Brother/sister-in-law',
'Parent': 'Parent',
'Grand-parent': 'Grandparent',
'Arrière-grand-parent': 'Great-grandparent',
'Aïeul(e) {n}e gén.': 'Ancestor, {n} generations up',
'Enfant': 'Child',
'Petit-enfant': 'Grandchild',
'Arrière-petit-enfant': 'Great-grandchild',
'Descendant(e) {n}e gén.': 'Descendant, {n} generations down',
'Frère/Sœur': 'Sibling',
'Oncle/Tante': 'Uncle/Aunt',
'Grand-oncle/tante': 'Great-uncle/aunt',
'Neveu/Nièce': 'Nephew/Niece',
'Petit-neveu/nièce': 'Grandnephew/niece',
'Cousin(e) germain(e)': 'First cousin',
'Petit-cousin(e)': 'First cousin once removed',
'Cousin(e) {n}e degré': 'Cousin, degree {n}',
'Cousin(e) éloigné(e)': 'Distant cousin',
'{n} liens': '{n} steps apart',
'{label} par alliance': '{label} by marriage',
// Le lien de parenté affiché au survol se nomme langue par langue dans utils/kinship/
// Glisser pour ajouter une personne (components/ProximityFeedback.jsx)
'Conjoint': 'Spouse',
+12
View File
@@ -51,6 +51,16 @@ export default {
'Statut': 'Status',
'En vie': 'Living',
'Décédé(e)': 'Deceased',
'Décédé': 'Deceased',
'Décédée': 'Deceased',
// Sexe d'une personne (utils/personSex.js, fiche et ajout)
'Sexe': 'Sex',
'Femme': 'Female',
'Homme': 'Male',
'Autre': 'Other',
'Non précisé': 'Not specified',
'Adopté': 'Adopted',
'Adoptée': 'Adopted',
'Décès': 'Died',
'Date de décès': 'Date of death',
'Âge': 'Age',
@@ -413,6 +423,8 @@ export default {
'Date inconnue': 'Date unknown',
'date inconnue': 'date unknown',
'Décédé(e), date inconnue': 'Deceased, date unknown',
'Décédé, date inconnue': 'Deceased, date unknown',
'Décédée, date inconnue': 'Deceased, date unknown',
'Prénom inconnu': 'Unknown first name',
'Je ne connais pas son prénom': "I don't know their first name",
'Il faut au moins un nom si le prénom est inconnu.': 'A last name is required when the first name is unknown.',
+46
View File
@@ -0,0 +1,46 @@
import { kinshipFacts } from './facts.js'
// Mot genré quand le sexe de la personne nommée est connu, forme neutre ou double sinon
const g = (sex, male, female, neutral) => (sex === 'male' ? male : sex === 'female' ? female : neutral)
export default function nameEn(path) {
const { ups, downs, hasSpouse, sex } = kinshipFacts(path)
// « Spouse » reste neutre : husband et wife supposeraient un mariage
if (hasSpouse && ups === 0 && downs === 0) return 'Spouse'
if (hasSpouse) {
// L'anglais distingue l'ordre : le parent du conjoint (in-law) n'est pas le conjoint du parent (step)
const viaSpouseFirst = path.steps[0].move === 'spouse'
if (ups === 1 && downs === 0) return viaSpouseFirst ? g(sex, 'Father-in-law', 'Mother-in-law', 'Parent-in-law') : g(sex, 'Stepfather', 'Stepmother', 'Stepparent')
if (ups === 0 && downs === 1) return viaSpouseFirst ? g(sex, 'Stepson', 'Stepdaughter', 'Stepchild') : g(sex, 'Son-in-law', 'Daughter-in-law', 'Child-in-law')
if (ups === 1 && downs === 1) return g(sex, 'Brother-in-law', 'Sister-in-law', 'Brother/sister-in-law')
}
let label
if (downs === 0 && ups > 0) {
if (ups === 1) label = g(sex, 'Father', 'Mother', 'Parent')
else if (ups === 2) label = g(sex, 'Grandfather', 'Grandmother', 'Grandparent')
else if (ups === 3) label = g(sex, 'Great-grandfather', 'Great-grandmother', 'Great-grandparent')
else label = `Ancestor, ${ups} generations up`
} else if (ups === 0 && downs > 0) {
if (downs === 1) label = g(sex, 'Son', 'Daughter', 'Child')
else if (downs === 2) label = g(sex, 'Grandson', 'Granddaughter', 'Grandchild')
else if (downs === 3) label = g(sex, 'Great-grandson', 'Great-granddaughter', 'Great-grandchild')
else label = `Descendant, ${downs} generations down`
} else if (ups === 1 && downs === 1) label = g(sex, 'Brother', 'Sister', 'Sibling')
else if (ups === 2 && downs === 1) label = g(sex, 'Uncle', 'Aunt', 'Uncle/Aunt')
else if (ups === 3 && downs === 1) label = g(sex, 'Great-uncle', 'Great-aunt', 'Great-uncle/aunt')
else if (ups === 1 && downs === 2) label = g(sex, 'Nephew', 'Niece', 'Nephew/Niece')
else if (ups === 1 && downs === 3) label = g(sex, 'Grandnephew', 'Grandniece', 'Grandnephew/niece')
else if (ups >= 2 && downs >= 2) {
const degree = Math.min(ups, downs) - 1
const removed = Math.abs(ups - downs)
if (degree === 1 && removed === 0) label = 'First cousin'
else if (degree === 1) label = 'First cousin once removed'
else if (removed === 0) label = `Cousin, degree ${degree}`
else label = 'Distant cousin'
} else label = `${ups + downs} steps apart`
return hasSpouse ? `${label} by marriage` : label
}
+25
View File
@@ -0,0 +1,25 @@
// Sexe d'une personne tel que l'appli le lit : 'male', 'female', 'other' (autre) ou null (non précisé).
// L'import GEDCOM écrit 'male'/'female'/'other' ; d'anciennes données ont pu garder 'M'/'F'.
// Pour nommer un lien, 'other' prend les formes neutres ou doubles, comme null.
export function normalizeSex(value) {
const key = String(value ?? '').trim().toLowerCase()
if (key === 'male' || key === 'm') return 'male'
if (key === 'female' || key === 'f') return 'female'
if (key === 'other' || key === 'x') return 'other'
return null
}
// Ce que le français et l'anglais lisent d'un chemin : générations montées et descendues
// (un frère ou une sœur compte une montée et une descente), passage par un conjoint, et sexe
// de la personne nommée, au bout du chemin.
export function kinshipFacts(path) {
let ups = 0, downs = 0, hasSpouse = false
for (const { move } of path.steps) {
if (move === 'up') ups++
else if (move === 'down') downs++
else if (move === 'sibling') { ups++; downs++ }
else if (move === 'spouse') hasSpouse = true
}
const target = path.steps[path.steps.length - 1]?.person
return { ups, downs, hasSpouse, sex: normalizeSex(target?.sex) }
}
+43
View File
@@ -0,0 +1,43 @@
import { kinshipFacts } from './facts.js'
// Mot genré quand le sexe de la personne nommée est connu, forme double sinon
const g = (sex, male, female, both) => (sex === 'male' ? male : sex === 'female' ? female : both)
export default function nameFr(path) {
const { ups, downs, hasSpouse, sex } = kinshipFacts(path)
if (hasSpouse && ups === 0 && downs === 0) return g(sex, 'Conjoint', 'Conjointe', 'Conjoint(e)')
if (hasSpouse) {
if (ups === 1 && downs === 0) return g(sex, 'Beau-père', 'Belle-mère', 'Beau-parent')
if (ups === 0 && downs === 1) return g(sex, 'Beau-fils', 'Belle-fille', 'Bel-enfant')
if (ups === 1 && downs === 1) return g(sex, 'Beau-frère', 'Belle-sœur', 'Beau-frère/sœur')
}
let label
if (downs === 0 && ups > 0) {
if (ups === 1) label = g(sex, 'Père', 'Mère', 'Parent')
else if (ups === 2) label = g(sex, 'Grand-père', 'Grand-mère', 'Grand-parent')
else if (ups === 3) label = g(sex, 'Arrière-grand-père', 'Arrière-grand-mère', 'Arrière-grand-parent')
else label = `${g(sex, 'Aïeul', 'Aïeule', 'Aïeul(e)')} ${ups}e gén.`
} else if (ups === 0 && downs > 0) {
if (downs === 1) label = g(sex, 'Fils', 'Fille', 'Enfant')
else if (downs === 2) label = g(sex, 'Petit-fils', 'Petite-fille', 'Petit-enfant')
else if (downs === 3) label = g(sex, 'Arrière-petit-fils', 'Arrière-petite-fille', 'Arrière-petit-enfant')
else label = `${g(sex, 'Descendant', 'Descendante', 'Descendant(e)')} ${downs}e gén.`
} else if (ups === 1 && downs === 1) label = g(sex, 'Frère', 'Sœur', 'Frère/Sœur')
else if (ups === 2 && downs === 1) label = g(sex, 'Oncle', 'Tante', 'Oncle/Tante')
else if (ups === 3 && downs === 1) label = g(sex, 'Grand-oncle', 'Grand-tante', 'Grand-oncle/tante')
else if (ups === 1 && downs === 2) label = g(sex, 'Neveu', 'Nièce', 'Neveu/Nièce')
else if (ups === 1 && downs === 3) label = g(sex, 'Petit-neveu', 'Petite-nièce', 'Petit-neveu/nièce')
else if (ups >= 2 && downs >= 2) {
const degree = Math.min(ups, downs) - 1
const removed = Math.abs(ups - downs)
if (degree === 1 && removed === 0) label = g(sex, 'Cousin germain', 'Cousine germaine', 'Cousin(e) germain(e)')
else if (degree === 1) label = g(sex, 'Petit-cousin', 'Petite-cousine', 'Petit-cousin(e)')
else if (removed === 0) label = `${g(sex, 'Cousin', 'Cousine', 'Cousin(e)')} ${degree}e degré`
else label = g(sex, 'Cousin éloigné', 'Cousine éloignée', 'Cousin(e) éloigné(e)')
} else label = `${ups + downs} liens`
return hasSpouse ? `${label} par alliance` : label
}
+24
View File
@@ -0,0 +1,24 @@
// Nom d'un lien de parenté, une fonction de nommage par langue (issue #114).
//
// Le chemin arrive complet : la personne de départ, puis chaque pas avec la personne atteinte.
// { from: { id, sex, birthYear }, steps: [{ move, person: { id, sex, birthYear } }] }
// move vaut 'up' (vers un parent), 'down' (vers un enfant), 'sibling' (vers un frère ou une sœur)
// ou 'spouse' (vers un conjoint). sex vaut 'male', 'female', 'other' (autre) ou null (non précisé).
//
// Chaque langue lit ce dont elle a besoin. Le français et l'anglais comptent les générations et
// regardent le sexe de la personne nommée (kinshipFacts). Une langue qui distingue le côté paternel
// ou maternel lit le sexe du premier parent du chemin (steps[0].person quand steps[0].move === 'up'),
// et les aînés et cadets se lisent sur les années de naissance de deux personnes du chemin.
// Brancher une langue : écrire ./<code>.js sur le modèle de ./fr.js et l'ajouter à NAMERS.
import { locale } from '../../i18n/index.js'
import nameFr from './fr.js'
import nameEn from './en.js'
export { kinshipFacts, normalizeSex } from './facts.js'
const NAMERS = { fr: nameFr, en: nameEn }
export function nameKinship(path, lang = locale) {
if (!path?.steps?.length) return ''
return (NAMERS[lang] || nameEn)(path)
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { kinshipFacts, nameKinship, normalizeSex } from './index.js'
import { computeRelationshipLabel, kinshipPath } from '../../components/galaxy/pathfinding.js'
// Chemin écrit à la main : moves = suite de pas, sex = sexe de la personne au bout
const path = (moves, sex = null, extra = {}) => ({
from: { id: 'moi', sex: null, birthYear: null },
steps: moves.map((move, i) => ({ move, person: { id: `p${i}`, sex: i === moves.length - 1 ? sex : null, birthYear: null, ...(extra[i] || {}) } })),
})
const both = (moves) => ['male', 'female', null].map((sex) => nameKinship(path(moves, sex), 'fr'))
const bothEn = (moves) => ['male', 'female', null].map((sex) => nameKinship(path(moves, sex), 'en'))
describe('nommage français', () => {
it('genre le mot quand le sexe est connu, garde la forme double sinon', () => {
expect(both(['up'])).toEqual(['Père', 'Mère', 'Parent'])
expect(both(['down'])).toEqual(['Fils', 'Fille', 'Enfant'])
expect(both(['sibling'])).toEqual(['Frère', 'Sœur', 'Frère/Sœur'])
expect(both(['up', 'up'])).toEqual(['Grand-père', 'Grand-mère', 'Grand-parent'])
expect(both(['down', 'down'])).toEqual(['Petit-fils', 'Petite-fille', 'Petit-enfant'])
expect(both(['up', 'up', 'up'])).toEqual(['Arrière-grand-père', 'Arrière-grand-mère', 'Arrière-grand-parent'])
expect(both(['down', 'down', 'down'])).toEqual(['Arrière-petit-fils', 'Arrière-petite-fille', 'Arrière-petit-enfant'])
expect(both(['up', 'sibling'])).toEqual(['Oncle', 'Tante', 'Oncle/Tante'])
expect(both(['up', 'up', 'sibling'])).toEqual(['Grand-oncle', 'Grand-tante', 'Grand-oncle/tante'])
expect(both(['sibling', 'down'])).toEqual(['Neveu', 'Nièce', 'Neveu/Nièce'])
expect(both(['sibling', 'down', 'down'])).toEqual(['Petit-neveu', 'Petite-nièce', 'Petit-neveu/nièce'])
expect(both(['up', 'sibling', 'down'])).toEqual(['Cousin germain', 'Cousine germaine', 'Cousin(e) germain(e)'])
expect(both(['up', 'up', 'sibling', 'down'])).toEqual(['Petit-cousin', 'Petite-cousine', 'Petit-cousin(e)'])
expect(both(['up', 'up', 'sibling', 'down', 'down'])).toEqual(['Cousin 2e degré', 'Cousine 2e degré', 'Cousin(e) 2e degré'])
expect(both(['up', 'up', 'up', 'sibling', 'down', 'down'])).toEqual(['Cousin éloigné', 'Cousine éloignée', 'Cousin(e) éloigné(e)'])
expect(both(['up', 'up', 'up', 'up'])).toEqual(['Aïeul 4e gén.', 'Aïeule 4e gén.', 'Aïeul(e) 4e gén.'])
})
it('alliances', () => {
expect(both(['spouse'])).toEqual(['Conjoint', 'Conjointe', 'Conjoint(e)'])
expect(both(['spouse', 'up'])).toEqual(['Beau-père', 'Belle-mère', 'Beau-parent'])
expect(both(['down', 'spouse'])).toEqual(['Beau-fils', 'Belle-fille', 'Bel-enfant'])
expect(both(['spouse', 'sibling'])).toEqual(['Beau-frère', 'Belle-sœur', 'Beau-frère/sœur'])
expect(both(['spouse', 'up', 'sibling'])).toEqual(['Oncle par alliance', 'Tante par alliance', 'Oncle/Tante par alliance'])
})
it('« Autre » prend les formes neutres ou doubles, comme non précisé', () => {
const autre = (moves, lang) => nameKinship(path(moves, 'other'), lang)
expect(['up', 'down', 'sibling', 'spouse'].map((m) => autre([m], 'fr'))).toEqual(['Parent', 'Enfant', 'Frère/Sœur', 'Conjoint(e)'])
expect(autre(['up', 'sibling', 'down'], 'fr')).toBe('Cousin(e) germain(e)')
expect(autre(['up', 'spouse'], 'fr')).toBe('Beau-parent')
expect(['up', 'down', 'sibling', 'up,sibling', 'up,spouse'].map((m) => autre(m.split(','), 'en'))).toEqual(['Parent', 'Child', 'Sibling', 'Uncle/Aunt', 'Stepparent'])
})
it('le sexe des personnes du milieu ne change pas le mot (seule la personne nommée compte)', () => {
const p = path(['up', 'sibling'], 'female', { 0: { sex: 'male' } })
expect(nameKinship(p, 'fr')).toBe('Tante')
})
})
describe('nommage anglais', () => {
it('genre le mot quand le sexe est connu', () => {
expect(bothEn(['up'])).toEqual(['Father', 'Mother', 'Parent'])
expect(bothEn(['down', 'down'])).toEqual(['Grandson', 'Granddaughter', 'Grandchild'])
expect(bothEn(['sibling'])).toEqual(['Brother', 'Sister', 'Sibling'])
expect(bothEn(['up', 'sibling'])).toEqual(['Uncle', 'Aunt', 'Uncle/Aunt'])
expect(bothEn(['sibling', 'down'])).toEqual(['Nephew', 'Niece', 'Nephew/Niece'])
expect(bothEn(['spouse', 'up'])).toEqual(['Father-in-law', 'Mother-in-law', 'Parent-in-law'])
expect(bothEn(['up', 'spouse'])).toEqual(['Stepfather', 'Stepmother', 'Stepparent'])
expect(bothEn(['spouse', 'down'])).toEqual(['Stepson', 'Stepdaughter', 'Stepchild'])
expect(bothEn(['down', 'spouse'])).toEqual(['Son-in-law', 'Daughter-in-law', 'Child-in-law'])
expect(bothEn(['spouse', 'sibling'])).toEqual(['Brother-in-law', 'Sister-in-law', 'Brother/sister-in-law'])
expect(bothEn(['spouse'])).toEqual(['Spouse', 'Spouse', 'Spouse'])
expect(bothEn(['up', 'sibling', 'down'])).toEqual(['First cousin', 'First cousin', 'First cousin'])
})
it('une langue inconnue retombe sur l’anglais', () => {
expect(nameKinship(path(['up'], 'female'), 'xx')).toBe('Mother')
})
})
describe('chemin de parenté depuis le chemin BFS', () => {
// moi → union de mes parents → mon père → union des grands-parents → ma tante
const edgePath = [
{ type: 'filiation', id: 'e1' },
{ type: 'couple', id: 'u1' },
{ type: 'filiation', id: 'e2' },
{ type: 'filiation', id: 'e3' },
]
const nodePath = ['p-1', 'u-1', 'p-2', 'u-2', 'p-3']
const people = { 'p-1': { id: 1, sex: 'female', birthYear: 1990 }, 'p-2': { id: 2, sex: 'M', birthYear: 1960 }, 'p-3': { id: 3, sex: 'F', birthYear: 1955 } }
it('garde chaque pas et la personne atteinte, pour les langues qui en ont besoin', () => {
const k = kinshipPath(edgePath, nodePath, (id) => people[id])
expect(k.from).toEqual({ id: 1, sex: 'female', birthYear: 1990 })
expect(k.steps).toEqual([
{ move: 'up', person: { id: 2, sex: 'male', birthYear: 1960 } },
{ move: 'sibling', person: { id: 3, sex: 'female', birthYear: 1955 } },
])
expect(kinshipFacts(k)).toEqual({ ups: 2, downs: 1, hasSpouse: false, sex: 'female' })
})
it('nomme avec le sexe de la personne au bout', () => {
expect(computeRelationshipLabel(edgePath, nodePath, (id) => people[id])).toBe('Tante')
expect(computeRelationshipLabel(edgePath)).toBe('Oncle/Tante')
})
})
describe('normalizeSex', () => {
it('lit les anciennes valeurs M/F et le reste en non précisé', () => {
expect(['male', 'M', 'female', 'f', 'other', 'X', null, '', 'U'].map(normalizeSex)).toEqual(['male', 'male', 'female', 'female', 'other', 'other', null, null, null])
})
})
+19
View File
@@ -0,0 +1,19 @@
import { t } from '../i18n/index.js'
import { normalizeSex } from './kinship/facts.js'
export { normalizeSex }
// Choix proposés à la saisie du sexe d'une personne (issue #114, tranché par Patch le 20/10) : trois boutons à
// bascule. Rien de choisi = non précisé (null), la valeur par défaut ; un second clic sur le choix actif y revient.
export const SEX_CHOICES = [
{ value: 'female', label: t('Femme') },
{ value: 'male', label: t('Homme') },
{ value: 'other', label: t('Autre') },
]
// Libellé d'une valeur enregistrée, « Non précisé » compris (relecture des propositions)
export function sexLabel(value) {
const sex = normalizeSex(value)
return SEX_CHOICES.find((choice) => choice.value === sex)?.label ?? t('Non précisé')
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest'
import { SEX_CHOICES, sexLabel } from './personSex.js'
describe('saisie du sexe (issue #114)', () => {
it('trois choix, non précisé par défaut quand rien n’est choisi', () => {
expect(SEX_CHOICES.map((c) => c.value)).toEqual(['female', 'male', 'other'])
})
it('libellé d’une valeur enregistrée, « Non précisé » si vide', () => {
expect([null, '', 'F', 'male', 'other'].map(sexLabel)).toEqual(['Non précisé', 'Non précisé', 'Femme', 'Homme', 'Autre'])
})
})