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:
parent
f6b651eeda
commit
5c3ad988f5
@ -15,11 +15,34 @@ export async function GET(request: NextRequest) {
|
||||
rating: searchParams.get('rating') ? parseInt(searchParams.get('rating')!) : undefined
|
||||
}
|
||||
|
||||
// Get photos with pagination
|
||||
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({
|
||||
photos,
|
||||
count: photos.length
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
count: photos.length,
|
||||
limit,
|
||||
offset,
|
||||
hasMore,
|
||||
currentPage,
|
||||
totalPages
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error)
|
||||
|
@ -180,15 +180,20 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
|
||||
}
|
||||
|
||||
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
|
||||
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()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4 border-b border-gray-200 dark:border-gray-600">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Select Directory to Scan
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Scrollable */}
|
||||
<div className="flex-1 p-6 min-h-0">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
@ -212,8 +217,9 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
|
||||
)}
|
||||
</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-48 overflow-y-auto z-10">
|
||||
<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}
|
||||
@ -225,14 +231,25 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="truncate" title={suggestion}>
|
||||
{suggestion}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
{/* 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}
|
||||
@ -240,12 +257,7 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
variant="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -22,21 +22,36 @@ export default function PhotoGrid({
|
||||
}: PhotoGridProps) {
|
||||
const [photos, setPhotos] = useState<Photo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sortBy, setSortBy] = useState<SortOption>('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('DESC')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
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 {
|
||||
if (reset) {
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
setError(null)
|
||||
|
||||
const offset = reset ? 0 : pagination.offset
|
||||
const params = new URLSearchParams({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: '100', // Load first 100 photos
|
||||
limit: pagination.limit.toString(),
|
||||
offset: offset.toString()
|
||||
})
|
||||
|
||||
if (directoryPath) {
|
||||
@ -50,12 +65,42 @@ export default function PhotoGrid({
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
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) {
|
||||
console.error('Error fetching photos:', error)
|
||||
setError('Failed to load photos')
|
||||
} finally {
|
||||
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()
|
||||
}, [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
|
||||
const filteredPhotos = photos.filter(photo => {
|
||||
if (!searchTerm) return true
|
||||
@ -101,6 +171,8 @@ export default function PhotoGrid({
|
||||
setSortBy(newSortBy)
|
||||
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
|
||||
@ -159,7 +231,8 @@ export default function PhotoGrid({
|
||||
Photos {directoryPath && `in ${directoryPath.split('/').pop()}`}
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -205,7 +278,7 @@ export default function PhotoGrid({
|
||||
<div className={`grid ${gridClasses[thumbnailSize]}`}>
|
||||
{filteredPhotos.map((photo) => (
|
||||
<PhotoThumbnail
|
||||
key={photo.id}
|
||||
key={`${photo.id}-${photo.filepath}`}
|
||||
photo={photo}
|
||||
size={thumbnailSize}
|
||||
showMetadata={showMetadata}
|
||||
@ -214,6 +287,33 @@ export default function PhotoGrid({
|
||||
))}
|
||||
</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 */}
|
||||
<ImageModal
|
||||
photo={selectedPhoto}
|
||||
|
@ -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) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
@ -156,11 +179,13 @@ export default function PhotoThumbnail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date taken */}
|
||||
{exif.date_time_original && (
|
||||
{/* Date taken with time */}
|
||||
{getBestDate() && (
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
@ -173,6 +198,21 @@ export default function PhotoThumbnail({
|
||||
<div>{formatFileSize(photo.filesize)}</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>
|
||||
)}
|
||||
|
@ -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
|
||||
database.exec(`
|
||||
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_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_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);
|
||||
`)
|
||||
}
|
||||
|
||||
|
@ -131,16 +131,58 @@ async function processPhotoFile(
|
||||
let photoData: any = null
|
||||
|
||||
try {
|
||||
// Check if photo already exists
|
||||
const existingPhoto = photoService.getPhotoByPath(filePath)
|
||||
if (existingPhoto) {
|
||||
stats = await stat(filePath)
|
||||
|
||||
// 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++
|
||||
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 = {
|
||||
filename,
|
||||
filepath: filePath,
|
||||
@ -177,12 +219,8 @@ async function processPhotoFile(
|
||||
result.photosAdded++
|
||||
console.log(`[FILE SCANNER] Successfully added photo: ${filename}`)
|
||||
|
||||
// Compute and store SHA256 hash
|
||||
// Store SHA256 hash
|
||||
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
|
||||
const hashRecord = photoService.createOrUpdateImageHash(sha256Hash)
|
||||
|
||||
@ -191,8 +229,8 @@ async function processPhotoFile(
|
||||
console.log(`[FILE SCANNER] Associated photo with hash: ${associated}`)
|
||||
|
||||
} catch (hashError) {
|
||||
console.error(`[FILE SCANNER] Error computing hash for ${filePath}:`, hashError)
|
||||
// Continue processing even if hash computation fails
|
||||
console.error(`[FILE SCANNER] Error storing hash for ${filePath}:`, hashError)
|
||||
// Continue processing even if hash storage fails
|
||||
}
|
||||
|
||||
// Log progress every 100 files
|
||||
|
@ -426,6 +426,85 @@ export class PhotoService {
|
||||
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()
|
Loading…
Reference in New Issue
Block a user