From 3097768af5d5df7af886117f89e9ccad447db53e Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 16 Oct 2026 20:00:40 +0200 Subject: [PATCH] =?UTF-8?q?Premi=C3=A8res=20photos=20:=20les=20autres=20fi?= =?UTF-8?q?chiers=20cit=C3=A9s=20deviennent=20des=20souvenirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit À 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 --- frontend/src/App.jsx | 68 +++++++++++++-- frontend/src/components/PhotoStartPanel.jsx | 93 ++++++++++++++++----- frontend/src/i18n/en/photos.js | 17 ++++ frontend/src/utils/photoImport.js | 26 +++++- frontend/src/utils/photoImport.test.js | 10 +++ 5 files changed, 184 insertions(+), 30 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a91e7b9..c8a82f0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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) */} diff --git a/frontend/src/components/PhotoStartPanel.jsx b/frontend/src/components/PhotoStartPanel.jsx index 1a224bd..ed1ac6e 100644 --- a/frontend/src/components/PhotoStartPanel.jsx +++ b/frontend/src/components/PhotoStartPanel.jsx @@ -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 && (

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

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

- {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.')} -

+

{intro()}

{exampleFolder && (

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

)} @@ -190,12 +230,14 @@ export default function PhotoStartPanel({ ) : found ? (
- {found.matches.length > 0 ? ( + {foundTotal > 0 ? ( <> -

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

+

{foundSummary()}

- ) : remaining.length > 0 ? ( + ) : somethingLeft ? (
)} diff --git a/frontend/src/i18n/en/photos.js b/frontend/src/i18n/en/photos.js index c4b462c..7f4c043 100644 --- a/frontend/src/i18n/en/photos.js +++ b/frontend/src/i18n/en/photos.js @@ -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", diff --git a/frontend/src/utils/photoImport.js b/frontend/src/utils/photoImport.js index 644540f..0164993 100644 --- a/frontend/src/utils/photoImport.js +++ b/frontend/src/utils/photoImport.js @@ -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 { diff --git a/frontend/src/utils/photoImport.test.js b/frontend/src/utils/photoImport.test.js index c0185be..c2972ba 100644 --- a/frontend/src/utils/photoImport.test.js +++ b/frontend/src/utils/photoImport.test.js @@ -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') + }) +})