tagliatelle

Simple self-hosted tagging filesystem webapp
Log | Files | Refs

commit 4980f16e0dbf7c28c395f1a785f693bdf09d0f1b
parent 8116c3476308e643eb98357611f689236e7c0b56
Author: Peter <peter@minskio.co.uk>
Date:   Sat, 24 Oct 2026 11:44:54 +0100

Add preliminary czkawka duplicate finding

Diffstat:
Ainternal/app/admin_duplicates.go | 586+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/app/routes.go | 6++++++
Mmain.go | 2++
Mreadme.md | 6+++---
Astatic/admin-duplicates.js | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mstatic/style.css | 27+++++++++++++++++++++++++++
Mtemplates/admin.html | 38++++++++++++++++++++++++++++++++++++++
7 files changed, 886 insertions(+), 3 deletions(-)

diff --git a/internal/app/admin_duplicates.go b/internal/app/admin_duplicates.go @@ -0,0 +1,586 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" +) + +// Binary name +var CzkawkaBin = "czkawka_cli" +// dupEntryRe matches lines like: "/path/to/img.jpg" - 1280x1280 - 124.57 KiB - Original +var dupEntryRe = regexp.MustCompile(`^"(.*)" - (\d+x\d+) - (.+?) - ([^-]+)$`) + +type DupItem struct { + Path string `json:"path"` + Dims string `json:"dims"` + Size string `json:"size"` + Similarity string `json:"similarity"` +} + +type dupState struct { + mu sync.Mutex + dirs []string + groups [][]DupItem + allowed map[string]bool +} +var dups = &dupState{allowed: map[string]bool{}} + +// Only allow one czkawka scan at a time. +var scanMu sync.Mutex + +// read report text output and parse into groups +func parseDuplicateResults(file string) ([][]DupItem, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var groups [][]DupItem + var cur []DupItem + flush := func() { + if len(cur) >= 2 { + groups = append(groups, cur) + } + cur = nil + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimRight(line, "\r") + if m := dupEntryRe.FindStringSubmatch(line); m != nil { + cur = append(cur, DupItem{ + Path: m[1], + Dims: m[2], + Size: m[3], + Similarity: strings.TrimSpace(m[4]), + }) + continue + } + // Anything else (blank line, "Found N images...", headers) ends a group. + flush() + } + flush() + return groups, nil +} + +func (s *dupState) set(dirs []string, groups [][]DupItem) { + s.mu.Lock() + defer s.mu.Unlock() + s.dirs = dirs + s.groups = groups + s.allowed = map[string]bool{} + for _, g := range groups { + for _, it := range g { + s.allowed[it.Path] = true + } + } +} + +// liveGroups drops groups missing files and with less than 2 files +func (s *dupState) liveGroups() [][]DupItem { + out := make([][]DupItem, 0, len(s.groups)) + for _, g := range s.groups { + var live []DupItem + for _, it := range g { + if _, err := os.Stat(it.Path); err == nil { + live = append(live, it) + } + } + if len(live) >= 2 { + out = append(out, live) + } + } + return out +} + +// sameGroup reports whether a and b appear together in one results group. +func (s *dupState) sameGroup(a, b string) bool { + for _, g := range s.groups { + var hasA, hasB bool + for _, it := range g { + hasA = hasA || it.Path == a + hasB = hasB || it.Path == b + } + if hasA && hasB { + return true + } + } + return false +} + +// remainingInGroup counts the existing files in the group containing path. +func (s *dupState) remainingInGroup(path string) int { + for _, g := range s.groups { + found := false + for _, it := range g { + if it.Path == path { + found = true + break + } + } + if !found { + continue + } + n := 0 + for _, it := range g { + if _, err := os.Stat(it.Path); err == nil { + n++ + } + } + return n + } + return 0 +} + +// dupResponse is what the scan and groups endpoints return. +type dupResponse struct { + Dirs []string `json:"dirs"` + Groups [][]DupItem `json:"groups"` +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("Warning: writeJSON: %v", err) + } +} + +// check JSON POST for CORS so other sites can't request actions +func decodeJSONPost(w http.ResponseWriter, r *http.Request, v any) bool { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return false + } + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + http.Error(w, "expected application/json", http.StatusUnsupportedMediaType) + return false + } + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return false + } + return true +} + +// cleans submitted directories, checks they exist and drops blanks and duplicates +func resolveScanDirs(inputs []string) ([]string, error) { + var dirs []string + seen := map[string]bool{} + for _, in := range inputs { + in = strings.TrimSpace(in) + if in == "" { + continue + } + abs, err := filepath.Abs(in) + if err != nil { + return nil, fmt.Errorf("invalid path %q: %w", in, err) + } + info, err := os.Stat(abs) + if err != nil { + return nil, fmt.Errorf("cannot read %s: %w", abs, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", abs) + } + if !seen[abs] { + seen[abs] = true + dirs = append(dirs, abs) + } + } + if len(dirs) == 0 { + return nil, errors.New("enter at least one directory to scan") + } + return dirs, nil +} + +// pathWithin reports whether p is dir itself or located beneath it. +func pathWithin(p, dir string) bool { + rel, err := filepath.Rel(dir, p) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +// run binary and write report +func runCzkawka(ctx context.Context, resultsFile string, dirs []string) error { + args := []string{"image"} + for _, d := range dirs { + args = append(args, "-d", d) + } + // Video thumbnails live in the uploads directory; they are derived files, not duplicates. + if uploadAbs, err := filepath.Abs(Cfg.UploadDir); err == nil { + thumbDir := filepath.Join(uploadAbs, "thumbnails") + for _, d := range dirs { + if pathWithin(thumbDir, d) { + if info, err := os.Stat(thumbDir); err == nil && info.IsDir() { + args = append(args, "-e", thumbDir) + } + break + } + } + } + args = append(args, "-f", resultsFile) + + log.Printf("Info: runCzkawka: %s %s", CzkawkaBin, strings.Join(args, " ")) + var out bytes.Buffer + cmd := exec.CommandContext(ctx, CzkawkaBin, args...) + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + + // czkawka_cli exits with 11 when it found results; that's not a failure. + var ee *exec.ExitError + if errors.As(err, &ee) && ee.ExitCode() == 11 { + return nil + } + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("%s was not found; install czkawka_cli or pass its location with the -czkawka flag", CzkawkaBin) + } + tail := strings.TrimSpace(out.String()) + if len(tail) > 600 { + tail = "..." + tail[len(tail)-600:] + } + if tail != "" { + return fmt.Errorf("czkawka failed: %w: %s", err, tail) + } + return fmt.Errorf("czkawka failed: %w", err) + } + return nil +} + +func duplicatesScanHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Dirs []string `json:"dirs"` + } + if !decodeJSONPost(w, r, &req) { + return + } + + dirs, err := resolveScanDirs(req.Dirs) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if !scanMu.TryLock() { + http.Error(w, "a scan is already running", http.StatusConflict) + return + } + defer scanMu.Unlock() + + tmpDir, err := os.MkdirTemp("", "tagliatelle-duplicates-") + if err != nil { + log.Printf("Error: duplicatesScanHandler: failed to create temp dir: %v", err) + http.Error(w, "failed to create temporary directory", http.StatusInternalServerError) + return + } + defer os.RemoveAll(tmpDir) + resultsFile := filepath.Join(tmpDir, "duplicates.log") + + if err := runCzkawka(r.Context(), resultsFile, dirs); err != nil { + log.Printf("Error: duplicatesScanHandler: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + groups, err := parseDuplicateResults(resultsFile) + if errors.Is(err, os.ErrNotExist) { + groups, err = nil, nil // czkawka writes no file when nothing was found + } + if err != nil { + log.Printf("Error: duplicatesScanHandler: failed to read results: %v", err) + http.Error(w, "failed to read czkawka results", http.StatusInternalServerError) + return + } + log.Printf("Info: duplicatesScanHandler: found %d groups in %s", len(groups), strings.Join(dirs, ", ")) + + dups.set(dirs, groups) + dups.mu.Lock() + defer dups.mu.Unlock() + writeJSON(w, dupResponse{Dirs: dirs, Groups: dups.liveGroups()}) +} + +func duplicatesGroupsHandler(w http.ResponseWriter, r *http.Request) { + dups.mu.Lock() + defer dups.mu.Unlock() + dirs := dups.dirs + if dirs == nil { + dirs = []string{} + } + writeJSON(w, dupResponse{Dirs: dirs, Groups: dups.liveGroups()}) +} + +func duplicatesImageHandler(w http.ResponseWriter, r *http.Request) { + p := r.URL.Query().Get("path") + dups.mu.Lock() + ok := dups.allowed[p] + dups.mu.Unlock() + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Cache-Control", "private, max-age=300") + http.ServeFile(w, r, p) +} + +func duplicatesDeleteHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if !decodeJSONPost(w, r, &req) { + return + } + + dups.mu.Lock() + defer dups.mu.Unlock() + if !dups.allowed[req.Path] { + http.Error(w, "path not in results", http.StatusForbidden) + return + } + if dups.remainingInGroup(req.Path) < 2 { + http.Error(w, "refusing to delete the last remaining file in a group", http.StatusConflict) + return + } + if err := os.Remove(req.Path); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("Error: duplicatesDeleteHandler: failed to delete %s: %v", req.Path, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + forgetUploadRecord(req.Path) + log.Printf("Info: duplicatesDeleteHandler: deleted %s", req.Path) + w.WriteHeader(http.StatusNoContent) +} + +// duplicatesDeleteGroupHandler deletes every listed file of one results group, +func duplicatesDeleteGroupHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Paths []string `json:"paths"` + } + if !decodeJSONPost(w, r, &req) { + return + } + if len(req.Paths) == 0 { + http.Error(w, "no paths given", http.StatusBadRequest) + return + } + + dups.mu.Lock() + defer dups.mu.Unlock() + for _, p := range req.Paths { + if !dups.allowed[p] || !dups.sameGroup(req.Paths[0], p) { + http.Error(w, "paths are not in one group of the results", http.StatusForbidden) + return + } + } + var failed []string + for _, p := range req.Paths { + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("Error: duplicatesDeleteGroupHandler: failed to delete %s: %v", p, err) + failed = append(failed, p) + continue + } + forgetUploadRecord(p) + log.Printf("Info: duplicatesDeleteGroupHandler: deleted %s", p) + } + if len(failed) > 0 { + http.Error(w, "could not delete: "+strings.Join(failed, ", "), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func duplicatesReplaceHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Src string `json:"src"` // the better file; it is moved + Dst string `json:"dst"` // the file being replaced + } + if !decodeJSONPost(w, r, &req) { + return + } + + dups.mu.Lock() + defer dups.mu.Unlock() + if req.Src == req.Dst { + http.Error(w, "src and dst are the same file", http.StatusBadRequest) + return + } + if !dups.allowed[req.Src] || !dups.allowed[req.Dst] || !dups.sameGroup(req.Src, req.Dst) { + http.Error(w, "paths are not a matching pair in the results", http.StatusForbidden) + return + } + + dest, err := replaceFile(req.Src, req.Dst) + if err != nil { + log.Printf("Error: duplicatesReplaceHandler: %s -> %s: %v", req.Src, req.Dst, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + syncUploadRecordsAfterReplace(req.Src, req.Dst, dest) + log.Printf("Info: duplicatesReplaceHandler: replaced %s with %s (now at %s)", req.Dst, req.Src, dest) + writeJSON(w, map[string]string{"path": dest}) +} + +// moves src into dst's location +// On extension mismatch, keep src's extension and remove dst's +func replaceFile(src, dst string) (string, error) { + dstInfo, err := os.Stat(dst) + if err != nil { + return "", err + } + if _, err := os.Stat(src); err != nil { + return "", err + } + + dest := dst + if sx, dx := filepath.Ext(src), filepath.Ext(dst); !strings.EqualFold(sx, dx) { + dest = strings.TrimSuffix(dst, dx) + sx + if _, err := os.Lstat(dest); err == nil { + return "", fmt.Errorf("%s already exists", dest) + } + } + + if err := moveFile(src, dest); err != nil { + return "", err + } + // try to keep existing permissions + _ = os.Chmod(dest, dstInfo.Mode().Perm()) + + if dest != dst { + if err := os.Remove(dst); err != nil { + return dest, fmt.Errorf("moved to %s, but could not remove old file: %w", dest, err) + } + } + return dest, nil +} + +// rename src to dest. If unable, copy to temp and rename in place then remove src +func moveFile(src, dest string) error { + if err := os.Rename(src, dest); err == nil { + return nil + } + + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + tmp, err := os.CreateTemp(filepath.Dir(dest), ".replace-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) // no-op once renamed + + if _, err := io.Copy(tmp, in); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmp.Name(), dest); err != nil { + return err + } + if err := os.Remove(src); err != nil { + return fmt.Errorf("copied to %s, but could not remove source: %w", dest, err) + } + return nil +} + +// try to keep the tagliatelle database in sync +func uploadRecord(absPath string) (id int64, filename, relPath string, ok bool) { + uploadAbs, err := filepath.Abs(Cfg.UploadDir) + if err != nil || !pathWithin(absPath, uploadAbs) { + return 0, "", "", false + } + rel, err := filepath.Rel(uploadAbs, absPath) + if err != nil { + return 0, "", "", false + } + if err := DB.QueryRow("SELECT id, filename FROM files WHERE path = ?", rel).Scan(&id, &filename); err != nil { + return 0, "", "", false + } + return id, filename, rel, true +} + +func removeThumbnail(filename string) { + thumb := filepath.Join(Cfg.UploadDir, "thumbnails", filename+".jpg") + if err := os.Remove(thumb); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("Warning: removeThumbnail: %s: %v", thumb, err) + } +} + +// remove DB records for removed files in upload dir +func forgetUploadRecord(absPath string) { + id, filename, _, ok := uploadRecord(absPath) + if !ok { + return + } + tx, err := DB.Begin() + if err != nil { + log.Printf("Warning: forgetUploadRecord: failed to start transaction for %s: %v", absPath, err) + return + } + defer tx.Rollback() + for _, q := range []string{ + "DELETE FROM file_tags WHERE file_id = ?", + "DELETE FROM file_properties WHERE file_id = ?", + "DELETE FROM files WHERE id = ?", + } { + if _, err := tx.Exec(q, id); err != nil { + log.Printf("Warning: forgetUploadRecord: %s (id=%d): %v", q, id, err) + return + } + } + if err := tx.Commit(); err != nil { + log.Printf("Warning: forgetUploadRecord: commit failed for id=%d: %v", id, err) + return + } + removeThumbnail(filename) +} + +func syncUploadRecordsAfterReplace(src, dst, dest string) { + forgetUploadRecord(src) + + id, filename, _, ok := uploadRecord(dst) + if !ok { + return + } + + if dest != dst { + uploadAbs, err := filepath.Abs(Cfg.UploadDir) + if err == nil { + if newRel, err := filepath.Rel(uploadAbs, dest); err == nil { + newName := filepath.Base(dest) + removeThumbnail(filename) + if _, err := DB.Exec("UPDATE files SET filename = ?, path = ? WHERE id = ?", newName, newRel, id); err != nil { + log.Printf("Warning: syncUploadRecordsAfterReplace: failed to update record id=%d: %v", id, err) + } + } + } + } else { + removeThumbnail(filename) // its content changed, so the old thumbnail is stale + } + + if _, err := DB.Exec("DELETE FROM file_properties WHERE file_id = ?", id); err != nil { + log.Printf("Warning: syncUploadRecordsAfterReplace: failed to clear properties for id=%d: %v", id, err) + } + computeProperties(id, dest) +} diff --git a/internal/app/routes.go b/internal/app/routes.go @@ -12,6 +12,12 @@ func RegisterRoutes() { http.HandleFunc("/add-yt", ytdlpHandler) http.HandleFunc("/add-local", localFileHandler) http.HandleFunc("/admin", adminHandler) + http.HandleFunc("/admin/duplicates/delete", duplicatesDeleteHandler) + http.HandleFunc("/admin/duplicates/delete-group", duplicatesDeleteGroupHandler) + http.HandleFunc("/admin/duplicates/groups", duplicatesGroupsHandler) + http.HandleFunc("/admin/duplicates/image", duplicatesImageHandler) + http.HandleFunc("/admin/duplicates/replace", duplicatesReplaceHandler) + http.HandleFunc("/admin/duplicates/scan", duplicatesScanHandler) http.HandleFunc("/bulk-tag", bulkTagHandler) http.HandleFunc("/cbz/", cbzViewerHandler) http.HandleFunc("/file/", fileRouter) diff --git a/main.go b/main.go @@ -15,7 +15,9 @@ func main() { // CLI flags dataDir := flag.String("d", ".", "Data directory (stores tagliatelle.db and uploads/ subfolder)") port := flag.String("p", "8080", "Port to listen on") + czkawka := flag.String("czkawka", "czkawka_cli", "Path to the czkawka_cli binary (used by the admin Duplicates tab)") flag.Parse() + app.CzkawkaBin = *czkawka // Derive paths from -d dbPath := filepath.Join(*dataDir, "tagliatelle.db") diff --git a/readme.md b/readme.md @@ -17,6 +17,7 @@ go run . -d your_directory -p 8080 Then access the server via a web browser, the default port is 8080. ### Dependencies +* `czkawka` for similar duplicate reporting * `ffprobe` and `ffmpeg` for converting incompatible video uploads * `sed` for custom bulk note manipulation * `yt-dlp` for video downloader @@ -38,6 +39,7 @@ Then access the server via a web browser, the default port is 8080. * In browser file management (delete, rename) * Self-organising, categorised notes, with optional `sed` operation rules * Orphan and reverse orphan finding +* Duplicate reporting and management via [czkawka](https://github.com/qarmin/czkawka). * Database backup and vacuum support * `tag=!`, `tag=!123` and `tag=x,value=!` for duplicating previously applied tags * Chainable `/and/tag/tag2/value2` and `/and/tag/tag2/previews` filter matching @@ -49,8 +51,7 @@ Then access the server via a web browser, the default port is 8080. ## Future plans * Detect `mpegts` videos and repair them. -* Implement [jdupes](https://codeberg.org/jbruchon/jdupes) and [czkawka](https://github.com/qarmin/czkawka) duplicate reporting. ## Credits * [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) for the go SQLite3 library -* [Fluent UI System Icons](https://icones.js.org/collection/fluent) for SVG icons used -\ No newline at end of file +* [Fluent UI System Icons](https://icones.js.org/collection/fluent) for SVG icons used diff --git a/static/admin-duplicates.js b/static/admin-duplicates.js @@ -0,0 +1,224 @@ +(function () { + const $ = (s) => document.querySelector(s); + + function updateCount() { + const all = document.querySelectorAll('#dup-groups .dup-group'); + const total = all.length; + const shown = [...all].filter((g) => !g.hidden).length; + const label = total === 1 ? ' group' : ' groups'; + $('#dup-count').textContent = shown === total ? total + label : shown + ' of ' + total + label; + const empty = $('#dup-empty'); + empty.textContent = total === 0 ? 'No similar images found.' : 'No groups match the filter.'; + empty.hidden = shown !== 0; + } + + // filter results: space separated, case-insensitively against paths with -negation support + function applyFilter() { + const terms = $('#dup-filter').value.toLowerCase().split(/\s+/).filter(Boolean); + const include = terms.filter((t) => t[0] !== '-').map((t) => t); + const exclude = terms.filter((t) => t[0] === '-' && t.length > 1).map((t) => t.slice(1)); + document.querySelectorAll('#dup-groups .dup-group').forEach((g) => { + const paths = [...g.querySelectorAll('.dup-card')].map((c) => c._item.path.toLowerCase()); + const ok = include.every((t) => paths.some((p) => p.includes(t))) && + !exclude.some((t) => paths.some((p) => p.includes(t))); + g.hidden = !ok; + }); + updateCount(); + } + + // highlight matching part of path in groups + function highlightGroup(groupEl) { + const cards = [...groupEl.querySelectorAll('.dup-card')]; + const paths = cards.map((c) => c._item.path); + let pre = 0, suf = 0; + if (paths.length >= 2) { + const min = Math.min(...paths.map((p) => p.length)); + while (pre < min && paths.every((p) => p[pre] === paths[0][pre])) pre++; + while (pre + suf < min && paths.every((p) => p[p.length - 1 - suf] === paths[0][paths[0].length - 1 - suf])) suf++; + } + cards.forEach((c) => { + const p = c._item.path; + const el = c.querySelector('.dup-path'); + el.textContent = ''; + const parts = [[p.slice(0, pre), true], [p.slice(pre, p.length - suf), false], [p.slice(p.length - suf), true]]; + for (const [text, match] of parts) { + if (!text) continue; + const span = document.createElement('span'); + if (match) span.className = 'dup-match'; + span.textContent = text; + el.append(span); + } + }); + } + + let msgTimer; + function showMsg(msg, isErr) { + const el = $('#dup-msg'); + el.textContent = msg; + el.className = isErr ? 'dup-msg--err' : 'dup-msg--ok'; + clearTimeout(msgTimer); + msgTimer = setTimeout(() => { el.textContent = ''; }, 8000); + } + const showErr = (msg) => showMsg(msg, true); + + async function post(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error((await res.text()).trim() || res.status); + return res; + } + + // Tracks images in a group to only show replace for pairs + function syncGroup(groupEl) { + const n = groupEl.querySelectorAll('.dup-card').length; + if (n < 2) { groupEl.remove(); } else { groupEl.dataset.count = n; highlightGroup(groupEl); } + updateCount(); + } + + async function del(item, card, groupEl) { + if (!confirm('Permanently delete this file?\n\n' + item.path)) return; + try { + await post('/admin/duplicates/delete', { path: item.path }); + card.remove(); + syncGroup(groupEl); + } catch (e) { + showErr('Delete failed: ' + e.message); + } + } + + async function delAll(groupEl) { + const items = [...groupEl.querySelectorAll('.dup-card')].map((c) => c._item); + if (!confirm('Permanently delete ALL ' + items.length + ' files in this group?\n\n' + + items.map((i) => i.path).join('\n') + '\n\nNo copy will be kept. This cannot be undone.')) return; + try { + await post('/admin/duplicates/delete-group', { paths: items.map((i) => i.path) }); + showMsg('Deleted ' + items.length + ' files'); + groupEl.remove(); + updateCount(); + } catch (e) { + showErr('Delete all failed: ' + e.message); + } + } + + // replace files prompt + async function replaceOther(item, card, groupEl) { + const other = [...groupEl.querySelectorAll('.dup-card')].find((c) => c !== card); + if (!other) return; + const dst = other._item; + if (!confirm( + 'Replace this file:\n\n' + dst.path + ' (' + dst.dims + ', ' + dst.size + ')\n\n' + + 'with this one:\n\n' + item.path + ' (' + item.dims + ', ' + item.size + ')\n\n' + + 'The better file is moved to the first location and the original is overwritten. ' + + 'This cannot be undone.')) return; + try { + const res = await post('/admin/duplicates/replace', { src: item.path, dst: dst.path }); + const { path } = await res.json(); + showMsg('Replaced: ' + path); + groupEl.remove(); // only one file is left, so the group is resolved + updateCount(); + } catch (e) { + showErr('Replace failed: ' + e.message); + } + } + + function render(groups) { + const root = $('#dup-groups'); + root.textContent = ''; + groups.forEach((g, i) => { + const groupEl = document.createElement('section'); + groupEl.className = 'dup-group'; + groupEl.dataset.count = g.length; + const h = document.createElement('h3'); + h.textContent = 'Group ' + (i + 1) + ' (' + g.length + ' images)'; + const all = document.createElement('button'); + all.type = 'button'; + all.className = 'dup-btn dup-btn--delall'; all.textContent = 'Delete all'; + all.title = 'Delete every file in this group'; + all.onclick = () => delAll(groupEl); + h.append(all); + groupEl.append(h); + + const row = document.createElement('div'); + row.className = 'dup-row'; + g.forEach((item) => { + const card = document.createElement('div'); + card.className = 'dup-card' + (item.similarity === 'Original' ? ' dup-card--original' : ''); + card._item = item; + + const url = '/admin/duplicates/image?path=' + encodeURIComponent(item.path); + const a = document.createElement('a'); + a.href = url; a.target = '_blank'; + const img = document.createElement('img'); + img.loading = 'lazy'; img.src = url; img.alt = ''; + a.append(img); + + const p = document.createElement('div'); + p.className = 'dup-path'; p.textContent = item.path; + + const meta = document.createElement('div'); + meta.className = 'dup-meta'; + for (const [cls, text] of [['dup-tag--sim', item.similarity], ['', item.dims], ['', item.size]]) { + const t = document.createElement('span'); + t.className = 'dup-tag ' + cls; t.textContent = text; + meta.append(t); + } + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'dup-btn dup-btn--del'; btn.textContent = 'Delete'; + btn.onclick = () => del(item, card, groupEl); + + const rep = document.createElement('button'); + rep.type = 'button'; + rep.className = 'dup-btn dup-btn--replace'; rep.textContent = 'Replace other with this'; + rep.title = 'Move this file over the other one in the pair'; + rep.onclick = () => replaceOther(item, card, groupEl); + + card.append(a, p, meta, btn, rep); + row.append(card); + }); + groupEl.append(row); + root.append(groupEl); + highlightGroup(groupEl); + }); + $('#dup-bar').hidden = false; + applyFilter(); + } + + async function scan(e) { + e.preventDefault(); + const dirs = [$('#dup-dir1').value, $('#dup-dir2').value].map((d) => d.trim()).filter(Boolean); + const btn = $('#dup-scan'); + const status = $('#dup-status'); + btn.disabled = true; + status.textContent = 'Scanning… this can take a while for large directories.'; + try { + const res = await post('/admin/duplicates/scan', { dirs }); + const data = await res.json(); + render(data.groups || []); + status.textContent = ''; + } catch (err) { + status.textContent = ''; + $('#dup-bar').hidden = false; + showErr('Scan failed: ' + err.message); + } finally { + btn.disabled = false; + } + } + + document.addEventListener('DOMContentLoaded', function () { + $('#dup-form').addEventListener('submit', scan); + $('#dup-filter').addEventListener('input', applyFilter); + // Show the previous scan again after a reload (results are kept in memory only). + fetch('/admin/duplicates/groups').then((r) => r.json()).then((data) => { + if (data.dirs && data.dirs.length) { + $('#dup-dir1').value = data.dirs[0]; + $('#dup-dir2').value = data.dirs[1] || ''; + render(data.groups || []); + } + }).catch(() => {}); + }); +})(); diff --git a/static/style.css b/static/style.css @@ -265,3 +265,30 @@ div.config-split {flex: 1} .admin-tip-box {margin-bottom:20px;padding:10px;background:#e7f3ff;border:1px solid #b3d9ff;border-radius:4px;max-width:500px} .admin-regen-form {max-width:500px;padding:20px;background:#f8f9fa;border:1px solid #ddd;border-radius:5px} .admin-mono-input {width:100%;padding:8px;font-size:14px;font-family:monospace} + +/* admin duplicates */ +.dup-form {max-width:600px} +.dup-status {margin-left:12px;color:#666} +.dup-bar {padding:10px 0;border-bottom:1px solid #ddd} +.dup-filter {margin-left:12px;padding:6px 8px;width:380px;max-width:100%;font-size:14px} +.dup-match {background:#fff3b0;color:#111;border-radius:2px} +.dup-bar[hidden],.dup-empty[hidden] {display:none} +#dup-msg {margin-left:12px} +.dup-msg--ok {color:#2a9d4a} +.dup-msg--err {color:#c0392b} +.dup-empty {margin-top:30px;text-align:center;color:#666} +.dup-group {border:1px solid #ccc;border-radius:6px;margin:16px 0;padding:10px} +.dup-group > h3 {font-size:13px;margin:0 0 8px;color:#666} +.dup-row {display:flex;flex-wrap:wrap;gap:12px;align-items:flex-start} +.dup-card {width:340px;border:1px solid #ddd;border-radius:4px;padding:6px;box-sizing:border-box} +.dup-card--original {border-color:#2a9d4a} +.dup-card img {display:block;width:100%;height:300px;object-fit:contain;background:#eee;cursor:zoom-in} +.dup-path {word-break:break-all;font-size:12px;margin:6px 0 2px;font-family:monospace} +.dup-meta {font-size:12px;color:#555} +.dup-tag {display:inline-block;padding:0 6px;border-radius:3px;background:#eee;margin-right:4px} +.dup-card--original .dup-tag--sim {background:#2a9d4a;color:#fff} +.dup-btn {margin-top:6px;padding:4px 10px;cursor:pointer} +.dup-btn--del {color:#c0392b} +.dup-btn--replace {margin-left:6px} +.dup-btn--delall {float:right;margin:0 0 8px;color:#fff;background:#c0392b;border:1px solid #a93226;border-radius:3px} +.dup-group:not([data-count="2"]) .dup-btn--replace {display:none} diff --git a/templates/admin.html b/templates/admin.html @@ -36,6 +36,9 @@ <button onclick="showAdminTab('thumbnails')" id="admin-tab-thumbnails" class="admin-tab-btn"> Thumbnails </button> + <button onclick="showAdminTab('duplicates')" id="admin-tab-duplicates" class="admin-tab-btn"> + Duplicates + </button> </div> <!-- Settings Tab --> @@ -368,6 +371,40 @@ </div> </div> +<!-- Duplicates Tab --> +<div id="admin-content-duplicates" class="admin-tab-content"> + <h2>Duplicates</h2> + <p class="admin-hint"> + Scan one or two directories with <code>czkawka_cli</code> for similar images, then compare, delete or replace them. + Paths are not saved. Files inside the uploads directory also have their database records updated. + </p> + + <form id="dup-form" class="dup-form"> + <div class="admin-form-group"> + <label for="dup-dir1" class="admin-form-label">Directory 1:</label> + <input type="text" id="dup-dir1" class="admin-mono-input" value="{{.Data.Config.UploadDir}}" placeholder="/path/to/directory"> + </div> + <div class="admin-form-group"> + <label for="dup-dir2" class="admin-form-label">Directory 2 (optional):</label> + <input type="text" id="dup-dir2" class="admin-mono-input" value="" placeholder="/path/to/another/directory"> + </div> + <button type="submit" id="dup-scan" class="admin-btn admin-btn--primary">Scan for duplicates</button> + <span id="dup-status" class="dup-status"></span> + </form> + + <div id="dup-bar" class="dup-bar" hidden> + <strong id="dup-count"></strong> + <input type="search" id="dup-filter" class="dup-filter" placeholder="Filter paths… prefix a word with - to exclude (e.g. -results)" autocomplete="off"> + <span id="dup-msg"></span> + </div> + <div id="dup-groups"></div> + <div id="dup-empty" class="dup-empty" hidden>No similar images found.</div> + + <a href="#top" class="back-to-top" title="Return to top"> + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m18 15-6-6-6 6"/></svg> + </a> +</div> + <script>window.initialAliasGroups = {{.Data.Config.TagAliases}};</script> <script>window.initialSedRules = {{.Data.Config.SedRules}};</script> <script>window.initialMagicLinks = {{.Data.Config.MagicLinks}};</script> @@ -375,6 +412,7 @@ <script src="/static/admin-tag-alias.js" defer></script> <script src="/static/admin-sed-rules.js" defer></script> <script src="/static/admin-magic-links.js" defer></script> +<script src="/static/admin-duplicates.js" defer></script> <script src="/static/admin-tabs.js"></script> <script src="/static/common.js" defer></script> <script src="/static/hover-preview.js" defer></script>