diff --git a/go.mod b/go.mod index 8f385f8..851d87a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module go.balki.me/collage-maker -go 1.21 +go 1.22 require ( go.balki.me/anyhttp v0.3.0 diff --git a/main.go b/main.go index 7442ec0..24b2bdb 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "io" "io/fs" "net/http" + "net/url" "os" "path" "regexp" @@ -23,29 +24,45 @@ import ( ) var ( - imagesDir string - collageDir string - devMode bool - collageNameGen *nameGen - imagesDirFs fs.FS - listenAddr string + imagesDir string + collageDir string + photosDir string + devMode bool + collageNameGen *nameGen + imagesDirFs fs.FS + listenAddr string + photoPrismURL *url.URL + photoPrismToken string //go:embed web webFS embed.FS ) func main() { + var ppURL string + flag.StringVar(&imagesDir, "images-dir", "images", "Sets the images dir") flag.StringVar(&collageDir, "collages-dir", "collages", "Sets the collages dir") + flag.StringVar(&photosDir, "photos-dir", "photos", "Cache directory for downloaded photos") flag.BoolVar(&devMode, "dev", false, "Serve local assets during development") flag.StringVar(&listenAddr, "addr", "127.0.0.1:8767", "Web listen address, see https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax") + flag.StringVar(&ppURL, "pp-url", "", "Base url for photoprism") + flag.StringVar(&photoPrismToken, "pp-token", "", "API token for photoprism") flag.Parse() + photoPrismURL = func() *url.URL { + photoPrismURL, err := url.Parse(ppURL) + if err != nil { + panic(err) + } + return photoPrismURL + }() collageNameGen = NewNameGen() imagesDirFs = os.DirFS(imagesDir) imagesURLPath := "images" collagesPath := "collages" + photosPath := "photos" addFileServer := func(path, dir string) { httpFileServer := http.FileServer(http.Dir(dir)) @@ -54,6 +71,7 @@ func main() { addFileServer(imagesURLPath, imagesDir) addFileServer(collagesPath, collageDir) + addFileServer(photosPath, photosDir) if devMode { httpFileServer := http.FileServer(http.Dir("web")) @@ -100,16 +118,65 @@ func main() { w.WriteHeader(http.StatusInternalServerError) return } - collageFile, err := MakeCollage(&collageReq) + collageFilePath, err := MakeCollage(&collageReq) if err != nil { slog.Error("failed to make collage", "error", err) w.WriteHeader(http.StatusInternalServerError) return } - if _, err := w.Write([]byte(collageFile)); err != nil { + if _, err := w.Write([]byte(collageFilePath)); err != nil { slog.Error("Failed to write collageFile", "error", err) } }) + + http.HandleFunc("/get-albums", func(w http.ResponseWriter, r *http.Request) { + albums, err := GetAlbums() + if err != nil { + slog.Error("failed to get albums", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + jData, err := json.Marshal(albums) + if err != nil { + slog.Error("failed to marshal albums", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jData) + }) + + http.HandleFunc("/load-photos/{albumID}", func(w http.ResponseWriter, r *http.Request) { + albumID := r.PathValue("albumID") + photos, err := LoadPhotos(albumID) + if err != nil { + slog.Error("failed to load photos", "error", err, "album", albumID) + w.WriteHeader(http.StatusInternalServerError) + return + } + photoURLs := []string{} + for _, photo := range photos { + photoURLs = append(photoURLs, fmt.Sprintf("/%s/%s.jpg", photosPath, photo.FileUID)) + } + go func() { + for _, photo := range photos { + _, err := DownloadPhoto(&photo) + if err != nil { + slog.Error("failed to download photo", "error", err, "album", albumID, "photoID", photo.FileUID) + } + } + }() + + jData, err := json.Marshal(photoURLs) + if err != nil { + slog.Error("failed to marshal photoURLs", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jData) + }) + addrType, server, done, err := anyhttp.Serve(listenAddr, idle.WrapHandler(nil)) if err != nil { slog.Error("anyhttp Serve failed", "error", err) @@ -141,3 +208,120 @@ func MakeCollage(req *collage.Request) (string, error) { } return collageFile, nil } + +type Album struct { + Title string `json:"Title"` + UID string `json:"UID"` +} + +func GetAlbums() ([]Album, error) { + albumURL := func() string { + u := *photoPrismURL + u.Path = "/api/v1/albums" + v := url.Values{} + v.Add("count", "20") + v.Add("type", "album") + u.RawQuery = v.Encode() + return u.String() + }() + + req, err := http.NewRequest("GET", albumURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", photoPrismToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + albums := []Album{} + + err = json.Unmarshal(respBytes, &albums) + if err != nil { + return nil, err + } + + return albums, nil +} + +type Photo struct { + Hash string `json:"Hash"` + FileUID string `json:"FileUID"` +} + +func LoadPhotos(albumID string) ([]Photo, error) { + loadPhotosURL := func() string { + u := *photoPrismURL + u.Path = "/api/v1/photos" + v := url.Values{} + v.Add("count", "50") + v.Add("s", albumID) + v.Add("merged", "true") + v.Add("video", "false") + u.RawQuery = v.Encode() + return u.String() + + }() + + req, err := http.NewRequest("GET", loadPhotosURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", photoPrismToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + photos := []Photo{} + err = json.Unmarshal(respBytes, &photos) + if err != nil { + return nil, err + } + + return photos, nil +} + +func DownloadPhoto(photo *Photo) (string, error) { + photoPath := path.Join(photosDir, fmt.Sprintf("%s.jpg", photo.FileUID)) + _, err := os.Stat(photoPath) + if err == nil { + return photoPath, nil + } else if !os.IsNotExist(err) { + return "", err + } + out, err := os.Create(photoPath) + if err != nil { + return "", fmt.Errorf("failed to create collage output file, err: %w", err) + } + defer out.Close() + downloadPhotoURL := func() string { + u := *photoPrismURL + u.Path = fmt.Sprintf("/api/v1/dl/%s", photo.Hash) + return u.String() + }() + req, err := http.NewRequest("GET", downloadPhotoURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", photoPrismToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + + _, err = io.Copy(out, resp.Body) + if err != nil { + return "", err + } + + return photoPath, nil +} diff --git a/web/choose.css b/web/choose.css new file mode 100644 index 0000000..5e09e58 --- /dev/null +++ b/web/choose.css @@ -0,0 +1,47 @@ +#album_photos { + display: flex; + gap: 1rem; + flex-wrap: wrap; + background-color: lightblue; + padding: 1rem; +} + +#album_photos img { + width: 200px; + height: 200px; +} + +#selected_photos { + display: flex; + gap: 1rem; + flex-wrap: wrap; + background-color: lightyellow; + padding: 1rem; +} + +#selected_photos img { + width: 100px; + height: 100px; +} + +.container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.controls { + background-color: lightgrey; + display: flex; + gap: 1rem; + padding: 1rem; +} + +#album_selector { + width: 400px; +} + +.current { + border: 5px solid; + border-color: red; +} diff --git a/web/choose.html b/web/choose.html new file mode 100644 index 0000000..08a55ee --- /dev/null +++ b/web/choose.html @@ -0,0 +1,38 @@ + + + + + + + + + + +

+
+
+
+
+ + + + +
+
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
+
+
+ + diff --git a/web/choose.js b/web/choose.js new file mode 100644 index 0000000..eaa23fb --- /dev/null +++ b/web/choose.js @@ -0,0 +1,153 @@ +"use strict"; + +// elements + +/** @type {HTMLButtonElement} */ +let loadAlbumsBtn + +/** @type {HTMLButtonElement} */ +let loadPhotosBtn + +/** @type {HTMLButtonElement} */ +let makeCollageBtn + +/** @type {HTMLSelectElement} */ +let albumSelect + +/** @type {HTMLParagraphElement} */ +let noticeP + +/** @type {HTMLDialogElement} */ +let noticeDialog + +/** @type {HTMLDivElement} */ +let albumPhotosDiv + +/** @type {HTMLDivElement} */ +let selectedPhotosDiv + +/** @type {HTMLImageElement} */ +let selectedPhotoImg + +function main() { + albumSelect = document.getElementById("album_selector") + loadAlbumsBtn = document.getElementById("load_albums_button") + loadPhotosBtn = document.getElementById("load_photos_button") + makeCollageBtn = document.getElementById("make_collage_button") + noticeDialog = document.getElementById("notice_dialog") + albumPhotosDiv = document.getElementById("album_photos") + selectedPhotosDiv = document.getElementById("selected_photos") + noticeP = document.getElementById("notice_p") + loadAlbumsBtn.onclick = () => loadAlbums() + loadPhotosBtn.onclick = () => loadPhotos() + makeCollageBtn.onclick = () => gotoCollage() + + /** + * @type HTMLImageElement[] + */ + const selectedPhotos = selectedPhotosDiv.getElementsByTagName("img") + for (const img of selectedPhotos) { + img.onclick = () => { + selectedPhotoImg?.classList.remove("current") + selectedPhotoImg = img + selectedPhotoImg.classList.add("current") + } + } + selectedPhotos[0].click() +} + +function loadAlbums() { + (async () => { + try { + closeNotice() + const resp = await fetch("get-albums") + const albums = await resp.json() + if(albums.length == 0) { + showNotice("No Albums found") + return + } + albumSelect.replaceChildren() // This empties existing options + for(const album of albums) { + albumSelect.add(new Option(album.Title, album.UID)) + } + } catch(e) { + console.log(e) + } + })(); +} + +function closeNotice() { + noticeDialog.close() +} + +/** + * @param {string} notice + */ +function showNotice(notice) { + noticeP.textContent = notice + noticeDialog.show() +} + +function loadPhotos() { + closeNotice() + const selected = albumSelect.selectedOptions + if(selected.length != 1) { + showNotice("Select an album to load photos") + return + } + const [elem] = selected; + + (async () => { + try { + const resp = await fetch(`load-photos/${elem.value}`) + /** + * @type String[] + */ + const photos = await resp.json() + if(photos.length == 0) { + showNotice("No Photos found") + return + } + albumPhotosDiv.replaceChildren() + for(const url of photos) { + const img = new Image() + img.src = url + albumPhotosDiv.appendChild(img) + } + + /** + * @type HTMLImageElement[] + */ + const photoImgs = albumPhotosDiv.children + for(const photo of photoImgs) { + photo.onclick = () => { + selectedPhotoImg.src = photo.src + } + } + } catch(e) { + console.log(e) + } + })(); +} + +function gotoCollage() { + /** + * @type HTMLImageElement[] + */ + const selectedPhotos = selectedPhotosDiv.getElementsByTagName("img") + /** + * @type String[] + */ + let photoUrls = []; + + for (const img of selectedPhotos) { + if (!img.src.endsWith("stock.svg")) { + photoUrls.push(img.src) + } + } + const encodedURLS = encodeURIComponent(JSON.stringify(photoUrls)) + window.location.href = `index.html?urls=${encodedURLS}` +} + + +main() diff --git a/web/index.js b/web/index.js index 14f1123..9292913 100644 --- a/web/index.js +++ b/web/index.js @@ -51,7 +51,7 @@ let collageUrlA let crops = []; let pageSize = "letter-landscape"; -const imageUrls = [ +let imageUrls = [ , // images start with index 1 "images/img1.jpg", "images/img2.jpg", @@ -75,6 +75,8 @@ function main() { for(const tmpl of document.getElementsByClassName("template")) { tmpl.onclick = () => applyTemplate(tmpl) } + const queryUrls = loadImageUrlsFromQuery() + imageUrls = [,].concat(queryUrls) applyTemplate(document.getElementById("default_template")) } @@ -210,4 +212,10 @@ function applyTemplate(templateDiv) { initCollage() } +function loadImageUrlsFromQuery() { + const params = new URLSearchParams(window.location.search) + const urlsstr = params.get('urls') + return JSON.parse(urlsstr) +} + main() diff --git a/web/stock.svg b/web/stock.svg new file mode 100644 index 0000000..7f1c0d2 --- /dev/null +++ b/web/stock.svg @@ -0,0 +1,4 @@ + + + +