diff --git a/src/__tests__/app/settings-screen.test.tsx b/src/__tests__/app/settings-screen.test.tsx index d632771..4f67769 100644 --- a/src/__tests__/app/settings-screen.test.tsx +++ b/src/__tests__/app/settings-screen.test.tsx @@ -170,7 +170,7 @@ describe('BackupScreen — Sauvegarde', () => { await waitFor(() => expect(writeExport).toHaveBeenCalledTimes(1)); expect(writeExport.mock.calls[0][1]).toBe('csv'); expect(writeExport.mock.calls[0][0]).toMatch( - /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded\n/, + /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt\n/, ); }); diff --git a/src/app/(tabs)/collection/index.tsx b/src/app/(tabs)/collection/index.tsx index 01dc39d..b28db12 100644 --- a/src/app/(tabs)/collection/index.tsx +++ b/src/app/(tabs)/collection/index.tsx @@ -1,70 +1,24 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useMemo, useState } from 'react'; -import { - FlatList, - Pressable, - RefreshControl, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; -import { Feather } from '@expo/vector-icons'; +import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native'; import { AppHeader } from '@/components/app-header'; +import { CollectionFilters } from '@/components/collection-filters'; import { EmptyState } from '@/components/empty-state'; import { LoadingView } from '@/components/loading-view'; import { MagazineCard } from '@/components/magazine-card'; -import { SelectField } from '@/components/select-field'; +import { Pagination } from '@/components/pagination'; import { Screen } from '@/components/screen'; -import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; import { useCollectionStore } from '@/store/use-collection-store'; import type { MagazineListItem } from '@/types'; - -const PAGE_SIZE = 20; - -/** Options de tri de la collection (M10R2-08). */ -const SORT_OPTIONS = ['Numéro ↑', 'Numéro ↓', 'Ajout récent', 'Édition (A → Z)'] as const; -type SortOption = (typeof SORT_OPTIONS)[number]; - -function pageWindow(current: number, total: number): number[] { - const start = Math.max(1, current - 2); - const end = Math.min(total, current + 2); - return Array.from({ length: end - start + 1 }, (_, i) => start + i); -} - -function sortMagazines(list: MagazineListItem[], sort: SortOption): MagazineListItem[] { - const sorted = [...list]; - switch (sort) { - case 'Numéro ↓': - return sorted.sort((a, b) => { - const na = a.issueNumber ?? Number.MIN_SAFE_INTEGER; - const nb = b.issueNumber ?? Number.MIN_SAFE_INTEGER; - if (na !== nb) return nb - na; - return a.publication.localeCompare(b.publication); - }); - case 'Ajout récent': - return sorted.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - case 'Édition (A → Z)': - return sorted.sort((a, b) => { - const ea = (a.edition?.trim() || '').toLowerCase(); - const eb = (b.edition?.trim() || '').toLowerCase(); - if (ea !== eb) return ea.localeCompare(eb); - return a.publication.localeCompare(b.publication); - }); - case 'Numéro ↑': - default: - return sorted.sort((a, b) => { - const na = a.issueNumber ?? Number.MAX_SAFE_INTEGER; - const nb = b.issueNumber ?? Number.MAX_SAFE_INTEGER; - if (na !== nb) return na - nb; - return a.publication.localeCompare(b.publication); - }); - } -} +import { + buildEditionOptions, + filterAndSortMagazines, + PAGE_SIZE, + type SortOption, +} from '@/lib/collection-list'; export default function CollectionScreen() { const router = useRouter(); @@ -78,7 +32,6 @@ export default function CollectionScreen() { const [editionFilter, setEditionFilter] = useState(null); const [sort, setSort] = useState('Numéro ↑'); const [page, setPage] = useState(1); - const [filtersOpen, setFiltersOpen] = useState(false); const [lastParamEdition, setLastParamEdition] = useState(undefined); const paramEdition = typeof params.edition === 'string' ? params.edition : undefined; @@ -87,7 +40,6 @@ export default function CollectionScreen() { if (paramEdition) { setEditionFilter(paramEdition); setPage(1); - setFiltersOpen(true); } } @@ -97,33 +49,12 @@ export default function CollectionScreen() { }, [load]), ); - const editions = useMemo( - () => - [ - ...new Set(magazines.map((m) => (m.edition?.trim() ? m.edition.trim() : 'Sans édition'))), - ].sort((a, b) => (a === 'Sans édition' ? 1 : b === 'Sans édition' ? -1 : a.localeCompare(b))), - [magazines], - ); + const editions = useMemo(() => buildEditionOptions(magazines), [magazines]); - const filtered = useMemo(() => { - const issue = issueQuery.trim(); - const list = magazines.filter((m) => { - if (editionFilter) { - const edition = m.edition?.trim() ? m.edition.trim() : 'Sans édition'; - if (edition !== editionFilter) { - return false; - } - } - if (issue) { - const parsed = Number(issue); - if (Number.isNaN(parsed) || m.issueNumber !== parsed) { - return false; - } - } - return true; - }); - return sortMagazines(list, sort); - }, [magazines, issueQuery, editionFilter, sort]); + const filtered = useMemo( + () => filterAndSortMagazines(magazines, editionFilter, issueQuery, sort), + [magazines, editionFilter, issueQuery, sort], + ); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages); @@ -165,75 +96,16 @@ export default function CollectionScreen() { - - [styles.searchButton, pressed && styles.buttonPressed]} - onPress={() => setFiltersOpen((o) => !o)} - testID="filter-toggle" - accessibilityRole="button" - accessibilityLabel="Rechercher" - accessibilityState={{ expanded: filtersOpen }}> - - Rechercher - - - - {filtersOpen ? ( - - - - Numéro - - - - - - - - {hasFilters ? ( - [styles.clearButton, pressed && styles.buttonPressed]} - onPress={clearFilters} - testID="filter-clear" - accessibilityRole="button" - accessibilityLabel="Effacer les filtres" - android_ripple={{ color: 'rgba(0,0,0,0.08)' }}> - - Effacer les filtres - - ) : null} - - ) : null} - + {loading && magazines.length === 0 ? ( @@ -252,81 +124,75 @@ export default function CollectionScreen() { actionTestID="collection-empty-scan" /> ) : ( - <> - - {filtered.length} résultat{filtered.length > 1 ? 's' : ''} - - item.id} - contentContainerStyle={styles.list} - keyboardShouldPersistTaps="handled" - testID="collection-list" - refreshControl={ - - } - renderItem={({ item }) => ( - router.push(`/collection/${item.id}`)} - /> - )} - /> - {totalPages > 1 ? ( - - [styles.pageButton, pressed && styles.buttonPressed]} - onPress={() => goToPage(safePage - 1)} - disabled={safePage <= 1} - accessibilityRole="button" - accessibilityLabel="Page précédente" - accessibilityState={{ disabled: safePage <= 1 }} - testID="pagination-prev"> - - - {pageWindow(safePage, totalPages).map((p) => ( - [ - styles.pageButton, - p === safePage && styles.pageButtonActive, - pressed && styles.buttonPressed, - ]} - onPress={() => goToPage(p)} - disabled={p === safePage} - accessibilityRole="button" - accessibilityLabel={`Page ${p}`} - accessibilityState={{ selected: p === safePage }} - testID={`pagination-page-${p}`}> - - {p} - - - ))} - [styles.pageButton, pressed && styles.buttonPressed]} - onPress={() => goToPage(safePage + 1)} - disabled={safePage >= totalPages} - accessibilityRole="button" - accessibilityLabel="Page suivante" - accessibilityState={{ disabled: safePage >= totalPages }} - testID="pagination-next"> - - - - ) : null} - + router.push(`/collection/${item.id}`)} + tintColor={colors.navActive} + listStyle={styles.list} + /> )} + goToPage(safePage - 1)} + onNext={() => goToPage(safePage + 1)} + onPage={goToPage} + /> ); } +function CollectionList({ + items, + resultCount, + loading, + onRefresh, + onPress, + tintColor, + listStyle, +}: { + items: MagazineListItem[]; + resultCount: number; + loading: boolean; + onRefresh: () => void; + onPress: (item: MagazineListItem) => void; + tintColor: string; + listStyle: object; +}) { + const colors = useThemeColors(); + return ( + <> + + {resultCount} résultat{resultCount > 1 ? 's' : ''} + + item.id} + contentContainerStyle={listStyle} + keyboardShouldPersistTaps="handled" + testID="collection-list" + refreshControl={ + + } + renderItem={({ item }) => onPress(item)} />} + /> + + ); +} + +function makeResultCountStyles(colors: ThemeColors) { + return StyleSheet.create({ + resultCount: { + fontSize: 13, + color: colors.textSecondary, + }, + }); +} + function makeStyles(colors: ThemeColors) { return StyleSheet.create({ screen: { @@ -335,102 +201,8 @@ function makeStyles(colors: ThemeColors) { padding: Spacing.four, gap: Spacing.three, }, - filters: { - gap: Spacing.two, - }, - searchButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: Spacing.two, - minHeight: 48, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - backgroundColor: colors.accent, - }, - searchButtonText: { - fontSize: 17, - fontWeight: '700', - color: colors.accentText, - }, - filtersPanel: { - gap: Spacing.two, - }, - filtersRow: { - flexDirection: 'row', - gap: Spacing.two, - alignItems: 'flex-start', - }, - filtersColumn: { - flex: 1, - }, - filterLabel: { - fontSize: 14, - fontWeight: '600', - color: colors.text, - marginTop: Spacing.two, - }, - input: { - backgroundColor: colors.backgroundElement, - borderRadius: 8, - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.two, - minHeight: HitTarget.minHeight, - fontSize: 16, - color: colors.text, - }, - clearButton: { - flexDirection: 'row', - alignItems: 'center', - alignSelf: 'flex-start', - gap: 6, - minHeight: HitTarget.minHeight, - paddingHorizontal: Spacing.two, - }, - clearButtonText: { - fontSize: 14, - fontWeight: '600', - color: colors.textSecondary, - }, - resultCount: { - fontSize: 13, - color: colors.textSecondary, - }, list: { gap: Spacing.two, }, - pagination: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: Spacing.two, - flexWrap: 'wrap', - paddingTop: Spacing.two, - }, - pageButton: { - minWidth: HitTarget.minHeight, - minHeight: HitTarget.minHeight, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: Spacing.two, - borderRadius: 8, - backgroundColor: colors.backgroundElement, - }, - pageButtonActive: { - backgroundColor: colors.accent, - }, - pageButtonText: { - fontSize: 16, - color: colors.text, - }, - pageButtonTextActive: { - fontSize: 16, - fontWeight: '700', - color: colors.accentText, - }, - buttonPressed: { - opacity: 0.7, - }, }); } diff --git a/src/app/scan/result.tsx b/src/app/scan/result.tsx index 2e28c44..9e2807a 100644 --- a/src/app/scan/result.tsx +++ b/src/app/scan/result.tsx @@ -1,17 +1,19 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback } from 'react'; -import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; +import { useScanResult } from '@/hooks/use-scan-result'; +import { makeSearchResultStyles } from '@/components/scan-search-views'; import { Screen } from '@/components/screen'; -import { toast } from '@/lib/toast'; import { useCollectionStore } from '@/store/use-collection-store'; export default function ScanResultScreen() { const router = useRouter(); const colors = useThemeColors(); const styles = makeStyles(colors); + const statusTextStyles = makeSearchResultStyles(colors); const params = useLocalSearchParams<{ id?: string; publication?: string; @@ -22,7 +24,6 @@ export default function ScanResultScreen() { const loadDetail = useCollectionStore((s) => s.loadDetail); const detail = useCollectionStore((s) => s.detail); const detailLoading = useCollectionStore((s) => s.detailLoading); - const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); const id = params.id ?? ''; const exists = id !== ''; @@ -36,34 +37,7 @@ export default function ScanResultScreen() { }, [id, loadDetail]), ); - const resolved = detail != null && detail.id === id; - const owned = resolved && detail.copies.length > 0; - const ownedCount = resolved ? detail.copies.length : 0; - - const handleAddCopy = () => { - if (!id) { - return; - } - const alreadyOwned = owned; - - const perform = async () => { - await addExistingCopy(id); - toast(alreadyOwned ? 'Exemplaire ajouté à la collection' : 'Ajouté à la collection'); - }; - - if (alreadyOwned) { - Alert.alert( - 'Vous possédez déjà ce magazine', - `Exemplaires actuels : ${ownedCount}\nVoulez-vous ajouter un deuxième exemplaire ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { text: 'Ajouter quand même', style: 'destructive', onPress: perform }, - ], - ); - } else { - perform(); - } - }; + const { resolved, owned, ownedCount, handleAddCopy } = useScanResult(detail, exists ? id : null); const handleManual = () => { router.replace({ @@ -89,7 +63,7 @@ export default function ScanResultScreen() { ) : null} {resolved ? ( {owned ? `✓ Possédé (${ownedCount})` : '○ Absent'} @@ -199,26 +173,6 @@ function makeStyles(colors: ThemeColors) { fontSize: 16, color: colors.textSecondary, }, - ownedText: { - fontSize: 14, - fontWeight: '700', - color: colors.ownedText, - backgroundColor: colors.ownedBg, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.two, - borderRadius: 8, - overflow: 'hidden', - }, - absentText: { - fontSize: 14, - fontWeight: '700', - color: colors.absentText, - backgroundColor: colors.absentBg, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.two, - borderRadius: 8, - overflow: 'hidden', - }, message: { fontSize: 16, color: colors.text, diff --git a/src/app/scan/search.tsx b/src/app/scan/search.tsx index a97cc1e..8b18b77 100644 --- a/src/app/scan/search.tsx +++ b/src/app/scan/search.tsx @@ -1,23 +1,20 @@ import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; -import { - Alert, - KeyboardAvoidingView, - Platform, - Pressable, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; +import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native'; import { Feather } from '@expo/vector-icons'; import { AutocompleteInput } from '@/components/autocomplete-input'; import { Screen } from '@/components/screen'; -import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { HitTarget } from '@/constants/theme'; +import { + SearchFoundResult, + SearchUnknownResult, + makeSearchResultStyles, + makeSearchScreenStyles, +} from '@/components/scan-search-views'; import { getDeps } from '@/dependencies'; import { useThemeColors } from '@/hooks/use-theme'; -import { toast } from '@/lib/toast'; +import { useScanResult } from '@/hooks/use-scan-result'; import { useCollectionStore } from '@/store/use-collection-store'; import type { OcrLookupResult } from '@/identification/identificationService'; @@ -26,11 +23,10 @@ const EMPTY_ISSUE = ''; export default function ScanSearchScreen() { const router = useRouter(); const colors = useThemeColors(); - const styles = makeStyles(colors); + const screenStyles = makeSearchScreenStyles(colors); const magazines = useCollectionStore((s) => s.magazines); const loadDetail = useCollectionStore((s) => s.loadDetail); const detail = useCollectionStore((s) => s.detail); - const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); const [publication, setPublication] = useState(''); const [issueNumber, setIssueNumber] = useState(EMPTY_ISSUE); @@ -52,6 +48,8 @@ export default function ScanSearchScreen() { }, [foundId, loadDetail]), ); + const { resolved, owned, ownedCount, handleAddCopy } = useScanResult(detail, foundId); + const issue = issueNumber.replace(/[^0-9]/g, ''); const canSearch = publication.trim().length > 0 && issue.length > 0 && !searching; @@ -80,10 +78,6 @@ export default function ScanSearchScreen() { setIssueNumber(EMPTY_ISSUE); }; - const resolved = detail != null && detail.id === foundId; - const owned = resolved && detail.copies.length > 0; - const ownedCount = resolved ? detail.copies.length : 0; - const handleCancel = () => { if (router.canGoBack()) { router.back(); @@ -92,29 +86,6 @@ export default function ScanSearchScreen() { } }; - const handleAddCopy = () => { - if (!foundId) { - return; - } - const alreadyOwned = owned; - const perform = async () => { - await addExistingCopy(foundId); - toast(alreadyOwned ? 'Exemplaire ajouté à la collection' : 'Ajouté à la collection'); - }; - if (alreadyOwned) { - Alert.alert( - 'Vous possédez déjà ce magazine', - `Exemplaires actuels : ${ownedCount}\nVoulez-vous ajouter un deuxième exemplaire ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { text: 'Ajouter quand même', style: 'destructive', onPress: perform }, - ], - ); - } else { - perform(); - } - }; - const handleManual = () => { router.replace({ pathname: '/scan/manual', @@ -126,12 +97,12 @@ export default function ScanSearchScreen() { return ( - + [styles.backButton, pressed && styles.pressed]} + style={({ pressed }) => [screenStyles.backButton, pressed && screenStyles.pressed]} onPress={handleCancel} testID="search-cancel" accessibilityRole="button" @@ -139,276 +110,110 @@ export default function ScanSearchScreen() { hitSlop={HitTarget.hitSlop}> - Rechercher un magazine + Rechercher un magazine {!result ? ( - - - - Numéro - - - [styles.primaryButton, !canSearch && styles.disabled]} - onPress={search} - disabled={!canSearch} - testID="search-submit" - accessibilityRole="button" - accessibilityState={{ disabled: !canSearch }}> - - {searching ? 'Recherche…' : 'Rechercher'} - - - {!canSearch && !searching ? ( - - Renseignez la publication et le numéro pour rechercher. - - ) : null} - + ) : result.status === 'found' ? ( - - Déjà dans votre collection - - - {result.publication} - - {result.issueNumber != null ? ( - N° {result.issueNumber} - ) : null} - {resolved ? ( - - {owned ? `✓ Possédé (${ownedCount})` : '○ Absent'} - - ) : ( - - Vérification… - - )} - - - [styles.primaryButton, pressed && styles.pressed]} - onPress={handleAddCopy} - testID="search-add" - accessibilityRole="button" - accessibilityLabel={owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'}> - - {owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'} - - - - [styles.secondaryButton, pressed && styles.pressed]} - onPress={() => router.replace(`/collection/${foundId}`)} - testID="search-view" - accessibilityRole="button" - accessibilityLabel="Voir la fiche du magazine"> - Voir la fiche - - - [styles.secondaryButton, pressed && styles.pressed]} - onPress={() => router.replace('/scan/barcode')} - testID="search-rescan" - accessibilityRole="button" - accessibilityLabel="Scanner à nouveau"> - Scanner à nouveau - - + router.replace(`/collection/${foundId}`)} + onRescan={() => router.replace('/scan/barcode')} + /> ) : ( - - Non référencé - - - « {result.publication} » n'est pas encore dans votre collection. - - {result.issueNumber != null ? ( - N° {result.issueNumber} - ) : null} - - - [styles.primaryButton, pressed && styles.pressed]} - onPress={handleManual} - testID="search-manual" - accessibilityRole="button" - accessibilityLabel="Saisir manuellement"> - Saisir manuellement - - - [styles.secondaryButton, pressed && styles.pressed]} - onPress={resetForm} - testID="search-again" - accessibilityRole="button" - accessibilityLabel="Rechercher un autre magazine"> - Rechercher un autre magazine - - + )} ); } -function makeStyles(colors: ThemeColors) { - return StyleSheet.create({ - screen: { - flex: 1, - padding: Spacing.four, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - marginBottom: Spacing.three, - }, - backButton: { - minWidth: HitTarget.minHeight, - minHeight: HitTarget.minHeight, - alignItems: 'center', - justifyContent: 'center', - }, - pressed: { - opacity: 0.7, - }, - title: { - flex: 1, - fontSize: 24, - lineHeight: 32, - fontWeight: '700', - color: colors.text, - }, - form: { - flex: 1, - gap: Spacing.three, - }, - label: { - fontSize: 14, - fontWeight: '600', - color: colors.text, - marginTop: Spacing.two, - }, - input: { - backgroundColor: colors.backgroundElement, - borderRadius: 8, - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.two, - minHeight: 44, - fontSize: 16, - color: colors.text, - }, - primaryButton: { - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - minHeight: 48, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - }, - disabled: { - opacity: 0.5, - }, - primaryButtonText: { - fontSize: 18, - fontWeight: '700', - color: colors.accentText, - }, - secondaryButton: { - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.backgroundElement, - minHeight: 48, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 12, - }, - secondaryButtonText: { - fontSize: 17, - fontWeight: '600', - color: colors.text, - }, - hint: { - fontSize: 13, - color: colors.textSecondary, - textAlign: 'center', - }, - resultTitle: { - fontSize: 22, - fontWeight: '700', - color: colors.text, - textAlign: 'center', - }, - card: { - backgroundColor: colors.backgroundElement, - borderRadius: 12, - padding: Spacing.four, - alignItems: 'center', - gap: Spacing.two, - }, - magazine: { - fontSize: 20, - fontWeight: '600', - color: colors.text, - textAlign: 'center', - }, - issue: { - fontSize: 16, - color: colors.textSecondary, - textAlign: 'center', - }, - message: { - fontSize: 16, - color: colors.text, - textAlign: 'center', - lineHeight: 22, - }, - muted: { - fontSize: 14, - color: colors.textSecondary, - textAlign: 'center', - }, - ownedText: { - fontSize: 14, - fontWeight: '700', - color: colors.ownedText, - backgroundColor: colors.ownedBg, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.two, - borderRadius: 8, - overflow: 'hidden', - }, - absentText: { - fontSize: 14, - fontWeight: '700', - color: colors.absentText, - backgroundColor: colors.absentBg, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.two, - borderRadius: 8, - overflow: 'hidden', - }, - }); +function SearchForm({ + publication, + publications, + issueNumber, + canSearch, + searching, + onPublicationChange, + onIssueChange, + onSearch, +}: { + publication: string; + publications: string[]; + issueNumber: string; + canSearch: boolean; + searching: boolean; + onPublicationChange: (v: string) => void; + onIssueChange: (v: string) => void; + onSearch: () => void; +}) { + const colors = useThemeColors(); + const styles = makeSearchResultStyles(colors); + const screenStyles = makeSearchScreenStyles(colors); + + return ( + + + + Numéro + + + [ + styles.primaryButton, + !canSearch && screenStyles.disabled, + pressed && screenStyles.pressed, + ]} + onPress={onSearch} + disabled={!canSearch} + testID="search-submit" + accessibilityRole="button" + accessibilityState={{ disabled: !canSearch }}> + {searching ? 'Recherche…' : 'Rechercher'} + + {!canSearch && !searching ? ( + + Renseignez la publication et le numéro pour rechercher. + + ) : null} + + ); } diff --git a/src/backup/__tests__/backup-service.test.ts b/src/backup/__tests__/backup-service.test.ts index 4803077..9ebf850 100644 --- a/src/backup/__tests__/backup-service.test.ts +++ b/src/backup/__tests__/backup-service.test.ts @@ -119,7 +119,15 @@ describe('BackupService.importCollection', () => { const before = await service.exportCollection(); const file = await service.exportCollection(); - file.magazines.push({ ...file.magazines[0], id: 'edition-soumise-a-echec' }); + const duplicate = { + ...file.magazines[0], + id: 'edition-soumise-a-echec', + copies: file.magazines[0].copies.map((copy) => ({ + ...copy, + id: `copie-${copy.id}`, + })), + }; + file.magazines.push(duplicate); // On force un échec déterministe lors de la 2e insertion d'édition, sans // dépendre du comportement de la contrainte de clé primaire (flaky en CI). @@ -143,6 +151,20 @@ describe('BackupService.importCollection', () => { expect(after.magazines).toEqual(before.magazines); expect(after.magazines[0].copies).toEqual(before.magazines[0].copies); }); + + it('préserve l’historique (createdAt/updatedAt) au round-trip', async () => { + await seedCollection(); + const source = await service.exportCollection(); + const original = source.magazines[0]; + original.createdAt = '2020-03-01T10:00:00.000Z'; + original.updatedAt = '2021-05-10T10:00:00.000Z'; + + await service.importCollection(service.toJson(source)); + + const after = await service.exportCollection(); + expect(after.magazines[0].createdAt).toBe('2020-03-01T10:00:00.000Z'); + expect(after.magazines[0].updatedAt).toBe('2021-05-10T10:00:00.000Z'); + }); }); describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => { @@ -189,6 +211,88 @@ describe('BackupService.importCollection — fichier invalide (US-BK-03)', () => const after = await service.exportCollection(); expect(after.magazines).toEqual(before.magazines); }); + + it('rejette des identifiants d’édition dupliqués', async () => { + const raw = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + magazines: [ + { id: 'dup', publication: 'P', issueNumber: 1, copies: [] }, + { id: 'dup', publication: 'P', issueNumber: 2, copies: [] }, + ], + }); + await expect(service.importCollection(raw)).rejects.toThrow( + 'Fichier invalide : des identifiants d’édition sont dupliqués.', + ); + }); + + it('rejette des identifiants d’exemplaire dupliqués', async () => { + const raw = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + magazines: [ + { + id: 'a', + publication: 'P', + copies: [{ id: 'c', notes: null, dateAdded: '2026-09-01T00:00:00Z' }], + }, + { + id: 'b', + publication: 'P', + copies: [{ id: 'c', notes: null, dateAdded: '2026-09-01T00:00:00Z' }], + }, + ], + }); + await expect(service.importCollection(raw)).rejects.toThrow( + 'Fichier invalide : des identifiants d’exemplaire sont dupliqués.', + ); + }); + + it('rejette un numéro d’édition fractionnaire', async () => { + const raw = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + magazines: [{ id: 'a', publication: 'P', issueNumber: 1.5, copies: [] }], + }); + await expect(service.importCollection(raw)).rejects.toThrow( + 'Fichier invalide : le numéro d’édition doit être un entier', + ); + }); + + it('rejette une date d’exemplaire ou d’export non conforme', async () => { + const badDate = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: '2026-09-01T00:00:00Z', + magazines: [ + { + id: 'a', + publication: 'P', + copies: [{ id: 'c', notes: null, dateAdded: 'pas-une-date' }], + }, + ], + }); + await expect(service.importCollection(badDate)).rejects.toBeInstanceOf(InvalidBackupError); + + const badExportedAt = JSON.stringify({ + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: 'hier', + magazines: [], + }); + await expect(service.importCollection(badExportedAt)).rejects.toBeInstanceOf( + InvalidBackupError, + ); + }); + + it('rejette un import dépassant la borne de taille', async () => { + const oversized = new BackupService(testDb, 100); + const raw = `publication,issueNumber,copyNotes,dateAdded\nPicsou,${'1'.repeat(200)},,\n`; + + await expect(oversized.importCollection(raw, 'csv')).rejects.toThrow( + 'Fichier invalide : import dépassant la taille maximale de 100 octets.', + ); + }); }); describe('BackupService.exportCollection.toCsv', () => { @@ -200,7 +304,7 @@ describe('BackupService.exportCollection.toCsv', () => { const lines = csv.trim().split('\n'); expect(lines[0]).toBe( - 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded', + 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt', ); expect(lines).toHaveLength(3); expect(lines[1]).toContain('Picsou Magazine,547,standard,FR,good,2023-03,3271234567890'); @@ -229,6 +333,20 @@ describe('BackupService.exportCollection.toCsv', () => { expect(lines[1]).toContain('Vacances,12,'); expect(lines[1]).toContain(',,,,'); }); + + it('round-trip CSV : une édition sans copie n’est pas réimportée en exemplaire fantôme', async () => { + await magazineRepo.create({ publication: 'Vacances', issueNumber: 12 }); + + const file = await service.exportCollection(); + const summary = await service.importCollection(service.toCsv(file), 'csv'); + + expect(summary).toEqual({ magazines: 1, copies: 0 }); + + const after = await service.exportCollection(); + expect(after.magazines).toHaveLength(1); + expect(after.magazines[0].publication).toBe('Vacances'); + expect(after.magazines[0].copies).toHaveLength(0); + }); }); describe('BackupService CSV import (US-BK-05)', () => { @@ -286,6 +404,37 @@ describe('BackupService CSV import (US-BK-05)', () => { expect(after.magazines[0].notes).toBe('note, suite \n ligne 2'); expect(after.magazines[0].copies[0].notes).toBe('Acheté 0,50 €'); }); + + it('préserve un CRLF à l’intérieur d’un champ entre guillemets', async () => { + const header = + 'publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded'; + const row = [ + '"Retour\nChamp"', + '5', + '', + '', + '', + '', + '', + '', + '', + '"note\r\nmulti"', + '2026-09-01T00:00:00Z', + ].join(','); + const csv = `${header}\n${row}\n`; + await service.importCollection(csv, 'csv'); + const after = await service.exportCollection(); + expect(after.magazines[0].publication).toBe('Retour\nChamp'); + expect(after.magazines[0].copies[0].notes).toBe('note\r\nmulti'); + }); + + it('gère des retours chariot nus (\r) comme séparateurs de ligne', async () => { + const csv = + `publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded\r` + + `Picsou,5,,,,,,,,,2026-09-01T00:00:00Z\rDoublon,6,,,,,,,,,2026-09-02T00:00:00Z\r`; + const summary = await service.importCollection(csv, 'csv'); + expect(summary).toEqual({ magazines: 2, copies: 2 }); + }); }); describe('BackupService — validation de structure (JSON)', () => { @@ -346,7 +495,10 @@ describe('BackupService — CSV cas limites', () => { }); it('ignore les lignes vides en fin de fichier', async () => { - const summary = await service.validateCollection(`${headers}\nPicsou,5,,,,,,,,,\n\n\n`, 'csv'); + const summary = await service.validateCollection( + `${headers}\nPicsou,5,,,,,,,,,2026-09-01T00:00:00Z\n\n\n`, + 'csv', + ); expect(summary).toEqual({ magazines: 1, copies: 1 }); }); @@ -357,22 +509,35 @@ describe('BackupService — CSV cas limites', () => { expect(after.magazines[0].copies[0].notes).toBe('il a dit "OK"'); }); - it('préserve les espaces autour des champs texte non vides', async () => { + it('normalise les espaces autour des champs texte', async () => { const csv = `${headers}\nPicsou,5, edition , FR ,,2020-01,,, ,,\n`; await service.importCollection(csv, 'csv'); const after = await service.exportCollection(); - expect(after.magazines[0].edition).toBe(' edition '); - expect(after.magazines[0].language).toBe(' FR '); + expect(after.magazines[0].edition).toBe('edition'); + expect(after.magazines[0].language).toBe('FR'); expect(after.magazines[0].notes).toBeNull(); + expect(after.magazines[0].copies).toHaveLength(0); }); - it('assigne un statut et une date par défaut aux champs exemplaire vides', async () => { + it('n’importe pas d’exemplaire fantôme pour une ligne aux champs exemplaire vides', async () => { const csv = `${headers}\nPicsou,5,,,,,,,,,\n`; await service.importCollection(csv, 'csv'); const after = await service.exportCollection(); + expect(after.magazines).toHaveLength(1); + expect(after.magazines[0].copies).toHaveLength(0); + }); + + it('préserve les variantes d’édition (edition/language/condition) à l’import CSV', async () => { + const csv = + `${headers}\nPicsou,5,FR,,,,,,,,2026-09-01T00:00:00Z\n` + + `Picsou,5,BE,,,,,,,,2026-09-01T00:00:00Z\n`; + await service.importCollection(csv, 'csv'); + const after = await service.exportCollection(); + expect(after.magazines).toHaveLength(2); + const editions = after.magazines.map((m) => m.edition).sort(); + expect(editions).toEqual(['BE', 'FR']); expect(after.magazines[0].copies).toHaveLength(1); - expect(after.magazines[0].copies[0].notes).toBeNull(); - expect(after.magazines[0].copies[0].dateAdded).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(after.magazines[1].copies).toHaveLength(1); }); }); diff --git a/src/backup/backup-csv.ts b/src/backup/backup-csv.ts new file mode 100644 index 0000000..816b4b6 --- /dev/null +++ b/src/backup/backup-csv.ts @@ -0,0 +1,221 @@ +import { generateId } from '@/utils/id'; +import { InvalidBackupError } from './backup-parsers'; +import { + BACKUP_FORMAT, + BACKUP_VERSION, + type BackupCopy, + type BackupFile, + type BackupMagazine, +} from './backup-types'; +import { APP_VERSION } from '@/utils/app-version'; + +export const BACKUP_CSV_HEADERS = [ + 'publication', + 'issueNumber', + 'edition', + 'language', + 'condition', + 'publicationDate', + 'barcode', + 'notes', + 'ocrText', + 'copyNotes', + 'dateAdded', +] as const; + +export function escapeCsvField(value: string): string { + if (/[",\n\r]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export function parseCsv(raw: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let inQuotes = false; + let index = 0; + const text = raw.replace(/^\uFEFF/, ''); + while (index < text.length) { + const char = text[index]; + if (inQuotes) { + if (char === '"') { + if (text[index + 1] === '"') { + field += '"'; + index += 2; + continue; + } + inQuotes = false; + index += 1; + continue; + } + field += char; + index += 1; + continue; + } + if (char === '"') { + inQuotes = true; + index += 1; + continue; + } + if (char === ',') { + row.push(field); + field = ''; + index += 1; + continue; + } + if (char === '\n' || char === '\r') { + row.push(field); + rows.push(row); + row = []; + field = ''; + index += 1; + // '\r\n' (Windows), '\r' seul (Mac), '\n' seul : tous terminent une ligne. + if (char === '\r' && text[index] === '\n') { + index += 1; + } + continue; + } + field += char; + index += 1; + } + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + return rows.filter((r) => !(r.length === 1 && r[0].trim() === '')); +} + +function asCsvString(value: string | undefined): string | null { + const trimmed = (value ?? '').trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function parseCsvBackup(raw: string): BackupFile { + const rows = parseCsv(raw); + if (rows.length === 0) { + throw new InvalidBackupError('Fichier invalide : le fichier CSV est vide.'); + } + + const headers = rows[0].map((header) => header.trim()); + const missing = BACKUP_CSV_HEADERS.filter((expected) => !headers.includes(expected)); + if (missing.length > 0) { + throw new InvalidBackupError( + `Fichier invalide : fichiers d’import CSV attendus — en-têtes manquants : ${missing.join(', ')}.`, + ); + } + + const columnIndex = (name: string): number => headers.indexOf(name); + + const magazines: BackupMagazine[] = []; + const byKey = new Map(); + + for (const values of rows.slice(1)) { + const at = (name: string): string => values[columnIndex(name)] ?? ''; + const publication = at('publication').trim(); + if (publication.length === 0) { + throw new InvalidBackupError( + 'Fichier invalide : une ligne du CSV ne contient pas de publication.', + ); + } + + const issueRaw = at('issueNumber').trim(); + let issueNumber: number | null = null; + if (issueRaw.length > 0) { + if (!/^\d{1,6}$/.test(issueRaw)) { + throw new InvalidBackupError( + 'Fichier invalide : le numéro d’édition doit être un entier positif.', + ); + } + issueNumber = Number(issueRaw); + } + + const edition = asCsvString(at('edition')); + const language = asCsvString(at('language')); + const condition = asCsvString(at('condition')); + + // Clé d'édition complète : deux lignes ne définissent la même édition que si + // tous les champs métier coïncident. Préserve les variantes (FR vs BE, + // numéros à `issueNumber` null distincts par état/édition) au lieu de + // collapse la 1ère ligne seulement. + const key = `${publication}::${issueNumber ?? ''}::${edition ?? ''}::${language ?? ''}::${condition ?? ''}`; + let magazine = byKey.get(key); + if (!magazine) { + magazine = { + id: generateId(), + publication, + issueNumber, + edition, + language, + condition, + publicationDate: asCsvString(at('publicationDate')), + barcode: asCsvString(at('barcode')), + notes: asCsvString(at('notes')), + ocrText: asCsvString(at('ocrText')), + copies: [], + createdAt: asCsvString(at('createdAt')), + updatedAt: asCsvString(at('updatedAt')), + }; + byKey.set(key, magazine); + magazines.push(magazine); + } + + const copyNotes = at('copyNotes').trim(); + const dateAdded = at('dateAdded').trim(); + // Une ligne aux champs exemplaire vides désigne une édition **sans copie** + // (l'export `toCsv` émet une telle ligne pour la conserver). Ne pas la + // réimporter comme un exemplaire fantôme daté d'aujourd'hui. + if (copyNotes.length === 0 && dateAdded.length === 0) { + continue; + } + const copy: BackupCopy = { + id: generateId(), + notes: copyNotes.length > 0 ? copyNotes : null, + dateAdded: dateAdded.length > 0 ? dateAdded : new Date().toISOString(), + }; + magazine.copies.push(copy); + } + + return { + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: new Date().toISOString(), + appVersion: APP_VERSION, + magazines, + }; +} + +export function toCsv(file: BackupFile): string { + const header = [...BACKUP_CSV_HEADERS, 'createdAt', 'updatedAt'].join(','); + const lines: string[] = []; + for (const magazine of file.magazines) { + const base = [ + escapeCsvField(magazine.publication), + magazine.issueNumber === null ? '' : escapeCsvField(String(magazine.issueNumber)), + escapeCsvField(magazine.edition ?? ''), + escapeCsvField(magazine.language ?? ''), + escapeCsvField(magazine.condition ?? ''), + escapeCsvField(magazine.publicationDate ?? ''), + escapeCsvField(magazine.barcode ?? ''), + escapeCsvField(magazine.notes ?? ''), + escapeCsvField(magazine.ocrText ?? ''), + ]; + const copies = + magazine.copies.length > 0 + ? magazine.copies + : [{ id: magazine.id, notes: null, dateAdded: '' }]; + for (const copy of copies) { + lines.push( + [ + ...base, + escapeCsvField(copy.notes ?? ''), + escapeCsvField(copy.dateAdded), + escapeCsvField(magazine.createdAt ?? ''), + escapeCsvField(magazine.updatedAt ?? ''), + ].join(','), + ); + } + } + return [header, ...lines].join('\n') + '\n'; +} diff --git a/src/backup/backup-parsers.ts b/src/backup/backup-parsers.ts new file mode 100644 index 0000000..cc58d0b --- /dev/null +++ b/src/backup/backup-parsers.ts @@ -0,0 +1,164 @@ +import { APP_VERSION } from '@/utils/app-version'; +import { + BACKUP_FORMAT, + BACKUP_VERSION, + type BackupFile, + type BackupMagazine, +} from './backup-types'; + +export class InvalidBackupError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidBackupError'; + } +} + +function asNullableString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'string') { + throw new InvalidBackupError('Fichier invalide : certaines valeurs de texte sont mal formées.'); + } + return value; +} + +function asNullableNumber(value: unknown): number | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new InvalidBackupError( + 'Fichier invalide : certaines valeurs numériques sont mal formées.', + ); + } + return value; +} + +function asNullableInteger(value: unknown): number | null { + const number = asNullableNumber(value); + if (number !== null && !Number.isInteger(number)) { + throw new InvalidBackupError( + 'Fichier invalide : le numéro d’édition doit être un entier (reçu : ' + `${number}).`, + ); + } + return number; +} + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; + +function asNullableIsoDate(value: unknown): string | null { + const date = asNullableString(value); + if (date !== null && !ISO_DATE_RE.test(date)) { + throw new InvalidBackupError( + `Fichier invalide : la date « ${date} » n’est pas au format ISO-8601 attendu.`, + ); + } + return date; +} + +function asNonEmptyString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new InvalidBackupError(`Fichier invalide : le champ « ${field} » est manquant ou vide.`); + } + return value; +} + +export function parseBackupFile(raw: unknown): BackupFile { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new InvalidBackupError( + 'Fichier invalide : la structure racine doit être un objet de sauvegarde.', + ); + } + + const root = raw as Record; + + if (root['format'] !== BACKUP_FORMAT) { + throw new InvalidBackupError('Fichier invalide : ce n’est pas un export de PicsouCollection.'); + } + + if (root['version'] !== BACKUP_VERSION) { + throw new InvalidBackupError( + `Fichier invalide : version non prise en charge (attendu : ${BACKUP_VERSION}).`, + ); + } + + if (!Array.isArray(root['magazines'])) { + throw new InvalidBackupError('Fichier invalide : la liste des éditions est absente.'); + } + + const magazines: BackupMagazine[] = []; + const seenMagazineIds = new Set(); + const seenCopyIds = new Set(); + for (const entry of root['magazines']) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new InvalidBackupError('Fichier invalide : une édition est mal formée.'); + } + + const magazine = entry as Record; + + const id = asNonEmptyString(magazine['id'], 'identifiant d’édition'); + if (seenMagazineIds.has(id)) { + throw new InvalidBackupError('Fichier invalide : des identifiants d’édition sont dupliqués.'); + } + seenMagazineIds.add(id); + + if (!Array.isArray(magazine['copies'])) { + throw new InvalidBackupError('Fichier invalide : la liste des exemplaires est absente.'); + } + + const copies = []; + for (const copyEntry of magazine['copies']) { + if (typeof copyEntry !== 'object' || copyEntry === null || Array.isArray(copyEntry)) { + throw new InvalidBackupError('Fichier invalide : un exemplaire est mal formé.'); + } + const copy = copyEntry as Record; + const copyId = asNonEmptyString(copy['id'], 'identifiant d’exemplaire'); + if (seenCopyIds.has(copyId)) { + throw new InvalidBackupError( + 'Fichier invalide : des identifiants d’exemplaire sont dupliqués.', + ); + } + seenCopyIds.add(copyId); + copies.push({ + id: copyId, + notes: asNullableString(copy['notes']), + dateAdded: asNullableIsoDate(copy['dateAdded']) ?? '', + }); + } + + magazines.push({ + id, + publication: asNonEmptyString(magazine['publication'], 'publication'), + issueNumber: asNullableInteger(magazine['issueNumber']), + edition: asNullableString(magazine['edition']), + language: asNullableString(magazine['language']), + condition: asNullableString(magazine['condition']), + publicationDate: asNullableString(magazine['publicationDate']), + barcode: asNullableString(magazine['barcode']), + notes: asNullableString(magazine['notes']), + ocrText: asNullableString(magazine['ocrText']), + copies, + createdAt: asNullableIsoDate(magazine['createdAt']), + updatedAt: asNullableIsoDate(magazine['updatedAt']), + }); + } + + return { + format: BACKUP_FORMAT, + version: BACKUP_VERSION, + exportedAt: asNullableIsoDate(root['exportedAt']) ?? new Date().toISOString(), + appVersion: asNullableString(root['appVersion']) ?? APP_VERSION, + magazines, + }; +} + +export function parseJsonBackup(raw: string): BackupFile { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new InvalidBackupError('Fichier invalide : le contenu n’est pas un JSON lisible.'); + } + return parseBackupFile(parsed); +} diff --git a/src/backup/backup-service.ts b/src/backup/backup-service.ts index 657b5d2..c324f4f 100644 --- a/src/backup/backup-service.ts +++ b/src/backup/backup-service.ts @@ -1,26 +1,21 @@ import type { Database } from '@/database/types'; import { APP_VERSION } from '@/utils/app-version'; -import { generateId } from '@/utils/id'; +import { parseJsonBackup, InvalidBackupError } from './backup-parsers'; +import { parseCsvBackup, toCsv as toCsvFn } from './backup-csv'; import { BACKUP_FORMAT, BACKUP_VERSION, - type BackupCopy, type BackupFile, type BackupFormat, type BackupMagazine, type ImportSummary, } from './backup-types'; -/** - * Erreur de validation d'un fichier d'import (US-BK-03). - * L'import est rejeté sans aucune modification des données. - */ -export class InvalidBackupError extends Error { - constructor(message: string) { - super(message); - this.name = 'InvalidBackupError'; - } -} +export { InvalidBackupError } from './backup-parsers'; +export { BACKUP_CSV_HEADERS, escapeCsvField, parseCsv, toCsv } from './backup-csv'; + +/** Taille maximale d'un fichier d'import (octets), pour borner la mémoire utilisée. */ +export const MAX_IMPORT_BYTES = 25 * 1024 * 1024; type MagazineRow = { id: string; @@ -44,285 +39,11 @@ type CopyRow = { date_added: string; }; -function asNullableString(value: unknown): string | null { - if (value === null || value === undefined) { - return null; - } - if (typeof value !== 'string') { - throw new InvalidBackupError('Fichier invalide : certaines valeurs de texte sont mal formées.'); - } - return value; -} - -function asNullableNumber(value: unknown): number | null { - if (value === null || value === undefined) { - return null; - } - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new InvalidBackupError( - 'Fichier invalide : certaines valeurs numériques sont mal formées.', - ); - } - return value; -} - -function asNonEmptyString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new InvalidBackupError(`Fichier invalide : le champ « ${field} » est manquant ou vide.`); - } - return value; -} - -function parseBackupFile(raw: unknown): BackupFile { - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - throw new InvalidBackupError( - 'Fichier invalide : la structure racine doit être un objet de sauvegarde.', - ); - } - - const root = raw as Record; - - if (root['format'] !== BACKUP_FORMAT) { - throw new InvalidBackupError('Fichier invalide : ce n’est pas un export de PicsouCollection.'); - } - - if (root['version'] !== BACKUP_VERSION) { - throw new InvalidBackupError( - `Fichier invalide : version non prise en charge (attendu : ${BACKUP_VERSION}).`, - ); - } - - if (!Array.isArray(root['magazines'])) { - throw new InvalidBackupError('Fichier invalide : la liste des éditions est absente.'); - } - - const magazines: BackupMagazine[] = []; - for (const entry of root['magazines']) { - if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - throw new InvalidBackupError('Fichier invalide : une édition est mal formée.'); - } - - const magazine = entry as Record; - - const id = asNonEmptyString(magazine['id'], 'identifiant d’édition'); - - if (!Array.isArray(magazine['copies'])) { - throw new InvalidBackupError('Fichier invalide : la liste des exemplaires est absente.'); - } - - const copies: BackupCopy[] = []; - for (const copyEntry of magazine['copies']) { - if (typeof copyEntry !== 'object' || copyEntry === null || Array.isArray(copyEntry)) { - throw new InvalidBackupError('Fichier invalide : un exemplaire est mal formé.'); - } - const copy = copyEntry as Record; - copies.push({ - id: asNonEmptyString(copy['id'], 'identifiant d’exemplaire'), - notes: asNullableString(copy['notes']), - dateAdded: asNullableString(copy['dateAdded']) ?? '', - }); - } - - magazines.push({ - id, - publication: asNonEmptyString(magazine['publication'], 'publication'), - issueNumber: asNullableNumber(magazine['issueNumber']), - edition: asNullableString(magazine['edition']), - language: asNullableString(magazine['language']), - condition: asNullableString(magazine['condition']), - publicationDate: asNullableString(magazine['publicationDate']), - barcode: asNullableString(magazine['barcode']), - notes: asNullableString(magazine['notes']), - ocrText: asNullableString(magazine['ocrText']), - copies, - }); - } - - return { - format: BACKUP_FORMAT, - version: BACKUP_VERSION, - exportedAt: asNullableString(root['exportedAt']) ?? new Date().toISOString(), - appVersion: asNullableString(root['appVersion']) ?? APP_VERSION, - magazines, - }; -} - -/** - * En-têtes de l'export CSV v1 (cf. `06-DATA-MODEL.md` §6.1). Une ligne = un - * exemplaire ; en-têtes attendus pour valider l'import (US-BK-05). - */ -export const BACKUP_CSV_HEADERS = [ - 'publication', - 'issueNumber', - 'edition', - 'language', - 'condition', - 'publicationDate', - 'barcode', - 'notes', - 'ocrText', - 'copyNotes', - 'dateAdded', -] as const; - -function escapeCsvField(value: string): string { - if (/[",\n\r]/.test(value)) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; -} - -/** - * Parseur CSV minimal : séparateur virgule, guillemets doubles, échappement - * `""`, gestion des retours à la ligne et de la virgule entre guillemets. - */ -function parseCsv(raw: string): string[][] { - const rows: string[][] = []; - let row: string[] = []; - let field = ''; - let inQuotes = false; - let index = 0; - const text = raw.replace(/^\uFEFF/, ''); - while (index < text.length) { - const char = text[index]; - if (inQuotes) { - if (char === '"') { - if (text[index + 1] === '"') { - field += '"'; - index += 2; - continue; - } - inQuotes = false; - index += 1; - continue; - } - field += char; - index += 1; - continue; - } - if (char === '"') { - inQuotes = true; - index += 1; - continue; - } - if (char === ',') { - row.push(field); - field = ''; - index += 1; - continue; - } - if (char === '\n') { - row.push(field); - rows.push(row); - row = []; - field = ''; - index += 1; - continue; - } - if (char === '\r') { - index += 1; - continue; - } - field += char; - index += 1; - } - if (field.length > 0 || row.length > 0) { - row.push(field); - rows.push(row); - } - return rows.filter((r) => !(r.length === 1 && r[0].trim() === '')); -} - -function asCsvString(value: string | undefined): string | null { - const trimmed = (value ?? '').trim(); - return trimmed.length > 0 ? (value ?? '') : null; -} - -/** - * Construit un {@link BackupFile} à partir d'un export CSV. L'import est validé - * sur la présence des en-têtes attendus (§13.2) et rejette tout fichier - * non conforme via {@link InvalidBackupError}, sans modifier les données. - */ -function parseCsvBackup(raw: string): BackupFile { - const rows = parseCsv(raw); - if (rows.length === 0) { - throw new InvalidBackupError('Fichier invalide : le fichier CSV est vide.'); - } - - const headers = rows[0].map((header) => header.trim()); - const missing = BACKUP_CSV_HEADERS.filter((expected) => !headers.includes(expected)); - if (missing.length > 0) { - throw new InvalidBackupError( - `Fichier invalide : fichiers d’import CSV attendus — en-têtes manquants : ${missing.join(', ')}.`, - ); - } - - const columnIndex = (name: string): number => headers.indexOf(name); - - const magazines: BackupMagazine[] = []; - const byKey = new Map(); - - for (const values of rows.slice(1)) { - const at = (name: string): string => values[columnIndex(name)] ?? ''; - const publication = at('publication').trim(); - if (publication.length === 0) { - throw new InvalidBackupError( - 'Fichier invalide : une ligne du CSV ne contient pas de publication.', - ); - } - - const issueRaw = at('issueNumber').trim(); - let issueNumber: number | null = null; - if (issueRaw.length > 0) { - if (!/^\d{1,6}$/.test(issueRaw)) { - throw new InvalidBackupError( - 'Fichier invalide : le numéro d’édition doit être un entier positif.', - ); - } - issueNumber = Number(issueRaw); - } - - const key = `${publication}::${issueNumber ?? ''}`; - let magazine = byKey.get(key); - if (!magazine) { - magazine = { - id: generateId(), - publication, - issueNumber, - edition: asCsvString(at('edition')), - language: asCsvString(at('language')), - condition: asCsvString(at('condition')), - publicationDate: asCsvString(at('publicationDate')), - barcode: asCsvString(at('barcode')), - notes: asCsvString(at('notes')), - ocrText: asCsvString(at('ocrText')), - copies: [], - }; - byKey.set(key, magazine); - magazines.push(magazine); - } - - const copyNotes = at('copyNotes').trim(); - const dateAdded = at('dateAdded').trim(); - const copy: BackupCopy = { - id: generateId(), - notes: copyNotes.length > 0 ? copyNotes : null, - dateAdded: dateAdded.length > 0 ? dateAdded : new Date().toISOString(), - }; - magazine.copies.push(copy); - } - - return { - format: BACKUP_FORMAT, - version: BACKUP_VERSION, - exportedAt: new Date().toISOString(), - appVersion: APP_VERSION, - magazines, - }; -} - export class BackupService { - constructor(private readonly db: Database) {} + constructor( + private readonly db: Database, + private readonly maxImportBytes: number = MAX_IMPORT_BYTES, + ) {} async exportCollection(): Promise { const magazineRows = await this.db.getAllAsync( @@ -338,7 +59,10 @@ export class BackupService { ORDER BY date_added, id`, ); - const copiesByMagazine = new Map(); + const copiesByMagazine = new Map< + string, + { id: string; notes: string | null; dateAdded: string }[] + >(); for (const row of copyRows) { const copies = copiesByMagazine.get(row.magazine_id) ?? []; copies.push({ @@ -361,6 +85,8 @@ export class BackupService { notes: row.notes, ocrText: row.ocr_text, copies: copiesByMagazine.get(row.id) ?? [], + createdAt: row.created_at, + updatedAt: row.updated_at, })); return { @@ -376,57 +102,22 @@ export class BackupService { return JSON.stringify(file, null, 2); } - /** - * Sérialise la sauvegarde en CSV v1 (une ligne = un exemplaire, cf. §6.1). - * Les éditions sans exemplaire sont conservées via une ligne aux champs - * exemplaire vides. - */ toCsv(file: BackupFile): string { - const header = BACKUP_CSV_HEADERS.join(','); - const lines: string[] = []; - for (const magazine of file.magazines) { - const base = [ - escapeCsvField(magazine.publication), - magazine.issueNumber === null ? '' : escapeCsvField(String(magazine.issueNumber)), - escapeCsvField(magazine.edition ?? ''), - escapeCsvField(magazine.language ?? ''), - escapeCsvField(magazine.condition ?? ''), - escapeCsvField(magazine.publicationDate ?? ''), - escapeCsvField(magazine.barcode ?? ''), - escapeCsvField(magazine.notes ?? ''), - escapeCsvField(magazine.ocrText ?? ''), - ]; - const copies = - magazine.copies.length > 0 - ? magazine.copies - : [{ id: magazine.id, notes: null, dateAdded: '' }]; - for (const copy of copies) { - lines.push( - [...base, escapeCsvField(copy.notes ?? ''), escapeCsvField(copy.dateAdded)].join(','), - ); - } - } - return [header, ...lines].join('\n') + '\n'; + return toCsvFn(file); } private parseBackupRaw(raw: string, format: BackupFormat): BackupFile { + if (raw.length > this.maxImportBytes) { + throw new InvalidBackupError( + `Fichier invalide : import dépassant la taille maximale de ${this.maxImportBytes} octets.`, + ); + } if (format === 'csv') { return parseCsvBackup(raw); } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new InvalidBackupError('Fichier invalide : le contenu n’est pas un JSON lisible.'); - } - return parseBackupFile(parsed); + return parseJsonBackup(raw); } - /** - * Valide uniquement le contenu d'un fichier, sans modifier les données. - * Utilisé pour afficher un récapitulatif et demander confirmation **avant** - * le remplacement (§14.4). Rejette via {@link InvalidBackupError} (US-BK-03). - */ async validateCollection(raw: string, format: BackupFormat = 'json'): Promise { const file = this.parseBackupRaw(raw, format); return { @@ -435,11 +126,6 @@ export class BackupService { }; } - /** - * Importe une sauvegarde (JSON ou CSV) en remplaçant la collection existante. - * Toute erreur de validation ou d'écriture → transaction annulée, données - * intactes (stratégie « remplacement complet », cf. fonctionnelle §13.3). - */ async importCollection(raw: string, format: BackupFormat = 'json'): Promise { const file = this.parseBackupRaw(raw, format); @@ -464,8 +150,8 @@ export class BackupService { magazine.barcode, magazine.notes, magazine.ocrText, - new Date().toISOString(), - new Date().toISOString(), + magazine.createdAt ?? new Date().toISOString(), + magazine.updatedAt ?? new Date().toISOString(), ); for (const copy of magazine.copies) { diff --git a/src/backup/backup-types.ts b/src/backup/backup-types.ts index a61dd18..3312302 100644 --- a/src/backup/backup-types.ts +++ b/src/backup/backup-types.ts @@ -22,6 +22,8 @@ export type BackupMagazine = { notes: string | null; ocrText: string | null; copies: BackupCopy[]; + createdAt: string | null; + updatedAt: string | null; }; export type BackupFile = { diff --git a/src/backup/native-file-gateway.ts b/src/backup/native-file-gateway.ts index 5fa3312..f4eb272 100644 --- a/src/backup/native-file-gateway.ts +++ b/src/backup/native-file-gateway.ts @@ -1,5 +1,7 @@ import type { BackupFormat } from './backup-types'; import type { FileGateway } from './file-gateway'; +import { InvalidBackupError } from './backup-parsers'; +import { MAX_IMPORT_BYTES } from './backup-service'; const DEFAULT_FILENAME_PREFIX = 'picsou-collection'; @@ -67,6 +69,11 @@ export class NativeFileGateway implements FileGateway { } const asset = result.assets[0]; + if (asset.size != null && asset.size > MAX_IMPORT_BYTES) { + throw new InvalidBackupError( + `Fichier invalide : import dépassant la taille maximale de ${MAX_IMPORT_BYTES} octets.`, + ); + } return { name: asset.name, content: await new File(asset.uri).text() }; } } diff --git a/src/components/__tests__/scan-search-views.test.tsx b/src/components/__tests__/scan-search-views.test.tsx new file mode 100644 index 0000000..3fef099 --- /dev/null +++ b/src/components/__tests__/scan-search-views.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { SearchFoundResult, SearchUnknownResult } from '@/components/scan-search-views'; + +describe('SearchFoundResult', () => { + const onAddCopy = jest.fn(); + const onView = jest.fn(); + const onRescan = jest.fn(); + + beforeEach(() => { + onAddCopy.mockClear(); + onView.mockClear(); + onRescan.mockClear(); + }); + + it('affiche le numéro quand il est renseigné et le statut possédé', () => { + render( + , + ); + + expect(screen.getByText('N° 547')).toBeTruthy(); + expect(screen.getByTestId('search-status-owned')).toHaveTextContent('✓ Possédé (2)'); + fireEvent.press(screen.getByTestId('search-add')); + expect(onAddCopy).toHaveBeenCalled(); + }); + + it('affiche « absent » quand l’édition n’est pas possédée', () => { + render( + , + ); + + expect(screen.queryByText('N° 547')).toBeNull(); + expect(screen.getByTestId('search-status-absent')).toHaveTextContent('○ Absent'); + }); + + it('affiche la vérification en cours tant que l’édition n’est pas résolue', () => { + render( + , + ); + + expect(screen.getByTestId('search-loading')).toHaveTextContent('Vérification…'); + }); + + it('sait relancer un scan ou ouvrir la fiche', () => { + render( + , + ); + + fireEvent.press(screen.getByTestId('search-view')); + expect(onView).toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('search-rescan')); + expect(onRescan).toHaveBeenCalled(); + }); +}); + +describe('SearchUnknownResult', () => { + const onManual = jest.fn(); + const onAgain = jest.fn(); + + it('affiche la publication, avec ou sans numéro', () => { + render( + , + ); + expect(screen.getByText(/Picsou Magazine/)).toBeTruthy(); + expect(screen.getByText('N° 547')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('search-manual')); + expect(onManual).toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('search-again')); + expect(onAgain).toHaveBeenCalled(); + }); + + it('s’affiche sans numéro quand celui-ci est absent', () => { + render( + , + ); + expect(screen.queryByText('N° 547')).toBeNull(); + }); +}); diff --git a/src/components/__tests__/use-magazine-form.test.ts b/src/components/__tests__/use-magazine-form.test.ts new file mode 100644 index 0000000..9f2d6e1 --- /dev/null +++ b/src/components/__tests__/use-magazine-form.test.ts @@ -0,0 +1,106 @@ +import { + buildMagazineInput, + initialFormValues, + publicationDateFrom, + type FormValues, +} from '@/components/use-magazine-form'; + +describe('publicationDateFrom', () => { + it('renvoie la date ISO YYYY-MM quand mois et année sont fournis', () => { + expect(publicationDateFrom('03', '2023')).toBe('2023-03'); + }); + + it('renvoie null si le mois ou l’année manquent', () => { + expect(publicationDateFrom(null, '2023')).toBeNull(); + expect(publicationDateFrom('03', null)).toBeNull(); + expect(publicationDateFrom(null, null)).toBeNull(); + }); +}); + +describe('buildMagazineInput', () => { + it('convertit les valeurs brutes en entrée métier avec trim', () => { + const values: FormValues = { + publication: ' Picsou ', + issueNumber: '547', + edition: ' standard ', + language: ' FR ', + condition: '', + month: '03', + year: '2023', + barcode: '', + notes: ' n° spécial ', + }; + expect(buildMagazineInput(values)).toEqual({ + publication: 'Picsou', + issueNumber: 547, + edition: 'standard', + language: 'FR', + condition: null, + publicationDate: '2023-03', + barcode: null, + notes: 'n° spécial', + }); + }); + + it('convertit les champs vides en null', () => { + const values: FormValues = { + publication: 'Mickey', + issueNumber: '', + edition: '', + language: '', + condition: ' ', + month: null, + year: null, + barcode: ' ', + notes: '', + }; + expect(buildMagazineInput(values)).toEqual({ + publication: 'Mickey', + issueNumber: null, + edition: null, + language: null, + condition: null, + publicationDate: null, + barcode: null, + notes: null, + }); + }); +}); + +describe('initialFormValues', () => { + it('préremplit depuis une fiche édition existante', () => { + const values = initialFormValues({ + initial: { + id: 'm1', + publication: 'Picsou', + issueNumber: 547, + edition: 'standard', + language: 'FR', + condition: null, + publicationDate: '2023-03', + barcode: '3271234567890', + notes: 'nc', + ocrText: null, + createdAt: '', + updatedAt: '', + }, + }); + expect(values.publication).toBe('Picsou'); + expect(values.issueNumber).toBe('547'); + expect(values.month).toBe('03'); + expect(values.year).toBe('2023'); + expect(values.barcode).toBe('3271234567890'); + }); + + it('applique les valeurs initiales explicites en priorité', () => { + const values = initialFormValues({ + initialBarcode: '5901234123457', + initialPublication: 'Vacances', + initialIssueNumber: 12, + initialYear: '2020', + }); + expect(values.publication).toBe('Vacances'); + expect(values.issueNumber).toBe('12'); + expect(values.year).toBe('2020'); + }); +}); diff --git a/src/components/collection-filters.tsx b/src/components/collection-filters.tsx new file mode 100644 index 0000000..9ab45ec --- /dev/null +++ b/src/components/collection-filters.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { SelectField } from '@/components/select-field'; +import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; +import { SORT_OPTIONS, type SortOption } from '@/lib/collection-list'; + +export function makeCollectionFilterStyles(colors: ThemeColors) { + return StyleSheet.create({ + filters: { + gap: Spacing.two, + }, + searchButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.two, + minHeight: 48, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + backgroundColor: colors.accent, + }, + searchButtonText: { + fontSize: 17, + fontWeight: '700', + color: colors.accentText, + }, + filtersPanel: { + gap: Spacing.two, + }, + filtersRow: { + flexDirection: 'row', + gap: Spacing.two, + alignItems: 'flex-start', + }, + filtersColumn: { + flex: 1, + }, + filterLabel: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginTop: Spacing.two, + }, + input: { + backgroundColor: colors.backgroundElement, + borderRadius: 8, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.two, + minHeight: HitTarget.minHeight, + fontSize: 16, + color: colors.text, + }, + clearButton: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: 6, + minHeight: HitTarget.minHeight, + paddingHorizontal: Spacing.two, + }, + clearButtonText: { + fontSize: 14, + fontWeight: '600', + color: colors.textSecondary, + }, + buttonPressed: { + opacity: 0.7, + }, + }); +} + +type Props = { + issueQuery: string; + editionFilter: string | null; + sort: SortOption; + editions: string[]; + onIssueChange: (value: string) => void; + onEditionChange: (value: string | null) => void; + onSortChange: (value: string | null) => void; + onClear: () => void; +}; + +export function CollectionFilters({ + issueQuery, + editionFilter, + sort, + editions, + onIssueChange, + onEditionChange, + onSortChange, + onClear, +}: Props) { + const colors = useThemeColors(); + const [open, setOpen] = useState(Boolean(editionFilter)); + const styles = makeCollectionFilterStyles(colors); + const hasFilters = issueQuery.trim().length > 0 || editionFilter !== null; + + return ( + + [styles.searchButton, pressed && styles.buttonPressed]} + onPress={() => setOpen((o) => !o)} + testID="filter-toggle" + accessibilityRole="button" + accessibilityLabel="Rechercher" + accessibilityState={{ expanded: open }}> + + Rechercher + + + + {open ? ( + + + + Numéro + + + + + + + + {hasFilters ? ( + [styles.clearButton, pressed && styles.buttonPressed]} + onPress={onClear} + testID="filter-clear" + accessibilityRole="button" + accessibilityLabel="Effacer les filtres" + android_ripple={{ color: 'rgba(0,0,0,0.08)' }}> + + Effacer les filtres + + ) : null} + + ) : null} + + ); +} diff --git a/src/components/drawer-content.tsx b/src/components/drawer-content.tsx index 3916449..90f83b6 100644 --- a/src/components/drawer-content.tsx +++ b/src/components/drawer-content.tsx @@ -15,8 +15,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; -import { slug } from '@/lib/slug'; import { useSettingsStore } from '@/store/use-settings-store'; +import { Collapsible, DrawerItem, SubItem, makeDrawerItemStyles } from './drawer-items'; const DRAWER_WIDTH = 300; const CLOSE_MS = 250; @@ -31,88 +31,55 @@ export function isLeftEdgeGesture(dx: number, x0: number): boolean { return dx > 10 && x0 < 30; } -type DrawerItemProps = { - icon: React.ComponentProps['name']; - label: string; - route: string; - colors: ThemeColors; - onPress: (route: string) => void; -}; - -function DrawerItem({ icon, label, route, colors, onPress }: DrawerItemProps) { - const styles = makeStyles(colors); - return ( - [styles.drawerItem, pressed && styles.pressed]} - onPress={() => onPress(route)} - accessibilityRole="button" - accessibilityLabel={label} - testID={`drawer-item-${slug(label)}`}> - - {label} - - ); -} - -type CollapsibleProps = { - label: string; - icon: React.ComponentProps['name']; - expanded: boolean; - onToggle: () => void; - children: React.ReactNode; -}; - -function Collapsible({ label, icon, expanded, onToggle, children }: CollapsibleProps) { - const colors = useThemeColors(); - const styles = makeStyles(colors); - return ( - <> - [styles.drawerItem, pressed && styles.pressed]} - onPress={onToggle} - accessibilityRole="button" - accessibilityLabel={label} - accessibilityState={{ expanded }} - testID={`drawer-collapsible-${slug(label)}`}> - - {label} - - - {expanded && {children}} - - ); -} - -function SubItem({ - icon, - label, - route, - colors, - onPress, -}: { - icon: React.ComponentProps['name']; - label: string; - route: string; - colors: ThemeColors; - onPress: (route: string) => void; -}) { - const styles = makeStyles(colors); - return ( - [styles.drawerSubItem, pressed && styles.pressed]} - onPress={() => onPress(route)} - accessibilityRole="button" - accessibilityLabel={label} - testID={`drawer-sub-${slug(label)}`}> - - {label} - - ); +function makeDrawerStyles(colors: ThemeColors) { + return StyleSheet.create({ + overlay: { + flex: 1, + flexDirection: 'row', + }, + backdrop: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + }, + drawerPanel: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + width: DRAWER_WIDTH, + elevation: 16, + shadowColor: '#000', + shadowOffset: { width: 2, height: 0 }, + shadowOpacity: 0.25, + shadowRadius: 8, + }, + drawer: { + flex: 1, + backgroundColor: colors.background, + }, + drawerHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + padding: Spacing.four, + borderBottomWidth: 1, + borderBottomColor: colors.backgroundElement, + }, + drawerTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + }, + drawerSection: { + paddingVertical: Spacing.two, + borderBottomWidth: 1, + borderBottomColor: colors.backgroundElement, + }, + }); } /** @@ -135,7 +102,8 @@ export function DrawerMenu({ editions?: string[]; }) { const colors = useThemeColors(); - const styles = makeStyles(colors); + const styles = makeDrawerStyles(colors); + const itemStyles = makeDrawerItemStyles(colors); const router = useRouter(); const insets = useSafeAreaInsets(); const reducedMotion = useSettingsStore((s) => s.reducedMotion); @@ -251,23 +219,23 @@ export function DrawerMenu({ [styles.drawerItem, pressed && styles.pressed]} + style={({ pressed }) => [itemStyles.drawerItem, pressed && itemStyles.pressed]} onPress={() => setScanExpanded((e) => !e)} accessibilityRole="button" accessibilityLabel="Scan" accessibilityState={{ expanded: scanExpanded }} testID="drawer-collapsible-scan"> - Scan + Scan {scanExpanded && ( - + ); } - -function makeStyles(colors: ThemeColors) { - return StyleSheet.create({ - overlay: { - flex: 1, - flexDirection: 'row', - }, - backdrop: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.4)', - }, - drawerPanel: { - position: 'absolute', - top: 0, - left: 0, - bottom: 0, - width: DRAWER_WIDTH, - elevation: 16, - shadowColor: '#000', - shadowOffset: { width: 2, height: 0 }, - shadowOpacity: 0.25, - shadowRadius: 8, - }, - drawer: { - flex: 1, - backgroundColor: colors.background, - }, - drawerHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - padding: Spacing.four, - borderBottomWidth: 1, - borderBottomColor: colors.backgroundElement, - }, - drawerTitle: { - fontSize: 18, - fontWeight: '700', - color: colors.text, - }, - drawerSection: { - paddingVertical: Spacing.two, - borderBottomWidth: 1, - borderBottomColor: colors.backgroundElement, - }, - drawerItem: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.three, - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.three, - minHeight: 48, - }, - drawerItemLabel: { - fontSize: 16, - fontWeight: '500', - color: colors.text, - flex: 1, - }, - drawerChevron: { - marginLeft: 'auto', - }, - drawerSubSection: { - paddingLeft: Spacing.five, - }, - drawerSubItem: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.two, - minHeight: 44, - }, - drawerSubItemLabel: { - fontSize: 14, - color: colors.textSecondary, - flex: 1, - }, - pressed: { - opacity: 0.7, - }, - }); -} diff --git a/src/components/drawer-items.tsx b/src/components/drawer-items.tsx new file mode 100644 index 0000000..f539008 --- /dev/null +++ b/src/components/drawer-items.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; +import { slug } from '@/lib/slug'; + +export function makeDrawerItemStyles(colors: ThemeColors) { + return StyleSheet.create({ + drawerItem: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.three, + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.three, + minHeight: 48, + }, + drawerItemLabel: { + fontSize: 16, + fontWeight: '500', + color: colors.text, + flex: 1, + }, + drawerChevron: { + marginLeft: 'auto', + }, + drawerSubSection: { + paddingLeft: Spacing.five, + }, + drawerSubItem: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.two, + minHeight: 44, + }, + drawerSubItemLabel: { + fontSize: 14, + color: colors.textSecondary, + flex: 1, + }, + pressed: { + opacity: 0.7, + }, + }); +} + +type DrawerItemProps = { + icon: React.ComponentProps['name']; + label: string; + route: string; + colors: ThemeColors; + onPress: (route: string) => void; +}; + +export function DrawerItem({ icon, label, route, colors, onPress }: DrawerItemProps) { + const styles = makeDrawerItemStyles(colors); + return ( + [styles.drawerItem, pressed && styles.pressed]} + onPress={() => onPress(route)} + accessibilityRole="button" + accessibilityLabel={label} + testID={`drawer-item-${slug(label)}`}> + + {label} + + ); +} + +type CollapsibleProps = { + label: string; + icon: React.ComponentProps['name']; + expanded: boolean; + onToggle: () => void; + children: React.ReactNode; +}; + +export function Collapsible({ label, icon, expanded, onToggle, children }: CollapsibleProps) { + const colors = useThemeColors(); + const styles = makeDrawerItemStyles(colors); + return ( + <> + [styles.drawerItem, pressed && styles.pressed]} + onPress={onToggle} + accessibilityRole="button" + accessibilityLabel={label} + accessibilityState={{ expanded }} + testID={`drawer-collapsible-${slug(label)}`}> + + {label} + + + {expanded && {children}} + + ); +} + +export function SubItem({ + icon, + label, + route, + colors, + onPress, +}: { + icon: React.ComponentProps['name']; + label: string; + route: string; + colors: ThemeColors; + onPress: (route: string) => void; +}) { + const styles = makeDrawerItemStyles(colors); + return ( + [styles.drawerSubItem, pressed && styles.pressed]} + onPress={() => onPress(route)} + accessibilityRole="button" + accessibilityLabel={label} + testID={`drawer-sub-${slug(label)}`}> + + {label} + + ); +} diff --git a/src/components/magazine-form-styles.ts b/src/components/magazine-form-styles.ts new file mode 100644 index 0000000..7506527 --- /dev/null +++ b/src/components/magazine-form-styles.ts @@ -0,0 +1,124 @@ +import { StyleSheet } from 'react-native'; +import { Spacing, type ThemeColors } from '@/constants/theme'; + +export function makeMagazineFormStyles(colors: ThemeColors) { + return StyleSheet.create({ + formScroll: { + flex: 1, + }, + formKeyboard: { + flex: 1, + }, + form: { + gap: Spacing.two, + paddingBottom: Spacing.four, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginTop: Spacing.two, + }, + input: { + backgroundColor: colors.backgroundElement, + borderRadius: 8, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.two, + minHeight: 44, + fontSize: 16, + color: colors.text, + }, + notesInput: { + minHeight: 72, + textAlignVertical: 'top', + }, + formError: { + marginTop: Spacing.two, + fontSize: 14, + fontWeight: '600', + color: colors.danger, + textAlign: 'center', + }, + detailsToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + marginTop: Spacing.three, + alignSelf: 'flex-start', + minHeight: 44, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 8, + backgroundColor: colors.backgroundElement, + }, + detailsToggleText: { + fontSize: 15, + fontWeight: '600', + color: colors.accentTextOnLight, + }, + details: { + gap: Spacing.two, + }, + dateRow: { + flexDirection: 'row', + gap: Spacing.three, + }, + dateCol: { + flex: 1, + }, + barcodeRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + }, + barcodeInput: { + flex: 1, + }, + scanButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + justifyContent: 'center', + backgroundColor: colors.backgroundElement, + borderWidth: 1, + borderColor: colors.accent, + borderRadius: 8, + minHeight: 44, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.two, + }, + scanButtonText: { + fontSize: 15, + fontWeight: '600', + color: colors.text, + }, + buttonPressed: { + opacity: 0.8, + }, + submit: { + marginTop: Spacing.three, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + minHeight: 48, + paddingVertical: Spacing.three, + borderRadius: 12, + }, + submitDisabled: { + opacity: 0.5, + }, + submitText: { + fontSize: 18, + fontWeight: '700', + color: colors.accentText, + }, + submitHint: { + fontSize: 13, + color: colors.textSecondary, + textAlign: 'center', + }, + keyboardSpacer: { + height: 200, + }, + }); +} diff --git a/src/components/magazine-form.tsx b/src/components/magazine-form.tsx index 9919c23..d20397c 100644 --- a/src/components/magazine-form.tsx +++ b/src/components/magazine-form.tsx @@ -1,11 +1,10 @@ import { useFocusEffect, useRouter } from 'expo-router'; -import { useCallback, useImperativeHandle, useState, type Ref } from 'react'; +import { useCallback, useState, type Ref } from 'react'; import { KeyboardAvoidingView, Platform, Pressable, ScrollView, - StyleSheet, Text, TextInput, View, @@ -14,29 +13,37 @@ import { Feather } from '@expo/vector-icons'; import { AutocompleteInput } from '@/components/autocomplete-input'; import { SelectField } from '@/components/select-field'; -import { Spacing, type ThemeColors } from '@/constants/theme'; import { useThemeColors } from '@/hooks/use-theme'; -import { consumePendingBarcode } from '@/lib/pending-barcode'; import { useCollectionStore } from '@/store/use-collection-store'; import type { CreateMagazineInput, Magazine } from '@/types'; +import { makeMagazineFormStyles } from './magazine-form-styles'; +import { useMagazineForm, type FormValues, type MagazineFormHandle } from './use-magazine-form'; -const MONTHS = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12'] as const; +export { + publicationDateFrom, + buildMagazineInput, + initialFormValues, + MagazineFormHandle, +} from './use-magazine-form'; + +const MONTHS_OPTIONS = [ + '01', + '02', + '03', + '04', + '05', + '06', + '07', + '08', + '09', + '10', + '11', + '12', +] as const; /** Années dynamiques : année courante → -39 ans (M10-08, fini `2025` en dur). */ const YEARS = Array.from({ length: 40 }, (_, i) => String(new Date().getFullYear() - i)); -type FormValues = { - publication: string; - issueNumber: string; - edition: string; - language: string; - condition: string; - month: string | null; - year: string | null; - barcode: string; - notes: string; -}; - type Props = { initial?: Magazine; initialBarcode?: string; @@ -45,24 +52,9 @@ type Props = { initialYear?: string | null; submitLabel: string; onSubmit: (input: CreateMagazineInput) => Promise | void; - /** - * Poignée impérative permettant à l'écran d'hôte de déclencher la soumission - * depuis le header (icône Valider), tout en gardant le bouton pied de formulaire. - */ ref?: Ref; }; -export type MagazineFormHandle = { - submit: () => Promise; -}; - -function publicationDateFrom(month: string | null, year: string | null): string | null { - if (!month || !year) { - return null; - } - return `${year}-${month}`; -} - export function MagazineForm({ initial, initialBarcode, @@ -77,38 +69,18 @@ export function MagazineForm({ const router = useRouter(); const magazines = useCollectionStore((s) => s.magazines); const [detailsOpen, setDetailsOpen] = useState(false); - const [values, setValues] = useState(() => { - const date = initial?.publicationDate ?? ''; - const [year, month] = date.length === 7 ? date.split('-') : ['', '']; - const prefilledYear = initialYear ?? (year || null); - return { - publication: initialPublication ?? initial?.publication ?? '', - issueNumber: - initialIssueNumber != null - ? String(initialIssueNumber) - : initial?.issueNumber != null - ? String(initial.issueNumber) - : '', - edition: initial?.edition ?? '', - language: initial?.language ?? '', - condition: initial?.condition ?? '', - month: month || null, - year: prefilledYear || null, - barcode: initial?.barcode ?? initialBarcode ?? '', - notes: initial?.notes ?? '', - }; - }); - const [submitting, setSubmitting] = useState(false); - const [formError, setFormError] = useState(null); + const { values, set, submitting, formError, canSubmit, handleSubmit, consumeBarcode } = + useMagazineForm({ + initial, + initialBarcode, + initialPublication, + initialIssueNumber, + initialYear, + onSubmit, + ref, + }); - const styles = makeStyles(colors); - - const set = (key: keyof FormValues, value: string | null) => { - setValues((prev) => ({ ...prev, [key]: value })); - if (formError) { - setFormError(null); - } - }; + const styles = makeMagazineFormStyles(colors); const publications = [...new Set(magazines.map((m) => m.publication))]; const editions = [...new Set(magazines.map((m) => m.edition).filter((e): e is string => !!e))]; @@ -120,46 +92,10 @@ export function MagazineForm({ useFocusEffect( useCallback(() => { - const pending = consumePendingBarcode(); - if (pending) { - setValues((prev) => ({ ...prev, barcode: pending })); - } - }, []), + consumeBarcode(); + }, [consumeBarcode]), ); - const canSubmit = values.publication.trim().length > 0 && !submitting; - - const handleSubmit = useCallback(async () => { - if (!canSubmit) { - return; - } - const issueDigits = values.issueNumber.trim(); - if (issueDigits && !/^\d+$/.test(issueDigits)) { - setFormError('Le numéro doit être composé uniquement de chiffres.'); - return; - } - setSubmitting(true); - const input: CreateMagazineInput = { - publication: values.publication.trim(), - issueNumber: issueDigits ? Number(issueDigits) : null, - edition: values.edition.trim() || null, - language: values.language.trim() || null, - condition: values.condition.trim() || null, - publicationDate: publicationDateFrom(values.month, values.year), - barcode: values.barcode.trim() || null, - notes: values.notes.trim() || null, - }; - try { - await onSubmit(input); - } catch (err) { - setFormError(err instanceof Error ? err.message : 'Erreur lors de l’enregistrement.'); - } finally { - setSubmitting(false); - } - }, [canSubmit, onSubmit, values]); - - useImperativeHandle(ref, () => ({ submit: () => handleSubmit() }), [handleSubmit]); - return ( - {detailsOpen && ( - - set('language', v)} - placeholder="Ex : FR" - testID="field-language" - accessibilityLabel="Langue" - /> - - État - set('condition', v)} - placeholder="Ex : Neuf, usé, abîmé…" - placeholderTextColor={colors.textSecondary} - testID="field-condition" - accessibilityLabel="État" - /> - - Date de publication - - - set('month', v)} - testID="select-month" - /> - - - set('year', v)} - testID="select-year" - /> - - - - Code-barres - - set('barcode', v)} - placeholder="Ex : 3271234000011" - keyboardType="default" - autoCapitalize="characters" - placeholderTextColor={colors.textSecondary} - testID="field-barcode" - accessibilityLabel="Code-barres" - /> - [styles.scanButton, pressed && styles.buttonPressed]} - onPress={openBarcodeScanner} - testID="barcode-scan" - accessibilityRole="button" - accessibilityLabel="Scanner le code-barres"> - - Scanner - - - - Notes - set('notes', v)} - placeholder="Informations complémentaires…" - multiline - placeholderTextColor={colors.textSecondary} - testID="field-notes" - accessibilityLabel="Notes" - /> - - )} + {detailsOpen ? ( + + ) : null} {formError ? ( @@ -330,124 +191,104 @@ export function MagazineForm({ ); } -function makeStyles(colors: ThemeColors) { - return StyleSheet.create({ - formScroll: { - flex: 1, - }, - formKeyboard: { - flex: 1, - }, - form: { - gap: Spacing.two, - paddingBottom: Spacing.four, - }, - label: { - fontSize: 14, - fontWeight: '600', - color: colors.text, - marginTop: Spacing.two, - }, - input: { - backgroundColor: colors.backgroundElement, - borderRadius: 8, - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.two, - minHeight: 44, - fontSize: 16, - color: colors.text, - }, - notesInput: { - minHeight: 72, - textAlignVertical: 'top', - }, - formError: { - marginTop: Spacing.two, - fontSize: 14, - fontWeight: '600', - color: colors.danger, - textAlign: 'center', - }, - detailsToggle: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - marginTop: Spacing.three, - alignSelf: 'flex-start', - minHeight: 44, - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.three, - borderRadius: 8, - backgroundColor: colors.backgroundElement, - }, - detailsToggleText: { - fontSize: 15, - fontWeight: '600', - color: colors.accentTextOnLight, - }, - details: { - gap: Spacing.two, - }, - dateRow: { - flexDirection: 'row', - gap: Spacing.three, - }, - dateCol: { - flex: 1, - }, - barcodeRow: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - }, - barcodeInput: { - flex: 1, - }, - scanButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - justifyContent: 'center', - backgroundColor: colors.backgroundElement, - borderWidth: 1, - borderColor: colors.accent, - borderRadius: 8, - minHeight: 44, - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.two, - }, - scanButtonText: { - fontSize: 15, - fontWeight: '600', - color: colors.text, - }, - buttonPressed: { - opacity: 0.8, - }, - submit: { - marginTop: Spacing.three, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.accent, - minHeight: 48, - paddingVertical: Spacing.three, - borderRadius: 12, - }, - submitDisabled: { - opacity: 0.5, - }, - submitText: { - fontSize: 18, - fontWeight: '700', - color: colors.accentText, - }, - submitHint: { - fontSize: 13, - color: colors.textSecondary, - textAlign: 'center', - }, - keyboardSpacer: { - height: 200, - }, - }); +function DetailsSection({ + values, + set, + editions, + languages, + onScanBarcode, +}: { + values: FormValues; + set: (key: keyof FormValues, value: string | null) => void; + editions: string[]; + languages: string[]; + onScanBarcode: () => void; +}) { + const colors = useThemeColors(); + const styles = makeMagazineFormStyles(colors); + + return ( + + set('language', v)} + placeholder="Ex : FR" + testID="field-language" + accessibilityLabel="Langue" + /> + + État + set('condition', v)} + placeholder="Ex : Neuf, usé, abîmé…" + placeholderTextColor={colors.textSecondary} + testID="field-condition" + accessibilityLabel="État" + /> + + Date de publication + + + set('month', v)} + testID="select-month" + /> + + + set('year', v)} + testID="select-year" + /> + + + + Code-barres + + set('barcode', v)} + placeholder="Ex : 3271234000011" + keyboardType="default" + autoCapitalize="characters" + placeholderTextColor={colors.textSecondary} + testID="field-barcode" + accessibilityLabel="Code-barres" + /> + [styles.scanButton, pressed && styles.buttonPressed]} + onPress={onScanBarcode} + testID="barcode-scan" + accessibilityRole="button" + accessibilityLabel="Scanner le code-barres"> + + Scanner + + + + Notes + set('notes', v)} + placeholder="Informations complémentaires…" + multiline + placeholderTextColor={colors.textSecondary} + testID="field-notes" + accessibilityLabel="Notes" + /> + + ); } diff --git a/src/components/pagination.tsx b/src/components/pagination.tsx new file mode 100644 index 0000000..fdd2705 --- /dev/null +++ b/src/components/pagination.tsx @@ -0,0 +1,108 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Feather } from '@expo/vector-icons'; + +import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; +import { pageWindow } from '@/lib/collection-list'; + +function makePaginationStyles(colors: ThemeColors) { + return StyleSheet.create({ + pagination: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.two, + flexWrap: 'wrap', + paddingTop: Spacing.two, + }, + pageButton: { + minWidth: HitTarget.minHeight, + minHeight: HitTarget.minHeight, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: Spacing.two, + borderRadius: 8, + backgroundColor: colors.backgroundElement, + }, + pageButtonActive: { + backgroundColor: colors.accent, + }, + pageButtonText: { + fontSize: 16, + color: colors.text, + }, + pageButtonTextActive: { + fontSize: 16, + fontWeight: '700', + color: colors.accentText, + }, + buttonPressed: { + opacity: 0.7, + }, + }); +} + +export function Pagination({ + current, + total, + onPrev, + onNext, + onPage, +}: { + current: number; + total: number; + onPrev: () => void; + onNext: () => void; + onPage: (page: number) => void; +}) { + const colors = useThemeColors(); + const styles = makePaginationStyles(colors); + + if (total <= 1) { + return null; + } + + return ( + + [styles.pageButton, pressed && styles.buttonPressed]} + onPress={onPrev} + disabled={current <= 1} + accessibilityRole="button" + accessibilityLabel="Page précédente" + accessibilityState={{ disabled: current <= 1 }} + testID="pagination-prev"> + + + {pageWindow(current, total).map((p) => ( + [ + styles.pageButton, + p === current && styles.pageButtonActive, + pressed && styles.buttonPressed, + ]} + onPress={() => onPage(p)} + disabled={p === current} + accessibilityRole="button" + accessibilityLabel={`Page ${p}`} + accessibilityState={{ selected: p === current }} + testID={`pagination-page-${p}`}> + + {p} + + + ))} + [styles.pageButton, pressed && styles.buttonPressed]} + onPress={onNext} + disabled={current >= total} + accessibilityRole="button" + accessibilityLabel="Page suivante" + accessibilityState={{ disabled: current >= total }} + testID="pagination-next"> + + + + ); +} diff --git a/src/components/scan-search-views.tsx b/src/components/scan-search-views.tsx new file mode 100644 index 0000000..f2fcc54 --- /dev/null +++ b/src/components/scan-search-views.tsx @@ -0,0 +1,281 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { HitTarget, Spacing, type ThemeColors } from '@/constants/theme'; +import { useThemeColors } from '@/hooks/use-theme'; + +export function makeSearchResultStyles(colors: ThemeColors) { + return StyleSheet.create({ + form: { + flex: 1, + gap: Spacing.three, + }, + resultTitle: { + fontSize: 22, + fontWeight: '700', + color: colors.text, + textAlign: 'center', + }, + card: { + backgroundColor: colors.backgroundElement, + borderRadius: 12, + padding: Spacing.four, + alignItems: 'center', + gap: Spacing.two, + }, + magazine: { + fontSize: 20, + fontWeight: '600', + color: colors.text, + textAlign: 'center', + }, + issue: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + }, + message: { + fontSize: 16, + color: colors.text, + textAlign: 'center', + lineHeight: 22, + }, + muted: { + fontSize: 14, + color: colors.textSecondary, + textAlign: 'center', + }, + ownedText: { + fontSize: 14, + fontWeight: '700', + color: colors.ownedText, + backgroundColor: colors.ownedBg, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.two, + borderRadius: 8, + overflow: 'hidden', + }, + absentText: { + fontSize: 14, + fontWeight: '700', + color: colors.absentText, + backgroundColor: colors.absentBg, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.two, + borderRadius: 8, + overflow: 'hidden', + }, + primaryButton: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + minHeight: 48, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + }, + primaryButtonText: { + fontSize: 18, + fontWeight: '700', + color: colors.accentText, + }, + secondaryButton: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.backgroundElement, + minHeight: 48, + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.three, + borderRadius: 12, + }, + secondaryButtonText: { + fontSize: 17, + fontWeight: '600', + color: colors.text, + }, + pressed: { + opacity: 0.7, + }, + }); +} + +type SearchFoundResultProps = { + publication: string; + issueNumber: number | null; + owned: boolean; + ownedCount: number; + resolved: boolean; + onAddCopy: () => void; + onView: () => void; + onRescan: () => void; +}; + +export function SearchFoundResult({ + publication, + issueNumber, + owned, + ownedCount, + resolved, + onAddCopy, + onView, + onRescan, +}: SearchFoundResultProps) { + const colors = useThemeColors(); + const styles = makeSearchResultStyles(colors); + + return ( + + Déjà dans votre collection + + + {publication} + + {issueNumber != null ? N° {issueNumber} : null} + {resolved ? ( + + {owned ? `✓ Possédé (${ownedCount})` : '○ Absent'} + + ) : ( + + Vérification… + + )} + + + [styles.primaryButton, pressed && styles.pressed]} + onPress={onAddCopy} + testID="search-add" + accessibilityRole="button" + accessibilityLabel={owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'}> + + {owned ? 'Ajouter un exemplaire' : 'Ajouter à la collection'} + + + + [styles.secondaryButton, pressed && styles.pressed]} + onPress={onView} + testID="search-view" + accessibilityRole="button" + accessibilityLabel="Voir la fiche du magazine"> + Voir la fiche + + + [styles.secondaryButton, pressed && styles.pressed]} + onPress={onRescan} + testID="search-rescan" + accessibilityRole="button" + accessibilityLabel="Scanner à nouveau"> + Scanner à nouveau + + + ); +} + +type SearchUnknownResultProps = { + publication: string; + issueNumber: number | null; + onManual: () => void; + onAgain: () => void; +}; + +export function SearchUnknownResult({ + publication, + issueNumber, + onManual, + onAgain, +}: SearchUnknownResultProps) { + const colors = useThemeColors(); + const styles = makeSearchResultStyles(colors); + + return ( + + Non référencé + + + « {publication} » n'est pas encore dans votre collection. + + {issueNumber != null ? N° {issueNumber} : null} + + + [styles.primaryButton, pressed && styles.pressed]} + onPress={onManual} + testID="search-manual" + accessibilityRole="button" + accessibilityLabel="Saisir manuellement"> + Saisir manuellement + + + [styles.secondaryButton, pressed && styles.pressed]} + onPress={onAgain} + testID="search-again" + accessibilityRole="button" + accessibilityLabel="Rechercher un autre magazine"> + Rechercher un autre magazine + + + ); +} + +export function makeSearchScreenStyles(colors: ThemeColors) { + return StyleSheet.create({ + screen: { + flex: 1, + padding: Spacing.four, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + marginBottom: Spacing.three, + }, + backButton: { + minWidth: HitTarget.minHeight, + minHeight: HitTarget.minHeight, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + flex: 1, + fontSize: 24, + lineHeight: 32, + fontWeight: '700', + color: colors.text, + }, + form: { + flex: 1, + gap: Spacing.three, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginTop: Spacing.two, + }, + input: { + backgroundColor: colors.backgroundElement, + borderRadius: 8, + paddingHorizontal: Spacing.three, + paddingVertical: Spacing.two, + minHeight: 44, + fontSize: 16, + color: colors.text, + }, + disabled: { + opacity: 0.5, + }, + hint: { + fontSize: 13, + color: colors.textSecondary, + textAlign: 'center', + }, + pressed: { + opacity: 0.7, + }, + }); +} diff --git a/src/components/use-magazine-form.ts b/src/components/use-magazine-form.ts new file mode 100644 index 0000000..072b4f4 --- /dev/null +++ b/src/components/use-magazine-form.ts @@ -0,0 +1,145 @@ +import { useCallback, useImperativeHandle, useState } from 'react'; +import type { Ref } from 'react'; +import { consumePendingBarcode } from '@/lib/pending-barcode'; +import type { CreateMagazineInput, Magazine } from '@/types'; + +export type FormValues = { + publication: string; + issueNumber: string; + edition: string; + language: string; + condition: string; + month: string | null; + year: string | null; + barcode: string; + notes: string; +}; + +export type MagazineFormHandle = { + submit: () => Promise; +}; + +/** Convertit (mois, année) en date ISO « YYYY-MM » ou null si incomplète. */ +export function publicationDateFrom(month: string | null, year: string | null): string | null { + if (!month || !year) { + return null; + } + return `${year}-${month}`; +} + +type BuildInputOptions = { + initial?: Magazine; + initialBarcode?: string; + initialPublication?: string; + initialIssueNumber?: number | null; + initialYear?: string | null; +}; + +export function initialFormValues(options: BuildInputOptions): FormValues { + const { initial, initialBarcode, initialPublication, initialIssueNumber, initialYear } = options; + const date = initial?.publicationDate ?? ''; + const [year, month] = date.length === 7 ? date.split('-') : ['', '']; + const prefilledYear = initialYear ?? (year || null); + return { + publication: initialPublication ?? initial?.publication ?? '', + issueNumber: + initialIssueNumber != null + ? String(initialIssueNumber) + : initial?.issueNumber != null + ? String(initial.issueNumber) + : '', + edition: initial?.edition ?? '', + language: initial?.language ?? '', + condition: initial?.condition ?? '', + month: month || null, + year: prefilledYear || null, + barcode: initial?.barcode ?? initialBarcode ?? '', + notes: initial?.notes ?? '', + }; +} + +/** Construit l'entrée métier à partir des valeurs brutes du formulaire. */ +export function buildMagazineInput(values: FormValues): CreateMagazineInput { + const issueDigits = values.issueNumber.trim(); + return { + publication: values.publication.trim(), + issueNumber: issueDigits ? Number(issueDigits) : null, + edition: values.edition.trim() || null, + language: values.language.trim() || null, + condition: values.condition.trim() || null, + publicationDate: publicationDateFrom(values.month, values.year), + barcode: values.barcode.trim() || null, + notes: values.notes.trim() || null, + }; +} + +type Params = { + initial?: Magazine; + initialBarcode?: string; + initialPublication?: string; + initialIssueNumber?: number | null; + initialYear?: string | null; + onSubmit: (input: CreateMagazineInput) => Promise | void; + ref?: Ref; +}; + +export function useMagazineForm({ + initial, + initialBarcode, + initialPublication, + initialIssueNumber, + initialYear, + onSubmit, + ref, +}: Params) { + const [values, setValues] = useState(() => + initialFormValues({ + initial, + initialBarcode, + initialPublication, + initialIssueNumber, + initialYear, + }), + ); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + const set = (key: keyof FormValues, value: string | null) => { + setValues((prev) => ({ ...prev, [key]: value })); + if (formError) { + setFormError(null); + } + }; + + const consumeBarcode = useCallback(() => { + const pending = consumePendingBarcode(); + if (pending) { + setValues((prev) => ({ ...prev, barcode: pending })); + } + }, []); + + const canSubmit = values.publication.trim().length > 0 && !submitting; + + const handleSubmit = useCallback(async () => { + if (!canSubmit) { + return; + } + const issueDigits = values.issueNumber.trim(); + if (issueDigits && !/^\d+$/.test(issueDigits)) { + setFormError('Le numéro doit être composé uniquement de chiffres.'); + return; + } + setSubmitting(true); + try { + await onSubmit(buildMagazineInput(values)); + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Erreur lors de l’enregistrement.'); + } finally { + setSubmitting(false); + } + }, [canSubmit, onSubmit, values]); + + useImperativeHandle(ref, () => ({ submit: () => handleSubmit() }), [handleSubmit]); + + return { values, set, submitting, formError, canSubmit, handleSubmit, consumeBarcode }; +} diff --git a/src/database/__tests__/migrations.test.ts b/src/database/__tests__/migrations.test.ts index 1b7cadb..5a1a420 100644 --- a/src/database/__tests__/migrations.test.ts +++ b/src/database/__tests__/migrations.test.ts @@ -65,6 +65,46 @@ describe('migrate', () => { expect(db.execAsync).toHaveBeenCalledWith(MIGRATION_001); }); + it('enveloppe chaque migration dans BEGIN/COMMIT avec mise à jour de user_version', async () => { + const db = { + getFirstAsync: jest + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ user_version: getSchemaVersion() }), + execAsync: jest.fn().mockResolvedValue(undefined), + } as unknown as Database; + + await migrate(db); + + const calls = (db.execAsync as jest.Mock).mock.calls.map((call) => call[0]); + expect(calls[0]).toBe('BEGIN'); + expect(calls).toContain(MIGRATION_001); + expect(calls).toContain(`PRAGMA user_version = ${getSchemaVersion()}`); + expect(calls[calls.length - 1]).toBe('COMMIT'); + }); + + it('annule la migration (ROLLBACK) et relance l’erreur en cas d’échec', async () => { + const db = { + getFirstAsync: jest.fn().mockResolvedValue({ user_version: 0 }), + execAsync: jest.fn().mockImplementation((sql: string) => { + if (sql === 'PRAGMA user_version = 4') { + throw new Error('Échec forcé'); + } + return Promise.resolve(undefined); + }), + } as unknown as Database; + + await expect(migrate(db)).rejects.toThrow('Échec forcé'); + + const calls = (db.execAsync as jest.Mock).mock.calls.map((call) => call[0]); + expect(calls[0]).toBe('BEGIN'); + // La migration n°4 a échoué → ROLLBACK final (les migrations 1-3 ont COMMIT). + expect(calls[calls.length - 1]).toBe('ROLLBACK'); + expect(calls.filter((sql) => sql === 'ROLLBACK')).toHaveLength(1); + // user_version inchangé : la base reste cohérente pour une relance. + expect(await db.getFirstAsync('PRAGMA user_version')).toEqual({ user_version: 0 }); + }); + it('signale une migration incomplète si la version finale est fausse', async () => { const db = { getFirstAsync: jest.fn().mockResolvedValue(undefined), diff --git a/src/database/migrations.ts b/src/database/migrations.ts index d471b76..ad6b079 100644 --- a/src/database/migrations.ts +++ b/src/database/migrations.ts @@ -25,8 +25,22 @@ export async function migrate(db: Database): Promise { for (const migration of MIGRATIONS) { if (migration.version > current) { - await migration.up(db); - await db.execAsync(`PRAGMA user_version = ${migration.version}`); + // Chaque migration dans une transaction : un échec partiel annule le DDL + // déjà appliqué, évitant une base « mi-figée » avec un `user_version` + // inchangé qui bloquerait toute relance. + await db.execAsync('BEGIN'); + try { + await migration.up(db); + await db.execAsync(`PRAGMA user_version = ${migration.version}`); + await db.execAsync('COMMIT'); + } catch (error) { + try { + await db.execAsync('ROLLBACK'); + } catch { + // Ignorer l'échec du rollback : on renvoie l'erreur d'origine. + } + throw error; + } } } diff --git a/src/database/repositories/__tests__/magazine-repository.test.ts b/src/database/repositories/__tests__/magazine-repository.test.ts index 137bc4a..0d7efd0 100644 --- a/src/database/repositories/__tests__/magazine-repository.test.ts +++ b/src/database/repositories/__tests__/magazine-repository.test.ts @@ -104,6 +104,33 @@ describe('magazineRepository.create', () => { }); }); +describe('magazineRepository.createWithCopy', () => { + it('cree l’edition et son premier exemplaire dans une seule transaction', async () => { + const result = await repo.createWithCopy({ + publication: 'Picsou Magazine', + issueNumber: 547, + }); + + expect(result.magazine.publication).toBe('Picsou Magazine'); + expect(result.magazine.issueNumber).toBe(547); + expect(result.copyId).toBeTruthy(); + + const copy = await testDb.getFirstAsync<{ + id: string; + magazine_id: string; + notes: string | null; + }>('SELECT id, magazine_id, notes FROM collection_items WHERE id = ?', result.copyId); + expect(copy?.magazine_id).toBe(result.magazine.id); + expect(copy?.notes).toBeNull(); + }); + + it('refuse une publication vide', async () => { + await expect(repo.createWithCopy({ publication: ' ' })).rejects.toThrow( + 'La publication est obligatoire.', + ); + }); +}); + describe('magazineRepository.findManyByBarcode', () => { it('retourne toutes les editions partageant le meme code-barres', async () => { const first = await repo.create({ @@ -191,6 +218,17 @@ describe('magazineRepository.findByPublicationAndIssue', () => { expect(await repo.findByPublicationAndIssue('Picsou Magazine', null)).toBeNull(); expect(await repo.findByPublicationAndIssue(' ', 547)).toBeNull(); }); + + it('choisit toujours la plus ancienne édition quand plusieurs existent (déterministe)', async () => { + await repo.create({ publication: 'Picsou Magazine', issueNumber: 547, edition: 'BE' }); + await repo.create({ publication: 'Picsou Magazine', issueNumber: 547, edition: 'FR' }); + + const first = await repo.findByPublicationAndIssue('Picsou Magazine', 547); + const second = await repo.findByPublicationAndIssue('Picsou Magazine', 547); + + expect(first?.edition).toBe('BE'); + expect(second?.id).toBe(first?.id); + }); }); describe('magazineRepository.list', () => { @@ -210,6 +248,16 @@ describe('magazineRepository.list', () => { ]); }); + it('trie sans tenir compte de la casse ni des accents', async () => { + await repo.create({ publication: 'Échos Vacances', issueNumber: 1 }); + await repo.create({ publication: 'etu-SORCIER', issueNumber: 1 }); + await repo.create({ publication: 'Espiègle', issueNumber: 1 }); + + const list = await repo.list(); + + expect(list.map((m) => m.publication)).toEqual(['Échos Vacances', 'Espiègle', 'etu-SORCIER']); + }); + it('compte le nombre dexemplaires possedes', async () => { const single = await repo.create({ publication: 'Picsou Magazine', issueNumber: 547 }); const double = await repo.create({ publication: 'Super Picsou Géant', issueNumber: 30 }); @@ -320,6 +368,24 @@ describe('magazineRepository.search', () => { expect(results[0].quantity).toBe(1); }); + + it('traite % et _ comme des caracteres litteraux', async () => { + await repo.create({ publication: 'Picsou 100%', issueNumber: 1 }); + await repo.create({ publication: 'Picsou_20', issueNumber: 2 }); + await repo.create({ publication: 'Picsou Magazine', issueNumber: 3 }); + + expect((await repo.search('100%')).map((m) => m.publication)).toEqual(['Picsou 100%']); + expect((await repo.search('Picsou_20')).map((m) => m.publication)).toEqual(['Picsou_20']); + expect((await repo.search('%')).map((m) => m.publication)).toEqual(['Picsou 100%']); + expect((await repo.search('_')).map((m) => m.publication)).toEqual(['Picsou_20']); + }); + + it('ignore les accents et la casse dans les deux sens', async () => { + await repo.create({ publication: 'Super Picsou Géant', issueNumber: 30 }); + + expect((await repo.search('geant')).map((m) => m.publication)).toEqual(['Super Picsou Géant']); + expect((await repo.search('ÉAN')).map((m) => m.publication)).toEqual(['Super Picsou Géant']); + }); }); describe('magazineRepository.findById', () => { diff --git a/src/database/repositories/magazine-repository.ts b/src/database/repositories/magazine-repository.ts index 31e5e95..6cf94b3 100644 --- a/src/database/repositories/magazine-repository.ts +++ b/src/database/repositories/magazine-repository.ts @@ -52,6 +52,35 @@ function toMagazine( }; } +/** + * Tri stable, insensible à la casse et aux accents (collation française) : + * le `ORDER BY` SQLite utilise la collation BINARY et `lower()` est ASCII-only. + */ +function compareByPublication( + a: Pick, + b: Pick, +): number { + const byPublication = a.publication.localeCompare(b.publication, 'fr', { + sensitivity: 'base', + }); + if (byPublication !== 0) { + return byPublication; + } + const byIssue = (a.issueNumber ?? Infinity) - (b.issueNumber ?? Infinity); + if (byIssue !== 0) { + return byIssue; + } + return a.id.localeCompare(b.id); +} + +/** Normalise une chaîne : casse + accents (prétraitement en JS, SQLite est ASCII-only). */ +function normalizeText(value: string): string { + return value + .toLocaleLowerCase('fr') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); +} + export class MagazineRepository { constructor(private readonly db: Database) {} @@ -99,17 +128,16 @@ export class MagazineRepository { async list(): Promise { const rows = await this.db.getAllAsync< Omit & { quantity: number } - >( - `${LIST_SELECT} - GROUP BY m.id - ORDER BY m.publication, m.issue_number`, - ); + >(`${LIST_SELECT} + GROUP BY m.id`); - return rows.map((row) => ({ ...toMagazine(row), quantity: row.quantity })); + return rows + .map((row) => ({ ...toMagazine(row), quantity: row.quantity })) + .sort(compareByPublication); } async search(query: string): Promise { - const term = query.trim(); + const term = normalizeText(query.trim()); if (!term) { return this.list(); } @@ -117,20 +145,14 @@ export class MagazineRepository { const numeric = Number(term); const isNumeric = Number.isFinite(numeric); - const rows = await this.db.getAllAsync< - Omit & { quantity: number } - >( - `${LIST_SELECT} - WHERE m.publication LIKE '%' || ? || '%' - OR (? = 1 AND m.issue_number = ?) - GROUP BY m.id - ORDER BY m.publication, m.issue_number`, - term, - isNumeric ? 1 : 0, - isNumeric ? numeric : 0, - ); - - return rows.map((row) => ({ ...toMagazine(row), quantity: row.quantity })); + const all = await this.list(); + return all + .filter( + (magazine) => + (isNumeric && magazine.issueNumber === numeric) || + normalizeText(magazine.publication).includes(term), + ) + .sort(compareByPublication); } async findById(id: string): Promise { @@ -217,6 +239,75 @@ export class MagazineRepository { return magazine; } + /** + * Crée une édition **et** son premier exemplaire dans une seule transaction + * (US-COL : l'ajout d'une nouveauté produit toujours un exemplaire). Un échec + * de l'un des deux INSERT annule l'ensemble : pas d'édition orpheline. + */ + async createWithCopy( + input: CreateMagazineInput, + ): Promise<{ magazine: Magazine; copyId: string }> { + const publication = input.publication.trim(); + if (!publication) { + throw new Error('La publication est obligatoire.'); + } + + const now = new Date().toISOString(); + const magazine: Magazine = { + id: generateId(), + publication, + issueNumber: input.issueNumber ?? null, + edition: input.edition ?? null, + language: input.language ?? null, + condition: input.condition ?? null, + publicationDate: input.publicationDate ?? null, + barcode: input.barcode ?? null, + notes: input.notes ?? null, + ocrText: input.ocrText ?? null, + createdAt: now, + updatedAt: now, + }; + const copyId = generateId(); + + await this.db.execAsync('BEGIN'); + try { + await this.db.runAsync( + `INSERT INTO magazines + (id, publication, issue_number, edition, language, condition, publication_date, + barcode, notes, ocr_text, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + magazine.id, + magazine.publication, + magazine.issueNumber, + magazine.edition, + magazine.language, + magazine.condition, + magazine.publicationDate, + magazine.barcode, + magazine.notes, + magazine.ocrText, + magazine.createdAt, + magazine.updatedAt, + ); + await this.db.runAsync( + `INSERT INTO collection_items (id, magazine_id, notes, date_added) + VALUES (?, ?, NULL, ?)`, + copyId, + magazine.id, + now, + ); + await this.db.execAsync('COMMIT'); + } catch (error) { + try { + await this.db.execAsync('ROLLBACK'); + } catch { + // Ignorer l'échec du rollback : on renvoie l'erreur d'origine. + } + throw error; + } + + return { magazine, copyId }; + } async update(id: string, input: CreateMagazineInput): Promise { const current = await this.db.getFirstAsync( `SELECT id, publication, issue_number, edition, language, condition, publication_date, diff --git a/src/hooks/use-scan-result.ts b/src/hooks/use-scan-result.ts new file mode 100644 index 0000000..364afc4 --- /dev/null +++ b/src/hooks/use-scan-result.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; +import { Alert } from 'react-native'; + +import { toast } from '@/lib/toast'; +import { useCollectionStore } from '@/store/use-collection-store'; +import type { MagazineDetail } from '@/types'; + +/** + * Statut partagé entre les écrans « résultat du scan » et « recherche + * manuelle » : résolution de l'édition affichée, possession d'un exemplaire + * et ajout d'un exemplaire (avec confirmation si déjà possédé). + */ +export function useScanResult(detail: MagazineDetail | null, id: string | null) { + const addExistingCopy = useCollectionStore((s) => s.addExistingCopy); + + const resolved = detail != null && detail.id === id; + const owned = resolved && detail.copies.length > 0; + const ownedCount = resolved ? detail.copies.length : 0; + + const handleAddCopy = useCallback(() => { + if (!id) { + return; + } + const alreadyOwned = owned; + + const perform = async () => { + await addExistingCopy(id); + toast(alreadyOwned ? 'Exemplaire ajouté à la collection' : 'Ajouté à la collection'); + }; + + if (alreadyOwned) { + Alert.alert( + 'Vous possédez déjà ce magazine', + `Exemplaires actuels : ${ownedCount}\nVoulez-vous ajouter un deuxième exemplaire ?`, + [ + { text: 'Annuler', style: 'cancel' }, + { text: 'Ajouter quand même', style: 'destructive', onPress: perform }, + ], + ); + } else { + perform(); + } + }, [id, owned, ownedCount, addExistingCopy]); + + return { resolved, owned, ownedCount, handleAddCopy }; +} diff --git a/src/identification/__tests__/scanBarcode.test.ts b/src/identification/__tests__/scanBarcode.test.ts index fd714be..8d1cafd 100644 --- a/src/identification/__tests__/scanBarcode.test.ts +++ b/src/identification/__tests__/scanBarcode.test.ts @@ -48,6 +48,28 @@ describe('validateBarcode', () => { }); }); + it('rejette un ISBN-10 invalide et n’accepte X qu’en position de checksum', () => { + expect(validateBarcode('0096059077')).toEqual({ + valid: true, + type: 'GENERIC', + normalized: '0096059077', + }); + // Un X en position interne n’est jamais validé comme ISBN-10 (retombe en GENERIC). + const middleX = validateBarcode('08044X957X'); + expect(middleX.valid).toBe(true); + if (middleX.valid) { + expect(middleX.type).toBe('GENERIC'); + } + }); + + it('replie en GENERIC un code de 10 chiffres au checksum ISBN-10 invalide', () => { + expect(validateBarcode('1234567890')).toEqual({ + valid: true, + type: 'GENERIC', + normalized: '1234567890', + }); + }); + it('rejette un code vide', () => { expect(validateBarcode('')).toEqual({ valid: false, diff --git a/src/identification/scanBarcode.ts b/src/identification/scanBarcode.ts index 948e602..a600f71 100644 --- a/src/identification/scanBarcode.ts +++ b/src/identification/scanBarcode.ts @@ -67,13 +67,17 @@ export function validateBarcode(raw: string): BarcodeValidation { return { valid: false, reason: 'Aucun chiffre détecté.' }; } - // ISBN-10 (longueur exacte, peut contenir un 'X' en fin de checksum) + // ISBN-10 (longueur exacte, peut contenir un 'X' en position de checksum uniquement) const trimmedIsbn = s.toUpperCase(); - if (trimmedIsbn.length === ISBN_10_LENGTH && /^[0-9X]{10}$/.test(trimmedIsbn)) { + if (trimmedIsbn.length === ISBN_10_LENGTH && /^[0-9]{9}[0-9X]$/.test(trimmedIsbn)) { if (isValidIsbn10(trimmedIsbn)) { return { valid: true, type: 'ISBN-10', normalized: trimmedIsbn }; } - return { valid: false, reason: 'ISBN-10 invalide (checksum).' }; + if (!/^\d{10}$/.test(trimmedIsbn)) { + return { valid: false, reason: 'ISBN-10 invalide (checksum).' }; + } + // 10 chiffres dont la somme de contrôle ne valide pas l'ISBN-10 : ce peut + // être un codage non standard (CODE39/ITF…) — on tente GENERIC plus bas. } // EAN-13 / ISBN-13 (13 chiffres, éventuellement groupés par séparateurs) diff --git a/src/lib/__tests__/collection-list.test.ts b/src/lib/__tests__/collection-list.test.ts new file mode 100644 index 0000000..f8d97b3 --- /dev/null +++ b/src/lib/__tests__/collection-list.test.ts @@ -0,0 +1,115 @@ +import type { MagazineListItem } from '@/types'; +import { + buildEditionOptions, + filterAndSortMagazines, + pageWindow, + sortMagazines, +} from '@/lib/collection-list'; + +const m = ( + over: Partial & { id: string; publication: string }, +): MagazineListItem => ({ + id: over.id, + publication: over.publication, + issueNumber: over.issueNumber ?? null, + edition: over.edition ?? null, + language: null, + condition: null, + publicationDate: null, + barcode: null, + notes: null, + ocrText: null, + createdAt: over.createdAt ?? '', + updatedAt: '', + quantity: over.quantity ?? 0, +}); + +describe('sortMagazines', () => { + const list = [ + m({ id: 'a', publication: 'B', issueNumber: 2 }), + m({ id: 'b', publication: 'A', issueNumber: 10 }), + m({ id: 'c', publication: 'C', issueNumber: null }), + ]; + + it('trie par numéro croissant puis publication (null en fin)', () => { + const sorted = sortMagazines(list, 'Numéro ↑'); + expect(sorted.map((x) => x.id)).toEqual(['a', 'b', 'c']); + }); + + it('trie par numéro décroissant', () => { + const sorted = sortMagazines(list, 'Numéro ↓'); + expect(sorted.map((x) => x.id)).toEqual(['b', 'a', 'c']); + }); + + it('trie par édition (A → Z) puis publication (sans édition en tête)', () => { + const editionList = [ + m({ id: 'a', publication: 'X', edition: 'FR' }), + m({ id: 'b', publication: 'Y', edition: 'EN' }), + m({ id: 'c', publication: 'Z', edition: null }), + ]; + const sorted = sortMagazines(editionList, 'Édition (A → Z)'); + expect(sorted.map((x) => x.id)).toEqual(['c', 'b', 'a']); + }); + + it('trie par ajout récent', () => { + const recentList = [ + m({ id: 'a', publication: 'X', createdAt: '2020-01-01' }), + m({ id: 'b', publication: 'Y', createdAt: '2023-01-01' }), + ]; + const sorted = sortMagazines(recentList, 'Ajout récent'); + expect(sorted.map((x) => x.id)).toEqual(['b', 'a']); + }); + + it('ne mute pas la liste source', () => { + const original = [...list]; + sortMagazines(list, 'Numéro ↑'); + expect(list.map((x) => x.id)).toEqual(original.map((x) => x.id)); + }); +}); + +describe('pageWindow', () => { + it('renvoie la fenêtre autour de la page courante', () => { + expect(pageWindow(5, 10)).toEqual([3, 4, 5, 6, 7]); + }); + it('borne au début', () => { + expect(pageWindow(1, 5)).toEqual([1, 2, 3]); + }); + it('borne à la fin', () => { + expect(pageWindow(10, 10)).toEqual([8, 9, 10]); + }); +}); + +describe('buildEditionOptions', () => { + it('liste les éditions distinctes avec « Sans édition » en fin', () => { + const list = [ + m({ id: 'a', publication: 'X', edition: 'FR' }), + m({ id: 'b', publication: 'Y', edition: 'FR' }), + m({ id: 'c', publication: 'Z', edition: null }), + m({ id: 'd', publication: 'W', edition: ' EN ' }), + ]; + expect(buildEditionOptions(list)).toEqual(['EN', 'FR', 'Sans édition']); + }); +}); + +describe('filterAndSortMagazines', () => { + const list = [ + m({ id: 'a', publication: 'X', issueNumber: 5, edition: 'FR' }), + m({ id: 'b', publication: 'Y', issueNumber: 10, edition: 'EN' }), + m({ id: 'c', publication: 'Z', issueNumber: 5, edition: null }), + ]; + + it('filtre par édition', () => { + const out = filterAndSortMagazines(list, 'FR', '', 'Numéro ↑'); + expect(out.map((x) => x.id)).toEqual(['a']); + }); + + it('filtre par numéro', () => { + const out = filterAndSortMagazines(list, null, '5', 'Numéro ↑'); + expect(out.map((x) => x.id)).toEqual(['a', 'c']); + }); + + it('retourne tout sans filtre', () => { + const out = filterAndSortMagazines(list, null, '', 'Numéro ↑'); + expect(out).toHaveLength(3); + }); +}); diff --git a/src/lib/collection-list.ts b/src/lib/collection-list.ts new file mode 100644 index 0000000..ddf196c --- /dev/null +++ b/src/lib/collection-list.ts @@ -0,0 +1,79 @@ +import type { MagazineListItem } from '@/types'; + +export const PAGE_SIZE = 20; + +/** Options de tri de la collection (M10R2-08). */ +export const SORT_OPTIONS = ['Numéro ↑', 'Numéro ↓', 'Ajout récent', 'Édition (A → Z)'] as const; +export type SortOption = (typeof SORT_OPTIONS)[number]; + +/** Fenêtre de pagination autour de la page courante (±2). */ +export function pageWindow(current: number, total: number): number[] { + const start = Math.max(1, current - 2); + const end = Math.min(total, current + 2); + return Array.from({ length: end - start + 1 }, (_, i) => start + i); +} + +export function sortMagazines(list: MagazineListItem[], sort: SortOption): MagazineListItem[] { + const sorted = [...list]; + switch (sort) { + case 'Numéro ↓': + return sorted.sort((a, b) => { + const na = a.issueNumber ?? Number.MIN_SAFE_INTEGER; + const nb = b.issueNumber ?? Number.MIN_SAFE_INTEGER; + if (na !== nb) return nb - na; + return a.publication.localeCompare(b.publication); + }); + case 'Ajout récent': + return sorted.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + case 'Édition (A → Z)': + return sorted.sort((a, b) => { + const ea = (a.edition?.trim() || '').toLowerCase(); + const eb = (b.edition?.trim() || '').toLowerCase(); + if (ea !== eb) return ea.localeCompare(eb); + return a.publication.localeCompare(b.publication); + }); + case 'Numéro ↑': + default: + return sorted.sort((a, b) => { + const na = a.issueNumber ?? Number.MAX_SAFE_INTEGER; + const nb = b.issueNumber ?? Number.MAX_SAFE_INTEGER; + if (na !== nb) return na - nb; + return a.publication.localeCompare(b.publication); + }); + } +} + +/** Éditions distinctes triées (avec « Sans édition » en fin) pour le filtre. */ +export function buildEditionOptions(list: MagazineListItem[]): string[] { + return [ + ...new Set(list.map((m) => (m.edition?.trim() ? m.edition.trim() : 'Sans édition'))), + ].sort((a, b) => (a === 'Sans édition' ? 1 : b === 'Sans édition' ? -1 : a.localeCompare(b))); +} + +/** Applique les filtres (édition + numéro) puis le tri. */ +export function filterAndSortMagazines( + list: MagazineListItem[], + editionFilter: string | null, + issueQuery: string, + sort: SortOption, +): MagazineListItem[] { + const issue = issueQuery.trim(); + const filtered = list.filter((m) => { + if (editionFilter) { + const edition = m.edition?.trim() ? m.edition.trim() : 'Sans édition'; + if (edition !== editionFilter) { + return false; + } + } + if (issue) { + const parsed = Number(issue); + if (Number.isNaN(parsed) || m.issueNumber !== parsed) { + return false; + } + } + return true; + }); + return sortMagazines(filtered, sort); +} diff --git a/src/store/__tests__/use-backup-store.test.ts b/src/store/__tests__/use-backup-store.test.ts index d943581..83d4dd5 100644 --- a/src/store/__tests__/use-backup-store.test.ts +++ b/src/store/__tests__/use-backup-store.test.ts @@ -108,7 +108,7 @@ describe('useBackupStore.exportCollection', () => { const [csv, format] = writeExport.mock.calls[0]; expect(format).toBe('csv'); expect(csv).toMatch( - /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded\n/, + /^publication,issueNumber,edition,language,condition,publicationDate,barcode,notes,ocrText,copyNotes,dateAdded,createdAt,updatedAt\n/, ); expect(useBackupStore.getState().lastExport?.name).toBe('picsou-collection-2026-09-01.csv'); }); diff --git a/src/store/__tests__/use-collection-store.test.ts b/src/store/__tests__/use-collection-store.test.ts index 388a13d..77d7990 100644 --- a/src/store/__tests__/use-collection-store.test.ts +++ b/src/store/__tests__/use-collection-store.test.ts @@ -3,12 +3,14 @@ import type { MagazineListItem } from '@/types'; const mockList = jest.fn(); const mockCreate = jest.fn(); +const mockCreateWithCopy = jest.fn(); const mockDelete = jest.fn(); const mockFindById = jest.fn(); const mockUpdate = jest.fn(); const mockMagazineRepo = { list: mockList, create: mockCreate, + createWithCopy: mockCreateWithCopy, delete: mockDelete, findById: mockFindById, update: mockUpdate, @@ -81,15 +83,9 @@ describe('useCollectionStore', () => { expect(state.loaded).toBe(false); }); - it('ajoute une edition et un exemplaire, puis incremente le compteur', async () => { + it('ajoute une edition et un exemplaire dans une transaction, puis incremente le compteur', async () => { const created = { ...magazine, quantity: 1 }; - mockCreate.mockResolvedValue(created); - mockAddCopy.mockResolvedValue({ - id: 'c1', - magazineId: 'mag-1', - notes: null, - dateAdded: 'x', - }); + mockCreateWithCopy.mockResolvedValue({ magazine: created, copyId: 'c1' }); await useCollectionStore .getState() @@ -98,7 +94,10 @@ describe('useCollectionStore', () => { const state = useCollectionStore.getState(); expect(state.magazines).toHaveLength(1); expect(state.totalCopies).toBe(1); - expect(mockAddCopy).toHaveBeenCalledWith('mag-1'); + expect(mockCreateWithCopy).toHaveBeenCalledWith({ + publication: 'Picsou Magazine', + issueNumber: 547, + }); }); it('supprime une edition et decremente le compteur', async () => { @@ -242,7 +241,7 @@ describe('useCollectionStore', () => { }); it('relance l’erreur quand l’ajout d’une edition échoue', async () => { - mockCreate.mockRejectedValue(new Error('Code-barres déjà enregistré.')); + mockCreateWithCopy.mockRejectedValue(new Error('Code-barres déjà enregistré.')); await expect( useCollectionStore.getState().addMagazine({ publication: 'Picsou Magazine' }), diff --git a/src/store/use-collection-store.ts b/src/store/use-collection-store.ts index c09478b..146be64 100644 --- a/src/store/use-collection-store.ts +++ b/src/store/use-collection-store.ts @@ -71,12 +71,9 @@ export const useCollectionStore = create((set, get) => ({ } }, - clearDetail: () => set({ detail: null }), - addMagazine: async (input) => { - const { magazineRepository, collectionRepository } = getDeps(); - const magazine = await magazineRepository.create(input); - await collectionRepository.addCopy(magazine.id); + const { magazineRepository } = getDeps(); + const { magazine } = await magazineRepository.createWithCopy(input); const item: MagazineListItem = { ...magazine, quantity: 1 }; set((state) => ({ magazines: [item, ...state.magazines],