fix(import): skip files already tracked in library - #2480
trevorswanson wants to merge 18 commits into
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
| // its canonical library destination, so comparing path strings alone is not | ||
| // sufficient to keep a repeated scan from presenting the same file again. | ||
| func fileMatchesTracked(path string, tracked []os.FileInfo) bool { | ||
| info, err := os.Stat(path) |
|
Addressed the CodeQL The manual-import folder is intentionally user-selectable, so removing the query parameter would break the feature. The raw query value is now isolated in Validation: |
| } | ||
| path = resolved | ||
| info, err := os.Stat(path) //nolint:gosec // #nosec G304 -- symlink-resolved and confirmed inside a configured library root; RequireAdmin enforced at route level | ||
| info, err := os.Stat(resolved) //nolint:gosec // #nosec G304 -- resolved by ResolveContained after symlink-aware root containment; route requires admin |
|
Honestly it looks like the CodeQL issue was there before bindery/internal/api/manual_import.go Line 100 in b323202 So I don't know if I'm going to solve for that one here, CodeQL's doing its job, but I don't think my PR actually made it less secure if the original source was already suppressing the alert with nosec? |
417eded to
90e954c
Compare
|
Thanks for chasing this. Quick note on CodeQL: Two things before merge. Big folders come back empty. The filter runs after Scan cost scales with the library. Line 567 stats every One question. Already imported files are now unreachable, no toggle, no UI change. Corrupt file or a relink can't be surfaced. Prefer a "show already imported" switch to a hard filter? Rest looks good, tests green here too. |
e09819f to
307c2ea
Compare
Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
The manual-import scan looked up book_files once per confident catalogue match (N+1) to check whether a match's format was already imported. ListByBooks/ListFilesForBooks replace that with one query. BookFileRepo also gets an atomic version counter bumped on every mutation, so a derived cache (the scan's tracked-file index) can tell cheaply whether it needs to rebuild instead of re-querying on every request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fn38ejN3ZtW9HNAjcS4p8m Signed-off-by: Claude <noreply@anthropic.com>
enumerateImportUnits capped at 1000 raw units before the already-tracked filter ran, so a folder where the first 1000 units were all already imported came back empty with truncated=true even though untracked files existed further down the tree. The tracked/hardlink check now runs inside the walk via a skip predicate, so the cap counts only surfaced units; when the separate post-lookup "book already has this format" check still empties part of a page, Scan re-walks (bounded by maxScanRounds) to refill it instead of returning a short, misleadingly non-empty-looking truncated page. Also adds includeImported (default off) to disable both filters and label each unit's AlreadyImported status instead of dropping it, and skips the tracked-file hardlink stat/walk for tracked paths confirmed to be on a different device than the scan root (the common NFS/ multi-mount case) — both feeding the new tracked-file index cache that rebuilds only when book_files actually changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fn38ejN3ZtW9HNAjcS4p8m Signed-off-by: Claude <noreply@anthropic.com>
Filtering already-imported files out of the bulk scan made them unreachable, with no way to spot a corrupt file or one needing a relink. Both scan UIs (Settings > Import > Bulk folder import, and the /import page) get a "Show already imported" checkbox (default off, mirroring the wanted-list includeExcluded pattern) that re-scans with includeImported and labels surfaced units instead of pre-selecting them. The truncation banner also now renders whenever the scan reports truncated, not only when the item list happens to be non-empty. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fn38ejN3ZtW9HNAjcS4p8m Signed-off-by: Claude <noreply@anthropic.com>
confirmedCrossDevice duplicated the same device-comparison logic already sitting in hardlinkableReason's own copy of importer.sameDevice -- justified there as avoiding an api->importer dependency, but that dependency already exists throughout this package (manual_import.go, scan_walk.go, books.go, ...). Export sameDevice as SameDevice and call it directly instead of adding a fourth copy of the same comparison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fn38ejN3ZtW9HNAjcS4p8m Signed-off-by: Claude <noreply@anthropic.com>
FolderScanSection and ManualImportPage had near-duplicated scan state and logic (path/scanning/items/truncated/showImported plus the run/ toggle functions), which had already started drifting between the two copies. useFolderScan consolidates it, following the existing usePolling/useView hook convention. Consolidating surfaced a real bug shared by both copies: neither reset `truncated` when a new scan started, so toggling "show already imported" (or, on the settings page, editing the path) after a truncated scan could leave that banner showing over a later, untruncated result. Fixed once in the hook instead of twice. Also tightens two related correctness gaps the "show already imported" toggle introduced in ManualImportPage: "Select all matched" no longer sweeps up an already-imported unit the toggle deliberately left unselected, and the per-row Import button is now disabled to match what clicking it actually does, instead of silently no-op'ing on an unselected row. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fn38ejN3ZtW9HNAjcS4p8m Signed-off-by: Claude <noreply@anthropic.com>
Move it into the header row next to the "Manual Import" title, matching the Wanted page's "Show excluded" placement, instead of sitting on its own line below the scan input. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016BZJo7qdcVP6K8vRiF42C8 Signed-off-by: Claude <noreply@anthropic.com>
307c2ea to
67c24ab
Compare
|
Sorry for the messy commit history -- I merged in the wrong direction on my fork and had to rebase/clean up, I accidentally combined all my PRs into this one, and had a difficult time detangling it. The last 14 commits, all pushed at 9/10/2026 9:18pm EDT, should be the sum of the changes. I didn't want to lose the conversaiton history here but I'd be happy to cut a clean branch and make a new PR -- or if you're planning to squash, it might be a moot point. On to your feedback:
Left my refactor in since you said its' good
This should be addressed now, filtering before the cap and refilling after, to ensure we don't truncate prematurely.
Added caching so that we don't have to stat every file every time. I believe this should improve performance. Unfortunately since we're using that path variable again, CodeQL isn't happy.
Added a "show already imported" checkbox, matching the formatting/placement of the "Show excluded" box on the wanted page. |
vavallee
left a comment
There was a problem hiding this comment.
Thanks for the rework, the starvation fix and the toggle both look right and the tests for them are good. Three things before this can go in.
-
The tracked file cache goes stale. It is keyed on BookFileRepo.version, but book_files rows also disappear through the FK cascade when a book or author is deleted, and through UntrackFilePath in calibre rollback, and none of those bump the counter. I reproduced it: seed a tracked epub, scan (hidden), delete the book keeping the file, scan again, still hidden. Either bump the version in those paths too or key the cache on something read from the table itself.
-
The cold rebuild is heavier than the version I measured last time. Same setup, 3000 tracked rows and 300 new files: main does 8 stats per scan, this branch does about 15,600 cold and 609 warm. Cold is roughly 5 stats per tracked row because confirmedCrossDevice restats the root and both paths for every row, and the cache is cold after every import, so scan then import then scan hits it every time. Stat the scan root once per rebuild and compare device ids directly. The warm number is also doubled because isAlreadyTracked runs in roundSkip and again in the results loop for the same unit, and the second call only matters when includeImported is on.
-
Four new i18n keys only exist as inline defaults. Please add manualImport.alreadyImported, manualImport.showImported, settings.import.bulkAlreadyImported and settings.import.bulkShowImported to en.json.
Also six of the commits are authored and signed off as Claude rather than you. I squash on merge so it will not matter for the final commit, just flagging that the DCO trailer on those is not really a certification.
CodeQL alerts on samedevice_unix.go are the same moved taint source thing as before, ignore them.
|
Hi @trevorswanson, checking in on this one. The rework fixed the big things and I'd like to get it merged, there are just the three items from my last review left:
Don't worry about the commit history, I squash on merge. The branch is a bit behind main but still merges cleanly, so no rebase needed either. If you're short on time, say the word and I'm happy to push those three fixes to your branch myself so your work lands with you as the author. Otherwise I'll leave it with you. |
|
Hey sorry, life got in the way. I'll try to get to your feedback across my PRs today |
…kip-tracked Resolves the manual-import wizard's move from web/src/pages/ManualImportPage.tsx to web/src/pages/import/FolderImportView.tsx (upstream vavallee#2671's Import-page refactor split it into FolderImportView/FolderImportRow/folderImport.ts and added the "In your library" adoption view alongside it): the skip-tracked scan and the "Show already imported" toggle now live in the new files, and the equivalent Settings > Import bulk-scan UI is gone there too, replaced by upstream's link out to the Import page. internal/api/manual_import.go's Scan handler keeps our resolveImportFolder extraction, now using upstream's outsideRootsMessage for the 403 body. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3vXCo9PwmffPWUik6Gwvh Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
…ut redundant stats Two issues from review on vavallee#2480: The cache was keyed on BookFileRepo's own atomic mutation counter, which only advances when BookFileRepo's own methods run. book_files rows also disappear through the books(id) ON DELETE CASCADE FK when a book or author is deleted (BookRepo.Delete), and through BookRepo.UntrackFilePath's calibre-rollback DELETE — neither touches BookFileRepo, so neither bumped the counter. Reproduced: seed a tracked epub, scan (hidden), delete the book keeping the file, scan again — still hidden. BookFileRepo.Fingerprint replaces the counter with a (count, maxID) snapshot read straight off the table, which catches every mutation regardless of which code path made it. The cold rebuild also cost far more than it should: confirmedCrossDevice restatted the scan root AND re-stat'd every tracked path (once directly, once inside importer.SameDevice) before the loop's own os.Stat ran a third time on the same path — up to 5 stats per tracked row, versus main's 8 stats for a whole scan. trackedFileIndex now resolves the root's device once per call and reads each tracked path's device off the FileInfo the loop already has, down to 1 stat per row. Separately, isAlreadyTracked ran once in roundSkip and again in the results loop for the same unit; roundSkip already filters every already-tracked unit out of cands when includeImported is off, so the second call only ever matters when includeImported is on, and can be skipped otherwise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3vXCo9PwmffPWUik6Gwvh Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
Both strings existed only as inline t() defaults in FolderImportView and FolderImportRow, flagged in review on vavallee#2480. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3vXCo9PwmffPWUik6Gwvh Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
…ture Signed-off-by: Trevor Swanson <83826109+trevorswanson@users.noreply.github.com>
| // re-stat'd scanRoot on every tracked row in the library; see trackedFileIndex | ||
| // for the review that caught it). | ||
| func deviceID(path string) (uint64, bool) { | ||
| fi, err := os.Stat(path) |
|
Looks like a lot has changed since I started this PR so I rebased to resolve all the merge conflicts and then went after your feedback. Rebase:
PR Feedback:
Re-ran your exact benchmark shape (3000 tracked rows, 300 new files) with two worktrees at the commit before this fix and at HEAD, counting actual
This doesn't reach main's flat 8-stats-regardless-of-library-size, because main has no already-tracked concept at all — its cost doesn't depend on tracked-row count, ours still does (once per cache rebuild, each tracked row needs one stat to confirm it's still on disk). What this fix removes is the extra ~5× multiplier on top of that, not the O(n) itself.
Commit sign-off/DCO — I added my DCO to each commit with co-authored by Claude but it's causing the CI to error just like in #2486 -- let me know what you want me to do with it. Verified: full build (linux/darwin/windows), |
Summary
book_filesduring manual folder-import scansMotivation
Re-scanning a library folder currently presents already-imported files again. Files whose names no longer match catalogue metadata can appear as unmatched, forcing manual re-selection. Folder import should default to new/unmatched files.
Testing
go test ./internal/db ./internal/importer ./internal/apivia Go 1.26.6 containerTestManualImportScan_SkipsAlreadyTrackedFilesRelated: #1292