PicChronoImporter is a macOS SwiftUI application for importing photos and videos from an SD card or a selected folder.
The app scans source files, resolves capture dates from metadata, organizes imported files into date-based destination folders, and lets the user review, filter, sort, select, and resolve conflicts before importing.
The primary expected camera workflow includes FUJIFILM X100VI RAW and compressed image files, but the app must not be specific to RAF files.
The intended user workflow is:
- Select a source volume or folder.
- Select a scan scope.
- Select a destination root folder.
- Select a folder naming template.
- Scan source files.
- Review detected files.
- Filter, sort, and select files to import.
- Resolve conflicts before execution.
- Import selected files.
- Review the result summary.
v1 supports one source at a time.
A source can be:
- a mounted volume
- a selected folder
Source selection persistence:
- The app supports selecting a mounted volume root, including default volume names such as
/Volumes/Untitled. - In the current non-sandboxed macOS app, if macOS rejects security-scoped bookmark creation for a selected volume root, the app may persist that selection using a standard bookmark.
- This is a v1 compatibility behavior for non-sandboxed builds. If App Sandbox support is introduced, volume-root source selection and bookmark restoration must be re-evaluated because standard bookmarks must not be treated as long-term sandbox access grants.
The user can choose a scan scope:
- DCIM only
- entire volume
- selected custom folder
The app may remember the previous scan scope for a known volume when a volume identifier is available.
The user selects a destination root folder.
The app creates subfolders under the destination root according to the resolved capture date and selected folder naming template.
If a capture date cannot be resolved, the file must be placed under:
UnknownDate
The selected folder template must not be applied to unknown dates.
Folder templates are limited to year, month, and day.
Allowed placeholders:
yyyyMMMdd
Allowed separators:
/-
/ means folder hierarchy.
yyyy-MM-ddyyyyMMdd
yyyy/MM-ddyyyy/yyyy-MM-ddyyyy/MMyyyy/M
yyyy/MM/ddyyyy/yyyy-MM/yyyy-MM-dd
Use this priority order:
- EXIF
DateTimeOriginal - Alternate metadata such as digitized date, image create date, or movie container creation date
- File timestamp
- Unknown date
The resolved date source should be represented in the domain model so the UI can show where the date came from.
Conceptual date sources:
exifDateTimeOriginalexifAlternatefileTimestampunknown
On macOS, video creation dates are loaded from the media container with
AVFoundation and represented as exifAlternate by the existing Core contract.
A readable video without container creation metadata falls back to its file
timestamp. A video metadata read failure remains an item-level review error,
and cancellation propagates.
Each file is treated as an independent import item.
Do not assume a strict RAW+JPEG pair model.
This is important because camera workflows may generate multiple compressed images from one RAW file, such as camera-internal RAW development with different film simulations.
RAW files and compressed images are both first-class import items.
Classify files into:
rawImagecompressedImagevideosidecarother
Use UTType where practical, then extension fallback.
File extension sets should be centralized and extensible.
Do not hardcode behavior to RAF only.
RAW extensions may include RAF, DNG, CR2, CR3, NEF, ARW, RW2, ORF, and others as the implementation grows.
Compressed-image extension fallback includes both HIF and HEIC. FUJIFILM
X100VI HEIF files may use HIF on the memory card and HEIC after transfer.
After scanning, the app displays a review list.
Each row should show:
- selection checkbox
- thumbnail or icon
- filename
- extension
- media kind
- capture date
- date source
- planned destination path
- file size
- status
- conflict action, where applicable
The Review table shows the thumbnail immediately to the right of the selection checkbox inside the Import cell. Its media-kind column shows text only so an icon fallback is not duplicated in the same row. The Result table keeps the thumbnail inside the Source cell. Neither table adds a dedicated thumbnail column.
- The thumbnail frame is fixed at
32 x 32 pt, uses a4 ptcorner radius, and center-crops generated images. - RAW images, compressed images, and videos use asynchronous macOS Quick Look thumbnail generation.
- Sidecars, other files, loading state, explicit generation errors, and generation timeouts use the media-kind icon in the same fixed frame. Loading does not add a spinner or progress indicator.
- The Review screen always requests the thumbnail from
ScanItem.file.absolutePathin the selected source folder. - The Result screen first reuses a successful in-memory thumbnail for the same
review session and item. On a cache miss,
copied,renamed, andoverwrittenoutcomes use the actual destination path recorded indestinationRelativePath; all other outcomes use the source path. - A renamed result must use the resulting destination path, not the originally planned path.
Thumbnail generation is presentation infrastructure and must not be added to Core use cases or result models.
- App-managed thumbnail caching is memory-only and scoped to one review session. Starting a new review session clears successful images, failures, and pending requests.
- At most four Quick Look generations may execute concurrently. Identical pending requests are coalesced.
- The successful-image cache is limited to
32 MiBand512entries. - A
30 secondinsurance timeout starts only after a request receives a generation slot and Quick Look generation actually starts. Time spent waiting for a slot is not included. Task cancellation or timeout must cancel the corresponding Quick Look request. - An explicit error or timeout is not retried automatically during the same session for the same file URL, file size, modification date, requested size, and scale. A changed URL or file identity is a new request.
- Cancellation caused by a row leaving the visible table is not failure-cached, so redisplaying the row may request the thumbnail again.
- An error does not trigger a second source/destination fallback request. It changes only the thumbnail to the localized unavailable icon and tooltip; it must not show a workflow banner or alert, change item status or import results, or block selection and import operations.
The Review table displays at most 100 filtered items per page. Search,
filters, sorting, status counts, and bulk selection continue to operate on the
complete review session rather than only the current page. Changing search,
filter, or sort criteria returns the table to the first page.
This presentation bound keeps table accessibility, row hosting, and thumbnail work responsive without changing per-file import semantics.
Use these conceptual statuses:
unclassifiednotImportedcheckingduplicateconflictneedsReviewconflictDuringImport
Meanings:
unclassified: initial state before classification or duplicate detectionnotImported: no duplicate or conflict was detectedchecking: background duplicate/hash check is in progressduplicate: content hash matches an indexed destination fileconflict: conflict candidate exists and requires user decisionneedsReview: metadata, permission, or file-system problem requires user attentionconflictDuringImport: destination state changed after review and before import execution
Required bulk actions:
- select all
- clear selection
- select not-imported only
- select conflicts only
select not-imported only must exclude:
- duplicates
- conflicts
- needsReview items
- conflictDuringImport items
Required filters:
- status
- media kind
- selection state
- filename search
Required sort keys:
- capture date
- status
- media kind
- size
- filename
Use two-stage detection.
Stage A is fast and approximate.
Use:
- app-maintained destination index
- same destination filename check where useful
- file size where useful
v1 must not scan and index the entire existing destination folder.
Stage A must not conclude that a file is a true duplicate by filename alone.
The v1 Stage A implementation queries the app-maintained destination cache for
an entry with the same persistent destination volume and file size. The query
uses a dedicated role + volume + size index and does not enumerate the
destination folder. If there is no same-size candidate, duplicate detection
skips source hashing but still performs cached planned-path and live
destination conflict checks.
Stage B confirms duplicates by content hash.
A file is duplicate only if its content hash matches an indexed destination file.
Use cached source hash where possible.
Use SHA-256 or equivalent strong hash.
Hash computation may run in the background.
Default conflict action is:
skip
Conflicts are resolved before import in the review screen.
Individual conflicted files may override the default action:
skiprenameoverwrite
Do not show repeated modal popups during import for each conflict.
During import, determine the destination path and prepare the source hash before the final destination re-check. The per-file order is:
- determine the destination path, including rename, and create its parent directory
- prepare the source hash and upsert the source cache entry when available
- perform the final destination existence/hash check
- copy or replace the file
- update the destination cache only when its persistent volume scope is available
If the destination changed since review:
- mark the item as
conflictDuringImport - skip that file
- continue importing other files
- report it in the result summary
If a destination appears after the final check but before a non-overwrite copy,
the importer must return a typed destination-exists error. Treat it as
conflictDuringImport, preserve the destination, and continue with later items.
v1 supports only one active import session at a time.
Do not implement multiple simultaneous import sessions in v1.
v1 presents one main workflow window. Do not expose commands for creating additional windows or using window tabs, because multiple views must not control the same import session.
The app may use a lock file or equivalent guard to avoid accidental double execution.
The UI should disable the start button while an import session is active.
Use a cache store behind the CacheStore protocol.
The intended production implementation is SQLite.
The cache stores:
- source file identity to content hash
- destination content hash index for files imported by this app
The cache must not store:
- image bodies
- thumbnails
- large binary previews
The presentation-layer thumbnail store described in section 9.1 is separate
from CacheStore and SQLite. The app must not create its own thumbnail disk
cache. OS-managed caching used internally by Quick Look is allowed.
The v1 cache maintenance policy is fixed, not user-configurable:
- remove entries whose
last_seen_atis strictly earlier thannow - 365 days - after the age sweep, keep the checkpointed SQLite main database at or below
128 MiB(128 * 1024 * 1024bytes) where physically possible
The size sweep uses least-recently-used order, with last_seen_at ASC, id ASC
as the deterministic eviction order. It checkpoints WAL first. If reclaiming
free pages can satisfy the limit, it vacuums before deleting rows; otherwise it
deletes a batch proportional to the measured excess, vacuums, remeasures, and
repeats. If an empty database still cannot meet the limit, the result must
explicitly report that the limit was not reached.
Settings reports databaseSizeBytes as the SQLite main file plus WAL size. The
128 MiB maintenance decision is made against the main file after checkpoint,
because WAL bytes have been incorporated before the size decision.
Live mode starts one asynchronous maintenance task during ViewModel startup. The first scan, Settings refresh, clear-all, or clear-volume operation waits for that task. Maintenance failure is logged but does not make the app unusable. Fixture and preview modes do not automatically run startup maintenance.
File-system root paths locate files but are not stable cache volume identities.
ScanRequest, ReviewSession, and ImportPlan carry separate source and
destination CacheVolumeScope values from scan through import.
For a persistent live scope, PlatformMac must resolve the symlink-normalized selected root to:
- the underlying volume UUID, normalized to lowercase
- a
rootRelativePathrelative to the underlying volume root, with no leading slash
Cache relative_path values are volume-root-relative:
source rootRelativePath + scanned file relative path
destination rootRelativePath + planned destination relative path
UI display, file operations, and DestinationConflictSnapshot continue to use
paths relative to the selected source or destination root as appropriate.
If the UUID, volume root, or safe relative path cannot be resolved, use
CacheVolumeScope.unavailable. Do not substitute a mount path as an identifier.
Processing continues, but persistent reads and writes for that volume are
bypassed. Live destination lookup and the import-time final check remain
mandatory even when destination cache scope is unavailable.
Fixture mode uses existing JSON volumeUuid values. If a fixture has no file or
cache entry for one side, that side uses a deterministic fixture-only identifier.
Fixture version 1 also accepts an additive optional volumes array containing
volumeUuid, optional displayName, optional capacityBytes, and
lastUsedAt. A fixture without this array derives deterministic fallback volume
summaries from its UUIDs and fixture now value.
Use id INTEGER PRIMARY KEY for SQLite row identity.
Use uniqueness equivalent to:
role + volume_uuid + relative_path + size_bytes + mtime
Roles:
sourcedest
Use indexes for:
- content hash lookup
- destination role, volume, and size candidate lookup
- last seen cleanup
The SQLite cache schema version is 2. Opening a version 1 database deletes
all cache_entries and then all volumes in one transaction before setting
user_version = 2, because v1 path-scoped rows are derived data and cannot be
safely reinterpreted as UUID-backed identities. Existing version 2 rows are
preserved, and versions newer than supported are rejected.
Support:
- clear all cache
- clear cache for a volume
- automatic cleanup by age
- automatic cleanup by size
- removal of entries for missing files where practical
- stats for settings UI
Clearing all cache or one volume removes only app-managed cache records. It must never delete source photos, videos, or files in the destination tree.
clearVolume removes both source and destination entries for the same UUID.
Per-volume summaries are ordered by last_used_at DESC, volume_uuid and expose
the display name, total capacity at metadata collection time, last-used date,
source count, destination count, total count, and UUID. Metadata updates affect
existing volume rows only; they do not create empty volume rows, and a nil new
value preserves the stored name or capacity.
The UI should identify volumes using:
- display name
- capacity
- last used date
- UUID shown only as detail information
Settings shows the fixed 365 days / 128 MiB policy and a destructive,
confirmed clear action for each volume. The confirmation includes the display
name and entry count and states that original and destination files are not
deleted. When a display name is unavailable, show the UUID; when capacity is
unavailable, show a localized unavailable value. Notices and all Settings copy
must follow an in-app language change.
Before using an app-cached destination content-hash or planned-path candidate,
validate only that candidate against the currently mounted volume. Resolve the
lowercase UUID, accept only safe volume-relative paths, and compare the current
file size and modification date with the exact cached identity. Treat not-found
as missing and a size or modification-date mismatch as changed; remove that
exact cache row in either case. A stale hash candidate triggers another cache
lookup, while a stale path candidate falls through to the normal live conflict
check. Permission, volume-resolution, or other I/O failures keep the row and
mark only the affected item needsReview; cancellation propagates. Do not scan
an entire volume or build a full destination index for this validation.
Use JSON fixtures for UI tests and fake dependencies.
Fixture files should live under:
MacApp/PicChronoImporter/Resources/Fixtures/
Suggested fixtures:
basic_20items.jsonhas_duplicates.jsonhas_conflicts.jsonunknown_dates.jsonlarge_1000items.json
Fixtures must be deterministic.
A fixture file fault may include an optional thumbnailError so thumbnail
success and explicit failure can be reproduced without real image files or an
SD card.
UI tests must not require:
- a real SD card
- a real user photo library
- manual file selection
- network access
Prefer standard macOS window, toolbar, form, table, group, and control hierarchy over decorative chrome.
Use Liquid Glass selectively for the unified toolbar, important actions, and transient or floating surfaces. Do not wrap every content section in Glass or rely on decorative background gradients.
Display PicChronoImporter only once in the unified toolbar. Do not duplicate the app name in both the native window title and a content header.
Keep the app-name label visually plain inside the unified toolbar; do not place it in its own decorative Glass container.
Screen headings may pair an SF Symbol with text for recognition, but the icon must not be isolated inside a decorative circular Glass frame.
Keep the unified toolbar and content visually continuous on every workflow screen. Suppress the automatic AppKit titlebar separator and render the toolbar over the same single subdued bar-material layer that fills the window background. Do not stack separate toolbar and content material layers, because even equivalent materials can leave a visible seam. Individual toolbar controls may retain their own Liquid Glass surfaces.
When a workflow table is inset from the window edges, give its outer boundary a subtle continuous corner radius. Apply the same treatment consistently to the Review and Result tables. Preserve the native table header, rows, scrolling, and system background instead of turning the table into a decorative card.
For related form groups, align label and value columns across group boundaries and keep trailing actions in a stable column. A Picker with an adjacent row label must hide its own visual label so the setting name is not repeated, and its selected value must remain readable without truncation at the minimum window size.
Automated tests should cover:
- folder template generation
- UnknownDate handling
- capture date resolution priority
- media classification
- filtering
- sorting
- bulk selection
- conflict policy
- duplicate state transitions
- cache reuse
- import result behavior
- fixture-driven UI flows
- Review and Result thumbnail source routing and in-memory cache reuse
- thumbnail concurrency, coalescing, timeout, cancellation, and failure-cache behavior using short injected durations rather than a real 30-second wait
- non-blocking thumbnail fallback behavior in fixture-driven UI flows
- opt-in representative real-media validation for RAF, JPEG, HIF or HEIC, and MOV without making private media a deterministic test dependency
The Phase 11D representative baseline passed on Xcode 26.6 and macOS 26.5.2 with X100VI RAF, JPEG, HIF, HEIC, and MOV files. The run covered live scanning, metadata dates and sources, destination planning, source/destination Quick Look thumbnails, duplicate detection, real copies, sizes, and SHA-256 equality.
Use Swift Testing for Core tests where practical.
Use XCUITest for UI tests.
Do not implement in v1:
- multiple simultaneous import sessions
- full destination library indexing
- strict RAW+JPEG pair UI
- menu bar resident behavior
- Windows UI
- real-time folder watching
- background automatic import