Add duplicate detection, conflict handling, and fix pagination issues

- Add photo_conflicts table for files with same path but different content
- Implement SHA256-based duplicate detection in file scanner
- Add conflict detection methods to PhotoService
- Skip identical files with info logging, store conflicts with warnings
- Fix infinite scroll pagination race conditions with functional state updates
- Add scroll throttling to prevent rapid API calls
- Enhance PhotoThumbnail with comprehensive EXIF date/time display
- Add composite React keys to prevent duplicate rendering issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-08-27 10:55:28 -05:00
parent f6b651eeda
commit 5c3ad988f5
7 changed files with 405 additions and 83 deletions

View File

@ -15,11 +15,34 @@ export async function GET(request: NextRequest) {
rating: searchParams.get('rating') ? parseInt(searchParams.get('rating')!) : undefined rating: searchParams.get('rating') ? parseInt(searchParams.get('rating')!) : undefined
} }
// Get photos with pagination
const photos = photoService.getPhotos(options) const photos = photoService.getPhotos(options)
// Get total count for pagination metadata
const totalCountOptions = { ...options }
delete totalCountOptions.limit
delete totalCountOptions.offset
const totalPhotos = photoService.getPhotos(totalCountOptions)
const totalCount = totalPhotos.length
// Calculate pagination metadata
const limit = options.limit || 20
const offset = options.offset || 0
const hasMore = (offset + photos.length) < totalCount
const currentPage = Math.floor(offset / limit) + 1
const totalPages = Math.ceil(totalCount / limit)
return NextResponse.json({ return NextResponse.json({
photos, photos,
count: photos.length pagination: {
total: totalCount,
count: photos.length,
limit,
offset,
hasMore,
currentPage,
totalPages
}
}) })
} catch (error) { } catch (error) {
console.error('Error fetching photos:', error) console.error('Error fetching photos:', error)

View File

@ -180,72 +180,84 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
} }
const modalContent = ( const modalContent = (
<div className="absolute inset-0 bg-black bg-opacity-50 z-[9999]" style={{ display: 'block', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, height: '100vh', width: '100vw' }}> <div className="fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4">
<div <div
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 p-6 rounded-lg w-96 shadow-2xl" className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white"> {/* Header */}
Select Directory to Scan <div className="p-6 pb-4 border-b border-gray-200 dark:border-gray-600">
</h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Select Directory to Scan
<div className="relative"> </h2>
<input
type="text"
value={directory}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(suggestions.length > 0)}
placeholder="/path/to/photos"
className="w-full p-2 pr-10 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
autoFocus
/>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
{isValidating && (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent" />
)}
{!isValidating && isValid === true && (
<IconCheck className="h-4 w-4 text-green-600" />
)}
{!isValidating && (isValid === false || (directory && isValid === null)) && (
<IconX className="h-4 w-4 text-red-600" />
)}
</div>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-48 overflow-y-auto z-10">
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={(el) => { suggestionRefs.current[index] = el }}
onClick={() => handleSuggestionClick(suggestion)}
className={`px-3 py-2 cursor-pointer text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-600 last:border-b-0 ${
index === selectedSuggestionIndex
? 'bg-blue-100 dark:bg-blue-900'
: 'hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
>
{suggestion}
</div>
))}
</div>
)}
</div> </div>
<div className="flex gap-2 mt-4"> {/* Content Area - Scrollable */}
<Button <div className="flex-1 p-6 min-h-0">
onClick={handleSave} <div className="relative">
disabled={!isValid || isValidating} <input
variant="primary" type="text"
> value={directory}
Save onChange={handleInputChange}
</Button> onKeyDown={handleKeyDown}
<Button onFocus={() => setShowSuggestions(suggestions.length > 0)}
onClick={handleClose} placeholder="/path/to/photos"
variant="secondary" className="w-full p-2 pr-10 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
> autoFocus
Cancel />
</Button> <div className="absolute right-3 top-1/2 transform -translate-y-1/2">
{isValidating && (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent" />
)}
{!isValidating && isValid === true && (
<IconCheck className="h-4 w-4 text-green-600" />
)}
{!isValidating && (isValid === false || (directory && isValid === null)) && (
<IconX className="h-4 w-4 text-red-600" />
)}
</div>
{/* Suggestions - Now with better scrolling */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-64 overflow-y-auto z-10">
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={(el) => { suggestionRefs.current[index] = el }}
onClick={() => handleSuggestionClick(suggestion)}
className={`px-3 py-2 cursor-pointer text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-600 last:border-b-0 ${
index === selectedSuggestionIndex
? 'bg-blue-100 dark:bg-blue-900'
: 'hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
>
<div className="truncate" title={suggestion}>
{suggestion}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Fixed Footer with Buttons */}
<div className="p-6 pt-4 border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-750 rounded-b-lg">
<div className="flex gap-2 justify-end">
<Button
onClick={handleClose}
variant="secondary"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!isValid || isValidating}
variant="primary"
>
Save
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -22,21 +22,36 @@ export default function PhotoGrid({
}: PhotoGridProps) { }: PhotoGridProps) {
const [photos, setPhotos] = useState<Photo[]>([]) const [photos, setPhotos] = useState<Photo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<SortOption>('created_at') const [sortBy, setSortBy] = useState<SortOption>('created_at')
const [sortOrder, setSortOrder] = useState<SortOrder>('DESC') const [sortOrder, setSortOrder] = useState<SortOrder>('DESC')
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null) const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null)
const [pagination, setPagination] = useState({
total: 0,
hasMore: false,
currentPage: 1,
limit: 20,
offset: 0
})
const fetchPhotos = async () => { const fetchPhotos = async (reset = true) => {
try { try {
setLoading(true) if (reset) {
setLoading(true)
setPhotos([])
} else {
setLoadingMore(true)
}
setError(null) setError(null)
const offset = reset ? 0 : pagination.offset
const params = new URLSearchParams({ const params = new URLSearchParams({
sortBy, sortBy,
sortOrder, sortOrder,
limit: '100', // Load first 100 photos limit: pagination.limit.toString(),
offset: offset.toString()
}) })
if (directoryPath) { if (directoryPath) {
@ -50,12 +65,42 @@ export default function PhotoGrid({
} }
const data = await response.json() const data = await response.json()
setPhotos(data.photos || [])
if (reset) {
setPhotos(data.photos || [])
} else {
// Prevent duplicates by filtering out photos we already have using functional update
setPhotos(prev => {
const incomingPhotos = data.photos || []
const newPhotos = incomingPhotos.filter(newPhoto =>
!prev.some(existingPhoto => existingPhoto.id === newPhoto.id)
)
console.log(`[PHOTO GRID] Received ${incomingPhotos.length} photos, ${newPhotos.length} are new, ${incomingPhotos.length - newPhotos.length} were duplicates`)
return [...prev, ...newPhotos]
})
}
setPagination({
total: data.pagination.total,
hasMore: data.pagination.hasMore,
currentPage: data.pagination.currentPage,
limit: data.pagination.limit,
offset: offset + (data.photos || []).length
})
} catch (error) { } catch (error) {
console.error('Error fetching photos:', error) console.error('Error fetching photos:', error)
setError('Failed to load photos') setError('Failed to load photos')
} finally { } finally {
setLoading(false) setLoading(false)
setLoadingMore(false)
}
}
const loadMorePhotos = () => {
if (!loadingMore && pagination.hasMore) {
console.log(`[PHOTO GRID] Loading more photos: offset=${pagination.offset}, current photos=${photos.length}`)
fetchPhotos(false)
} }
} }
@ -63,6 +108,31 @@ export default function PhotoGrid({
fetchPhotos() fetchPhotos()
}, [directoryPath, sortBy, sortOrder]) }, [directoryPath, sortBy, sortOrder])
// Infinite scroll effect
useEffect(() => {
let scrollTimeout: NodeJS.Timeout | null = null
const handleScroll = () => {
if (scrollTimeout) return // Prevent multiple calls
scrollTimeout = setTimeout(() => {
if (
window.innerHeight + document.documentElement.scrollTop + 1000 >=
document.documentElement.offsetHeight
) {
loadMorePhotos()
}
scrollTimeout = null
}, 100) // 100ms throttle
}
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
if (scrollTimeout) clearTimeout(scrollTimeout)
}
}, [loadingMore, pagination.hasMore])
// Filter photos based on search term // Filter photos based on search term
const filteredPhotos = photos.filter(photo => { const filteredPhotos = photos.filter(photo => {
if (!searchTerm) return true if (!searchTerm) return true
@ -101,6 +171,8 @@ export default function PhotoGrid({
setSortBy(newSortBy) setSortBy(newSortBy)
setSortOrder('DESC') // Default to descending for new field setSortOrder('DESC') // Default to descending for new field
} }
// Reset pagination when sorting changes
setPagination(prev => ({ ...prev, currentPage: 1, hasMore: false, offset: 0 }))
} }
// Grid classes based on thumbnail size - made thumbnails larger // Grid classes based on thumbnail size - made thumbnails larger
@ -159,7 +231,8 @@ export default function PhotoGrid({
Photos {directoryPath && `in ${directoryPath.split('/').pop()}`} Photos {directoryPath && `in ${directoryPath.split('/').pop()}`}
</h2> </h2>
<span className="text-sm text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400">
{filteredPhotos.length} photo{filteredPhotos.length !== 1 ? 's' : ''} {filteredPhotos.length} of {pagination.total} photo{pagination.total !== 1 ? 's' : ''}
{pagination.hasMore && <span className="ml-1"> Loading more...</span>}
</span> </span>
</div> </div>
@ -205,7 +278,7 @@ export default function PhotoGrid({
<div className={`grid ${gridClasses[thumbnailSize]}`}> <div className={`grid ${gridClasses[thumbnailSize]}`}>
{filteredPhotos.map((photo) => ( {filteredPhotos.map((photo) => (
<PhotoThumbnail <PhotoThumbnail
key={photo.id} key={`${photo.id}-${photo.filepath}`}
photo={photo} photo={photo}
size={thumbnailSize} size={thumbnailSize}
showMetadata={showMetadata} showMetadata={showMetadata}
@ -214,6 +287,33 @@ export default function PhotoGrid({
))} ))}
</div> </div>
{/* Load More Button/Indicator */}
{pagination.hasMore && !loading && (
<div className="flex flex-col items-center justify-center py-8">
{loadingMore ? (
<>
<div className="animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent mb-2"></div>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading more photos...</p>
</>
) : (
<button
onClick={loadMorePhotos}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Load More Photos
</button>
)}
</div>
)}
{/* End of Results */}
{!loading && !loadingMore && !pagination.hasMore && photos.length > 0 && (
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<p className="text-sm">You've reached the end of your photos</p>
<p className="text-xs mt-1">Showing all {pagination.total} photos</p>
</div>
)}
{/* Full Resolution Image Modal */} {/* Full Resolution Image Modal */}
<ImageModal <ImageModal
photo={selectedPhoto} photo={selectedPhoto}

View File

@ -69,6 +69,29 @@ export default function PhotoThumbnail({
} }
} }
const formatDateTime = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return 'Unknown'
}
}
// Get the best available date from EXIF data
const getBestDate = () => {
if (exif.date_time_original) return exif.date_time_original
if (exif.date_time_digitized) return exif.date_time_digitized
if (exif.date_time) return exif.date_time
if (photo.created_at) return photo.created_at
return null
}
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B' if (bytes === 0) return '0 B'
const k = 1024 const k = 1024
@ -156,11 +179,13 @@ export default function PhotoThumbnail({
</div> </div>
)} )}
{/* Date taken */} {/* Date taken with time */}
{exif.date_time_original && ( {getBestDate() && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<IconCalendar className="w-3 h-3" /> <IconCalendar className="w-3 h-3" />
<span>{formatDate(exif.date_time_original)}</span> <span title={`Original: ${exif.date_time_original || 'N/A'}\nDigitized: ${exif.date_time_digitized || 'N/A'}\nModified: ${exif.date_time || 'N/A'}`}>
{formatDateTime(getBestDate()!)}
</span>
</div> </div>
)} )}
</div> </div>
@ -173,6 +198,21 @@ export default function PhotoThumbnail({
<div>{formatFileSize(photo.filesize)}</div> <div>{formatFileSize(photo.filesize)}</div>
</div> </div>
</div> </div>
{/* Third row: Additional EXIF date info for large thumbnails */}
{size === 'large' && (exif.date_time_original || exif.date_time_digitized || exif.date_time) && (
<div className="text-gray-500 text-xs mt-1 space-y-0.5">
{exif.date_time_original && exif.date_time_original !== getBestDate() && (
<div>📷 Taken: {formatDateTime(exif.date_time_original)}</div>
)}
{exif.date_time_digitized && exif.date_time_digitized !== getBestDate() && (
<div>💾 Digitized: {formatDateTime(exif.date_time_digitized)}</div>
)}
{exif.date_time && exif.date_time !== getBestDate() && (
<div>📝 Modified: {formatDateTime(exif.date_time)}</div>
)}
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -134,6 +134,33 @@ function initializeTables(database: Database.Database) {
) )
`) `)
// Create photo_conflicts table for files with same path but different content
database.exec(`
CREATE TABLE IF NOT EXISTS photo_conflicts (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
filepath TEXT NOT NULL,
directory TEXT NOT NULL,
filesize INTEGER NOT NULL,
created_at DATETIME NOT NULL,
modified_at DATETIME NOT NULL,
width INTEGER,
height INTEGER,
format TEXT,
favorite BOOLEAN DEFAULT FALSE,
rating INTEGER CHECK (rating >= 0 AND rating <= 5),
description TEXT,
metadata TEXT, -- JSON string
thumbnail_blob BLOB, -- Cached thumbnail image data
thumbnail_size INTEGER, -- Size parameter used for cached thumbnail
thumbnail_generated_at DATETIME, -- When thumbnail was generated
indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
conflict_detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
original_photo_id TEXT, -- Reference to the original photo record
conflict_reason TEXT -- Why this was flagged as a conflict
)
`)
// Create indexes for better performance // Create indexes for better performance
database.exec(` database.exec(`
CREATE INDEX IF NOT EXISTS idx_photos_directory ON photos (directory); CREATE INDEX IF NOT EXISTS idx_photos_directory ON photos (directory);
@ -152,6 +179,9 @@ function initializeTables(database: Database.Database) {
CREATE INDEX IF NOT EXISTS idx_photo_hashes_hash_id ON photo_hashes (hash_id); CREATE INDEX IF NOT EXISTS idx_photo_hashes_hash_id ON photo_hashes (hash_id);
CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_size ON photos (thumbnail_size); CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_size ON photos (thumbnail_size);
CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_generated_at ON photos (thumbnail_generated_at); CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_generated_at ON photos (thumbnail_generated_at);
CREATE INDEX IF NOT EXISTS idx_photo_conflicts_filepath ON photo_conflicts (filepath);
CREATE INDEX IF NOT EXISTS idx_photo_conflicts_original_photo_id ON photo_conflicts (original_photo_id);
CREATE INDEX IF NOT EXISTS idx_photo_conflicts_detected_at ON photo_conflicts (conflict_detected_at);
`) `)
} }

View File

@ -131,16 +131,58 @@ async function processPhotoFile(
let photoData: any = null let photoData: any = null
try { try {
// Check if photo already exists stats = await stat(filePath)
const existingPhoto = photoService.getPhotoByPath(filePath)
if (existingPhoto) { // Compute SHA256 hash first for conflict detection
console.log(`[FILE SCANNER] Computing SHA256 hash for: ${filename}`)
const sha256Hash = await computeFileHash(filePath)
console.log(`[FILE SCANNER] Computed hash for ${filename}: ${sha256Hash}`)
// Check for conflicts with existing photos
const conflictCheck = photoService.checkForPhotoConflict(filePath, sha256Hash)
if (conflictCheck.isDuplicate) {
console.info(`[FILE SCANNER] Skipping duplicate file (same path, same content): ${filename}`)
result.photosSkipped++ result.photosSkipped++
return return
} }
stats = await stat(filePath) if (conflictCheck.hasConflict && conflictCheck.existingPhoto) {
console.warn(`[FILE SCANNER] CONFLICT DETECTED: File ${filename} has same path but different content than existing photo`)
// Create basic photo record // Create basic photo record for the conflicting file
photoData = {
filename,
filepath: filePath,
directory: basePath,
filesize: stats.size,
created_at: stats.birthtime.toISOString(),
modified_at: stats.mtime.toISOString(),
favorite: false,
metadata: JSON.stringify({
extension: extname(filename).toLowerCase(),
scanned_at: new Date().toISOString()
})
}
// Try to extract image metadata (width, height, format)
try {
const metadata = await extractImageMetadata(filePath)
Object.assign(photoData, metadata)
} catch (metadataError) {
console.warn(`[FILE SCANNER] Could not extract metadata for ${filePath}:`, metadataError)
}
// Store in conflicts table
const conflictReason = `File has same path as existing photo but different SHA256 hash. Original hash: ${conflictCheck.existingPhoto.id}, New hash: ${sha256Hash}`
const conflictId = photoService.createPhotoConflict(photoData, conflictCheck.existingPhoto.id, conflictReason)
console.warn(`[FILE SCANNER] Stored conflict record with ID: ${conflictId}`)
result.errors++
return
}
// Create basic photo record for new file
photoData = { photoData = {
filename, filename,
filepath: filePath, filepath: filePath,
@ -177,12 +219,8 @@ async function processPhotoFile(
result.photosAdded++ result.photosAdded++
console.log(`[FILE SCANNER] Successfully added photo: ${filename}`) console.log(`[FILE SCANNER] Successfully added photo: ${filename}`)
// Compute and store SHA256 hash // Store SHA256 hash
try { try {
console.log(`[FILE SCANNER] Computing SHA256 hash for: ${filename}`)
const sha256Hash = await computeFileHash(filePath)
console.log(`[FILE SCANNER] Computed hash for ${filename}: ${sha256Hash}`)
// Create or update hash record // Create or update hash record
const hashRecord = photoService.createOrUpdateImageHash(sha256Hash) const hashRecord = photoService.createOrUpdateImageHash(sha256Hash)
@ -191,8 +229,8 @@ async function processPhotoFile(
console.log(`[FILE SCANNER] Associated photo with hash: ${associated}`) console.log(`[FILE SCANNER] Associated photo with hash: ${associated}`)
} catch (hashError) { } catch (hashError) {
console.error(`[FILE SCANNER] Error computing hash for ${filePath}:`, hashError) console.error(`[FILE SCANNER] Error storing hash for ${filePath}:`, hashError)
// Continue processing even if hash computation fails // Continue processing even if hash storage fails
} }
// Log progress every 100 files // Log progress every 100 files

View File

@ -426,6 +426,85 @@ export class PhotoService {
return result.changes > 0 return result.changes > 0
} }
} }
// Conflict detection and handling methods
checkForPhotoConflict(filepath: string, sha256Hash: string): { hasConflict: boolean, existingPhoto?: Photo, isDuplicate: boolean } {
const existingPhoto = this.getPhotoByPath(filepath)
if (!existingPhoto) {
return { hasConflict: false, isDuplicate: false }
}
// Get the hash of the existing photo
const getPhotoHash = this.db.prepare(`
SELECT ih.sha256_hash
FROM photo_hashes ph
JOIN image_hashes ih ON ph.hash_id = ih.id
WHERE ph.photo_id = ?
`)
const existingHashRow = getPhotoHash.get(existingPhoto.id) as { sha256_hash: string } | null
if (!existingHashRow) {
// Existing photo has no hash, assume it's different content
return { hasConflict: true, existingPhoto, isDuplicate: false }
}
const existingHash = existingHashRow.sha256_hash
if (existingHash === sha256Hash) {
// Same file, same content - it's a duplicate
return { hasConflict: false, existingPhoto, isDuplicate: true }
} else {
// Same path, different content - it's a conflict
return { hasConflict: true, existingPhoto, isDuplicate: false }
}
}
createPhotoConflict(
photoData: Omit<Photo, 'id' | 'indexed_at' | 'thumbnail_blob' | 'thumbnail_size' | 'thumbnail_generated_at'>,
originalPhotoId: string,
conflictReason: string
): string {
const id = randomUUID()
const insertConflict = this.db.prepare(`
INSERT INTO photo_conflicts (
id, filename, filepath, directory, filesize, created_at, modified_at,
width, height, format, favorite, rating, description, metadata,
original_photo_id, conflict_reason
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
try {
const params = [
id, // string
photoData.filename, // string
photoData.filepath, // string
photoData.directory, // string
photoData.filesize, // number
photoData.created_at, // string
photoData.modified_at, // string
photoData.width || null, // number or null
photoData.height || null, // number or null
photoData.format || null, // string or null
photoData.favorite || false, // boolean
photoData.rating || null, // number or null
photoData.description || null, // string or null
photoData.metadata || null, // string (JSON) or null
originalPhotoId, // string
conflictReason // string
]
insertConflict.run(...params)
return id
} catch (error) {
console.error('Error creating photo conflict:', error)
throw new Error('Failed to create photo conflict record')
}
}
getPhotoConflicts(): Array<Photo & { original_photo_id: string, conflict_reason: string, conflict_detected_at: string }> {
const selectConflicts = this.db.prepare('SELECT * FROM photo_conflicts ORDER BY conflict_detected_at DESC')
return selectConflicts.all() as Array<Photo & { original_photo_id: string, conflict_reason: string, conflict_detected_at: string }>
}
} }
export const photoService = new PhotoService() export const photoService = new PhotoService()