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') + }) +})