diff --git a/CHANGELOG.md b/CHANGELOG.md index e41764b..caf37f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Cockpit Navigator 0.5.1-1 +## Cockpit Navigator 0.5.2-1 -* Allow modal popups to scroll if overflowing past page. -* Moves focus to next input in modal popup when enter is pressed. \ No newline at end of file +* Implement uploading of entire directories. +* Add cancel option to in-progress file uploads. \ No newline at end of file diff --git a/README.md b/README.md index 45c420c..5f98d48 100644 --- a/README.md +++ b/README.md @@ -23,17 +23,17 @@ With no command line use needed, you can: # Installation ## From Github Release ### Ubuntu -1. `$ wget https://github.com/45Drives/cockpit-navigator/releases/download/v0.5.1/cockpit-navigator_0.5.1-1focal_all.deb` -1. `# apt install ./cockpit-navigator_0.5.1-1focal_all.deb` +1. `$ wget https://github.com/45Drives/cockpit-navigator/releases/download/v0.5.2/cockpit-navigator_0.5.2-1focal_all.deb` +1. `# apt install ./cockpit-navigator_0.5.2-1focal_all.deb` ### EL7 -1. `# yum install https://github.com/45Drives/cockpit-navigator/releases/download/v0.5.1/cockpit-navigator-0.5.1-1.el7.noarch.rpm` +1. `# yum install https://github.com/45Drives/cockpit-navigator/releases/download/v0.5.2/cockpit-navigator-0.5.2-1.el7.noarch.rpm` ### EL8 -1. `# dnf install https://github.com/45Drives/cockpit-navigator/releases/download/v0.5.1/cockpit-navigator-0.5.1-1.el8.noarch.rpm` +1. `# dnf install https://github.com/45Drives/cockpit-navigator/releases/download/v0.5.2/cockpit-navigator-0.5.2-1.el8.noarch.rpm` ## From Source 1. Ensure dependencies are installed: `cockpit`, `python3`, `rsync`, `zip`. 1. `$ git clone https://github.com/45Drives/cockpit-navigator.git` 1. `$ cd cockpit-navigator` -1. `$ git checkout ` (v0.5.1 is latest) +1. `$ git checkout ` (v0.5.2 is latest) 1. `# make install` ## From 45Drives Repositories ### Ubuntu diff --git a/manifest.json b/manifest.json index fb36ddc..cda581c 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "cockpit-navigator", "title": "Cockpit Navigator", "prerelease": false, - "version": "0.5.1", + "version": "0.5.2", "buildVersion": "1", "author": "Josh Boudreau ", "url": "https://github.com/45Drives/cockpit-navigator", @@ -54,7 +54,7 @@ ], "changelog": { "urgency": "medium", - "version": "0.5.1", + "version": "0.5.2", "buildVersion": "1", "ignore": [], "date": null, diff --git a/navigator/components/FileUpload.js b/navigator/components/FileUpload.js index f65139b..c27bf75 100644 --- a/navigator/components/FileUpload.js +++ b/navigator/components/FileUpload.js @@ -19,27 +19,31 @@ import {NavWindow} from "./NavWindow.js"; import {format_time_remaining} from "../functions.js"; +import {ModalPrompt} from "./ModalPrompt.js"; export class FileUpload { /** * * @param {File|Blob} file * @param {NavWindow} nav_window_ref + * @param {string|undefined} path_prefix */ - constructor(file, nav_window_ref) { + constructor(file, nav_window_ref, path_prefix = "") { try { this.chunk_size = (parseInt(cockpit.info.version) > 238)? 1048576 : 65536; } catch(e) { console.log(e); this.chunk_size = 65536; } - this.filename = file.name; + this.filename = path_prefix + file.name; this.nav_window_ref = nav_window_ref; - this.path = nav_window_ref.pwd().path_str() + "/" + file.name; + this.path = nav_window_ref.pwd().path_str() + "/" + this.filename; this.reader = new FileReader(); this.chunks = this.slice_file(file); this.chunk_index = 0; this.timestamp = Date.now(); + this.modal_prompt = new ModalPrompt(); + this.using_webkit = true; } check_if_exists() { @@ -58,6 +62,21 @@ export class FileUpload { header.classList.add("nav-notification-header"); notification.appendChild(header); header.innerText = "Uploading " + this.filename; + header.style.position = "relative"; + header.style.paddingRight = "1em"; + + var cancel = document.createElement("i"); + cancel.classList.add("fa", "fa-times"); + cancel.style.position = "absolute" + cancel.style.right = "0"; + cancel.style.cursor = "pointer"; + cancel.onclick = () => { + if (this.proc) { + this.reader.onload = () => {}; + this.done(); + } + } + header.appendChild(cancel); var info = document.createElement("div"); info.classList.add("flex-row", "space-between"); @@ -94,7 +113,7 @@ export class FileUpload { /** * * @param {File|Blob} file - * @returns {Array} + * @returns {Blob[]} */ slice_file(file) { var offset = 0; @@ -110,35 +129,48 @@ export class FileUpload { } async upload() { - if (await this.check_if_exists()) { - if (!await this.nav_window_ref.modal_prompt.confirm(this.filename + ": File exists. Replace?", "", true)) - return; - } this.make_html_element(); this.proc = cockpit.spawn(["/usr/share/cockpit/navigator/scripts/write-chunks.py3", this.path], {err: "out", superuser: "try"}); this.proc.fail((e, data) => { this.reader.onload = () => {} this.done(); - this.nav_window_ref.modal_prompt.alert(data); + this.nav_window_ref.modal_prompt.alert(e, data); }) this.proc.done((data) => { this.nav_window_ref.refresh(); }) - this.reader.onload = (function(uploader_ref) { - return async function(evt) { - uploader_ref.write_to_file(evt, uploader_ref.chunk_index * uploader_ref.chunk_size); - uploader_ref.chunk_index++; - uploader_ref.progress.value = uploader_ref.chunk_index; - if (uploader_ref.chunk_index < uploader_ref.num_chunks) - uploader_ref.reader.readAsArrayBuffer(uploader_ref.chunks[uploader_ref.chunk_index]); - else { - uploader_ref.done(); - } - }; - })(this); - this.reader.readAsArrayBuffer(this.chunks[0]); + this.reader.onerror = (evt) => { + this.modal_prompt.alert("Failed to read file: " + this.filename, "Upload of directories not supported."); + this.done(); + } + this.reader.onload = (evt) => { + this.write_to_file(evt, this.chunk_index * this.chunk_size); + this.chunk_index++; + this.progress.value = this.chunk_index; + if (this.chunk_index < this.num_chunks) + this.reader.readAsArrayBuffer(this.chunks[this.chunk_index]); + else { + this.done(); + } + }; + try { + this.reader.readAsArrayBuffer(this.chunks[0]); + } catch { + this.reader.onload = () => {}; + if (this.using_webkit) { + this.proc.input(JSON.stringify({seek: 0, chunk: ""}), true); + } else { + this.modal_prompt.alert("Failed to read file: " + this.filename, "Upload of directories and empty files not supported."); + } + this.done(); + } } + /** + * + * @param {ArrayBuffer} buffer + * @returns + */ arrayBufferToBase64(buffer) { let binary = ''; let bytes = new Uint8Array(buffer); diff --git a/navigator/components/ModalPrompt.js b/navigator/components/ModalPrompt.js index 5bc2eb2..5f940d1 100644 --- a/navigator/components/ModalPrompt.js +++ b/navigator/components/ModalPrompt.js @@ -111,6 +111,7 @@ export class ModalPrompt { this.footer.innerHTML = ""; this.footer.appendChild(this.ok); this.show(); + this.ok.focus(); return new Promise((resolve, reject) => { this.ok.onclick = () => { resolve(); @@ -137,6 +138,10 @@ export class ModalPrompt { else this.yes.classList.add(primary_btn); this.show(); + if (danger) + this.no.focus(); + else + this.yes.focus(); return new Promise((resolve, reject) => { let resolve_true = () => { resolve(true); @@ -179,7 +184,7 @@ export class ModalPrompt { let label = document.createElement("label"); label.innerText = request.label; label.htmlFor = key; - label.style.marginRight = "1em"; + label.style.paddingRight = "1em"; label.style.flexBasis = "0"; label.style.flexGrow = "1"; let req = document.createElement("input"); @@ -196,6 +201,9 @@ export class ModalPrompt { case "text": req.style.flexGrow = "3"; break; + case "checkbox": + label.style.cursor = req.style.cursor = "pointer"; + break; default: break; } diff --git a/navigator/components/NavDragDrop.js b/navigator/components/NavDragDrop.js index 3ffc75f..4b72dfb 100644 --- a/navigator/components/NavDragDrop.js +++ b/navigator/components/NavDragDrop.js @@ -18,6 +18,7 @@ */ import {FileUpload} from "./FileUpload.js"; +import { ModalPrompt } from "./ModalPrompt.js"; import {NavWindow} from "./NavWindow.js"; export class NavDragDrop { @@ -27,65 +28,148 @@ export class NavDragDrop { * @param {NavWindow} nav_window_ref */ constructor(drop_area, nav_window_ref) { - drop_area.addEventListener("dragenter", this); - drop_area.addEventListener("dragover", this); - drop_area.addEventListener("dragleave", this); - drop_area.addEventListener("drop", this); + drop_area.addEventListener("dragenter", this, false); + drop_area.addEventListener("dragover", this, false); + drop_area.addEventListener("dragleave", this, false); + drop_area.addEventListener("drop", this, false); this.drop_area = drop_area; this.nav_window_ref = nav_window_ref; + this.modal_prompt = new ModalPrompt(); } - handleEvent(e) { - e.preventDefault(); + /** + * + * @param {FileSystemEntry} item + * @param {string} path + * @returns {Promise} + */ + async scan_files(item, path) { + let new_uploads = []; + if (item.isDirectory) { + if (!path && !await this.modal_prompt.confirm(`Copy whole directory: ${item.fullPath}?`, "", true)) + return new_uploads; + let directoryReader = item.createReader(); + let promise = new Promise((resolve, reject) => { + directoryReader.readEntries(async (entries) => { + for (const entry of entries) { + new_uploads.push(... await this.scan_files(entry, path + item.name + "/")); + } + resolve(); + }); + }) + await promise; + } else { + let promise = new Promise((resolve, reject) => { + item.file((file) => { + resolve(file); + }) + }); + new_uploads.push(new FileUpload(await promise, this.nav_window_ref, path)); + } + return new_uploads; + } + + /** + * + * @param {DataTransferItemList} items + * @returns {Promise} + */ + handle_drop_advanced(items) { + return new Promise(async (resolve, reject) => { + let uploads = []; + for (let i = 0; i < items.length; i++) { + let item = items[i]?.webkitGetAsEntry?.() ?? items[i]?.getAsEntry?.() ?? null; + let path = ""; + if (item) { + let new_uploads = await this.scan_files(item, path); + console.log(new_uploads); + uploads.push(... new_uploads); + } else { + reject(); + } + } + resolve(uploads); + }) + } + + /** + * + * @param {FileUpload[]} uploads + * @returns {FileUpload[]} + */ + async handle_conflicts(uploads) { + let keepers = []; + let requests = {}; + for (let upload of uploads) { + if (!await upload.check_if_exists()) { + keepers.push(upload.filename); + continue; + } + let request = {}; + request.label = upload.filename; + request.type = "checkbox"; + let id = upload.filename; + requests[id] = request; + } + if (Object.keys(requests).length > 0) { + let responses = await this.nav_window_ref.modal_prompt.prompt( + "Conflicts found while uploading. Replace?", + requests + ) + if (responses === null) + return null; + for (let key of Object.keys(responses)) { + if (responses[key]) + keepers.push(key); + } + } + return uploads.filter((upload) => keepers.includes(upload.filename)); + } + + /** + * + * @param {Event} e + */ + async handleEvent(e) { switch(e.type){ case "dragenter": + e.preventDefault(); + e.stopPropagation(); this.drop_area.classList.add("drag-enter"); break; case "dragover": + e.preventDefault(); + e.stopPropagation(); break; case "dragleave": + e.preventDefault(); + e.stopPropagation(); this.drop_area.classList.remove("drag-enter"); break; case "drop": - if (e.dataTransfer.items) { - for (let item of e.dataTransfer.items) { - if (item.kind === 'file') { - var file = item.getAsFile(); - if (file.type === "" && file.size !== 0) { - this.nav_window_ref.modal_prompt.alert(file.name + ": Cannot upload folders."); - continue; - } - if (file.size === 0) { - var proc = cockpit.spawn( - ["/usr/share/cockpit/navigator/scripts/touch.py3", this.nav_window_ref.pwd().path_str() + "/" + file.name], - {superuser: "try", err: "out"} - ); - proc.done(() => { - this.nav_window_ref.refresh(); - }); - proc.fail((e, data) => { - this.nav_window_ref.modal_prompt.alert(data); - }); - } else { - var uploader = new FileUpload(file, this.nav_window_ref); - uploader.upload(); - } - } - } - } else { - for (let file of ev.dataTransfer.files) { - if (file.type === "") - continue; - var uploader = new FileUpload(file, this.nav_window_ref); - uploader.upload(); + let uploads; + let items = e.dataTransfer.items; + e.preventDefault(); + e.stopPropagation(); + try { + uploads = await this.handle_drop_advanced(items); + } catch { + uploads = []; + for (let file of e.dataTransfer.files) { + let uploader = new FileUpload(file, this.nav_window_ref); + uploader.using_webkit = false; + uploads.push(uploader); } } this.drop_area.classList.remove("drag-enter"); + if (uploads.length === 0) + break; + uploads = await this.handle_conflicts(uploads); + uploads.forEach((upload) => {upload.upload()}); break; default: this.drop_area.classList.remove("drag-enter"); break; } - e.stopPropagation(); } } diff --git a/navigator/navigator.css b/navigator/navigator.css index eee3968..fd861d1 100644 --- a/navigator/navigator.css +++ b/navigator/navigator.css @@ -35,6 +35,7 @@ --nav-border-radius: 4px; --symlink-symbol-color: var(--navigation); --list-view-header: var(--selected); + --outline-color: black; } [data-theme="dark"] { @@ -53,6 +54,11 @@ --nav-entry-color: #555F6E; --symlink-symbol-color: var(--navigation); --list-view-header: var(--container); + --outline-color: white; +} + +button { + outline-color: var(--outline-color) !important; } html { diff --git a/navigator/scripts/write-chunks.py3 b/navigator/scripts/write-chunks.py3 index 8beaf37..3ccb8df 100755 --- a/navigator/scripts/write-chunks.py3 +++ b/navigator/scripts/write-chunks.py3 @@ -32,27 +32,35 @@ import sys import json def write_chunk(chunk, file): + if not file: + path = sys.argv[1] + parent_path = os.path.dirname(path) + if not os.path.exists(parent_path): + os.makedirs(parent_path, exist_ok=True) + elif os.path.isfile(parent_path): + print(parent_path + ": exists and is not a directory.") + sys.exit(1) + try: + file = open(path, "wb") + except Exception as e: + print(e) + sys.exit(1) seek = chunk["seek"] data = base64.b64decode(chunk["chunk"]) file.seek(seek) file.write(data) def main(): + file = None if len(sys.argv) != 2: print("Invalid number of arguments.") sys.exit(1) - path = sys.argv[1] - try: - file = open(path, "wb") - except Exception as e: - print(e) - sys.exit(1) while True: try: json_in = input() except EOFError: break - json_list = json_in.split("\n") + json_list = json_in.split("\n") # need to split in case writes happen faster than reads for json_obj in json_list: try: obj_in = json.loads(json_obj) @@ -63,7 +71,8 @@ def main(): log.close() sys.exit(1) write_chunk(obj_in, file) - file.close() + if file: + file.close() sys.exit(0) if __name__ == "__main__": diff --git a/packaging/el7/main.spec b/packaging/el7/main.spec index f0811a7..82e65a8 100644 --- a/packaging/el7/main.spec +++ b/packaging/el7/main.spec @@ -32,6 +32,9 @@ rm -rf %{buildroot} /usr/share/cockpit/navigator/* %changelog +* Fri Jul 16 2021 Josh Boudreau 0.5.2-1 +- Implement uploading of entire directories. +- Add cancel option to in-progress file uploads. * Thu Jul 15 2021 Josh Boudreau 0.5.1-1 - Allow modal popups to scroll if overflowing past page. - Moves focus to next input in modal popup when enter is pressed. diff --git a/packaging/el8/main.spec b/packaging/el8/main.spec index f0811a7..82e65a8 100644 --- a/packaging/el8/main.spec +++ b/packaging/el8/main.spec @@ -32,6 +32,9 @@ rm -rf %{buildroot} /usr/share/cockpit/navigator/* %changelog +* Fri Jul 16 2021 Josh Boudreau 0.5.2-1 +- Implement uploading of entire directories. +- Add cancel option to in-progress file uploads. * Thu Jul 15 2021 Josh Boudreau 0.5.1-1 - Allow modal popups to scroll if overflowing past page. - Moves focus to next input in modal popup when enter is pressed. diff --git a/packaging/focal/changelog b/packaging/focal/changelog index b9508fd..09e4eb4 100644 --- a/packaging/focal/changelog +++ b/packaging/focal/changelog @@ -1,3 +1,10 @@ +cockpit-navigator (0.5.2-1focal) focal; urgency=medium + + * Implement uploading of entire directories. + * Add cancel option to in-progress file uploads. + + -- Josh Boudreau Fri, 16 Jul 2021 13:56:55 -0300 + cockpit-navigator (0.5.1-1focal) focal; urgency=medium * Allow modal popups to scroll if overflowing past page.