Premières photos : les autres fichiers cités deviennent des souvenirs

À l'étape du dossier de photos, les fichiers cités par le logiciel en plus
des portraits (actes, registres, photos de famille) sont retrouvés dans le
même dossier et ajoutés aux souvenirs de chaque personne, avec le titre et
la source venus du GEDCOM. Un seul bouton « Tout ajouter » ; les fichiers
restants sont gardés pour rouvrir l'étape depuis les paramètres. Pour #59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-16 20:00:40 +02:00
1 parent 897ada2665
commit 3097768af5
5 files changed
+184 -30

No files matched your search

+62 -6
View File
@@ -45,6 +45,7 @@ import {
updatePerson,
uploadAnnotationPhoto,
reorderSiblings,
uploadPersonMedia,
} from './api/treeApi'
import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
import { useAuth } from './hooks/useAuth'
@@ -58,10 +59,10 @@ import { useAnnotations } from './hooks/useAnnotations'
import AnnotationToolbar from './components/annotations/AnnotationToolbar'
import TextInputOverlay from './components/annotations/TextInputOverlay'
import { filiations, getChildren, getParents, getPersonUnions, medias, persons, unions } from './data/mockData'
import { loadPhotoRefs, pickFirstFaces, prepareImageForUpload, savePhotoRefs, sizeBucket } from './utils/photoImport'
import { loadMediaRefs, loadPhotoRefs, pickFirstFaces, prepareImageForUpload, saveMediaRefs, savePhotoRefs, sizeBucket } from './utils/photoImport'
import { fileToBase64, getAccountErrorMessage } from './utils/errorMessages'
import { getSiblingParentLinks } from './utils/familyLinks'
import { getUploadMimeType, validateMediaFile } from './utils/mediaUpload'
import { getUploadMimeType, inferMediaTypeFromMimeType, validateMediaFile } from './utils/mediaUpload'
import {
getSource,
identifyUser,
@@ -482,7 +483,7 @@ function App() {
const [adminPanelVisible, setAdminPanelVisible] = useState(false)
// Premiers visages : fenêtre ouverte après un import GEDCOM, ou depuis les paramètres de l'arbre
const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], faces: [] })
const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], mediaRefs: [], faces: [] })
const photoStartDirtyRef = useRef(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [showDraftBanner, setShowDraftBanner] = useState(false)
@@ -575,12 +576,13 @@ function App() {
.filter(Boolean),
}), [linkedSelfPersonId, tree.treeContext.rootPersonId])
const openPhotoStart = useCallback(({ personsImported = null, refs = null, treeId = null } = {}) => {
const openPhotoStart = useCallback(({ personsImported = null, refs = null, mediaRefs = null, treeId = null } = {}) => {
photoStartDirtyRef.current = false
setPhotoStart({
visible: true,
personsImported,
refs: refs ?? loadPhotoRefs(treeId || tree.treeContext.treeId),
mediaRefs: mediaRefs ?? loadMediaRefs(treeId || tree.treeContext.treeId),
faces: computeFirstFaces(),
})
}, [computeFirstFaces, tree.treeContext.treeId])
@@ -2394,13 +2396,16 @@ function App() {
if (imported) {
const refs = Array.isArray(imported.photoRefs) ? imported.photoRefs : []
const mediaRefs = Array.isArray(imported.mediaRefs) ? imported.mediaRefs : []
savePhotoRefs(createdTree.id, refs)
saveMediaRefs(createdTree.id, mediaRefs)
trackAppEvent('gedcom_imported', {
persons: sizeBucket(imported.personsCreated || 0),
photo_refs: sizeBucket(refs.length),
media_refs: sizeBucket(mediaRefs.length),
via: 'wizard',
})
openPhotoStart({ personsImported: (imported.personsCreated || 0) + (imported.personsReused || 0), refs })
openPhotoStart({ personsImported: (imported.personsCreated || 0) + (imported.personsReused || 0), refs, mediaRefs })
} else {
// L'arbre existe déjà : on l'ouvre vide et on dit où réessayer, plutôt que d'en créer un second
setTreeImportError(importError)
@@ -2561,13 +2566,16 @@ function App() {
const handleGedcomImported = useCallback(async (result, treeIdOverride = null) => {
await refreshTreeAndKeepSelection(null, { relayout: true })
const refs = Array.isArray(result?.photoRefs) ? result.photoRefs : []
const mediaRefs = Array.isArray(result?.mediaRefs) ? result.mediaRefs : []
savePhotoRefs(treeIdOverride || tree.treeContext.treeId, refs)
saveMediaRefs(treeIdOverride || tree.treeContext.treeId, mediaRefs)
trackAppEvent('gedcom_imported', {
persons: sizeBucket(result?.personsCreated || 0),
photo_refs: sizeBucket(refs.length),
media_refs: sizeBucket(mediaRefs.length),
})
setAdminPanelVisible(false)
openPhotoStart({ personsImported: (result?.personsCreated || 0) + (result?.personsReused || 0), refs })
openPhotoStart({ personsImported: (result?.personsCreated || 0) + (result?.personsReused || 0), refs, mediaRefs })
}, [refreshTreeAndKeepSelection, openPhotoStart, tree.treeContext.treeId])
// Un portrait envoyé sans recharger l'arbre (rechargé une fois, à la fermeture de la fenêtre).
@@ -2617,6 +2625,51 @@ function App() {
return { placedIds }
}, [sendPortrait])
// Les autres fichiers retrouvés dans le dossier : des souvenirs, avec le titre et la source du logiciel
const handlePhotoStartMedia = useCallback(async (matches, onProgress) => {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
const added = []
for (let i = 0; i < matches.length; i += 1) {
const { personId, file, caption, source } = matches[i]
try {
if (!accessToken || !tree.treeContext.treeId) break
const prepared = await prepareImageForUpload(file)
const type = inferMediaTypeFromMimeType(prepared.type)
if (!validateMediaFile(prepared, type)) {
const payload = {
type,
fileName: prepared.name || file.name,
mimeType: getUploadMimeType(prepared, type),
sizeBytes: prepared.size,
dataBase64: await fileToBase64(prepared),
caption: caption ? String(caption).slice(0, 280) : null,
source: source ? String(source).slice(0, 500) : null,
}
// Trop d'envois d'un coup (429) : on attend et on réessaie, deux fois au plus
for (let attempt = 0; ; attempt += 1) {
try {
await uploadPersonMedia(tree.treeContext.treeId, personId, payload, accessToken)
added.push(matches[i])
photoStartDirtyRef.current = true
break
} catch (error) {
if (error?.status === 429 && attempt < 2) {
await new Promise((resolve) => window.setTimeout(resolve, 15000))
continue
}
break
}
}
}
} catch {
// Fichier illisible : compté comme non envoyé
}
onProgress?.(i + 1)
}
trackAppEvent('memories_bulk_added', { added: sizeBucket(added.length), failed: sizeBucket(matches.length - added.length) })
return { added }
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId])
const handlePhotoStartClose = useCallback(async () => {
setPhotoStart((current) => ({ ...current, visible: false }))
if (photoStartDirtyRef.current) {
@@ -2978,10 +3031,13 @@ function App() {
visible={photoStart.visible}
personsImported={photoStart.personsImported}
photoRefs={photoStart.refs}
mediaRefs={photoStart.mediaRefs}
faces={photoStart.faces}
onUploadOne={handlePhotoStartUploadOne}
onBulkPlace={handlePhotoStartBulk}
onBulkMedia={handlePhotoStartMedia}
onRefsChange={(refs) => savePhotoRefs(tree.treeContext.treeId, refs)}
onMediaRefsChange={(refs) => saveMediaRefs(tree.treeContext.treeId, refs)}
onClose={handlePhotoStartClose}
/>
{/* Annotation Toolbar (visible uniquement en mode édition) */}
+70 -23
View File
@@ -33,22 +33,28 @@ function folderOf(filePath) {
// Après un import GEDCOM (ou depuis les paramètres) : retrouver les photos que le logiciel connaissait,
// et demander les premiers visages un par un.
const refKey = (ref) => `${ref.personId}|${ref.file}`
export default function PhotoStartPanel({
visible,
personsImported = null,
photoRefs = [],
mediaRefs = [],
faces = [],
onUploadOne,
onBulkPlace,
onBulkMedia,
onRefsChange,
onMediaRefsChange,
onClose,
}) {
const folderInputRef = useRef(null)
const faceInputRef = useRef(null)
const faceTargetRef = useRef(null)
const [remaining, setRemaining] = useState(photoRefs)
const [remainingMedia, setRemainingMedia] = useState(mediaRefs)
const [found, setFound] = useState(null)
const [bulk, setBulk] = useState({ running: false, done: 0, total: 0, placed: null, failed: 0 })
const [bulk, setBulk] = useState({ running: false, done: 0, total: 0, placed: null, added: 0, failed: 0 })
const [faceState, setFaceState] = useState({})
const [shownFaces, setShownFaces] = useState(faces)
const [placedTotal, setPlacedTotal] = useState(0)
@@ -57,8 +63,9 @@ export default function PhotoStartPanel({
useEffect(() => {
if (!visible) return
setRemaining(photoRefs)
setRemainingMedia(mediaRefs)
setFound(null)
setBulk({ running: false, done: 0, total: 0, placed: null, failed: 0 })
setBulk({ running: false, done: 0, total: 0, placed: null, added: 0, failed: 0 })
setFaceState({})
setShownFaces(faces)
setPlacedTotal(0)
@@ -80,7 +87,7 @@ export default function PhotoStartPanel({
return undefined
}, [visible])
const exampleFolder = useMemo(() => folderOf(remaining[0]?.file), [remaining])
const exampleFolder = useMemo(() => folderOf(remaining[0]?.file || remainingMedia[0]?.file), [remaining, remainingMedia])
if (!visible) return null
@@ -88,15 +95,28 @@ export default function PhotoStartPanel({
const files = Array.from(event.target.files || [])
event.target.value = ''
if (files.length === 0) return
setBulk((b) => ({ ...b, placed: null, failed: 0 }))
setFound(matchPhotoFiles(remaining, files))
setBulk((b) => ({ ...b, placed: null, added: 0, failed: 0 }))
const portraits = matchPhotoFiles(remaining, files)
const media = matchPhotoFiles(remainingMedia, files)
setFound({ matches: portraits.matches, media: media.matches })
}
const handlePlace = async () => {
if (!found?.matches.length || bulk.running) return
const total = found.matches.length
setBulk({ running: true, done: 0, total, placed: null, failed: 0 })
const result = await onBulkPlace(found.matches, (done) => setBulk((b) => ({ ...b, done })))
const mediaMatches = found?.media || []
if ((!found?.matches.length && !mediaMatches.length) || bulk.running) return
const portraitCount = found.matches.length
const total = portraitCount + mediaMatches.length
setBulk({ running: true, done: 0, total, placed: null, added: 0, failed: 0 })
const result = portraitCount
? await onBulkPlace(found.matches, (done) => setBulk((b) => ({ ...b, done })))
: { placedIds: [] }
const mediaResult = mediaMatches.length && onBulkMedia
? await onBulkMedia(mediaMatches, (done) => setBulk((b) => ({ ...b, done: portraitCount + done })))
: { added: [] }
const addedKeys = new Set(mediaResult.added.map(refKey))
const nextRemainingMedia = remainingMedia.filter((ref) => !addedKeys.has(refKey(ref)))
setRemainingMedia(nextRemainingMedia)
onMediaRefsChange?.(nextRemainingMedia)
const placedIds = new Set(result.placedIds)
// Une personne de la liste des visages dont la photo vient d'être placée passe en « Ajoutée »
const shownIds = new Set(shownFaces.map((p) => String(p.id)))
@@ -112,8 +132,8 @@ export default function PhotoStartPanel({
setRemaining(nextRemaining)
onRefsChange?.(nextRemaining)
setFound(null)
setPlacedTotal((n) => n + placedIds.size)
setBulk({ running: false, done: total, total, placed: placedIds.size, failed: total - placedIds.size })
setPlacedTotal((n) => n + placedIds.size + addedKeys.size)
setBulk({ running: false, done: total, total, placed: placedIds.size, added: addedKeys.size, failed: total - placedIds.size - addedKeys.size })
}
const pickFace = (personId) => {
@@ -137,7 +157,29 @@ export default function PhotoStartPanel({
})
}
const refsKnown = photoRefs.length > 0
const refsKnown = photoRefs.length > 0 || mediaRefs.length > 0
const somethingLeft = remaining.length > 0 || remainingMedia.length > 0
const foundMedia = found?.media || []
const foundTotal = (found?.matches.length || 0) + foundMedia.length
const intro = () => {
if (remaining.length > 0 && remainingMedia.length > 0) {
return remaining.length === 1
? t('Votre logiciel associait une photo à 1 personne, et citait {m} autres fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse place le portrait et ajoute le reste aux souvenirs.', { m: remainingMedia.length })
: t('Votre logiciel associait une photo à {n} personnes, et citait {m} autres fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse place les portraits et ajoute le reste aux souvenirs.', { n: remaining.length, m: remainingMedia.length })
}
if (remainingMedia.length > 0) {
return plural(remainingMedia.length, 'Votre logiciel citait 1 fichier, un acte ou une photo de famille. Choisissez le dossier où il le range, Parenthèse l’ajoute aux souvenirs.', 'Votre logiciel citait {n} fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse les ajoute aux souvenirs de chaque personne.')
}
return plural(remaining.length, 'Votre logiciel associait une photo à 1 personne. Choisissez le dossier où il la range, Parenthèse place le portrait.', 'Votre logiciel associait une photo à {n} personnes. Choisissez le dossier où il les range, Parenthèse place chaque portrait.')
}
const foundSummary = () => {
if (foundMedia.length === 0) return plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length })
if (found.matches.length === 0) return plural(foundMedia.length, '1 fichier retrouvé sur {k}.', '{n} fichiers retrouvés sur {k}.', { k: remainingMedia.length })
return t('{portraits} et {files} retrouvés.', {
portraits: plural(found.matches.length, '1 portrait', '{n} portraits'),
files: plural(foundMedia.length, '1 autre fichier', '{n} autres fichiers'),
})
}
const addedCount = Object.values(faceState).filter((s) => s?.preview && !s.fromFolder).length + placedTotal
return (
@@ -159,11 +201,9 @@ export default function PhotoStartPanel({
{refsKnown && (
<section className="psp-block psp-block--folder" aria-labelledby="pspFolderTitle">
<h3 id="pspFolderTitle" className="psp-h">{t('Vos photos sont déjà sur votre ordinateur')}</h3>
{remaining.length > 0 ? (
{somethingLeft ? (
<>
<p className="psp-p">
{plural(remaining.length, 'Votre logiciel associait une photo à 1 personne. Choisissez le dossier où il la range, Parenthèse place le portrait.', 'Votre logiciel associait une photo à {n} personnes. Choisissez le dossier où il les range, Parenthèse place chaque portrait.')}
</p>
<p className="psp-p">{intro()}</p>
{exampleFolder && (
<p className="pz-hint">{t('Il les rangeait dans {folder}', { folder: exampleFolder })}</p>
)}
@@ -190,12 +230,14 @@ export default function PhotoStartPanel({
</div>
) : found ? (
<div className="psp-found">
{found.matches.length > 0 ? (
{foundTotal > 0 ? (
<>
<p className="psp-p"><strong>{plural(found.matches.length, '1 photo retrouvée sur {k}.', '{n} photos retrouvées sur {k}.', { k: remaining.length })}</strong></p>
<p className="psp-p"><strong>{foundSummary()}</strong></p>
<div className="psp-row-actions">
<button type="button" className="pz-btn pz-btn--accent" onClick={handlePlace}>
{plural(found.matches.length, 'Placer ce portrait', 'Placer les {n} portraits')}
{foundMedia.length > 0
? plural(foundTotal, 'Ajouter ce fichier', 'Tout ajouter ({n})')
: plural(found.matches.length, 'Placer ce portrait', 'Placer les {n} portraits')}
</button>
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => folderInputRef.current?.click()}>
{t('Un autre dossier')}
@@ -212,7 +254,7 @@ export default function PhotoStartPanel({
</>
)}
</div>
) : remaining.length > 0 ? (
) : somethingLeft ? (
<button type="button" className="pz-btn pz-btn--accent psp-folder-btn" onClick={() => folderInputRef.current?.click()}>
<FolderOpen size={18} strokeWidth={2} aria-hidden="true" />
{bulk.placed != null ? t('Choisir un autre dossier') : t('Choisir le dossier des photos')}
@@ -221,12 +263,17 @@ export default function PhotoStartPanel({
{bulk.placed != null && !bulk.running && (
<p className="pz-success" role="status">
{plural(bulk.placed, '1 portrait placé.', '{n} portraits placés.')}
{(bulk.placed > 0 || bulk.added === 0) && plural(bulk.placed, '1 portrait placé.', '{n} portraits placés.')}
{bulk.added > 0 && `${bulk.placed > 0 ? ' ' : ''}${plural(bulk.added, '1 fichier ajouté aux souvenirs.', '{n} fichiers ajoutés aux souvenirs.')}`}
{bulk.failed > 0 && ` ${t("{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).", { f: bulk.failed })}`}
</p>
)}
{!bulk.running && remaining.length > 0 && (
<p className="pz-hint">{t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}</p>
{!bulk.running && somethingLeft && (
<p className="pz-hint">
{remainingMedia.length > 0
? t('Seuls les fichiers cités par votre logiciel sont envoyés, vers votre arbre uniquement.')
: t('Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.')}
</p>
)}
</section>
)}
+17
View File
@@ -24,6 +24,23 @@ export default {
"{n} portraits placés.": "{n} portraits placed.",
"{f} n'ont pas pu être envoyés (format non pris en charge ou fichier trop lourd).": "{f} couldn't be sent (unsupported format or file too large).",
"Seules les photos qui correspondent à une personne sont envoyées, vers votre arbre uniquement.": "Only the photos that match a person are sent, and only to your tree.",
// Autres fichiers cités par le logiciel (actes, photos de famille) : des souvenirs
"Votre logiciel associait une photo à 1 personne, et citait {m} autres fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse place le portrait et ajoute le reste aux souvenirs.": "Your software linked a photo to 1 person, and also listed {m} other files, records or family photos. Choose the folder where it keeps them, Parenthèse places the portrait and adds the rest to the memories.",
"Votre logiciel associait une photo à {n} personnes, et citait {m} autres fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse place les portraits et ajoute le reste aux souvenirs.": "Your software linked a photo to {n} people, and also listed {m} other files, records or family photos. Choose the folder where it keeps them, Parenthèse places the portraits and adds the rest to the memories.",
"Votre logiciel citait 1 fichier, un acte ou une photo de famille. Choisissez le dossier où il le range, Parenthèse l’ajoute aux souvenirs.": "Your software listed 1 file, a record or a family photo. Choose the folder where it keeps it, Parenthèse adds it to the memories.",
"Votre logiciel citait {n} fichiers, actes ou photos de famille. Choisissez le dossier où il les range, Parenthèse les ajoute aux souvenirs de chaque personne.": "Your software listed {n} files, records or family photos. Choose the folder where it keeps them, Parenthèse adds them to each person's memories.",
"1 fichier retrouvé sur {k}.": "1 file found out of {k}.",
"{n} fichiers retrouvés sur {k}.": "{n} files found out of {k}.",
"{portraits} et {files} retrouvés.": "{portraits} and {files} found.",
"1 portrait": "1 portrait",
"{n} portraits": "{n} portraits",
"1 autre fichier": "1 other file",
"{n} autres fichiers": "{n} other files",
"Ajouter ce fichier": "Add this file",
"Tout ajouter ({n})": "Add everything ({n})",
"1 fichier ajouté aux souvenirs.": "1 file added to the memories.",
"{n} fichiers ajoutés aux souvenirs.": "{n} files added to the memories.",
"Seuls les fichiers cités par votre logiciel sont envoyés, vers votre arbre uniquement.": "Only the files listed by your software are sent, and only to your tree.",
"Ou commencez par ces visages": "Or start with these faces",
"Commencez par ces visages": "Start with these faces",
"Envoi": "Sending",
+25 -1
View File
@@ -56,7 +56,8 @@ export function matchPhotoFiles(photoRefs, files) {
bestScore = score
}
}
matches.push({ personId: ref.personId, file: best })
// Les autres champs de la référence (légende, source d'un souvenir) suivent le fichier retrouvé
matches.push({ ...ref, file: best })
}
return { matches, missing }
}
@@ -158,6 +159,29 @@ export function savePhotoRefs(treeId, refs) {
}
}
// Les autres fichiers cités (actes, photos de famille) : gardés à part, pour rouvrir l'étape plus tard
const MEDIA_REFS_KEY = (treeId) => `parenthese_media_refs:${treeId}`
export function saveMediaRefs(treeId, refs) {
if (!treeId) return
try {
if (refs?.length) localStorage.setItem(MEDIA_REFS_KEY(treeId), JSON.stringify(refs))
else localStorage.removeItem(MEDIA_REFS_KEY(treeId))
} catch {
// Stockage plein ou indisponible : l'étape ne se rouvrira pas, l'import reste fait
}
}
export function loadMediaRefs(treeId) {
if (!treeId) return []
try {
const parsed = JSON.parse(localStorage.getItem(MEDIA_REFS_KEY(treeId)) || '[]')
return Array.isArray(parsed) ? parsed.filter((r) => r && r.personId && r.file) : []
} catch {
return []
}
}
export function loadPhotoRefs(treeId) {
if (!treeId) return []
try {
+10
View File
@@ -75,3 +75,13 @@ describe('sizeBucket', () => {
expect([0, 3, 12, 80, 450, 2697].map(sizeBucket)).toEqual(['0', '1-9', '10-49', '50-199', '200-999', '1000+'])
})
})
describe('souvenirs cités par le logiciel', () => {
it('garde la légende et la source avec le fichier retrouvé', () => {
const refs = [{ personId: 'a', file: 'C:\\Heredis\\Médias\\Naissance Ragot.jpg', caption: 'Naissance Ragot', source: 'Registre des naissances, Acte n° 12' }]
const { matches } = matchPhotoFiles(refs, [file('Naissance Ragot.jpg', 'Médias/Naissance Ragot.jpg')])
expect(matches).toHaveLength(1)
expect(matches[0]).toMatchObject({ personId: 'a', caption: 'Naissance Ragot', source: 'Registre des naissances, Acte n° 12' })
expect(matches[0].file.name).toBe('Naissance Ragot.jpg')
})
})