mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
27 files changed
+644
-137
No files matched your search
@@ -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()
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? '')
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 : ''
|
||||
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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é')
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user