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.