diff --git a/README.md b/README.md index 2f397f6..247c9fc 100644 --- a/README.md +++ b/README.md @@ -78,16 +78,16 @@ Main features: - [X] Show storage info - [X] Metadata extraction from photos (EXIF) - [X] Show photo location in a map -- [ ] Organize photos: - - [ ] Upload new photos - - [ ] Move photos - - [ ] Delete photos +- [X] Organize photos in bulk: + - [X] Upload new photos + - [X] Move photos + - [X] Delete photos - [X] Save favorites - - [ ] Easy selection + - [X] Easy selection +- [X] Search for duplicates - [ ] Authentication - [ ] Photos timeline with virtual scroll - [ ] View all places from photos in a map -- [ ] Search for duplicates - [ ] Tool for renaming files - [ ] Image resizing according with screen diff --git a/package-lock.json b/package-lock.json index 67a107a..f8fd9b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,9 @@ "@mui/icons-material": "^5.14.14", "@mui/material": "^5.14.14", "@reduxjs/toolkit": "^1.9.7", + "@rpldy/upload-drop-zone": "^1.4.1", + "@rpldy/upload-preview": "^1.4.1", + "@rpldy/uploady": "^1.4.1", "@tabler/icons-react": "^2.39.0", "leaflet": "^1.9.4", "notistack": "v3.0.1", @@ -3034,6 +3037,169 @@ "node": ">= 8.0.0" } }, + "node_modules/@rpldy/abort": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/abort/-/abort-1.5.0.tgz", + "integrity": "sha512-aIIYak/RpaxPS57sUfsqAX0oBApeJTD45y8Z1hBKeYFWvACI0KE9jHZsD6ObvN0561ANdly6lm6EfTU8+HukVw==", + "dependencies": { + "@rpldy/raw-uploader": "^1.5.0", + "@rpldy/shared": "^1.5.0", + "@rpldy/simple-state": "^1.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/life-events": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/life-events/-/life-events-1.5.0.tgz", + "integrity": "sha512-vM1UjVh3jxO2PvxNa1qq8uintgRUXOcTu0bMmNnSRSpbii/Gb3+/lBWUbe+JqnE4m4p1aG7Q/RUHYqh2ZCBUVw==", + "dependencies": { + "@rpldy/shared": "^1.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/raw-uploader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/raw-uploader/-/raw-uploader-1.5.0.tgz", + "integrity": "sha512-Uv8WFaEXldzOjU0SGLgyZnTdCXdjKmVKIPOOeTEdRGeu5z2Mr49o0a0EjQ3IUIJrz0cbB49unUiFGQdqOWI0QA==", + "dependencies": { + "@rpldy/life-events": "^1.5.0", + "@rpldy/shared": "^1.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/sender": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/sender/-/sender-1.5.0.tgz", + "integrity": "sha512-2IXIGD9iXljNE2Kq+hZDM/aqOCoyf934DMHYhixH+t09w1CJLSl0yiv5hDpMQGknOPRqZGEeE8qW0DUYZx3EGg==", + "dependencies": { + "@rpldy/shared": "^1.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/shared": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/shared/-/shared-1.5.0.tgz", + "integrity": "sha512-ExOHPwEKxhlOCUpeb4dMhE9MCKIncJYpw+w0hgUUqF5orUmFlzHq1kSij2IRV2rEidGBlK1+90IyrEvAzaRadg==", + "dependencies": { + "invariant": "^2.2.4", + "just-throttle": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/shared-ui": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/shared-ui/-/shared-ui-1.5.0.tgz", + "integrity": "sha512-tcq52m3WZDZ8FR4a6VnLi8mB1HKLJPK2sQw6XGFGLVUS8KADan/S4BqnX5EcRfqhEJ4U52vsZA5FuO9TgaEUQQ==", + "dependencies": { + "@rpldy/shared": "^1.5.0", + "@rpldy/uploader": "^1.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@rpldy/simple-state": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/simple-state/-/simple-state-1.5.0.tgz", + "integrity": "sha512-ukfKdsHxbf9K3RYvV+COtELiE9KmSF+aXXH5F7biQiZNV3hnzWWcZwtyDKW6xBiAtUPEnSkrsDxHEKeaPLWBEw==", + "dependencies": { + "@rpldy/shared": "^1.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/upload-drop-zone": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/upload-drop-zone/-/upload-drop-zone-1.5.0.tgz", + "integrity": "sha512-9c/qioNLsaX53WVS7dNBc+qizB8c0ezKMu2d3mPAPDOE41geSVAzr/zzgU0TEYON79pIs4efh+8m2dVyF/viJQ==", + "dependencies": { + "@rpldy/shared-ui": "^1.5.0", + "html-dir-content": "^0.3.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@rpldy/upload-preview": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/upload-preview/-/upload-preview-1.5.0.tgz", + "integrity": "sha512-dc0v2zS60l4DaEzR2Kf2akkovvHtKYlunvGnWI1gMzIgd+MJKbVAzOgYiioO+B5dQm+Ef1WadlNYf3eK/DyrRA==", + "dependencies": { + "@rpldy/shared": "^1.5.0", + "@rpldy/shared-ui": "^1.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@rpldy/uploader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/uploader/-/uploader-1.5.0.tgz", + "integrity": "sha512-nKQAuuAIk+fkHteaxciLtdms10hqPRYvuP49RAZ5ynDhvAJ3YRtIrGS1sZb2jxgCshJPzSh+BEen+GC09REQhw==", + "dependencies": { + "@rpldy/abort": "^1.5.0", + "@rpldy/life-events": "^1.5.0", + "@rpldy/raw-uploader": "^1.5.0", + "@rpldy/sender": "^1.5.0", + "@rpldy/shared": "^1.5.0", + "@rpldy/simple-state": "^1.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/uploady": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/uploady/-/uploady-1.5.0.tgz", + "integrity": "sha512-gCz7xk7gtn3ZXnnYEuiPYxIja51nNsbGELreqd8wLhScPEYPui0tfbhfdLZ9ktB+GRl8V8+KAo6hIqCi5t0hVA==", + "dependencies": { + "@rpldy/life-events": "^1.5.0", + "@rpldy/shared": "^1.5.0", + "@rpldy/shared-ui": "^1.5.0", + "@rpldy/uploader": "^1.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -5206,6 +5372,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/html-dir-content": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/html-dir-content/-/html-dir-content-0.3.2.tgz", + "integrity": "sha512-a1EJZbvBGmmFwk9VxFhEgaHkyXUXKTkw0jr0FCvXKCqgzO1H0wbFQbbzRA6FhR3twxAyjqVc80bzGHEmKrYsSw==" + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -5305,6 +5476,14 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -6008,6 +6187,11 @@ "node": ">=0.10.0" } }, + "node_modules/just-throttle": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-4.2.0.tgz", + "integrity": "sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg==" + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -10406,6 +10590,109 @@ "picomatch": "^2.2.2" } }, + "@rpldy/abort": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/abort/-/abort-1.5.0.tgz", + "integrity": "sha512-aIIYak/RpaxPS57sUfsqAX0oBApeJTD45y8Z1hBKeYFWvACI0KE9jHZsD6ObvN0561ANdly6lm6EfTU8+HukVw==", + "requires": { + "@rpldy/raw-uploader": "^1.5.0", + "@rpldy/shared": "^1.5.0", + "@rpldy/simple-state": "^1.5.0" + } + }, + "@rpldy/life-events": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/life-events/-/life-events-1.5.0.tgz", + "integrity": "sha512-vM1UjVh3jxO2PvxNa1qq8uintgRUXOcTu0bMmNnSRSpbii/Gb3+/lBWUbe+JqnE4m4p1aG7Q/RUHYqh2ZCBUVw==", + "requires": { + "@rpldy/shared": "^1.5.0" + } + }, + "@rpldy/raw-uploader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/raw-uploader/-/raw-uploader-1.5.0.tgz", + "integrity": "sha512-Uv8WFaEXldzOjU0SGLgyZnTdCXdjKmVKIPOOeTEdRGeu5z2Mr49o0a0EjQ3IUIJrz0cbB49unUiFGQdqOWI0QA==", + "requires": { + "@rpldy/life-events": "^1.5.0", + "@rpldy/shared": "^1.5.0" + } + }, + "@rpldy/sender": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/sender/-/sender-1.5.0.tgz", + "integrity": "sha512-2IXIGD9iXljNE2Kq+hZDM/aqOCoyf934DMHYhixH+t09w1CJLSl0yiv5hDpMQGknOPRqZGEeE8qW0DUYZx3EGg==", + "requires": { + "@rpldy/shared": "^1.5.0" + } + }, + "@rpldy/shared": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/shared/-/shared-1.5.0.tgz", + "integrity": "sha512-ExOHPwEKxhlOCUpeb4dMhE9MCKIncJYpw+w0hgUUqF5orUmFlzHq1kSij2IRV2rEidGBlK1+90IyrEvAzaRadg==", + "requires": { + "invariant": "^2.2.4", + "just-throttle": "^4.2.0" + } + }, + "@rpldy/shared-ui": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/shared-ui/-/shared-ui-1.5.0.tgz", + "integrity": "sha512-tcq52m3WZDZ8FR4a6VnLi8mB1HKLJPK2sQw6XGFGLVUS8KADan/S4BqnX5EcRfqhEJ4U52vsZA5FuO9TgaEUQQ==", + "requires": { + "@rpldy/shared": "^1.5.0", + "@rpldy/uploader": "^1.5.0" + } + }, + "@rpldy/simple-state": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/simple-state/-/simple-state-1.5.0.tgz", + "integrity": "sha512-ukfKdsHxbf9K3RYvV+COtELiE9KmSF+aXXH5F7biQiZNV3hnzWWcZwtyDKW6xBiAtUPEnSkrsDxHEKeaPLWBEw==", + "requires": { + "@rpldy/shared": "^1.5.0" + } + }, + "@rpldy/upload-drop-zone": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/upload-drop-zone/-/upload-drop-zone-1.5.0.tgz", + "integrity": "sha512-9c/qioNLsaX53WVS7dNBc+qizB8c0ezKMu2d3mPAPDOE41geSVAzr/zzgU0TEYON79pIs4efh+8m2dVyF/viJQ==", + "requires": { + "@rpldy/shared-ui": "^1.5.0", + "html-dir-content": "^0.3.2" + } + }, + "@rpldy/upload-preview": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/upload-preview/-/upload-preview-1.5.0.tgz", + "integrity": "sha512-dc0v2zS60l4DaEzR2Kf2akkovvHtKYlunvGnWI1gMzIgd+MJKbVAzOgYiioO+B5dQm+Ef1WadlNYf3eK/DyrRA==", + "requires": { + "@rpldy/shared": "^1.5.0", + "@rpldy/shared-ui": "^1.5.0" + } + }, + "@rpldy/uploader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/uploader/-/uploader-1.5.0.tgz", + "integrity": "sha512-nKQAuuAIk+fkHteaxciLtdms10hqPRYvuP49RAZ5ynDhvAJ3YRtIrGS1sZb2jxgCshJPzSh+BEen+GC09REQhw==", + "requires": { + "@rpldy/abort": "^1.5.0", + "@rpldy/life-events": "^1.5.0", + "@rpldy/raw-uploader": "^1.5.0", + "@rpldy/sender": "^1.5.0", + "@rpldy/shared": "^1.5.0", + "@rpldy/simple-state": "^1.5.0" + } + }, + "@rpldy/uploady": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@rpldy/uploady/-/uploady-1.5.0.tgz", + "integrity": "sha512-gCz7xk7gtn3ZXnnYEuiPYxIja51nNsbGELreqd8wLhScPEYPui0tfbhfdLZ9ktB+GRl8V8+KAo6hIqCi5t0hVA==", + "requires": { + "@rpldy/life-events": "^1.5.0", + "@rpldy/shared": "^1.5.0", + "@rpldy/shared-ui": "^1.5.0", + "@rpldy/uploader": "^1.5.0" + } + }, "@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -12047,6 +12334,11 @@ } } }, + "html-dir-content": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/html-dir-content/-/html-dir-content-0.3.2.tgz", + "integrity": "sha512-a1EJZbvBGmmFwk9VxFhEgaHkyXUXKTkw0jr0FCvXKCqgzO1H0wbFQbbzRA6FhR3twxAyjqVc80bzGHEmKrYsSw==" + }, "idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -12124,6 +12416,14 @@ "side-channel": "^1.0.4" } }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -12628,6 +12928,11 @@ "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", "dev": true }, + "just-throttle": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-4.2.0.tgz", + "integrity": "sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg==" + }, "leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", diff --git a/package.json b/package.json index 3be7c87..f5d5389 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "@mui/icons-material": "^5.14.14", "@mui/material": "^5.14.14", "@reduxjs/toolkit": "^1.9.7", + "@rpldy/upload-drop-zone": "^1.4.1", + "@rpldy/upload-preview": "^1.4.1", + "@rpldy/uploady": "^1.4.1", "@tabler/icons-react": "^2.39.0", "leaflet": "^1.9.4", "notistack": "v3.0.1", diff --git a/server/album.go b/server/album.go index bf64776..3d3199d 100644 --- a/server/album.go +++ b/server/album.go @@ -3,11 +3,18 @@ package main import ( "encoding/json" "errors" + "fmt" "io/fs" "log" "path/filepath" "sort" "strings" + "time" + + "golang.org/x/exp/maps" + + "github.com/hlubek/readercomp" + "github.com/timshannon/bolthold" ) type Album struct { @@ -189,3 +196,208 @@ func (album Album) MarshalJSON() ([]byte, error) { // Marshal the preprocessed struct to JSON return json.Marshal(alias) } + +type DuplicateFile struct { + From int `json:"from"` + To int `json:"to"` +} + +type DuplicateFound struct { + Photo *Photo `json:"photo"` + Files []DuplicateFile `json:"files"` + Equal bool `json:"equal"` // All files were matched + Partial bool `json:"partial"` // Only some files were matched + Incomplete bool `json:"incomplete"` // Found photos with more files + Conflict bool `json:"conflict"` // Combination of Partial and Incomplete + SameAlbum bool `json:"samealbum"` // Photos are in the same album +} +type Duplicate struct { + Photo *Photo `json:"photo"` + Found []DuplicateFound `json:"found"` +} + +const FILE_NOT_FOUND = -1 + +func (album *Album) Duplicates() (map[string]interface{}, error) { + var dups = make(map[string][]DuplicateFound) + + // Collect all file sizes from the album we want to analyze + var sizes []interface{} + for _, photo := range album.photosMap { + for _, size := range photo.FileSizes { + sizes = append(sizes, size) + } + } + + // Iterate through collections (seaching and comparing)) + for _, collection := range config.collections { + fmt.Println("#################", collection.Name) + i := 0 + total := time.Now() + + var dbPhotosList []*Photo + start := time.Now() + // Search for all files in the collection that have the same size + err := collection.cache.store.Find(&dbPhotosList, bolthold.Where("FileSizes").In(sizes...).Index("FileSizes")) + fmt.Println("SEARCHING:", time.Since(start).String()) + if err != nil { + fmt.Println(err) + continue + } + + // Maps sizes to the corresponding photo + dbPhotosMap := make(map[int64][]*Photo) + for _, dbPhoto := range dbPhotosList { + for _, size := range dbPhoto.FileSizes { + dbPhotosMap[size] = append(dbPhotosMap[size], dbPhoto) + } + } + + // For every photo in the album + for _, photo := range album.photosMap { + // Select only matching photos from the search results + dbPhotos := make(map[string]*Photo) + for _, file := range photo.Files { + for _, dbPhoto := range dbPhotosMap[file.Size] { + // Same photo, skip + if dbPhoto.Collection == photo.Collection && dbPhoto.Album == photo.Album && dbPhoto.Id == photo.Id { + continue + } + dbPhotos[dbPhoto.Key()] = dbPhoto + } + } + + var visited = make(map[string]bool) // Photos visited from search results + var compareTime = time.Now() + for _, dbPhoto := range dbPhotos { + // Avoid comparing the same photos more than once + if _, ok := visited[dbPhoto.Key()]; ok { + continue + } + visited[dbPhoto.Key()] = true + + var matchAny = false + var allFilesDb = true + var files []DuplicateFile + var matchFiles = make(map[string]bool) // Files found from the photo were are analyzing + for dbFileNr, dbFile := range dbPhoto.Files { + var fileNotFound = true + for fileNr, file := range photo.Files { + if dbFile.Size == file.Size { + equal, err := readercomp.FilesEqual(file.Path, dbFile.Path) + if err != nil { + continue + } + if equal { + matchAny = true + fileNotFound = false + matchFiles[file.Id] = true + files = append(files, DuplicateFile{From: fileNr, To: dbFileNr}) + } else { + log.Printf("Missed file comparison:\n- Source: %s\n- Target: %s\n", file.Path, dbFile.Path) + } + } + } + if fileNotFound { + allFilesDb = false + files = append(files, DuplicateFile{From: FILE_NOT_FOUND, To: dbFileNr}) + } + } + + // Any file found? + if matchAny { + // Files that were not matched with from file from the search + var allFiles = true + for fileNr, file := range photo.Files { + if _, ok := matchFiles[file.Id]; !ok { + allFiles = false + files = append(files, DuplicateFile{From: fileNr, To: FILE_NOT_FOUND}) + } + } + + // Add to the list of found photos, determine status of the duplicate + dups[photo.Id] = append(dups[photo.Id], DuplicateFound{ + Photo: dbPhoto, + Files: files, + Equal: allFiles && allFilesDb, + Incomplete: allFiles && !allFilesDb, + Partial: !allFiles && allFilesDb, + Conflict: !allFiles && !allFilesDb, + SameAlbum: dbPhoto.Collection == photo.Collection && dbPhoto.Album == photo.Album, + }) + } + } + + i++ + fmt.Printf("COMPARING (%d/%d) %7s %s\n", i, len(album.photosMap), time.Since(compareTime).Round(time.Millisecond).String(), photo.Id) + } + fmt.Println("TOTAL:", time.Since(total).String()) + } + + var listUnique []*Photo + var listKeep []Duplicate + var listDelete []Duplicate + var listConflict []Duplicate + var listSameAlbum []Duplicate + var listAlbums = make(map[string]PseudoAlbum) + for key, photo := range album.photosMap { + if found, ok := dups[key]; ok { + + var unique, keep, delete, conflict, sameAlbum = true, false, false, false, false + for _, entry := range found { + // Keep track of albums where photos were found + keyAlbum := entry.Photo.Collection + ":" + entry.Photo.Album + if _, ok := listAlbums[keyAlbum]; !ok { + listAlbums[keyAlbum] = PseudoAlbum{entry.Photo.Collection, entry.Photo.Album} + } + + // Dertermine best action to apply + switch { + // Delete + case !entry.SameAlbum && (entry.Equal || entry.Incomplete): + unique, keep, delete, conflict, sameAlbum = false, false, true, false, false + // Same album + case !delete && entry.SameAlbum: + unique, keep, delete, conflict, sameAlbum = false, false, false, false, true + // Keep + case !delete && !sameAlbum && entry.Partial: + unique, keep, delete, conflict, sameAlbum = false, true, false, false, false + // Conflict + case !delete && !sameAlbum && !keep && entry.Conflict: + unique, keep, delete, conflict, sameAlbum = false, false, false, true, false + } + + } + + switch { + case unique: + listUnique = append(listUnique, photo) + case keep: + listKeep = append(listKeep, Duplicate{Photo: photo, Found: found}) + case delete: + listDelete = append(listDelete, Duplicate{Photo: photo, Found: found}) + case conflict: + listConflict = append(listConflict, Duplicate{Photo: photo, Found: found}) + case sameAlbum: + listSameAlbum = append(listSameAlbum, Duplicate{Photo: photo, Found: found}) + } + } else { + listUnique = append(listUnique, photo) + } + } + + return map[string]interface{}{ + "albums": maps.Values(listAlbums), + "keep": listKeep, + "delete": listDelete, + "unique": listUnique, + "conflict": listConflict, + "samealbum": listSameAlbum, + "countKeep": len(listKeep), + "countDelete": len(listDelete), + "countUnique": len(listUnique), + "countConflict": len(listConflict), + "countSameAlbum": len(listSameAlbum), + "total": len(album.photosMap), + }, nil +} diff --git a/server/cache.go b/server/cache.go index 2b07a81..f6c7386 100644 --- a/server/cache.go +++ b/server/cache.go @@ -127,6 +127,12 @@ func (c *Cache) AddToListAlbums(albums ...*Album) { } } +func (c *Cache) RemoveFromListAlbums(albums ...string) { + for _, album := range albums { + c.albums.Delete(album) + } +} + func (c *Cache) IsListAlbumsLoaded() bool { var ret = false c.albums.Range(func(key, value any) bool { @@ -164,6 +170,11 @@ func (c *Cache) IsAlbumFullyScanned(album *Album) bool { var a AlbumSaved return c.store.Get(album.Name, &a) == nil } +func (c *Cache) RemoveAlbumSaved(albumName string) bool { + type AlbumSaved struct{} + var a AlbumSaved + return c.store.Delete(albumName, &a) == nil +} func (photo *Photo) Key() string { return PhotoKey(photo.Album, photo.Id) diff --git a/server/collection.go b/server/collection.go index 59cb19f..c5325b2 100644 --- a/server/collection.go +++ b/server/collection.go @@ -230,6 +230,30 @@ func (c *Collection) AddAlbum(info AddAlbumQuery) error { return nil } +func (c *Collection) DeleteAlbum(album *Album) error { + name := album.Name + if album.IsPseudo { + name += PSEUDO_ALBUM_EXT + } + p := filepath.Join(c.PhotosPath, name) + + // File or folder does not exist, cannot delete + if _, err := os.Stat(p); os.IsNotExist(err) { + return err + } + + // Remove album folder (if empty) or pseudo-album file + err := os.Remove(p) + if err != nil { + return err + } + + // Remove from cache + c.cache.RemoveFromListAlbums(album.Name) + c.cache.RemoveAlbumSaved(album.Name) + return nil +} + func (collection *Collection) StorageUsage() (CollectionStorage, error) { di, err := disk.Usage(collection.PhotosPath) if err != nil { diff --git a/server/go.mod b/server/go.mod index 719a6f8..e7f5139 100644 --- a/server/go.mod +++ b/server/go.mod @@ -8,6 +8,7 @@ require ( github.com/bluele/gcache v0.0.2 github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 + github.com/hlubek/readercomp v0.0.0-20210927065201-8f5e69adbe1c github.com/labstack/echo/v4 v4.11.1 github.com/mholt/goexif2 v0.0.0-20230302025153-4d89d35092b2 github.com/shirou/gopsutil v3.21.11+incompatible diff --git a/server/go.sum b/server/go.sum index 642d147..b5ccf5f 100644 --- a/server/go.sum +++ b/server/go.sum @@ -15,6 +15,8 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/hlubek/readercomp v0.0.0-20210927065201-8f5e69adbe1c h1:XNU2fYUmgiQD+V/9FYiBhM/XbsTYR/MFB/4AirnW/1w= +github.com/hlubek/readercomp v0.0.0-20210927065201-8f5e69adbe1c/go.mod h1:lax9oGcMHTg+S5EV4vttUkL3qam+ILZrDOeTmhOEXCA= github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4= github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= diff --git a/server/main.go b/server/main.go index b65e1ac..b9582ac 100644 --- a/server/main.go +++ b/server/main.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "path" + "path/filepath" "strconv" "strings" "time" @@ -89,6 +90,48 @@ func addAlbum(c echo.Context) error { } return c.JSON(http.StatusCreated, map[string]bool{"ok": true}) } +func deleteAlbum(c echo.Context) error { + collectionName := c.Param("collection") + albumName := c.Param("album") + + collection, err := GetCollection(collectionName) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + // Fetch album from disk + album, err := collection.GetAlbum(albumName) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + err = collection.DeleteAlbum(album) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusCreated, map[string]bool{"ok": true}) +} +func duplicates(c echo.Context) error { + collectionName := c.Param("collection") + albumName := c.Param("album") + + collection, err := GetCollection(collectionName) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + // Fetch album from disk + album, err := collection.GetAlbumWithPhotos(albumName, true, false) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + list, err := album.Duplicates() + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, list) +} func thumb(c echo.Context) error { collectionName := c.Param("collection") @@ -188,6 +231,113 @@ func file(c echo.Context) error { return c.File(file.Path) } +func upload(c echo.Context) error { + collection, err := GetCollection(c.Param("collection")) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + albumName := c.Param("album") + + // Fetch photo from cache + album, err := collection.GetAlbum(albumName) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + // Parse the multipart form + form, err := c.MultipartForm() + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + // Get all files from "file" key + files := form.File["file"] + + // Loop through files + for _, file := range files { + // Check if file already exists in album + p := filepath.Join(collection.PhotosPath, album.Name, file.Filename) + if _, err := os.Stat(p); !os.IsNotExist(err) { + return echo.NewHTTPError(http.StatusConflict, "file already exits") + } + + // Save file to album + err := SaveMultipartFile(file, p) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + } + + return c.JSON(http.StatusOK, map[string]bool{"ok": true}) +} + +func move(c echo.Context) error { + var query AlbumMoveQuery + + // Decode body + if err := c.Bind(&query); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + srcCollection, err := GetCollection(c.Param("collection")) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + dstCollection, err := GetCollection(query.Collection) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + albumName := c.Param("album") + + // Fetch album with photos + srcAlbum, err := srcCollection.GetAlbumWithPhotos(albumName, false, false) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + dstAlbum, err := dstCollection.GetAlbum(query.Album) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + // Move photos + stats, err := srcAlbum.MovePhotos(query.Mode, srcCollection, dstCollection, dstAlbum, query.Photos...) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusOK, stats) +} + +func delete(c echo.Context) error { + var query AlbumDeleteQuery + + // Decode body + if err := c.Bind(&query); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + collection, err := GetCollection(c.Param("collection")) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + albumName := c.Param("album") + + // Fetch album with photos + album, err := collection.GetAlbumWithPhotos(albumName, false, false) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + // Delete photos + err = album.DeletePhotos(collection, query.Photos...) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusOK, map[string]bool{"ok": true}) +} + func saveToPseudo(c echo.Context) error { var query PseudoAlbumSaveQuery @@ -328,9 +478,14 @@ func main() { api.GET("/collections/:collection/albums", albums) api.PUT("/collections/:collection/albums", addAlbum) api.GET("/collections/:collection/albums/:album", album) + api.DELETE("/collections/:collection/albums/:album", deleteAlbum) + api.GET("/collections/:collection/albums/:album/duplicates", duplicates) api.GET("/collections/:collection/albums/:album/photos/:photo/thumb", thumb) api.GET("/collections/:collection/albums/:album/photos/:photo/info", info) api.GET("/collections/:collection/albums/:album/photos/:photo/files/:file", file) + api.POST("/collections/:collection/albums/:album/photos", upload) + api.PUT("/collections/:collection/albums/:album/photos/move", move) + api.DELETE("/collections/:collection/albums/:album/photos", delete) api.PUT("/collections/:collection/albums/:album/pseudos", saveToPseudo) api.DELETE("/collections/:collection/albums/:album/pseudos", saveToPseudo) api.GET("/health", func(c echo.Context) error { diff --git a/server/organize.go b/server/organize.go new file mode 100644 index 0000000..e13e0bc --- /dev/null +++ b/server/organize.go @@ -0,0 +1,405 @@ +package main + +import ( + "errors" + "fmt" + "io" + "log" + "mime/multipart" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "golang.org/x/exp/slices" +) + +type AlbumMoveQuery struct { + Collection string `json:"collection"` // target collection + Album string `json:"album"` // target album + Photos []string `json:"photos"` // list of photo names to move + Mode string `json:"mode"` // move mode: cancel, skip, rename +} + +type AlbumDeleteQuery struct { + Photos []string `json:"photos"` // list of photo names to delete +} + +type PhotoMove struct { + src *Photo + dst *Photo +} + +// Compile the regular expression that matches " (number)" from the end +var RenameSeqRegExp = regexp.MustCompile(`\s\(\d+\)$`) + +// Taken from https://github.com/valyala/fasthttp/blob/0d0bbfee5a8dd12a82e442d3cbb11e56726dd06e/server.go#L1048 +// SaveMultipartFile saves multipart file fh under the given filename path. +func SaveMultipartFile(fh *multipart.FileHeader, path string) (err error) { + var ( + f multipart.File + ff *os.File + ) + f, err = fh.Open() + if err != nil { + return + } + + var ok bool + if ff, ok = f.(*os.File); ok { + // Windows can't rename files that are opened. + if err = f.Close(); err != nil { + return + } + + // If renaming fails we try the normal copying method. + // Renaming could fail if the files are on different devices. + if os.Rename(ff.Name(), path) == nil { + return nil + } + + // Reopen f for the code below. + if f, err = fh.Open(); err != nil { + return + } + } + + defer func() { + e := f.Close() + if err == nil { + err = e + } + }() + + if ff, err = os.Create(path); err != nil { + return + } + defer func() { + e := ff.Close() + if err == nil { + err = e + } + }() + _, err = io.Copy(ff, f) + return +} + +func RenameFilename(filename string, renameIndex int) string { + if renameIndex < 2 { + return filename + } + + ext := path.Ext(filename) + // Extract name and remove anything like " (number)" from the end + name := RenameSeqRegExp.ReplaceAllString(strings.TrimSuffix(filename, ext), "") + + return fmt.Sprintf("%s (%d)%s", name, renameIndex, ext) +} + +// Create a new copy of the photo object to used when moving +func (photo *Photo) CopyForMove(dstCollection *Collection, dstAlbum *Album, renameFilesSeq int) *Photo { + var files []*File + for _, file := range photo.Files { + rename := RenameFilename(file.Name(), renameFilesSeq) + files = append(files, &File{ + // Changed fields + Id: rename, + Path: filepath.Join(dstCollection.PhotosPath, dstAlbum.Name, rename), + // Copy the remainder + Type: file.Type, + MIME: file.MIME, + Width: file.Width, + Height: file.Height, + Date: file.Date, + Location: file.Location, + Orientation: file.Orientation, + Size: file.Size, + }) + } + //name := strings.TrimSuffix(files[0].Name(), filepath.Ext(files[0].Name())) + name := RenameFilename(photo.Title, renameFilesSeq) + return &Photo{ + // Changed fields + Id: strings.ToLower(name), + Title: name, + Collection: dstCollection.Name, + Album: dstAlbum.Name, + SubAlbum: "", // Move into a sub-album not supported + Files: files, + // Copy the remainder + HasThumb: photo.HasThumb, + Type: photo.Type, + Width: photo.Width, + Height: photo.Height, + Date: photo.Date, + Location: photo.Location, + Favorite: photo.Favorite, + } +} + +func UpdateFavorites(photos []PhotoMove) { + var favorites = make(map[string]map[string][]PhotoMove) + + // Group favorites by Collection and Album + for _, photo := range photos { + for _, favorite := range photo.src.Favorite { + if _, ok := favorites[favorite.Collection]; !ok { + favorites[favorite.Collection] = make(map[string][]PhotoMove) + } + favorites[favorite.Collection][favorite.Album] = append(favorites[favorite.Collection][favorite.Album], photo) + } + } + + for collectionName, albums := range favorites { + // Load collection + collection, err := GetCollection(collectionName) + if err != nil { + log.Println(err) + continue + } + for albumName, photos := range albums { + // Load album with photos + album, err := collection.GetAlbum(albumName) + if err != nil { + log.Println(err) + continue + } + // Load entries of the pseudo-album + entries, err := readPseudoAlbum(collection, album) + if err != nil { + log.Println(err) + continue + } + for i, entry := range entries { + for _, photo := range photos { + src, dst := photo.src, photo.dst + if entry.Collection == src.Collection && entry.Album == src.Album && entry.Photo == src.Id { + entries[i] = PseudoAlbumEntry{ + Collection: dst.Collection, + Album: dst.Album, + Photo: dst.Id, + } + } + } + } + // Write back updated entries of the pseudo-album + writePseudoAlbum(collection, album, entries...) + } + } +} + +func DeleteFavorites(photos *[]*Photo) { + var favorites = make(map[string]map[string][]*Photo) + + // Group favorites by Collection and Album + for _, photo := range *photos { + for _, favorite := range photo.Favorite { + if _, ok := favorites[favorite.Collection]; !ok { + favorites[favorite.Collection] = make(map[string][]*Photo) + } + favorites[favorite.Collection][favorite.Album] = append(favorites[favorite.Collection][favorite.Album], photo) + } + } + + for collectionName, albums := range favorites { + // Load collection + collection, err := GetCollection(collectionName) + if err != nil { + log.Println(err) + continue + } + for albumName, photos := range albums { + // Load album with photos + album, err := collection.GetAlbum(albumName) + if err != nil { + log.Println(err) + continue + } + // Load entries of the pseudo-album + entries, err := readPseudoAlbum(collection, album) + if err != nil { + log.Println(err) + continue + } + for i, entry := range entries { + for _, photo := range photos { + if entry.Collection == photo.Collection && entry.Album == photo.Album && entry.Photo == photo.Id { + entries = slices.Delete(entries, i, i+1) + } + } + } + // Write back updated entries of the pseudo-album + writePseudoAlbum(collection, album, entries...) + } + } +} + +func (srcAlbum *Album) MovePhotos(mode string, srcCollection *Collection, dstCollection *Collection, dstAlbum *Album, photoNames ...string) (map[string]int, error) { + defer srcCollection.cache.FlushInfo() + defer dstCollection.cache.FlushInfo() + + var photos []*Photo + + // Stats + countMovedPhotos := 0 + countMovedFiles := 0 + countSkippedPhotos := 0 + countRenamedPhotos := 0 + + // Gather all photos to move + for _, photoName := range photoNames { + photo, err := srcAlbum.GetPhoto(photoName) + if err != nil { + return nil, err + } + photos = append(photos, photo) + } + + // Determine the action when files already exist in the destination + // - cancel: do nothing + // - skip: skip files with conflicting names + // - rename: rename the file by adding a sequence number + var movePhotos []PhotoMove + var renamePhotos []*Photo + var moveFilesPath map[string]bool = make(map[string]bool) + for _, photo := range photos { + // Create a copy of photo updated to the new destination + newPhoto := photo.CopyForMove(dstCollection, dstAlbum, 1) + + // Check if any of the files exists in the destination + exists := false + for _, file := range newPhoto.Files { + // File already exists + if _, err := os.Stat(file.Path); err == nil { + exists = true + } + // File will exist after moving + if _, ok := moveFilesPath[file.Path]; ok { + exists = true + } else { + moveFilesPath[file.Path] = true + } + } + if exists { + switch mode { + case "cancel": + return nil, errors.New("the destination file already exists") + case "skip": + countSkippedPhotos++ + continue + case "rename": + renamePhotos = append(renamePhotos, photo) + continue + } + } + movePhotos = append(movePhotos, PhotoMove{photo, newPhoto}) + } + + // Process photos with files that require renaming + moveFilesPath = make(map[string]bool) + for _, photo := range movePhotos { + for _, file := range photo.dst.Files { + moveFilesPath[file.Path] = true + } + } + for _, photo := range renamePhotos { + var newPhoto *Photo + for seq, exists := 2, true; exists; seq++ { + // Create a copy of photo updated to the new destination + newPhoto = photo.CopyForMove(dstCollection, dstAlbum, seq) + // Check if any of the files exists in the destination + exists = false + for _, file := range newPhoto.Files { + _, err := os.Stat(file.Path) // Existing files + _, ok := moveFilesPath[file.Path] //Any of the files of the photos to be moved + if ok || !os.IsNotExist(err) { + exists = true + break + } + } + } + // Add file paths to be moved + for _, file := range newPhoto.Files { + moveFilesPath[file.Path] = true + } + movePhotos = append(movePhotos, PhotoMove{photo, newPhoto}) + countRenamedPhotos++ + } + + // Update favorite albums with new Collection/Album + UpdateFavorites(movePhotos) + + // Perform the actual move + for _, photo := range movePhotos { + // Move files + for i, file := range photo.dst.Files { + // Double check if we are not replacing files + if _, err := os.Stat(file.Path); os.IsNotExist(err) { + // Move the file to the new location with the new name + err := os.Rename(photo.src.Files[i].Path, file.Path) + if err != nil { + return nil, err + } + } else { + return nil, errors.New("the destination file already exists") + } + + countMovedFiles++ + } + + // Move thumbnail + srcThumbPath := photo.src.ThumbnailPath(srcCollection) + dstThumbPath := photo.dst.ThumbnailPath(dstCollection) + err := os.Rename(srcThumbPath, dstThumbPath) + if err != nil { // If not possible to rename, remove thumbnail for cleanup + os.Remove(srcThumbPath) + } + + // Move photo info + srcCollection.cache.DeletePhotoInfo(photo.src) + dstCollection.cache.AddPhotoInfo(photo.dst) + + countMovedPhotos++ + } + + return map[string]int{ + "moved_photos": countMovedPhotos, + "moved_files": countMovedFiles, + "skipped": countSkippedPhotos, + "renamed": countRenamedPhotos, + }, nil +} + +func (album *Album) DeletePhotos(collection *Collection, photoNames ...string) error { + var deletedPhotos []*Photo + + defer collection.cache.FlushInfo() + defer DeleteFavorites(&deletedPhotos) + + for _, photoName := range photoNames { + photo, err := album.GetPhoto(photoName) + if err != nil { + return err + } + + // Remove files + for _, file := range photo.Files { + err := os.Remove(file.Path) + if err != nil { + return err + } + } + + // Add to remove from pseudo-albums where is favorite + deletedPhotos = append(deletedPhotos, photo) + + // Remove info + collection.cache.DeletePhotoInfo(photo) + + // Remove thumbnail + thumbPath := photo.ThumbnailPath(collection) + os.Remove(thumbPath) + } + return nil +} diff --git a/src/App.tsx b/src/App.tsx index 6803ea2..3753f87 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import Layout from './Layout'; import Gallery from './Gallery'; import { DialogProvider } from './dialogs'; +import { UploadProvider } from './Upload'; import { selectTheme } from "./services/app"; function Home() { @@ -26,7 +27,9 @@ function Home() { function MainLayout() { return ( - + + + ); } diff --git a/src/Gallery.tsx b/src/Gallery.tsx index 5fc42ea..9772b24 100644 --- a/src/Gallery.tsx +++ b/src/Gallery.tsx @@ -3,6 +3,7 @@ import { useParams } from "react-router-dom"; import { useSelector } from 'react-redux'; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import Chip from "@mui/material/Chip"; import DangerousIcon from '@mui/icons-material/Dangerous'; import LinearProgress from '@mui/material/LinearProgress'; @@ -13,8 +14,10 @@ import Typography from "@mui/material/Typography"; import PhotoAlbum from "react-photo-album"; +import SelectionToolbar from "./SelectionToolbar"; import Thumb from "./Thumb"; -import { PhotoImageType, urls, AlbumType } from "./types"; +import { SelectionProvider } from "./Selection"; +import { PhotoType, PhotoImageType, urls, AlbumType } from "./types"; import { useGetAlbumQuery } from "./services/api"; import { selectZoom } from "./services/app"; import { useDialog } from "./dialogs"; @@ -68,6 +71,10 @@ const Gallery: FC = () => { const handleSubAlbum = (selected: string) => () => { setSubAlbum(selected === subAlbum ? "" : selected); } + + const handleDeleteAlbum = () => { + dialog.deleteAlbum(collection, album); + } // Loading if(isFetching) @@ -90,6 +97,7 @@ const Gallery: FC = () => { No photos in this album. + ); // All OK, render Gallery @@ -106,13 +114,16 @@ const Gallery: FC = () => { return (<> {hasSubAlbums && subAlbumsComponent} - + itemToId={i => i.id}> + + + ); } diff --git a/src/Selection.tsx b/src/Selection.tsx new file mode 100644 index 0000000..681c258 --- /dev/null +++ b/src/Selection.tsx @@ -0,0 +1,291 @@ +import React, { useContext, useEffect, useRef, useState } from "react"; +import "./selection.scss"; + +interface SelectionContextProps { + // Register selectable items with the provider + register: (item: T, setSelected: React.Dispatch>) => number; + // Trigger events on the selected items + onEvent: (name: string, index: number) => () => void; + // Selected indexes + indexes: () => number[]; + // Selected items + get: () => T[]; + // Select all items + all: () => void; + // Cancel current selection + cancel: () => void; + // Is currently selecting + isSelecting: boolean; +} + +const SelectionContext = React.createContext>({ + register: () => -1, + onEvent: () => () => undefined, + indexes: () => [], + get: () => [], + all: () => undefined, + cancel: () => undefined, + isSelecting: false, +}); + +interface SelectionProviderProps { + children?: React.ReactNode; + itemToId?(item: T): string; + onChange?(selection: T[]): void; + onIsSelecting?(isSelecting: boolean): void; +} + +interface SelectionData { + // List of items, used in the result of selection + items: { + // Actual item + v: T; + // Callback of registered item + cb: React.Dispatch>; + // Current selection state + selected: boolean; + }[], + // Map between items and indexes + keyIndexes: {[key: string]: number}, + // Counter of added items + nItems: number; + // Counter of selected items + count: number; + // Selection + selecting: boolean; + startIndex: number; + prevIndex: number; + value: boolean; + firstsMoves: number; +} + +function SelectionProvider({ children, itemToId, onChange, onIsSelecting }: SelectionProviderProps) { + const [isSelecting, setSelecing] = useState(false); + + const itemToIdFn = itemToId ? itemToId : (item: T) => String(item); + + const d = useRef>({ + items: [], + keyIndexes: {}, + nItems: 0, + count: 0, + selecting: false, + startIndex: 0, + prevIndex: 0, + value: false, + firstsMoves: 0, + }).current; + + const updateCount = (add: boolean) => { + d.count += add ? 1 : -1; + if(d.count > 0 && !isSelecting) { + setSelecing(true); + if(onIsSelecting) // Trigger event onIsSelecting if is set + onIsSelecting(true); + } + else if(d.count < 1 && isSelecting) { + setSelecing(false); + if(onIsSelecting) // Trigger event onIsSelecting if is set + onIsSelecting(false); + } + + // Trigger event onChange if is set + if(onChange) + onChange(get()); + }; + + const start = (index: number) => { + d.value = !d.items[index].selected; + d.selecting = true; + d.startIndex = index; + d.prevIndex = index; + d.firstsMoves = 0; + }; + + const select = (index: number) => { + const {selecting, firstsMoves, prevIndex, startIndex, value, items} = d; + + // Not selecting + if(!selecting) + return; + + // Number of moves that must occur befere start selecting + if(firstsMoves < 3) { + d.firstsMoves++; + return; + } + + // No changes in selection + if(prevIndex === index) { + // Special case for startIndex, select it if not selected + if(index === startIndex && items[index].selected !== value) { + items[index].selected = value; + updateCount(value); + items[index].cb(value); + } + return; + } + + // Side of startIndex, right (true) or left (false) + const side = (index >= startIndex); + // Indicates if selection is increasing (true) or decreasing (false) + const dir = (!side && prevIndex > index) || + ( side && prevIndex < index); + // Indicates if selection delta crosses the starting point + const cross = (!side && prevIndex >= startIndex) || + ( side && prevIndex <= startIndex); + // Interval of items that are changing + const offsetmin = side && !cross ? 1 : 0; + const offsetmax = !side && !cross ? 1 : 0; + const min = Math.min(prevIndex, index) + offsetmin; + const max = Math.max(prevIndex, index) - offsetmax; + // Iterate over changing items + for(let i = min; i <= max ; i++) { + const hasCrossed = (!side && i > startIndex) || + ( side && i < startIndex); + const newVal = (hasCrossed ? !dir : dir) === value; + // Selection has changed + if(items[i].selected !== newVal) { + // Select or deselect + items[i].selected = newVal; + // Update counter + updateCount(newVal); + // Trigger event on the component + items[i].cb(newVal); + } + } + d.prevIndex = index; + } + const stop = () => { + d.selecting = false; + }; + + + const indexes = () => { + const selected: number[] = []; + d.items.forEach((item, index) => { + if(item.selected) + selected.push(index); + }); + return selected; + }; + + const get = () => { + return d.items.filter(({selected}) => selected).map(({v}) => v); + }; + const all = () => { + d.items.forEach(({selected, cb}, index) => { + if(!selected) { + d.items[index].selected = true; + updateCount(true); + cb(true); + } + }); + }; + const cancel = () => { + d.items.forEach(({selected, cb}, index) => { + if(selected) { + d.items[index].selected = false; + updateCount(false); + cb(false); + } + }); + }; + + const register = (item: T, setSelected: React.Dispatch>) => { + const key = itemToIdFn(item); + // Item not present yet, creating a new index for it + if(d.keyIndexes[key] === undefined) + d.keyIndexes[key] = d.nItems++; // Set index for key, increment afterwards + + const index = d.keyIndexes[key]; + d.items[index] = { v: item, cb: setSelected, selected: false }; + return index; + } + + const onEvent = (name: string, index: number) => () => { + switch(name) { + // case "onClick": openLightbox(); break; + // case "onDoubleClick": saveFavorite(); break; + // case "onMouseEnter": mouseEnter(); break; + // case "onMouseLeave": mouseLeave(); break; + case "onMouseDown": start(index); break; + case "onMouseMove": select(index); break; + case "onMouseUp": stop(); break; + case "onTouchStartCapture": start(index); break; + case "onTouchMove": select(index); break; + case "onTouchEnd": stop(); break; + } + } + + return ( + +
+ {children} +
+
+ ); +} + +interface SelectableProps { + item: T; + children?: React.ReactNode; + onChange?: (selected: boolean, index: number) => void; +} + +function Selectable({ item, children, onChange }: SelectableProps) { + const ctx = useContext(SelectionContext); + const [selected, setSelected] = useState(false); + const [index] = useState(() => ctx.register(item, setSelected)); + const evt = ctx.onEvent; + + // Trigger onChange event when selected + useEffect(() => { + if(onChange) + onChange(selected, index); + }, [onChange, selected, index] ); + + return ( +
+ {children} + + ); +} + +function useSelection(): SelectionContextProps { + const context = React.useContext>(SelectionContext); + if (context === undefined) { + throw new Error('useSelection must be used within a SelectionProvider'); + } + + return context; +} + +export { SelectionProvider, Selectable, useSelection }; diff --git a/src/SelectionToolbar.tsx b/src/SelectionToolbar.tsx new file mode 100644 index 0000000..280f686 --- /dev/null +++ b/src/SelectionToolbar.tsx @@ -0,0 +1,44 @@ +import { FC } from "react"; +import { useParams } from "react-router-dom"; + +import BottomNavigation from "@mui/material/BottomNavigation"; +import BottomNavigationAction from "@mui/material/BottomNavigationAction"; +import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; +import DeselectIcon from '@mui/icons-material/Deselect'; +import Divider from "@mui/material/Divider"; +import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove'; +import SelectAllIcon from '@mui/icons-material/SelectAll'; + +import { PhotoImageType } from "./types"; +import { useDialog } from "./dialogs"; +import { useSelection } from "./Selection"; + +const SelectionToolbar: FC = () => { + const { collection = "", album = "" } = useParams(); + const { get, all, cancel, isSelecting } = useSelection(); + const dialog = useDialog(); + + const handleMove = () => { + dialog.move(collection, album, get()); + } + const handleDelete = () => { + dialog.delete(collection, album, get()); + } + + if(!isSelecting) + return null; + + return ( +
+ + } /> + } /> + + } /> + } /> + +
+ ); +} + +export default SelectionToolbar; diff --git a/src/Thumb.tsx b/src/Thumb.tsx index 78d3339..ba173c8 100644 --- a/src/Thumb.tsx +++ b/src/Thumb.tsx @@ -17,6 +17,7 @@ import { IconLivePhoto } from '@tabler/icons-react'; import { RenderPhotoProps } from "react-photo-album"; import BoxBar from "./BoxBar"; +import { Selectable } from "./Selection"; import { PhotoImageType } from "./types"; import useFavorite from "./favoriteHook"; import { useDialog } from "./dialogs"; @@ -27,6 +28,14 @@ const boxStyle: SxProps = { backgroundColor: "action.hover", cursor: "pointer", }; + +const selectedStyle: SxProps = { + outline: "5px solid dodgerblue", + outlineOffset: "-5px", + // border: "5px solid dodgerblue", + // boxSizing: "border-box", +}; + const iconsStyle: CSSProperties = { WebkitFilter: "drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.8))", filter: "drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.8))", @@ -45,6 +54,7 @@ export default (photos: PhotoImageType[], showIcons: boolean) => ({ photo, layou const { collection } = useParams(); const dialog = useDialog(); const [mouseOver, setMouseOver] = useState(false); + const [selected, setSelected] = useState(false); const favorite = useFavorite(); const selectedFavorite = favorite.get(); @@ -126,14 +136,17 @@ export default (photos: PhotoImageType[], showIcons: boolean) => ({ photo, layou ); return ( - - {renderDefaultPhoto({ wrapped: true })} - {showIcons && icons} - + + + {renderDefaultPhoto({ wrapped: true })} + {showIcons && icons} + + ); } diff --git a/src/Toolbar.tsx b/src/Toolbar.tsx index b2f4efd..b6ee386 100644 --- a/src/Toolbar.tsx +++ b/src/Toolbar.tsx @@ -1,12 +1,14 @@ import React, {FC, useContext, useState, forwardRef } from "react"; -import { useTheme, styled } from '@mui/material/styles'; +import { useParams } from 'react-router-dom'; import { useDispatch } from 'react-redux'; +import { useTheme, styled } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import AddAlbumIcon from '@mui/icons-material/AddPhotoAlternate'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ArrowRightIcon from '@mui/icons-material/ArrowRight'; import Box from "@mui/material/Box"; +import DifferenceIcon from '@mui/icons-material/Difference'; import Divider from "@mui/material/Divider"; import FavoriteMenu from './FavoriteMenu'; import IconButton from "@mui/material/IconButton"; @@ -20,6 +22,7 @@ import Tooltip from "@mui/material/Tooltip"; import ZoomInIcon from "@mui/icons-material/ZoomInRounded"; import ZoomOutIcon from "@mui/icons-material/ZoomOutRounded"; +import { Upload } from "./Upload"; import { useDialog } from './dialogs'; import { increaseZoom, decreaseZoom } from "./services/app"; @@ -123,9 +126,13 @@ export const ToolbarProvider: FC = ({ children }) => { } const ToolbarMenu : FC = () => { + const { collection, album} = useParams(); const dispatch = useDispatch(); const dialog = useDialog(); + // Do not add buttons that require an album to be opened + const inAlbum = (collection && album); + const zoomIn = () => { dispatch(increaseZoom()); } @@ -135,6 +142,18 @@ const ToolbarMenu : FC = () => { return ( + {inAlbum && [ + (), + ( dialog.duplicates(collection, album)} + icon={} + title="Duplicates" + tooltip="Find duplicated photos in this album" + aria-label="duplicates" />), + (), + ]} + dialog.newAlbum()} icon={} @@ -156,7 +175,6 @@ const ToolbarMenu : FC = () => { aria-label="zoom out" onClick={zoomOut} icon={} /> - ); } diff --git a/src/Upload.tsx b/src/Upload.tsx new file mode 100644 index 0000000..6b66116 --- /dev/null +++ b/src/Upload.tsx @@ -0,0 +1,257 @@ + +import { FC, useState, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; + +import Uploady, { + useUploady, + BatchItem, + FILE_STATES, + useItemAbortListener, + useItemCancelListener, + useItemErrorListener, + useItemFinalizeListener, + useItemFinishListener, + useItemProgressListener, + useItemStartListener, + useBatchFinalizeListener, + useBatchAddListener, + useAbortAll, +} from '@rpldy/uploady'; +import UploadPreview, { + PreviewComponentProps, + PreviewItem, + PreviewMethods +} from "@rpldy/upload-preview"; +import UploadDropZone from "@rpldy/upload-drop-zone"; + +import { Divider, styled } from '@mui/material'; +import AddToPhotosIcon from '@mui/icons-material/AddToPhotos'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import ClearAllIcon from '@mui/icons-material/ClearAll'; +import DangerousIcon from '@mui/icons-material/Dangerous'; +import DoneIcon from '@mui/icons-material/Done'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import ImageNotSupportedIcon from '@mui/icons-material/ImageNotSupported'; +import ListItem from '@mui/material/ListItem'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; + +import api from './services/api'; +import { ToolbarItem } from './Toolbar'; + +const StyledUploadDropZone = styled(UploadDropZone)({ + "&.drag-over": { + backgroundColor: "rgba(128,128,128,0.6)", + } +}); + +const StyledCircularProgress = styled(CircularProgress)({ + position: 'absolute', + top: -2, + left: -2, + zIndex: 1, +}); + +const StyledIcon = styled("div")({ + position: 'absolute', + top: 0, + left: 0, + zIndex: 1, + padding: "8px", + filter: "drop-shadow(0px 0px 1px white)", +}); + +const UploadEntry = ({ type, url, id, name, size }: PreviewComponentProps) => { + const [ item, setItem ] = useState({} as BatchItem); + + const st = item.state; + const isPending = (st === FILE_STATES.PENDING || st === FILE_STATES.ADDED || st === undefined); + const isUploading = (st === FILE_STATES.UPLOADING); + const isDone = (st === FILE_STATES.FINISHED); + const isError = (st === FILE_STATES.ABORTED || st === FILE_STATES.CANCELLED || st === FILE_STATES.ERROR); + + // Catch all item updates + useItemAbortListener((item) => setItem(item), id); + useItemCancelListener((item) => setItem(item), id); + useItemErrorListener((item) => setItem(item), id); + useItemFinalizeListener((item) => setItem(item), id); + useItemFinishListener((item) => setItem(item), id); + useItemProgressListener((item) => setItem(item), id); + useItemStartListener((item) => setItem(item), id); + + return ( + + + + + + + {isPending && } + {isUploading && } + {isDone && } + {isError && } + + + + + ); +} + + +interface UploadMenuProps { + open: boolean; + anchorEl: Element | null; + onOpen: () => void; + onClose: () => void; +} + +const UploadMenu: FC = ({ open, anchorEl, onOpen, onClose }) => { + const { collection, album } = useParams(); + const dispatch = useDispatch(); + const uploady = useUploady(); + const abortAll = useAbortAll(); + const previewMethodsRef = useRef(null); + const [inProgress, setInProgress] = useState(false); + const [isEmpty, setIsEmpty] = useState(false); + + const handleUpload = () => { + uploady.showFileUpload(); + }; + + // Open menu when new uploads are added + useBatchAddListener(() => { + onOpen(); + setInProgress(true); + }); + // Reload album after uploading + useBatchFinalizeListener(() => { + dispatch(api.util.invalidateTags([{ type: 'Album', id: `${collection}:${album}` }])); + setInProgress(false); + }); + + const onAbortOrClear = () => { + if(inProgress) + abortAll(); + else + previewMethodsRef.current?.clear(); + }; + + const onPreviewsChanged = (items: PreviewItem[]) => { + setIsEmpty(items.length === 0); + }; + + return ( + + + + Add photos + + + + {!isEmpty && + // Button to clear uploading the list + + + {inProgress ? : } + + + {inProgress ? "Stop current uploads": "Clear all" } + + + } + + <> + {isEmpty && + // Empty list for upload + + No items for uploading} + secondary={Use the button above ↑ to add more photos
Or
Drag and drop files here!
} /> +
+ } + + + +
+
+ ); +} + +interface UploadProviderProps { + children?: JSX.Element | JSX.Element[]; +} + +export const UploadProvider: FC = ({children}) => { + const { collection, album } = useParams(); + + const uploadUrl = (!collection || !album) ? undefined : + `/api/collections/${collection}/albums/${album}/photos`; + + return ( + + {children} + + ); +}; + +export const Upload: FC = () => { + const { collection, album } = useParams(); + const dropdownRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleOpenMenu = () => { + setOpen(true); + }; + + const handleCloseMenu = () => { + setOpen(false); + }; + + + // Do not add the upload button when not in a album + if(!collection || !album) + return null; + + return (<> + } + onClick={handleOpenMenu} + title="Upload" + aria-label="upload photos" + tooltip="Upload photos to this album" + subMenu + showTitle + /> + + + ); +} diff --git a/src/dialogs/Delete.tsx b/src/dialogs/Delete.tsx new file mode 100644 index 0000000..3b01ba3 --- /dev/null +++ b/src/dialogs/Delete.tsx @@ -0,0 +1,110 @@ +import { FC, useState } from 'react'; + +import Button from '@mui/material/Button'; +import CloseIcon from '@mui/icons-material/Close'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import IconButton from '@mui/material/IconButton'; +import FormControl from '@mui/material/FormControl'; +import TextField from '@mui/material/TextField'; + +import PhotoAlbum from 'react-photo-album'; + +import { QueryDeletePhotos, useDeletePhotosMutation } from '../services/api'; +import useNotification from '../Notification'; +import { PhotoImageType } from '../types'; + +interface DialogProps { + open: boolean; + collection: string; + album: string; + photos: PhotoImageType[]; + onClose: () => void; +} + +/* Dialog for delete action */ +const DeleteDialog: FC = ({collection, album, photos, open, onClose}) => { + const [deletePhotos] = useDeletePhotosMutation(); + const [answer, setAnswer] = useState(); + const [processingAction, setProcessingAction] = useState(false); + + const { successNotification, errorNotification } = useNotification(); + + const answerOk = (answer?.toLocaleLowerCase() === "yes"); + + const handleAnswer = (event: React.ChangeEvent) => { + setAnswer(event.target.value) + } + const handleClose = () => { + setAnswer(""); + onClose(); + } + + const handleDelete = async () => { + const query: QueryDeletePhotos = { + collection, + album, + target: { + photos: photos.map(photo => photo.id), + } + }; + + setProcessingAction(true); + try { + await deletePhotos(query).unwrap(); + successNotification(`${photos.length} photos were deleted`); + handleClose(); + } + catch(error) { + errorNotification("An error occured while deleting photos!"); + console.log(error); + } + setProcessingAction(false); + } + + return ( + + + Delete photos + + + + + + + Photos to be deleted: + + + + + + This will permanently delete the selected photos. If you are sure about that, + please type "yes" in the box bellow and press "Delete": + + + + + + + + + ) +} + +export default DeleteDialog; diff --git a/src/dialogs/DeleteAlbum.tsx b/src/dialogs/DeleteAlbum.tsx new file mode 100644 index 0000000..d90fd6b --- /dev/null +++ b/src/dialogs/DeleteAlbum.tsx @@ -0,0 +1,58 @@ +import { FC } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; + +import useNotification from '../Notification'; +import { useDeleteAlbumMutation } from '../services/api'; + +interface DialogProps { + open: boolean; + collection: string; + album: string; + onClose: () => void; +} + +const DeleteAlbumDialog: FC = ({collection, album, open, onClose}) => { + const navigate = useNavigate(); + const [deleteAlbum] = useDeleteAlbumMutation(); + const { successNotification, errorNotification } = useNotification(); + + const handleClose = () => { + onClose(); + } + + const handleDeleteAlbum = async () => { + try { + await deleteAlbum({ collection, album }).unwrap(); + successNotification(`Album ${album} successfully deleted`); + handleClose(); + navigate("/" + collection); + } + catch (error: any) { + errorNotification(`Error deleting album: ${error.data.message}!`); + console.log(error); + } + } + + return ( + + Deleting album {album} + + This action cannot be undone. Are you sure you want to delete this album? + The album will be deleted only if it is empty. + + + + + + + ); +} + +export default DeleteAlbumDialog; diff --git a/src/dialogs/Duplicates.tsx b/src/dialogs/Duplicates.tsx new file mode 100644 index 0000000..28da3f7 --- /dev/null +++ b/src/dialogs/Duplicates.tsx @@ -0,0 +1,452 @@ +import { FC, Fragment, useEffect, useState } from 'react'; +import { useTheme, SxProps, Theme } from "@mui/material/styles"; +import useMediaQuery from '@mui/material/useMediaQuery'; + +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import Avatar from '@mui/material/Avatar'; +import Badge from '@mui/material/Badge'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Chip from '@mui/material/Chip'; +import CloseIcon from '@mui/icons-material/Close'; +import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove'; +import Divider from '@mui/material/Divider'; +import FavoriteIcon from '@mui/icons-material/Favorite'; +import FileCopyIcon from '@mui/icons-material/FileCopy'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import IconButton from '@mui/material/IconButton'; +import InputLabel from '@mui/material/InputLabel'; +import LinearProgress from '@mui/material/LinearProgress'; +import Link from '@mui/material/Link'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemText from '@mui/material/ListItemText'; +import MenuItem from '@mui/material/MenuItem'; +import NewReleasesIcon from '@mui/icons-material/NewReleases'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import PinDropIcon from '@mui/icons-material/PinDrop'; +import RuleIcon from '@mui/icons-material/Rule'; +import SaveIcon from '@mui/icons-material/Save'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Tooltip from '@mui/material/Tooltip'; +import WarningIcon from '@mui/icons-material/Warning'; + +import { useDialog } from '.'; +import { Duplicate, ResponseDuplicates, useDuplicatedPhotosQuery } from '../services/api'; +import { SelectionProvider, Selectable, useSelection } from '../Selection'; +import { PhotoImageType, PhotoType, PseudoAlbumType, urls } from '../types'; +import useFavorite from '../favoriteHook'; + +const selectedStyle: SxProps = { + outline: "5px solid dodgerblue", + outlineOffset: "-5px", +}; + +interface ItemProps { + item: T; +} +const ItemDuplicated: FC> = ({item: {photo, found}}) => { + const dialog = useDialog(); + + const handleOpenPhoto = () => { + dialog.lightbox([photo, ...found.map(i => i.photo)], 0); + } + + return (<> + + + + ( + + {(partial || incomplete || conflict || samealbum) && + + {partial &&
  • Partial: found photo does not have all files
  • } + {incomplete &&
  • Incomplete: the photo is missing some files, found entry is more complete
  • } + {conflict &&
  • Conflict: the photo and the found entry both have missing files
  • } + {samealbum &&
  • Same Album: duplicated in the same album
    Do not delete all matches!
  • } + }> + +
    + } + {collection}: {album} - {id} + +
    + {files.map((f, i) => + {f.from < 0 ? <>∅ : photo.files[f.from].id} ↔ {f.to < 0 ? <>∅ : foundFiles[f.to].id}
    +
    )} +
    ) + )} + /> + ); +} +const ItemUnique: FC> = ({item}) => { + const dialog = useDialog(); + + const handleOpenPhoto = () => { + dialog.lightbox([item], 0); + } + + return (<> + + + + {item.files.map((f, i) => {f.id}
    )}} + /> + ); +} + +interface SelectableItemProps { + item: T; + component: FC>; +} +function SelectableItem({ item, component }: SelectableItemProps) { + const [selected, setSelected] = useState(false); + const Item = component; + + const handleSelect = (state: boolean) => { + setSelected(state); + } + + return ( + item={item} onChange={handleSelect}> + + + + + + ); +} + +interface ListItemsProps { + items: T[]; + component: FC>; +} +function ListItems({ items, component }: ListItemsProps) { + const { all, cancel } = useSelection(); + + if(items.length < 1) + return No items to display; + + return (<> + + + {items.map((item, index) => ())} + + + Select All -  + Clear Selection + + ); +} + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +interface DialogProps { + open: boolean; + collection: string; + album: string; + onClose: () => void; +} + +const defaultData: ResponseDuplicates = { + total: 0, + albums: [], + keep: [], + unique: [], + delete: [], + conflict: [], + samealbum: [], + countKeep: 0, + countDelete: 0, + countUnique: 0, + countConflict: 0, + countSameAlbum: 0 +} + +function filterList(list: Duplicate[], filter: PseudoAlbumType[], onlyMultiple: boolean): Duplicate[] { + const validList = list || []; + + const filteredAlbums = filter.length < 1 ? validList : + validList.map(d => ({ + ...d, + found: d.found.filter(p => filter.some(f => f.collection === p.photo.collection && f.album === p.photo.album)) + })).filter(d => d.found.length > 0); + + return onlyMultiple ? + filteredAlbums.filter(d => d.found.length > 1) : + filteredAlbums; +} + +const DuplicatesDialog: FC = ({open, collection, album, onClose}) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const dialog = useDialog(); + const favorite = useFavorite(); + const { data = defaultData, isFetching } = useDuplicatedPhotosQuery({ collection, album }, {skip: !open}); + const [albumsFilter, setAlbumsFilter] = useState([]); + const [onlyMultiple, setOnlyMultiple] = useState(false); + const [tab, setTab] = useState(0); + const [isSelectingDups, setSelectingDups] = useState(false); + const [isSelectingUniq, setSelectingUniq] = useState(false); + const [selDups, setSelDups] = useState([]); + const [selUniq, setSelUniq] = useState([]); + + const noSelection = isFetching || + // Delete, Keep, Conflict, Same + ((tab === 0 || tab === 1 || tab === 2 || tab === 3) && !isSelectingDups) || + // Unique + (tab === 4 && !isSelectingUniq); + + // Filter by selected albums + const filter = albumsFilter.map(album => JSON.parse(album) as PseudoAlbumType); + const listDelete = filterList(data.delete, filter, onlyMultiple); + const listKeep = filterList(data.keep, filter, onlyMultiple); + const listConflict = filterList(data.conflict, filter, onlyMultiple); + const listSameAlbum = filterList(data.samealbum, filter, onlyMultiple); + const listUnique = data.unique || []; + + // Clear album filter when opening + useEffect(() => setAlbumsFilter([]), [open]); + + const handleChangeAlbumsFilter = (event: SelectChangeEvent) => { + const val = event.target.value; + // On autofill we get a stringified value. + setAlbumsFilter(typeof val === "string" ? [val] : val); + }; + + const handleOnlyMultiple = (event: React.ChangeEvent) => { + setOnlyMultiple(event.target.checked); + }; + + const handleClose = () => { + onClose(); + }; + + const handleFavorite = async () => { + const isSelectingDupsBak = isSelectingDups; + const isSelectingUniqBak = isSelectingUniq; + setSelectingDups(false); + setSelectingUniq(false); + + const grouped = new Map>>(); + + if(tab === 0 || tab === 1 || tab === 2 || tab === 3) { + selDups.forEach(duplicate => { + duplicate.found.forEach(({photo}) => { + // Collection + const collection = grouped.get(photo.collection) || new Map>(); + if (!grouped.has(photo.collection)) + grouped.set(photo.collection, collection); + // Album + const album = collection.get(photo.album) || new Set(); + if (!collection.has(photo.album)) + collection.set(photo.album, album); + // Photo + album.add(photo.id); + }); + }); + } else if(tab === 4) { + selUniq.forEach(entry => { + // Collection + const collection = grouped.get(entry.collection) || new Map>(); + if (!grouped.has(entry.collection)) { + grouped.set(entry.collection, collection); + } + // Album + const album = collection.get(entry.album) || new Set(); + if (!collection.has(entry.album)) { + collection.set(entry.album, album); + } + // Photo + album.add(entry.id); + }) + } + + const promises: Promise[] = []; + grouped.forEach((collectionMap, collection) => { + collectionMap.forEach(async (albumSet, album) => { + promises.push(favorite.save({ collection, album, photos: Array.from(albumSet) }, [], true)); + }); + }); + await Promise.all(promises); + + setSelectingDups(isSelectingDupsBak); + setSelectingUniq(isSelectingUniqBak); + }; + + const handleMove = () => { + const selection = + tab === 0 || tab === 1 || tab === 2 || tab === 3 ? selDups.map(item => item.photo) : + tab === 4 ? selUniq : []; + // Create urls for thumbnails + const photos: PhotoImageType[] = selection.map(photo => ({ ...photo, src: urls.thumb(photo) })); + // Show move dialog + dialog.move(collection, album, photos); + onClose(); + }; + + const handleDelete = () => { + const selection = + tab === 0 || tab === 1 || tab === 2 || tab === 3 ? selDups.map(item => item.photo) : + tab === 4 ? selUniq : []; + // Create urls for thumbnails + const photos: PhotoImageType[] = selection.map(photo => ({ ...photo, src: urls.thumb(photo) })); + // Show delete dialog + dialog.delete(collection, album, photos); + onClose(); + }; + + const handleChangeTab = (_event: React.SyntheticEvent, newValue: number) => { + setTab(newValue); + // Selection is lost when swapping between tabs + setSelectingDups(false); + setSelectingUniq(false); + setSelDups([]); + setSelUniq([]); + }; + + return ( + + + Duplicated photos + + + + + + {isFetching ? ( + // Render progressbar while loading + + + + ):(<> + + The following photos were found duplicated in another places. From the list bellow, + please select which photos you want to bookmark, move or delete. + + + + Filter albums + + + + } label="Show only photos with multiple matches" /> + + + + } iconPosition="start" label="Delete" /> + } iconPosition="start" label="Keep" /> + } iconPosition="start" label="Conflict" /> + } iconPosition="start" label="Same" /> + } iconPosition="start" label="Unique" /> + {/* */} + + + onChange={setSelDups} onIsSelecting={setSelectingDups} itemToId={i => `${i.photo.collection}:${i.photo.album}:${i.photo.id}`}> + + + + + onChange={setSelDups} onIsSelecting={setSelectingDups} itemToId={i => `${i.photo.collection}:${i.photo.album}:${i.photo.id}`}> + + + + + onChange={setSelDups} onIsSelecting={setSelectingDups} itemToId={i => `${i.photo.collection}:${i.photo.album}:${i.photo.id}`}> + + + + + onChange={setSelDups} onIsSelecting={setSelectingDups} itemToId={i => `${i.photo.collection}:${i.photo.album}:${i.photo.id}`}> + + + + + onChange={setSelUniq} onIsSelecting={setSelectingUniq} itemToId={i => `${i.collection}:${i.album}:${i.id}`}> + + + + {!noSelection && + + Note well + Moving and deleting are performed on photos of this album, whilst bookmarking is over the photos found. + + } + )} + + + + + + + + + + ); +} + +export default DuplicatesDialog; diff --git a/src/dialogs/Move.tsx b/src/dialogs/Move.tsx new file mode 100644 index 0000000..aaf581b --- /dev/null +++ b/src/dialogs/Move.tsx @@ -0,0 +1,232 @@ +import { FC, useEffect, useState } from 'react'; + +import Autocomplete from '@mui/material/Autocomplete'; +import Button from '@mui/material/Button'; +import CloseIcon from '@mui/icons-material/Close'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormHelperText from '@mui/material/FormHelperText'; +import FormLabel from '@mui/material/FormLabel'; +import IconButton from '@mui/material/IconButton'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Radio from '@mui/material/Radio'; +import RadioGroup from '@mui/material/RadioGroup'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; +import TextField from '@mui/material/TextField'; + +import PhotoAlbum from 'react-photo-album'; + +import { + QueryAddAlbum, + QueryMovePhotos, + useAddAlbumMutation, + useGetAlbumsQuery, + useGetCollectionsQuery, + useMovePhotosMutation +} from '../services/api'; +import useNotification from '../Notification'; +import { PhotoImageType, MoveConflictMode } from '../types'; + +interface DialogProps { + open: boolean; + collection: string; + album: string; + photos: PhotoImageType[]; + onClose: () => void; +} + +const isValidAlbumName = (function () { + const rg1 = /^[^\\/:*?"<>|]+$/; // forbidden characters \ / : * ? " < > | + const rg2 = /^\./; // cannot start with dot (.) + const rg3 = /^(nul|prn|con|lpt[0-9]|com[0-9])(\.|$)/i; // forbidden file names + return (fname: string) => rg1.test(fname) && !rg2.test(fname) && !rg3.test(fname); +})(); + +function mostCommon(arr: PhotoImageType[]): string { + const dates = arr + .filter(v => !v?.date?.startsWith("0001-01-01")) + .map(v => v.date.slice(0, v.date.lastIndexOf('T'))); + + const dist: {[key: string]: number} = {}; + let best = ""; + dates.forEach((v) => { + dist[v] = (dist[v] || 0) + 1; + if(dist[v] > (dist[best] || 0)) + best = v; + }); + return best; +} + +/* Dialog for move action */ +const MoveDialog: FC = ({collection, album, photos, open, onClose}) => { + const [movePhotos] = useMovePhotosMutation(); + const [addAlbum] = useAddAlbumMutation(); + const [targetCollection, setTargetCollection] = useState(collection); + const [targetAlbum, setTargetAlbum] = useState(""); + const [mode, setMode] = useState(MoveConflictMode.Cancel); + const [isNewAlbum, setIsNewAlbum] = useState(false); + const [errorName, setErrorName] = useState(false); + const [processingAction, setProcessingAction] = useState(false); + + const { successNotification, errorNotification } = useNotification(); + const { data: collections = [], isFetching } = useGetCollectionsQuery(); + const { data: tmpAlbums = [] } = useGetAlbumsQuery({ collection: targetCollection }, { skip: !open || isFetching || targetCollection === "" }); + const albums = tmpAlbums.filter(v => !v.pseudo && v.name !== album); + + // Set initial values + // collection + useEffect(() => { + if(!isFetching && targetCollection === "") + setTargetCollection(collection) + }, [open, collection, targetCollection, isFetching]); + // album + useEffect(() => setTargetAlbum(mostCommon(photos)), [open, album, photos]); + + // Find out if it is a new album + useEffect(() => { + // Check if is not moving for the same album or it is a valid name + if((targetCollection === collection && targetAlbum === album) || !isValidAlbumName(targetAlbum)) { + setErrorName(true); + return; + } + // all good + setErrorName(false); + setIsNewAlbum(albums.find(a => a.name === targetAlbum) === undefined); + }, [collection, targetCollection, album, albums, targetAlbum]); + + const changeCollection = (event: SelectChangeEvent) => { + setTargetCollection(event.target.value); + }; + + const changeAlbum = (_event: React.SyntheticEvent, value: string | null) => { + setTargetAlbum(value?.trim() || ""); + }; + + const changeMode = (_event: React.ChangeEvent, value: string) => { + setMode(value as MoveConflictMode); + }; + + const handleClose = () => { + onClose(); + } + + const handleMove = async () => { + if(isNewAlbum) { + const addAlbumData: QueryAddAlbum = { + collection: targetCollection, + name: targetAlbum.trim(), + type: "regular" + } + + // Form validation + if(!addAlbumData.name) + return; + + setProcessingAction(true); + try { + await addAlbum(addAlbumData).unwrap(); + successNotification(`Album created with name ${addAlbumData.name}`); + } + catch(error) { + setProcessingAction(false); + errorNotification(`Could not create album named ${addAlbumData.name}!`); + console.log(error); + return; + } + } + + const query: QueryMovePhotos = { + collection, + album, + target: { + mode, + collection: targetCollection, + album: targetAlbum.trim(), + photos: photos.map(photo => photo.id), + } + }; + + setProcessingAction(true); + try { + const stats = await movePhotos(query).unwrap(); + successNotification(`${stats.moved_photos} photos (${stats.moved_files} files) + were moved to ${album}, ${stats.skipped} skipped and ${stats.renamed} renamed.`); + onClose(); + } + catch(error: any) { + errorNotification(`Error while moving photos: ${error.data.message}!`); + console.log(error); + } + setProcessingAction(false); + } + + return ( + + + Move photos + + + + + + + Select the destination for your photos: + + + Collection + + + + a.name)} + value={targetAlbum} + onChange={changeAlbum} + color='error' + renderInput={(params) => + } + /> + {errorName && "{targetAlbum}" is an invalid album name} + {!errorName && isNewAlbum && "{targetAlbum}" does not exist, a regular album will be created} + {!errorName && !isNewAlbum && Photos will moved into the album "{targetAlbum}"} + + + + On conflict + + } label="Cancel" title="Cancel everything before making any changes" /> + } label="Skip" title="Skip files in conflict and move everything else" /> + } label="Rename" title="Rename files in conflict by adding a sequence number between parenthesis" /> + + + + + + Photos to be moved: + + + + + + + + + ); +} + +export default MoveDialog; diff --git a/src/dialogs/index.tsx b/src/dialogs/index.tsx index 3296992..a17031a 100644 --- a/src/dialogs/index.tsx +++ b/src/dialogs/index.tsx @@ -1,6 +1,10 @@ -import React, { useState } from 'react' +import React, { useState } from 'react'; +import DeleteAlbumDialog from './DeleteAlbum'; +import DeleteDialog from './Delete'; +import DuplicatesDialog from './Duplicates'; import Lightbox from './Lightbox'; +import MoveDialog from './Move'; import NewAlbumDialog from './NewAlbum'; import PhotoInfoDialog from './PhotoInfo'; @@ -10,18 +14,37 @@ interface DialogContext { lightbox(photos: PhotoType[], selected: number): void; newAlbum(): void; info(photos: PhotoType[], selected: number): void; + move(collection: string, album: string, photos: PhotoImageType[]): void; + delete(collection: string, album: string, photos: PhotoImageType[]): void; + deleteAlbum(collection: string, album: string): void; + duplicates(collection: string, album: string): void; } const DialogContext = React.createContext({ lightbox: function (): void {}, newAlbum: function (): void {}, info: function (): void {}, + move: function (): void {}, + delete: function (): void {}, + deleteAlbum: function (): void {}, + duplicates: function (): void {}, }); interface DialogProviderProps { children?: React.ReactNode; } +interface CollectionAlbum { + collection: string; + album: string; +} + +interface CollectionAlbumPhotos { + collection: string; + album: string; + photos: PhotoImageType[]; +} + interface SelectedPhotos { photos: PhotoImageType[]; selected: number; @@ -31,6 +54,10 @@ const DialogProvider: React.FC = ({ children }) => { const [lightbox, setLightbox] = useState(null); const [newAlbum, setNewAlbum] = useState(false); const [info, setInfo] = useState(null); + const [move, setMove] = useState(null); + const [del, setDelete] = useState(null); + const [deleteAlbum, setDeleteAlbum] = useState(null); + const [duplicates, setDuplicates] = useState(null); // Lightbox const openLightbox = (photos: PhotoImageType[], selected: number) => { @@ -53,12 +80,44 @@ const DialogProvider: React.FC = ({ children }) => { const closeInfo = () => { setInfo(null); } + // Move + const openMove = (collection: string, album: string, photos: PhotoImageType[]) => { + setMove({collection, album, photos}); + } + const closeMove = () => { + setMove(null); + } + // Delete + const openDelete = (collection: string, album: string, photos: PhotoImageType[]) => { + setDelete({collection, album, photos}); + } + const closeDelete = () => { + setDelete(null); + } + // Delete Album + const openDeleteAlbum = (collection: string, album: string) => { + setDeleteAlbum({collection, album}); + } + const closeDeleteAlbum = () => { + setDeleteAlbum(null); + } + // Duplicates + const openDuplicates = (collection: string, album: string) => { + setDuplicates({collection, album}); + } + const closeDuplicates = () => { + setDuplicates(null); + } return ( { children } @@ -79,6 +138,32 @@ const DialogProvider: React.FC = ({ children }) => { selected={info?.selected || 0} onClose={closeInfo} /> + + + + + + + + ); } diff --git a/src/favoriteHook.ts b/src/favoriteHook.ts index 4de7134..2f1fecf 100644 --- a/src/favoriteHook.ts +++ b/src/favoriteHook.ts @@ -3,12 +3,14 @@ import { useParams } from "react-router-dom"; import { selectFavorite } from "./services/app"; import { QuerySaveFavorite, useSavePhotoToPseudoMutation } from './services/api'; +import { useSelection } from "./Selection"; import { PhotoImageType, PhotoType, PseudoAlbumType } from './types'; import useNotification from "./Notification"; const useFavorite = () => { const favorite = useSelector(selectFavorite); const { collection, album } = useParams(); + const { indexes: getSelection } = useSelection(); const [saveFavorite] = useSavePhotoToPseudoMutation(); const { infoNotification, errorNotification } = useNotification(); @@ -32,7 +34,8 @@ const useFavorite = () => { return; } - const indexes = [index]; + const selection = getSelection(); + const indexes = selection.includes(index) ? selection : [index]; const isFavorite = !(favoriteStatus(photos[index].favorite).isFavoriteThis); const saveData = { diff --git a/src/selection.scss b/src/selection.scss new file mode 100644 index 0000000..31d8866 --- /dev/null +++ b/src/selection.scss @@ -0,0 +1,14 @@ +.selection_ { + &_not-draggable, &_not-draggable img { + -webkit-user-drag: none; + -khtml-user-drag: none; + -moz-user-drag: none; + -o-user-drag: none; + user-drag: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -o-user-select: none; + user-select: none; + } +} diff --git a/src/services/api.ts b/src/services/api.ts index 182b34f..e9ba65a 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,7 +1,11 @@ import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; -import { CollectionType, PseudoAlbumType, AlbumType, PhotoType } from "../types"; +import { CollectionType, PseudoAlbumType, AlbumType, PhotoType, MoveConflictMode } from "../types"; import { changeFavorite } from "./app"; +export interface ResponseError { + message: string; +} + export interface QueryAlbums { collection?: string; } @@ -23,6 +27,32 @@ export interface QueryPhoto { photo?: string; } +export interface QueryMovePhotos { + collection: CollectionType["name"]; + album: AlbumType["name"]; + target: { + mode: MoveConflictMode; + collection: CollectionType["name"]; + album: AlbumType["name"]; + photos: PhotoType["title"][]; + } +} + +export interface ResponseMovePhotos { + moved_photos: number; + moved_files: number; + skipped: number; + renamed: number; +} + +export interface QueryDeletePhotos { + collection: CollectionType["name"]; + album: AlbumType["name"]; + target: { + photos: PhotoType["title"][]; + } +} + export interface QuerySaveFavorite { collection: CollectionType["name"]; album: AlbumType["name"]; @@ -35,6 +65,37 @@ export interface QuerySaveFavorite { } } +export interface Duplicate { + photo: PhotoType; + found: { + photo: PhotoType; + files: { + from: number; + to: number; + }[]; + // Flags + equal: boolean; // All files were matched + partial: boolean; // Only some files were matched + incomplete: boolean; // Found photos with more files + conflict: boolean; // Combination of Partial and Incomplete + samealbum: boolean; // Photos are in the same album + }[]; +} +export interface ResponseDuplicates { + albums: PseudoAlbumType[]; + keep: Duplicate[]; + delete: Duplicate[]; + unique: PhotoType[]; + conflict: Duplicate[]; + samealbum: Duplicate[]; + countKeep: number; + countDelete: number; + countUnique: number; + countConflict: number; + countSameAlbum: number; + total: number; +} + const albumId = (arg: { collection: string, album: string }) => arg.collection + ":" + arg.album; export const api = createApi({ @@ -67,6 +128,37 @@ export const api = createApi({ }), invalidatesTags: [ 'Pseudo', 'Albums'], }), + deleteAlbum: builder.mutation({ + query: ({ collection, album }) => ({ + url: `/collections/${collection}/albums/${album}`, + method: 'DELETE', + }), + invalidatesTags: (_result, _error, arg) => ['Pseudo', 'Albums', { type: 'Album', id: albumId(arg) }], + }), + duplicatedPhotos: builder.query({ + query: ({ collection, album }) => `/collections/${collection}/albums/${album}/duplicates` + }), + movePhotos: builder.mutation({ + query: ({ collection, album, target }) => ({ + url: `/collections/${collection}/albums/${album}/photos/move`, + method: 'PUT', + body: target, + }), + invalidatesTags: (_result, _error, arg) => [ + { type: 'Album', id: albumId(arg) }, + { type: 'Album', id: albumId(arg.target) } + ], + }), + deletePhotos: builder.mutation({ + query: ({ collection, album, target }) => ({ + url: `/collections/${collection}/albums/${album}/photos`, + method: 'DELETE', + body: target, + }), + invalidatesTags: (_result, _error, arg) => [ + { type: 'Album', id: albumId(arg) }, + ], + }), getPhotoInfo: builder.query({ query: ({collection, album, id }) => `/collections/${collection}/albums/${album}/photos/${id}/info`, }), @@ -109,6 +201,12 @@ export const { useGetAlbumsQuery, useGetAlbumQuery, useAddAlbumMutation, + useDeleteAlbumMutation, + useDuplicatedPhotosQuery, + useMovePhotosMutation, + useDeletePhotosMutation, useGetPhotoInfoQuery, useSavePhotoToPseudoMutation, } = api; + +export default api; diff --git a/src/types.ts b/src/types.ts index 500dabc..dca5fd8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,6 +57,12 @@ export interface FileType { export type PhotoImageType = PhotoType & Image; +export enum MoveConflictMode { + Cancel = "cancel", + Skip = "skip", + Rename = "rename", +} + export const urls = { thumb: (photo: PhotoType) => `/api/collections/${photo.collection}/albums/${photo.album}/photos/${photo.id}/thumb`, file: (photo: PhotoType, file: FileType) => `/api/collections/${photo.collection}/albums/${photo.album}/photos/${photo.id}/files/${file.id}`,