diff --git a/src/cli/cli.go b/src/cli/cli.go index bdb1546..f4733aa 100644 --- a/src/cli/cli.go +++ b/src/cli/cli.go @@ -397,13 +397,15 @@ func HandleUpload() { // Upload the files if len(uploadableMetadata) > 0 { - webdavupload.UploadMetadataFiles(c, uploadableMetadata) + webdavupload.UploadMetadataFiles(c, os.Stdout, uploadableMetadata) } if len(uploadableMedia) > 0 { - webdavupload.UploadMediaFiles(c, uploadableMedia) + webdavupload.UploadMediaFiles(c, os.Stdout, uploadableMedia) } // Generate the import config. + fmt.Println("Generating and uploading import configuration") webdavupload.SetImportConfigToTheRemote(c, config) + fmt.Println("DONE") } diff --git a/src/webdavupload/Upload.go b/src/webdavupload/Upload.go index 9c94254..b4de888 100644 --- a/src/webdavupload/Upload.go +++ b/src/webdavupload/Upload.go @@ -4,6 +4,8 @@ import ( "fmt" "path/filepath" "os" + "io" + "net/http" "runtime" "sync" "sync/atomic" @@ -21,7 +23,10 @@ func SetImportConfigToTheRemote(c *gowebdav.Client, config configloader.MDWebDav } // Uploads a list of files to the target folder. -func uploadFiles(c *gowebdav.Client, files []string, remoteTarget string, outputContext string) { +func uploadFiles(c *gowebdav.Client, w io.Writer, files []string, remoteTarget string, outputContext string) { + + // Check if the io.Writer is a http writer + _, wImplementsHttpFlusher := interface{}(w).(http.Flusher) total := len(files) var counter atomic.Uint64 @@ -34,7 +39,10 @@ func uploadFiles(c *gowebdav.Client, files []string, remoteTarget string, output semaphore := make(chan struct{}, maxConcTasks) wg := &sync.WaitGroup{} - fmt.Printf("Will upload %v files. Processing %v tasks at a time.\n", total, maxConcTasks) + fmt.Fprintf(w, "Will upload %v files. Processing %v tasks at a time.\n", total, maxConcTasks) + if wImplementsHttpFlusher == true { + w.(http.Flusher).Flush() + } for _, f := range(files) { @@ -56,7 +64,10 @@ func uploadFiles(c *gowebdav.Client, files []string, remoteTarget string, output c.WriteStream("./" + remoteTarget + "/" + basename, file, 0644) counter.Add(1) - fmt.Printf(outputContext + ". File %v of %v. Done. (File: %v)\n", counter, total, basename) + fmt.Fprintf(w, "Uploading %d of %d - File: %s (%s)\n", counter, total, basename, outputContext) + if wImplementsHttpFlusher == true { + w.(http.Flusher).Flush() + } <-semaphore // release @@ -70,15 +81,15 @@ func uploadFiles(c *gowebdav.Client, files []string, remoteTarget string, output } // Uploads the selected metadata files. -func UploadMetadataFiles(c *gowebdav.Client, files []string) { +func UploadMetadataFiles(c *gowebdav.Client, w io.Writer, files []string) { - uploadFiles(c, files, "IMPORT_XML", "Uploading metadata files") + uploadFiles(c, w, files, "IMPORT_XML", "Uploading metadata files") } // Uploads the selected media files. -func UploadMediaFiles(c *gowebdav.Client, files []string) { +func UploadMediaFiles(c *gowebdav.Client, w io.Writer, files []string) { - uploadFiles(c, files, "IMPORT_IMG", "Uploading media files") + uploadFiles(c, w, files, "IMPORT_IMG", "Uploading media files") } diff --git a/src/webui/assets/index.htm b/src/webui/assets/index.htm index cf99952..ebf6412 100644 --- a/src/webui/assets/index.htm +++ b/src/webui/assets/index.htm @@ -16,6 +16,7 @@

museum-digital:uploader

+
diff --git a/src/webui/assets/md-uploader.css b/src/webui/assets/md-uploader.css index 70ebc62..c4d3b71 100644 --- a/src/webui/assets/md-uploader.css +++ b/src/webui/assets/md-uploader.css @@ -14,22 +14,30 @@ --color-accent-hover: #455A64; --color-accent-focus: #78909C; - --color-accent-fg: #FFF; - --color-accent-hover-fg: #FFF; - --color-accent-focus-fg: #FFF; - - --color-accent2: #484848; - --color-accent2-fg: #FFF; - - --color-red: #EF5350; - --color-green: #4CAF50; - --color-blue: #0288D1; - --def-padding-main: 1em; --def-border-radius: 6px; +} + + +@media (prefers-color-scheme: dark) { + + :root { + --color-bg: #000; + --color-bg2: #2685AF; + --color-bg-transparent: rgba(0,0,0,.4); + --color-bg-transparent-stronger: rgba(0,0,0,.7); + --color-bg-transparent-dark: rgba(0,0,0,.4); + --color-fg: #FFF; + --color-fg2: #D6D6D6; + --color-bg-dark: #212121; + --color-fg-medium: #BDBDBD; + --color-borders: #424242; + + --color-accent: #546E7A; + --color-accent-hover: #90A4AE; + --color-accent-focus: #78909C; + } - --color-white: #FFF; - --color-black: #000; } html { @@ -37,6 +45,7 @@ html { linear-gradient(135deg,#0000 18.75%,var(--color-bg2) 0 31.25%,#0000 0), repeating-linear-gradient(45deg,var(--color-bg2) -6.25% 6.25%,var(--color-bg) 0 18.75%); background-size: 64px 64px; + color: var(--color-fg); padding: 0; font-family: Sans; } @@ -50,13 +59,17 @@ header img { height: 1em; } h1 { display: inline-block; margin-left: 1em; font-size: .8em; } -#wrap { display: grid; grid-template-columns: 400px 1fr; margin: 1em 2rem; grid-gap: 2em; } +#wrap { display: grid; grid-template-columns: 450px 1fr; margin: 1em 2rem; grid-gap: 2em; } #wrap > * { vertical-align: top; } +main, aside { background: var(--color-bg); border-radius: 1px solid var(--color-borders); box-shadow: 0 0 6px var(--color-borders); border-radius: 2px; } h2 { margin: 0; padding: .8rem; font-size: 1.4em; border-bottom: .15em solid var(--color-borders); } +h3 { margin: 0; padding: .8rem; border-bottom: .15em solid var(--color-borders); } + +input { background: var(--color-bg); color: var(--color-fg); } aside .label-input { display: grid; grid-template-columns: 150px 2fr; border-bottom: 1px solid var(--color-borders); } @@ -66,7 +79,46 @@ aside .label-input > * { display: inline-block; padding: .5rem .8rem; border: 0; outline: 0; } aside label { font-weight: bold; color: var(--color-fg-medium); } -select { background: transparent; } +aside .label-input3 { grid-template-columns: 150px auto 2em; } +aside .label-input3 button { background: none; transition: background .2s; } +aside .label-input3 button:hover { background: var(--color-accent); } + +select { background: var(--color-bg); color: var(--color-fg); } + +.updateB { display: block; width: calc(100% - 1em); background: var(--color-bg); + margin: .5em; padding: .5em; + text-align: center; + border: 1px solid var(--color-bg); border-radius: 2px; font-weight: bold; + color: var(--color-accent); transition: background .2s, color .2s;} +.updateB:hover { background: var(--color-accent-hover); color: var(--color-bg); } + +.upload-file-list > span { display: block; + padding: .3em .8rem; + font-family: Mono, Courier; font-size: .9em; + border-bottom: 1px solid var(--color-borders); transition: border .2s, background .2s; } +.upload-file-list > span:hover { background: var(--color-bg2); border-color: var(--color-accent-hover); } + +main { position: relative; } +main h3 { position: sticky; top: 0; background: var(--color-bg); } + +.transfer-overlay { display: block; + position: fixed; left: 5em; bottom: 0; width: calc(100% - 10em); + background: var(--color-bg); + border: 2px solid var(--color-accent-hover); border-bottom-width: 0; + box-shadow: 0 0 6px var(--color-borders); + z-index: 10; animation: slide-up .4s ease-out; } + +.transfer-msgs { font-size: .9em; } +.transfer-msgs > * { display: block; border-bottom: 1px solid var(--color-borders); transition: .2s; } + +.transfer-msgs > :hover { border-bottom-color: 1px solid var(--color-accent-hover); } + +@keyframes slide-up { + from { transform: translateY(100%); } + to { transform: initial; } +} + +iframe { display: block; width: 100%; border: none; height: 60vh; } /*** * Slider @@ -75,7 +127,7 @@ select { background: transparent; } * https://uiverse.io/mrhyddenn/old-fish-66 * */ /* The switch - the box around the slider */ -.switch { font-size: 17px; position: relative; display: inline-block; width: 62px; height: 35px; } +.switch { font-size: 17px; position: relative; display: inline-block; width: 62px; height: 35px; transform: scale(0.8); } /* Hide default HTML checkbox */ .switch input { opacity: 1; width: 0; height: 0; diff --git a/src/webui/assets/md-uploader.js b/src/webui/assets/md-uploader.js index 05967b5..101c7c5 100644 --- a/src/webui/assets/md-uploader.js +++ b/src/webui/assets/md-uploader.js @@ -6,7 +6,9 @@ class App { tls; config; parserList; + uploadableFiles; wrap; + actionsArea; aside; main; @@ -38,7 +40,6 @@ class App { async generateSidebar() { - console.log("hi"); this.aside.appendChild(this.generateTextElement("h2", "Settings")); // TODO // Set up fields @@ -58,6 +59,10 @@ class App { "Institution ID", "number", this.config.settings.institution_id); this.aside.appendChild(institutionIdArea.wrap); + const tokenArea = this.generateLabelInput("token", + "Token", "text", this.config.settings.token); + this.aside.appendChild(tokenArea.wrap); + const parserDiv = document.createElement("div"); parserDiv.classList.add("label-input"); @@ -116,10 +121,203 @@ class App { visibleDivOuter.appendChild(visibleDiv); this.aside.appendChild(visibleDivOuter); + // Additional settings + this.aside.appendChild(this.generateTextElement("h3", "Additional settings")); + + const addSettingsArea = document.createElement("div"); + + function generateAdditionalSettingsField(key, value) { + + const line = document.createElement("div"); + line.classList.add("label-input", "label-input3"); + + const newKey = document.createElement("input"); + newKey.placeholder = "Key (aspect to set)"; + newKey.required = "required"; + newKey.value = key; + line.appendChild(newKey); + + const newVal = document.createElement("input"); + newVal.placeholder = "Value (what to set it to?)"; + newVal.required = "required"; + newVal.value = value; + line.appendChild(newVal); + + const removeB = document.createElement("button"); + removeB.textContent = " x "; + line.appendChild(removeB); + + removeB.addEventListener('click', function() { + line.parentElement.removeChild(line); + }); + + return line; + + } + + if (this.config.settings.settings != undefined && this.config.settings.settings != null && this.config.settings.settings.length != 0) { + for (let settingKey in this.config.settings.settings) { + addSettingsArea.appendChild(generateAdditionalSettingsField(settingKey, this.config.settings.settings[settingKey])); + } + } + + this.aside.appendChild(addSettingsArea); + + const newSettingLine = document.createElement("form"); + newSettingLine.classList.add("label-input", "label-input3"); + + const newKey = document.createElement("input"); + newKey.placeholder = "Key (aspect to set)"; + newKey.required = "required"; + newSettingLine.appendChild(newKey); + + const newVal = document.createElement("input"); + newVal.placeholder = "Value (what to set it to?)"; + newVal.required = "required"; + newSettingLine.appendChild(newVal); + + const newSubmitB = document.createElement("button"); + newSubmitB.type = "submit"; + newSubmitB.textContent = " > "; + newSettingLine.appendChild(newSubmitB); + + newSettingLine.addEventListener('submit', function(e) { + e.preventDefault(); + e.stopPropagation(); + + addSettingsArea.appendChild(generateAdditionalSettingsField(newKey.value, newVal.value)); + + newKey.value = ""; + newVal.value = ""; + }); + + this.aside.appendChild(newSettingLine) + + const updateB = document.createElement("button"); + updateB.textContent = "Save / Update settings"; + updateB.classList.add("updateB"); + this.aside.appendChild(updateB); + + updateB.addEventListener('click', async function() { + + let toSave = { + instance: instanceLinkArea.input.value, + username: usernameArea.input.value, + mail: mailArea.input.value, + token: tokenArea.input.value, + institution_id: parseInt(institutionIdArea.input.value), + parser: parserSelect.value, + upload_directory: uploadFolderArea.input.value, + visible: visibleInput.checked, + settings: {} + }; + + for (const l of addSettingsArea.children) { + toSave.settings[l.children[0].value] = l.children[1].value; + } + + const response = await window.fetch('/update-settings', { + method: 'POST', cache: 'no-cache', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: "settings=" + encodeURIComponent(JSON.stringify(toSave, null, 2)) + }); + + if (!response.ok) { + console.log("Failed to save"); + window.alert("Failed to store."); + } + + const json = await response.json(); + + if (json.success === true) { + location.reload(); + } + else { + alert("Error:\n" + json.error); + } + + }); + } generateMain() { + this.main.appendChild(this.generateTextElement("h2", "Uploadable files")); + + // Metadata + const metadataSec = document.createElement("section"); + + metadataSec.appendChild(this.generateTextElement("h3", "Metadata files")); + const metadataList = document.createElement("div"); + metadataList.classList.add("upload-file-list"); + + for (const filename of this.uploadableFiles.metadata) { + const fileline = document.createElement("span"); + fileline.classList.add("icon-file"); + fileline.textContent = filename; + metadataList.appendChild(fileline); + } + + metadataSec.appendChild(metadataList); + + this.main.appendChild(metadataSec); + + // Media + const mediaSec = document.createElement("section"); + + mediaSec.appendChild(this.generateTextElement("h3", "Media files")); + const mediaList = document.createElement("div"); + mediaList.classList.add("upload-file-list"); + + for (const filename of this.uploadableFiles.media_files) { + const fileline = document.createElement("span"); + fileline.classList.add("icon-file"); + fileline.textContent = filename; + mediaList.appendChild(fileline); + } + + mediaSec.appendChild(mediaList); + this.main.appendChild(mediaSec); + + + } + + generateActionsMenu() { + + if (this.uploadableFiles.metadata.length === 0 && this.uploadableFiles.media_files.length === 0) { + return false; + } + + const uploadTrigger = document.createElement("span"); + uploadTrigger.textContent = "Upload"; + this.actionsArea.appendChild(uploadTrigger); + + const app = this; + uploadTrigger.addEventListener('click', function() { + + const transferOverlay = document.createElement("div"); + transferOverlay.id = "transfer-overlay"; + transferOverlay.classList.add("transfer-overlay"); + const hl = document.createElement("div"); + hl.appendChild(app.generateTextElement("h3", "Transfer")); + transferOverlay.appendChild(hl); + + const transferMsgs = document.createElement("div"); + transferMsgs.classList.add("transfer-msgs"); + transferOverlay.appendChild(transferMsgs); + + app.wrap.appendChild(transferOverlay); + + const iframe = document.createElement("iframe"); + iframe.src = "/trigger-upload"; + transferOverlay.appendChild(iframe); + + }, {once: true}); + + } render() { @@ -131,6 +329,7 @@ class App { else { this.generateSidebar(); this.generateMain(); + this.generateActionsMenu(); } } @@ -142,7 +341,7 @@ class App { let done = 0; function wrapFinish() { - if (done === 2) { + if (done === 3) { app.render(); } } @@ -161,6 +360,13 @@ class App { done++; wrapFinish(); }); + window.fetch('/list-uploadable', {method: 'GET', cache: 'no-cache', credentials: 'same-origin'}) + .then(function(response) { return response.json(); }) + .then(function(elements) { + app.uploadableFiles = elements; + done++; + wrapFinish(); + }); } constructor() { @@ -170,6 +376,7 @@ class App { this.aside = document.createElement("aside"); this.main = document.createElement("main"); + this.actionsArea = document.getElementById("actions"); this.wrap.appendChild(this.aside); this.wrap.appendChild(this.main); diff --git a/src/webui/headers.go b/src/webui/headers.go index 037b826..4a89353 100644 --- a/src/webui/headers.go +++ b/src/webui/headers.go @@ -10,15 +10,17 @@ func setDefaultHeaders(w http.ResponseWriter) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "SAMEORIGIN") w.Header().Set("X-XSS-Protection", "1; mode=block") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST") - w.Header().Set("Strict-Transport-Security", "max-age=63072000") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Expose-Headers", "Content-Type") + // w.Header().Set("Strict-Transport-Security", "max-age=63072000") } // Sets the HTTP headers expected to be returned when serving HTML pages. func setHeadersForHtml(w http.ResponseWriter) { setDefaultHeaders(w) - w.Header().Set("Content-Security-Policy", "default-src 'self' https:; font-src 'self'; object-src 'none'; frame-src 'self'; frame-ancestors 'none'; base-uri 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'") + w.Header().Set("Content-Security-Policy", "default-src 'self'; font-src 'self'; object-src 'none'; frame-src 'self'; frame-ancestors 'none'; base-uri 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'") } // Sets the HTTP headers expected to be returned when serving JSON. @@ -44,3 +46,10 @@ func setHeadersForSvg(w http.ResponseWriter) { setDefaultHeaders(w) w.Header().Set("Content-Type", "image/svg+xml") } +func setHeadersForEventStream(w http.ResponseWriter) { + setDefaultHeaders(w) + w.Header().Set("X-Accel-Buffering", "no") + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") +} diff --git a/src/webui/serveApiTriggerUpload.go b/src/webui/serveApiTriggerUpload.go new file mode 100644 index 0000000..a46e96b --- /dev/null +++ b/src/webui/serveApiTriggerUpload.go @@ -0,0 +1,52 @@ +package webui + +import ( + "fmt" + "net/http" + "gitea.armuli.eu/museum-digital/museum-digital-webdav-uploader/src/uploadsrcdir" + "gitea.armuli.eu/museum-digital/museum-digital-webdav-uploader/src/webdavupload" +) + +// Provides the API logic for uploading files. +func serveApiTriggerUpload(w http.ResponseWriter, r *http.Request) { + + fmt.Println("Upload requested") + setHeadersForEventStream(w) + w.(http.Flusher).Flush() + + fmt.Fprint(w, "Getting list of uploadable files\n") + w.(http.Flusher).Flush() + uploadableMetadata, uploadableMedia := uploadsrcdir.GetUploadableFiles(config) + + // If there are no files to upload, do nothing + if len(uploadableMetadata) == 0 && len(uploadableMedia) == 0 { + return + } + + fmt.Fprint(w, "Connecting to WebDAV share\n") + w.(http.Flusher).Flush() + c := webdavupload.GetWebdavClient(config) + fmt.Fprint(w, "Connected\n") + w.(http.Flusher).Flush() + + if webdavupload.CheckRemoteIsFree(c) == false { + fmt.Println("The remote is currently occupied (very recent files, or an import config is currently waiting to be processed, are present).") + return + } + + // Upload the files + if len(uploadableMetadata) > 0 { + fmt.Fprint(w, "Uploading metadata files\n") + w.(http.Flusher).Flush() + webdavupload.UploadMetadataFiles(c, w, uploadableMetadata) + } + if len(uploadableMedia) > 0 { + fmt.Fprint(w, "Uploading media files\n") + webdavupload.UploadMediaFiles(c, w, uploadableMedia) + } + + fmt.Fprint(w, "Generating and uploading import configuration\n") + webdavupload.SetImportConfigToTheRemote(c, config) + fmt.Fprint(w, "DONE\n") + +} diff --git a/src/webui/serveCss.go b/src/webui/serveCss.go index 4e5f70a..55229d7 100644 --- a/src/webui/serveCss.go +++ b/src/webui/serveCss.go @@ -9,6 +9,7 @@ import ( //go:embed assets/md-uploader.css var webuiCss string +// Serves the style sheet of the web app. func serveCss(w http.ResponseWriter, r *http.Request) { setHeadersForCss(w) fmt.Fprint(w, webuiCss) diff --git a/src/webui/server.go b/src/webui/server.go index 69173b3..1d4eb55 100644 --- a/src/webui/server.go +++ b/src/webui/server.go @@ -21,7 +21,7 @@ func Run() { http.HandleFunc("/list-uploadable", serveApiListUploadable) // API to list uploadable files http.HandleFunc("/list-parsers", serveApiListParsers) // API to list available parsers. http.HandleFunc("/update-settings", serveApiUpdateSettings) // API for updating settings - // http.HandleFunc("/trigger-upload", serveFilePage) // Serve page for specific files + http.HandleFunc("/trigger-upload", serveApiTriggerUpload) // Serve page for specific files // http.HandleFunc("/contribute.json", ) // Serve page for specific files http.HandleFunc("/md-uploader.css", serveCss) // Main CSS file. http.HandleFunc("/md-uploader.js", serveJs) // Main JS file.