chore (rewrite): upload component

This commit is contained in:
MickaelK 2024-06-13 02:10:50 +10:00
parent 11a01420db
commit 87c41adfe7
9 changed files with 553 additions and 452 deletions

View file

@ -6,6 +6,9 @@ import { report } from "../helpers/log.js";
export default async function main() { export default async function main() {
try { try {
let config = {};
// await Config.refresh()
await Promise.all([ // procedure with no outside dependencies await Promise.all([ // procedure with no outside dependencies
setup_translation(), setup_translation(),
setup_xdg_open(), setup_xdg_open(),
@ -16,10 +19,10 @@ export default async function main() {
setup_loader(), setup_loader(),
setup_history(), setup_history(),
]); ]);
// await Config.refresh()
await Promise.all([ // procedure with dependency on config await Promise.all([ // procedure with dependency on config
// setup_chromecast() // TODO // setup_chromecast() // TODO
setup_base(config),
]); ]);
window.dispatchEvent(new window.Event("pagechange")); window.dispatchEvent(new window.Event("pagechange"));
@ -68,6 +71,13 @@ async function setup_device() {
}); });
} }
async function setup_base(config) {
// TODO: base as config in admin
const $meta = document.createElement("base");
$meta.setAttribute("href", location.origin);
document.head.appendChild($meta);
}
// async function setup_sw() { // async function setup_sw() {
// if (!("serviceWorker" in window.navigator)) return; // if (!("serviceWorker" in window.navigator)) return;

View file

@ -25,9 +25,9 @@ export async function navigate(href) {
const trimPrefix = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length) : value; const trimPrefix = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length) : value;
export function currentRoute(r, notFoundRoute) { export function currentRoute(r, notFoundRoute) {
const currentRoute = "/" + trimPrefix( const currentRoute = trimPrefix(
window.location.pathname, window.location.pathname,
window.document.head.querySelector("base")?.getAttribute("href") || "/" window.document.head.querySelector("base")?.getAttribute("href") || ""
); );
for (const routeKey in r) { for (const routeKey in r) {
if (new RegExp("^" + routeKey + "$").test(currentRoute)) { if (new RegExp("^" + routeKey + "$").test(currentRoute)) {

View file

@ -1,350 +0,0 @@
import { onDestroy } from "../../lib/skeleton/index.js";
import rxjs from "../../lib/rx.js";
import fscache from "./cache.js";
import { extractPath, isDir } from "./helper.js";
/*
* The transcient cache is used to rerender the list of files in a particular location. That's used
* when the user is doing either of a touch, mkdir, rm or mv. It is split onto 2 parts:
* - the virtualFiles$: which are things we want to display in addition to what is currently
* visible on the screen
* - the mutationFiles$: which are things already on the screen which we need to mutate. For
* example when we want a particular file to show a loading spinner
*/
const virtualFiles$ = new rxjs.BehaviorSubject({
// "/tmp/": [],
// "/home/": [{ name: "test", type: "directory" }]
});
const mutationFiles$ = new rxjs.BehaviorSubject({
// "/home/": [{ name: "test", fn: (file) => file, ...]
});
export function touch(ajax$, path) {
const [basepath, filename] = extractPath(path);
const file = {
name: filename,
type: "file",
size: 0,
time: new Date().getTime(),
};
stateAdd(virtualFiles$, basepath, {
...file,
loading: true,
});
const onSuccess = async () => {
removeLoading(virtualFiles$, basepath, filename);
onDestroy(() => statePop(virtualFiles$, basepath, filename));
await fscache().update(basepath, ({ files, ...rest }) => ({
files: files.concat([file]),
...rest,
}));
};
const onFailure = (err, caught) => {
statePop(virtualFiles$, basepath, filename);
return rxjs.of(fscache().remove(basepath)).pipe(
rxjs.mergeMap(() => rxjs.EMPTY),
);
};
return ajax$.pipe(
rxjs.mergeMap(onSuccess),
rxjs.catchError(onFailure),
);
}
export function mkdir(ajax$, path) {
const [basepath, dirname] = extractPath(path);
const file = {
name: dirname,
type: "directory",
size: 0,
time: new Date().getTime(),
};
stateAdd(virtualFiles$, basepath, {
...file,
loading: true,
});
const onSuccess = async () => {
removeLoading(virtualFiles$, basepath, dirname);
onDestroy(() => statePop(virtualFiles$, basepath, dirname));
await fscache().update(basepath, ({ files, ...rest }) => ({
files: files.concat([file]),
...rest,
}));
};
const onFailure = () => {
statePop(virtualFiles$, basepath, dirname);
return rxjs.of(fscache().remove(basepath)).pipe(
rxjs.mergeMap(() => rxjs.EMPTY),
);
};
return ajax$.pipe(
rxjs.mergeMap(onSuccess),
rxjs.catchError(onFailure),
);
}
export function save(ajax$, path, size) {
const [basepath, filename] = extractPath(path);
const file = {
name: dirname,
type: "file",
size,
time: new Date().getTime(),
};
stateAdd(virtualFiles$, basepath, {
...file,
loading: true,
});
const onSuccess = async () => {
removeLoading(virtualFiles$, basepath, filename);
onDestroy(() => statePop(virtualFiles$, basepath, filename));
await fscache().update(basepath, ({ files, ...rest }) => ({
files: files.concat([file]),
...rest,
}));
};
const onFailure = () => {
statePop(virtualFiles$, basepath, dirname);
return rxjs.EMPTY;
};
return ajax$.pipe(
rxjs.tap(onSuccess),
rxjs.catchError(onFailure),
);
}
export function rm(ajax$, ...paths) {
if (paths.length === 0) return rxjs.of(null);
const arr = new Array(paths.length * 2);
let basepath = null;
for (let i=0;i<paths.length;i++) {
[arr[2*i], arr[2*i+1]] = extractPath(paths[i]);
if (i === 0) basepath = arr[2*i];
else if (basepath !== arr[2*i]) throw new Error("NOT_IMPLEMENTED");
}
stateAdd(mutationFiles$, basepath, {
name: basepath,
fn: (file) => {
for (let i=0;i<arr.length;i+=2) {
if (file.name === arr[i+1]) {
file.loading = true;
file.last = true;
}
}
return file;
},
});
const onSuccess = async () => {
stateAdd(mutationFiles$, basepath, {
name: basepath,
fn: (file) => {
for (let i=0;i<arr.length;i+=2) {
if (file.name === arr[i+1]) return null;
}
return file;
},
});
onDestroy(() => statePop(mutationFiles$, basepath, basepath));
await Promise.all(paths.map((path) => fscache().remove(path, false)));
await fscache().update(basepath, ({ files, ...rest }) => ({
files: files.filter(({ name }) => {
for (let i=0;i<arr.length;i+=2) {
if (name === arr[i+1]) {
return false;
}
}
return true;
}),
...rest,
}));
};
const onFailure = () => {
stateAdd(mutationFiles$, basepath, {
name: basepath,
fn: (file) => {
for (let i=0;i<arr.length;i+=2) {
if (file.name === arr[i+1]) {
delete file.loading;
delete file.last;
}
}
return file;
},
});
return rxjs.EMPTY;
};
return ajax$.pipe(
rxjs.tap(onSuccess),
rxjs.catchError(onFailure),
);
}
export function mv(ajax$, fromPath, toPath) {
const [fromBasepath, fromName] = extractPath(fromPath);
const [toBasepath, toName] = extractPath(toPath);
let type = null;
if (fromBasepath === toBasepath) {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === fromName) {
file.loading = true;
file.name = toName;
type = file.type;
}
return file;
},
});
} else {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === fromName) {
file.loading = true;
file.last = true;
type = file.type;
}
return file;
},
});
stateAdd(virtualFiles$, toBasepath, {
name: toName,
loading: true,
type,
});
}
const onSuccess = async () => {
fscache().remove(fromPath, false);
if (fromBasepath === toBasepath) {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === toName) delete file.loading;
return file;
},
});
await fscache().update(fromBasepath, ({ files, ...rest }) => {
return {
files: files.map((file) => {
if (file.name === fromName) {
file.name = toName;
}
return file;
}),
...rest,
};
});
} else {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === fromName) return null;
return file;
},
});
onDestroy(() => statePop(mutationFiles$, fromBasepath, fromName));
statePop(virtualFiles$, toBasepath, toName);
await fscache().update(fromBasepath, ({ files, ...rest }) => ({
files: files.filter((file) => file.name === fromName ? false : true),
...rest,
}))
await fscache().update(toBasepath, ({ files, ...rest }) => ({
files: files.concat([{
name: fromName,
time: new Date().getTime(),
type,
}]),
...rest,
}));
if (isDir(fromPath)) await fscache.remove(fromPath);
}
};
const onFailure = () => {
statePop(mutationFiles$, fromBasepath, fromName);
if (fromBasepath !== toBasepath) {
statePop(virtualFiles$, toBasepath, toName);
}
return rxjs.EMPTY;
};
return ajax$.pipe(
rxjs.tap(onSuccess),
rxjs.catchError(onFailure),
);
}
export function ls(path) {
return rxjs.pipe(
// case1: virtual files = additional files we want to see displayed in the UI
rxjs.switchMap(({ files, ...res }) => virtualFiles$.pipe(rxjs.mergeMap((virtualFiles) => {
const shouldContinue = !!(virtualFiles[path] && virtualFiles[path].length > 0);
if (!shouldContinue) return rxjs.of({ ...res, files });
return rxjs.of({
...res,
files: files.concat(virtualFiles[path]),
});
}))),
// case2: file mutation = update a file state, typically to add a loading state to an
// file or remove it entirely
rxjs.switchMap(({ files, ...res }) => mutationFiles$.pipe(rxjs.mergeMap((fns) => {
const shouldContinue = !!(fns[path] && fns[path].length > 0);
if (!shouldContinue) return rxjs.of({ ...res, files });
for (let i=files.length-1; i>=0; i--) {
for (let j=0; j<fns[path].length; j++) {
files[i] = fns[path][j].fn(files[i]);
if (!files[i]) {
files.splice(i, 1);
break;
}
}
}
return rxjs.of({ ...res, files });
}))),
);
}
function stateAdd(behavior, path, obj) {
let arr = behavior.value[path];
if (!arr) arr = [];
let alreadyKnown = false;
for (let i=0; i<arr.length; i++) {
if (arr[i].name === obj.name) {
alreadyKnown = true;
arr[i] = obj;
break;
}
}
if (!alreadyKnown) arr.push(obj);
behavior.next({
...behavior.value,
[path]: arr,
});
}
function statePop(behavior, path, filename) {
const arr = behavior.value[path];
if (!arr) return;
const newArr = arr.filter(({ name }) => name !== filename)
if (newArr.length === 0) {
const newState = { ...behavior.value };
delete newState[path];
behavior.next(newState);
return;
}
behavior.next({
...behavior.value,
[path]: newArr,
});
}
function removeLoading(behavior, path, filename) {
const arr = behavior.value[path];
if (!arr) return;
virtualFiles$.next({
...virtualFiles$.value,
[path]: arr.map((file) => {
if (file.name === filename) delete file.loading;
return file;
}),
});
}

View file

@ -6,7 +6,17 @@ import { loadCSS } from "../../helpers/loader.js";
import { getAction$, setAction } from "./state_newthing.js"; import { getAction$, setAction } from "./state_newthing.js";
import { currentPath } from "./helper.js"; import { currentPath } from "./helper.js";
import { mkdir, touch } from "./model_files.js"; import { mkdir as mkdir$, touch as touch$ } from "./model_files.js";
import { mkdir as mkdirVL, touch as touchVL, withVirtualLayer } from "./model_virtual_layer.js";
const touch = (path) => withVirtualLayer(
touch$(path),
touchVL(path),
);
const mkdir = (path) => withVirtualLayer(
mkdir$(path),
mkdirVL(path),
);
export default async function(render) { export default async function(render) {
const $node = createElement(` const $node = createElement(`

View file

@ -21,14 +21,26 @@ import { getAction$, setAction } from "./state_newthing.js";
import { setState, getState$ } from "./state_config.js"; import { setState, getState$ } from "./state_config.js";
import { clearCache } from "./cache.js"; import { clearCache } from "./cache.js";
import { getPermission, calculatePermission } from "./model_acl.js"; import { getPermission, calculatePermission } from "./model_acl.js";
import { rm, mv } from "./model_files.js";
import { currentPath, extractPath } from "./helper.js"; import { currentPath, extractPath } from "./helper.js";
import { rm as rm$, mv as mv$ } from "./model_files.js";
import { rm as rmVL, mv as mvVL, withVirtualLayer } from "./model_virtual_layer.js";
const modalOpt = { const modalOpt = {
withButtonsRight: t("OK"), withButtonsRight: t("OK"),
withButtonsLeft: t("CANCEL"), withButtonsLeft: t("CANCEL"),
}; };
const rm = (...paths) => withVirtualLayer(
rm$(...paths),
rmVL(...paths),
);
const mv = (from, to) => withVirtualLayer(
mv$(from, to),
mvVL(from, to),
);
export default async function(render) { export default async function(render) {
const $page = createElement(` const $page = createElement(`
<div class="component_submenu container"> <div class="component_submenu container">
@ -127,7 +139,7 @@ function componentLeft(render, { $scroll }) {
createModal(modalOpt), createModal(modalOpt),
basename(expandSelection()[0].path.replace(new RegExp("/$"), "")), basename(expandSelection()[0].path.replace(new RegExp("/$"), "")),
)), )),
rxjs.mergeMap((val) => { rxjs.mergeMap((val) => { // TODO: migrate to transcient impl
const path = expandSelection()[0].path; const path = expandSelection()[0].path;
const [basepath, filename] = extractPath(path); const [basepath, filename] = extractPath(path);
clearSelection(); clearSelection();
@ -141,7 +153,7 @@ function componentLeft(render, { $scroll }) {
createModal(modalOpt), createModal(modalOpt),
basename(expandSelection()[0].path.replace(new RegExp("/$"), "")).substr(0, 15), basename(expandSelection()[0].path.replace(new RegExp("/$"), "")).substr(0, 15),
)), )),
rxjs.mergeMap((val) => { rxjs.mergeMap((val) => { // TODO: migrate to transcient impl
const selection = expandSelection()[0].path; const selection = expandSelection()[0].path;
clearSelection(); clearSelection();
clearCache(selection); clearCache(selection);

View file

@ -17,7 +17,7 @@
padding: 20px 20px 5px 20px; padding: 20px 20px 5px 20px;
margin: 0 0 5px 0; margin: 0 0 5px 0;
font-size: 1.2em; font-size: 1.2em;
font-weight: 100; font-weight: normal;
} }
.component_upload h2 .percent { .component_upload h2 .percent {
color: var(--emphasis-primary); color: var(--emphasis-primary);

View file

@ -5,23 +5,26 @@ import { loadCSS } from "../../helpers/loader.js";
import { qs } from "../../lib/dom.js"; import { qs } from "../../lib/dom.js";
import { AjaxError } from "../../lib/error.js"; import { AjaxError } from "../../lib/error.js";
import assert from "../../lib/assert.js"; import assert from "../../lib/assert.js";
import { currentPath } from "./helper.js";
import { mkdir, save } from "./model_virtual_layer.js";
import t from "../../locales/index.js"; import t from "../../locales/index.js";
const workers$ = new rxjs.BehaviorSubject({ tasks: [], size: null });
export default function(render) { export default function(render) {
const $page = createFragment(` const $page = createFragment(`
<div is="component_filezone"></div> <div is="component_filezone"></div>
<div is="component_upload_fab"></div> <div is="component_upload_fab"></div>
`); `);
const tasks$ = new rxjs.BehaviorSubject([]);
if (!document.querySelector(`[is="component_upload_queue"]`)) { if (!document.querySelector(`[is="component_upload_queue"]`)) {
const $queue = createElement(`<div is="component_upload_queue"></div>`); const $queue = createElement(`<div is="component_upload_queue"></div>`);
document.body.appendChild($queue); document.body.appendChild($queue);
componentUploadQueue(createRender($queue), { tasks$ }); componentUploadQueue(createRender($queue), { workers$ });
} }
componentFilezone(createRender($page.children[0]), { tasks$ }); componentFilezone(createRender($page.children[0]), { workers$ });
componentUploadFAB(createRender($page.children[1]), { tasks$ }); componentUploadFAB(createRender($page.children[1]), { workers$ });
render($page); render($page);
} }
@ -29,7 +32,7 @@ export function init() {
return loadCSS(import.meta.url, "./ctrl_upload.css"); return loadCSS(import.meta.url, "./ctrl_upload.css");
} }
function componentUploadFAB(render, { tasks$ }) { function componentUploadFAB(render, { workers$ }) {
const $page = createElement(` const $page = createElement(`
<div class="component_mobilefileupload no-select"> <div class="component_mobilefileupload no-select">
<form> <form>
@ -46,17 +49,18 @@ function componentUploadFAB(render, { tasks$ }) {
</div> </div>
`); `);
effect(rxjs.fromEvent(qs($page, `input[type="file"]`), "change").pipe( effect(rxjs.fromEvent(qs($page, `input[type="file"]`), "change").pipe(
rxjs.tap(async (e) => tasks$.next(await processFiles(e.target.files))) rxjs.tap(async (e) => workers$.next(await processFiles(e.target.files))),
)); ));
render($page); render($page);
} }
function componentFilezone(render, { tasks$ }) { function componentFilezone(render, { workers$ }) {
const $target = document.body.querySelector(`[data-bind="filemanager-children"]`); const $target = document.body.querySelector(`[data-bind="filemanager-children"]`);
$target.ondragenter = (e) => { $target.ondragenter = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
$target.classList.add("dropzone"); $target.classList.add("dropzone");
e.dataTransfer.setData("type", "fileupload");
}; };
$target.ondragover = (e) => { $target.ondragover = (e) => {
e.preventDefault(); e.preventDefault();
@ -68,9 +72,13 @@ function componentFilezone(render, { tasks$ }) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const loadID = setTimeout(() => render(createElement("<div>LOADING</div>")), 2000); const loadID = setTimeout(() => render(createElement("<div>LOADING</div>")), 2000);
if (e.dataTransfer.items instanceof window.DataTransferItemList) tasks$.next(await processItems(e.dataTransfer.items)); if (e.dataTransfer.items instanceof window.DataTransferItemList) {
else if (e.dataTransfer.files instanceof window.FileList) tasks$.next(await processFiles(e.dataTransfer.files)); workers$.next(await processItems(e.dataTransfer.items));
else assert.fail("NOT_IMPLEMENTED - unknown entry type in ctrl_upload.js", entry); } else if (e.dataTransfer.files instanceof window.FileList) {
workers$.next(await processFiles(e.dataTransfer.files));
} else {
assert.fail("NOT_IMPLEMENTED - unknown entry type in ctrl_upload.js", entry);
}
$target.classList.remove("dropzone"); $target.classList.remove("dropzone");
clearTimeout(loadID); clearTimeout(loadID);
render(createFragment("")); render(createFragment(""));
@ -79,7 +87,7 @@ function componentFilezone(render, { tasks$ }) {
const MAX_WORKERS = 4; const MAX_WORKERS = 4;
function componentUploadQueue(render, { tasks$ }) { function componentUploadQueue(render, { workers$ }) {
const $page = createElement(` const $page = createElement(`
<div class="component_upload hidden"> <div class="component_upload hidden">
<h2 class="no-select">${t("Current Upload")} <h2 class="no-select">${t("Current Upload")}
@ -103,14 +111,15 @@ function componentUploadQueue(render, { tasks$ }) {
</div> </div>
`); `);
// feature1: close the queue and stop the upload // feature1: close the queue
effect(onClick(qs($page, `img[alt="close"]`)).pipe( onClick(qs($page, `img[alt="close"]`)).pipe(
rxjs.mergeMap(() => animate($page, { time: 200, keyframes: slideYOut(50) })), rxjs.mergeMap(() => animate($page, { time: 200, keyframes: slideYOut(50) })),
rxjs.tap(() => $page.classList.add("hidden")), rxjs.tap(() => $page.classList.add("hidden")),
)); ).subscribe();
// feature2: setup the task queue in the dom // feature2: setup the task queue in the dom
effect(tasks$.asObservable().pipe(rxjs.tap((tasks) => { workers$.subscribe(({ tasks }) => {
console.log("TASKS SETUP DOM", tasks);
if (tasks.length === 0) return; if (tasks.length === 0) return;
$page.classList.remove("hidden"); $page.classList.remove("hidden");
const $fragment = document.createDocumentFragment(); const $fragment = document.createDocumentFragment();
@ -123,7 +132,7 @@ function componentUploadQueue(render, { tasks$ }) {
$task.firstElementChild.nextElementSibling.textContent = t("Waiting"); $task.firstElementChild.nextElementSibling.textContent = t("Waiting");
} }
$content.appendChild($fragment); $content.appendChild($fragment);
}))); });
// feature3: process tasks // feature3: process tasks
const $icon = createElement(`<img class="component_icon" draggable="false" src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgdmlld0JveD0iMCAwIDM4NCA1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggc3R5bGU9ImZpbGw6ICM2MjY0Njk7IiBkPSJNMCAxMjhDMCA5Mi43IDI4LjcgNjQgNjQgNjRIMzIwYzM1LjMgMCA2NCAyOC43IDY0IDY0VjM4NGMwIDM1LjMtMjguNyA2NC02NCA2NEg2NGMtMzUuMyAwLTY0LTI4LjctNjQtNjRWMTI4eiIgLz4KPC9zdmc+Cg==" alt="stop" title="${t("Aborted")}">`) const $icon = createElement(`<img class="component_icon" draggable="false" src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgdmlld0JveD0iMCAwIDM4NCA1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggc3R5bGU9ImZpbGw6ICM2MjY0Njk7IiBkPSJNMCAxMjhDMCA5Mi43IDI4LjcgNjQgNjQgNjRIMzIwYzM1LjMgMCA2NCAyOC43IDY0IDY0VjM4NGMwIDM1LjMtMjguNyA2NC02NCA2NEg2NGMtMzUuMyAwLTY0LTI4LjctNjQtNjRWMTI4eiIgLz4KPC9zdmc+Cg==" alt="stop" title="${t("Aborted")}">`)
@ -131,10 +140,14 @@ function componentUploadQueue(render, { tasks$ }) {
const updateDOMTaskProgress = ($task, text) => $task.firstElementChild.nextElementSibling.textContent = text; const updateDOMTaskProgress = ($task, text) => $task.firstElementChild.nextElementSibling.textContent = text;
const updateDOMTaskSpeed = ($task, text) => $task.firstElementChild.firstElementChild.nextElementSibling.textContent = formatSpeed(text); const updateDOMTaskSpeed = ($task, text) => $task.firstElementChild.firstElementChild.nextElementSibling.textContent = formatSpeed(text);
const updateDOMGlobalSpeed = function (workersSpeed) { const updateDOMGlobalSpeed = function (workersSpeed) {
let last = 0;
return (nworker, currentWorkerSpeed) => { return (nworker, currentWorkerSpeed) => {
workersSpeed[nworker] = currentWorkerSpeed; workersSpeed[nworker] = currentWorkerSpeed;
if (new Date() - last <= 500) return;
last = new Date();
const speed = workersSpeed.reduce((acc, el) => acc + el, 0); const speed = workersSpeed.reduce((acc, el) => acc + el, 0);
$page.firstElementChild.nextElementSibling.firstElementChild.textContent = formatSpeed(speed); const $speed = $page.firstElementChild.nextElementSibling.firstElementChild;
$speed.textContent = formatSpeed(speed);
}; };
}(new Array(MAX_WORKERS).fill(0)); }(new Array(MAX_WORKERS).fill(0));
const updateDOMGlobalTitle = ($page, text) => $page.firstElementChild.nextElementSibling.childNodes[0].textContent = text; const updateDOMGlobalTitle = ($page, text) => $page.firstElementChild.nextElementSibling.childNodes[0].textContent = text;
@ -202,7 +215,8 @@ function componentUploadQueue(render, { tasks$ }) {
} }
}; };
const noFailureAllowed = (fn) => fn().catch(() => noFailureAllowed(fn)); const noFailureAllowed = (fn) => fn().catch(() => noFailureAllowed(fn));
effect(tasks$.pipe(rxjs.tap(async (newTasks) => { workers$.subscribe(async ({ tasks: newTasks }) => {
console.log("TASKS PROCESS", newTasks);
tasks = tasks.concat(newTasks); // add new tasks to the pool tasks = tasks.concat(newTasks); // add new tasks to the pool
while(true) { while(true) {
const nworker = reservations.indexOf(false); const nworker = reservations.indexOf(false);
@ -210,7 +224,7 @@ function componentUploadQueue(render, { tasks$ }) {
reservations[nworker] = true; reservations[nworker] = true;
noFailureAllowed(processWorkerQueue.bind(this, nworker)).then(() => reservations[nworker] = false); noFailureAllowed(processWorkerQueue.bind(this, nworker)).then(() => reservations[nworker] = false);
} }
}))); });
} }
class IExecutor { class IExecutor {
@ -219,7 +233,6 @@ class IExecutor {
run() { throw new Error("NOT_IMPLEMENTED"); } run() { throw new Error("NOT_IMPLEMENTED"); }
} }
const blob = new Blob(new Array(2 * 1024 * 1024).fill('a'), { type: "text/plain" });
function workerImplFile({ error, progress, speed }) { function workerImplFile({ error, progress, speed }) {
return new class Worker extends IExecutor { return new class Worker extends IExecutor {
constructor() { constructor() {
@ -232,16 +245,12 @@ function workerImplFile({ error, progress, speed }) {
this.xhr.abort(); this.xhr.abort();
} }
run({ stream }) { async run({ entry, path, virtual }) {
return new Promise((done, err) => { return new Promise((done, err) => {
console.log("EXECUTE", stream)
this.xhr = new XMLHttpRequest(); this.xhr = new XMLHttpRequest();
this.xhr.open("POST", "http://localhost:8334/api/files/cat?path=" + encodeURIComponent("/filestashtest/test/dummy.txt")); this.xhr.open("POST", "api/files/cat?path=" + encodeURIComponent(path));
this.xhr.withCredentials = true; this.xhr.withCredentials = true;
this.xhr.setRequestHeader("X-Requested-With", "XmlHttpRequest"); this.xhr.setRequestHeader("X-Requested-With", "XmlHttpRequest");
this.xhr.onerror = function(e) {
err(new AjaxError("failed", e, "FAILED"));
};
this.xhr.upload.onabort = () => { this.xhr.upload.onabort = () => {
err(new AjaxError("aborted", null, "ABORTED")); err(new AjaxError("aborted", null, "ABORTED"));
error(new AjaxError("aborted", null, "ABORTED")); error(new AjaxError("aborted", null, "ABORTED"));
@ -271,17 +280,25 @@ function workerImplFile({ error, progress, speed }) {
this.prevProgress.shift(); this.prevProgress.shift();
} }
}; };
// this.xhr.onreadystatechange = () => console.log(this.xhr.readyState);
this.xhr.onload = () => { this.xhr.onload = () => {
progress(100); progress(100);
virtual.afterSuccess();
done(); done();
}; };
this.xhr.send(stream); this.xhr.onerror = function(e) {
err(new AjaxError("failed", e, "FAILED"));
vitual.afterError();
};
entry.file(
(file) => this.xhr.send(file),
(err) => this.xhr.onerror(err),
);
}); });
} }
} }
} }
function workerImplDirectory({ error, progress }) { function workerImplDirectory({ error, progress }) {
return new class Worker extends IExecutor { return new class Worker extends IExecutor {
constructor() { constructor() {
@ -293,10 +310,10 @@ function workerImplDirectory({ error, progress }) {
this.xhr.abort(); this.xhr.abort();
} }
run() { run({ virtual, path }) {
return new Promise((done, err) => { return new Promise((done, err) => {
this.xhr = new XMLHttpRequest(); this.xhr = new XMLHttpRequest();
this.xhr.open("POST", "http://localhost:8334/api/files/mkdir?path=" + encodeURIComponent("/filestashtest/test/dummy.txt")); this.xhr.open("POST", "api/files/mkdir?path=" + encodeURIComponent(path));
this.xhr.withCredentials = true; this.xhr.withCredentials = true;
this.xhr.setRequestHeader("X-Requested-With", "XmlHttpRequest"); this.xhr.setRequestHeader("X-Requested-With", "XmlHttpRequest");
this.xhr.onerror = function(e) { this.xhr.onerror = function(e) {
@ -316,13 +333,14 @@ function workerImplDirectory({ error, progress }) {
err(new AjaxError("aborted", null, "ABORTED")); err(new AjaxError("aborted", null, "ABORTED"));
error(new AjaxError("aborted", null, "ABORTED")); error(new AjaxError("aborted", null, "ABORTED"));
clearInterval(id); clearInterval(id);
virtual.afterError();
}; };
this.xhr.onload = () => { this.xhr.onload = () => {
clearInterval(id); clearInterval(id);
progress(100); progress(100);
virtual.afterSuccess();
setTimeout(() => done(), 500); setTimeout(() => done(), 500);
}; };
console.log(stream)
this.xhr.send(null); this.xhr.send(null);
}); });
@ -330,7 +348,7 @@ function workerImplDirectory({ error, progress }) {
} }
} }
async function processFiles(filelist) { async function processFiles(filelist) { // TODO
const files = []; const files = [];
const detectFiletype = (file) => { const detectFiletype = (file) => {
// the 4096 is an heuristic I've observed and taken from: // the 4096 is an heuristic I've observed and taken from:
@ -354,10 +372,10 @@ async function processFiles(filelist) {
for (const currentFile of filelist) { for (const currentFile of filelist) {
const type = await detectFiletype(currentFile); const type = await detectFiletype(currentFile);
const file = { type, date: currentFile.lastModified, name: currentFile.name, path: currentFile.name }; const file = { type, date: currentFile.lastModified, name: currentFile.name, path: currentFile.name };
if (type === "file") file.size = currentFile.size; if (type === "directory") file.path += "/";
else if (type === "directory") file.path += "/"; else if (type === "file") {
else assert.fail(`NOT_SUPPORTED type="${type}"`, type); fs.push({ type: "file", path, exec: workerImplFile, entry: currentFile });
file.stream = currentFile // TODO: put a file object in there } else assert.fail(`NOT_SUPPORTED type="${type}"`, type);
file.exec = workerImplFile.bind(file); file.exec = workerImplFile.bind(file);
files.push(file); files.push(file);
} }
@ -366,26 +384,42 @@ async function processFiles(filelist) {
async function processItems(itemList) { async function processItems(itemList) {
const bfs = async (queue) => { const bfs = async (queue) => {
const fs = []; const tasks = [];
let path = "" let size = 0;
let path = "";
const basepath = currentPath();
while (queue.length > 0) { while (queue.length > 0) {
const entry = queue.shift(); const entry = queue.shift();
const path = entry.fullPath.substring(1); const path = basepath + entry.fullPath.substring(1);
let task = null;
if (entry === null) continue; if (entry === null) continue;
else if (entry.isFile) { if (entry.isFile) {
const file = await new Promise((done) => entry.file((file) => done(file))); const entrySize = await new Promise((done) => entry.getMetadata(({ size }) => done(size)));
fs.push({ type: "file", path, exec: workerImplFile, stream: file }); task = {
continue; type: "file", entry,
path,
exec: workerImplFile,
virtual: save(path, entrySize),
};
size += entrySize;
} else if (entry.isDirectory) { } else if (entry.isDirectory) {
fs.push({ type: "directory", path: path + "/", exec: workerImplDirectory }); task = {
type: "directory",
path: path + "/",
exec: workerImplDirectory,
virtual: mkdir(path),
};
size += 5000; // that's to calculate the remaining time for an upload, aka made up size is ok
queue = queue.concat(await new Promise((done) => { queue = queue.concat(await new Promise((done) => {
entry.createReader().readEntries(done) entry.createReader().readEntries(done);
})); }));
continue; } else {
}
assert.fail("NOT_IMPLEMENTED - unknown entry type in ctrl_upload.js", entry); assert.fail("NOT_IMPLEMENTED - unknown entry type in ctrl_upload.js", entry);
} }
return fs; task.virtual.before();
tasks.push(task);
}
return { tasks, size: 1000 };
} }
const entries = []; const entries = [];
for (const item of itemList) entries.push(item.webkitGetAsEntry()); for (const item of itemList) entries.push(item.webkitGetAsEntry());

View file

@ -4,10 +4,7 @@ import notification from "../../components/notification.js";
import { setPermissions } from "./model_acl.js"; import { setPermissions } from "./model_acl.js";
import fscache from "./cache.js"; import fscache from "./cache.js";
import { import { ls as middlewareLs } from "./model_virtual_layer.js";
rm as cacheRm, mv as cacheMv, save as cacheSave,
touch as cacheTouch, mkdir as cacheMkdir, ls as middlewareLs,
} from "./cache_transcient.js";
/* /*
* The naive approach would be to make an API call and refresh the screen after an action * The naive approach would be to make an API call and refresh the screen after an action
@ -22,55 +19,41 @@ import {
* 3. the new file is being persisted in the screen if the API call is a success * 3. the new file is being persisted in the screen if the API call is a success
*/ */
const errorNotification = rxjs.catchError((err) => { const withNotification = rxjs.catchError((err) => {
notification.error(err); notification.error(err);
throw err; throw err;
}); });
export function touch(path) { export const touch = (path) => ajax({
const ajax$ = ajax({ url: `api/files/touch?path=${encodeURIComponent(path)}`,
url: `/api/files/touch?path=${encodeURIComponent(path)}`,
method: "POST", method: "POST",
responseType: "json", responseType: "json",
}).pipe(errorNotification); }).pipe(withNotification);
return cacheTouch(ajax$, path);
}
export function mkdir(path) { export const mkdir = (path) => ajax({
const ajax$ = ajax({ url: `api/files/mkdir?path=${encodeURIComponent(path)}`,
url: `/api/files/mkdir?path=${encodeURIComponent(path)}`,
method: "POST", method: "POST",
responseType: "json", responseType: "json",
}).pipe(errorNotification); }).pipe(withNotification);
return cacheMkdir(ajax$, path);
}
export function rm(...paths) { export const rm = (...paths) => rxjs.forkJoin(paths.map((path) => ajax({
const ajax$ = rxjs.forkJoin(paths.map((path) => ajax({ url: `api/files/rm?path=${encodeURIComponent(path)}`,
url: `/api/files/rm?path=${encodeURIComponent(path)}`,
method: "POST", method: "POST",
responseType: "json", responseType: "json",
}).pipe(errorNotification))); }).pipe(withNotification)));
return cacheRm(ajax$, ...paths);
}
export function mv(from, to) { export const mv = (from, to) => ajax({
const ajax$ = ajax({ url: `api/files/mv?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
url: `/api/files/mv?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
method: "POST", method: "POST",
responseType: "json", responseType: "json",
}).pipe(errorNotification); }).pipe(withNotification);
return cacheMv(ajax$, from, to);
}
export function save(path) { // TODO export const save = (path) => rxjs.of(null).pipe(rxjs.delay(1000));
return rxjs.of(null).pipe(rxjs.delay(1000));
}
export function ls(path) { export const ls = (path) => {
const lsFromCache = (path) => rxjs.from(fscache().get(path)); const lsFromCache = (path) => rxjs.from(fscache().get(path));
const lsFromHttp = (path) => ajax({ const lsFromHttp = (path) => ajax({
url: `/api/files/ls?path=${encodeURIComponent(path)}`, url: `api/files/ls?path=${encodeURIComponent(path)}`,
method: "GET", method: "GET",
responseType: "json", responseType: "json",
}).pipe( }).pipe(
@ -114,14 +97,14 @@ export function ls(path) {
rxjs.tap(({ permissions }) => setPermissions(path, permissions)), rxjs.tap(({ permissions }) => setPermissions(path, permissions)),
middlewareLs(path), middlewareLs(path),
); );
} };
export function search(term) { export const search = (term) => {
const path = location.pathname.replace(new RegExp("^/files/"), "/"); const path = location.pathname.replace(new RegExp("^/files/"), "/");
return ajax({ return ajax({
url: `/api/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(term)}`, url: `api/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(term)}`,
responseType: "json" responseType: "json"
}).pipe(rxjs.map(({ responseJSON }) => ({ }).pipe(rxjs.map(({ responseJSON }) => ({
files: responseJSON.results, files: responseJSON.results,
}))); })));
} };

View file

@ -0,0 +1,402 @@
import { onDestroy } from "../../lib/skeleton/index.js";
import rxjs from "../../lib/rx.js";
import fscache from "./cache.js";
import { extractPath, isDir } from "./helper.js";
/*
* The virtual files is used to rerender the list of files in a particular location. That's used
* when we want to update the dom when doing either of a touch, mkdir, rm, mv, ...
*
* |---------------| |---------------|
* | LS | ---> | Virtual Layer | ---> Observable
* |---------------| |---------------|
*
* It is split onto 2 parts:
* - the virtualFiles$: which are things we want to display in addition to what is currently
* visible on the screen
* - the mutationFiles$: which are things already on the screen which we need to mutate. For
* example when we want a particular file to show a loading spinner, ...
*/
const virtualFiles$ = new rxjs.BehaviorSubject({
// "/tmp/": [],
// "/home/": [{ name: "test", type: "directory" }]
});
const mutationFiles$ = new rxjs.BehaviorSubject({
// "/home/": [{ name: "test", fn: (file) => file, ...]
});
class IVirtualLayer {
constructor() {}
before() { throw new Error("NOT_IMPLEMENTED"); }
async afterSuccess() { throw new Error("NOT_IMPLEMENTED"); }
async afterError() { throw new Error("NOT_IMPLEMENTED"); }
}
export function withVirtualLayer(ajax$, mutate) {
mutate.before();
return ajax$.pipe(
rxjs.tap((resp) => mutate.afterSuccess(resp)),
rxjs.catchError(mutate.afterError),
);
};
export function touch(path) {
const [basepath, filename] = extractPath(path);
const file = {
name: filename,
type: "file",
size: 0,
time: new Date().getTime(),
};
return new class TouchVL extends IVirtualLayer {
constructor() { super(); }
before() {
stateAdd(virtualFiles$, basepath, {
...file,
loading: true,
});
}
async afterSuccess() {
removeLoading(virtualFiles$, basepath, filename);
onDestroy(() => statePop(virtualFiles$, basepath, filename));
await fscache().update(basepath, ({ files, ...rest }) => ({
files: files.concat([file]),
...rest,
}));
}
async afterError(err, caught) {
statePop(virtualFiles$, basepath, filename);
return rxjs.of(fscache().remove(basepath)).pipe(
rxjs.mergeMap(() => rxjs.EMPTY),
);
}
}
}
export function mkdir(path) {
const [basepath, dirname] = extractPath(path);
const file = {
name: dirname,
type: "directory",
size: 0,
time: new Date().getTime(),
};
return new class MkdirVL extends IVirtualLayer {
constructor() { super(); }
before() {
stateAdd(virtualFiles$, basepath, {
...file,
loading: true,
});
}
async afterSuccess() {
removeLoading(virtualFiles$, basepath, dirname);
onDestroy(() => statePop(virtualFiles$, basepath, dirname));
await fscache().update(basepath, ({ files, ...rest }) => ({
files: files.concat([file]),
...rest,
}));
}
async afterError(err, caught) {
statePop(virtualFiles$, basepath, dirname);
return rxjs.of(fscache().remove(basepath)).pipe(
rxjs.mergeMap(() => rxjs.EMPTY),
);
}
}
}
export function save(path, size) {
const [basepath, filename] = extractPath(path);
const file = {
name: filename,
type: "file",
size,
time: new Date().getTime(),
};
return new class SaveVL extends IVirtualLayer {
constructor() { super(); }
before() {
stateAdd(virtualFiles$, basepath, {
...file,
loading: true,
});
}
async afterSuccess() {
removeLoading(virtualFiles$, basepath, filename);
onDestroy(() => statePop(virtualFiles$, basepath, filename));
await fscache().update(basepath, ({ files, ...rest }) => ({
files: files.concat([file]),
...rest,
}));
}
async afterError() {
statePop(virtualFiles$, basepath, dirname);
return rxjs.EMPTY;
}
}
}
export function rm(...paths) {
if (paths.length === 0) return rxjs.of(null);
const arr = new Array(paths.length * 2);
let basepath = null;
for (let i=0;i<paths.length;i++) {
[arr[2*i], arr[2*i+1]] = extractPath(paths[i]);
if (i === 0) basepath = arr[2*i];
else if (basepath !== arr[2*i]) throw new Error("NOT_IMPLEMENTED");
}
return new class RmVL extends IVirtualLayer {
constructor() { super(); }
before() {
stateAdd(mutationFiles$, basepath, {
name: basepath,
fn: (file) => {
for (let i=0;i<arr.length;i+=2) {
if (file.name === arr[i+1]) {
file.loading = true;
file.last = true;
}
}
return file;
},
});
}
async afterSuccess() {
stateAdd(mutationFiles$, basepath, {
name: basepath,
fn: (file) => {
for (let i=0;i<arr.length;i+=2) {
if (file.name === arr[i+1]) return null;
}
return file;
},
});
onDestroy(() => statePop(mutationFiles$, basepath, basepath));
await Promise.all(paths.map((path) => fscache().remove(path, false)));
await fscache().update(basepath, ({ files, ...rest }) => ({
files: files.filter(({ name }) => {
for (let i=0;i<arr.length;i+=2) {
if (name === arr[i+1]) {
return false;
}
}
return true;
}),
...rest,
}));
}
async afterError() {
stateAdd(mutationFiles$, basepath, {
name: basepath,
fn: (file) => {
for (let i=0;i<arr.length;i+=2) {
if (file.name === arr[i+1]) {
delete file.loading;
delete file.last;
}
}
return file;
},
});
return rxjs.EMPTY;
}
}
}
export function mv(fromPath, toPath) {
const [fromBasepath, fromName] = extractPath(fromPath);
const [toBasepath, toName] = extractPath(toPath);
let type = null;
return new class MvVL extends IVirtualLayer {
constructor(){ super(); }
before() {
if (fromBasepath === toBasepath) this._beforeSamePath();
else this._beforeSamePath();
}
_beforeSamePath() {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === fromName) {
file.loading = true;
file.name = toName;
type = file.type;
}
return file;
},
});
}
_beforeDifferentPath() {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === fromName) {
file.loading = true;
file.last = true;
type = file.type;
}
return file;
},
});
stateAdd(virtualFiles$, toBasepath, {
name: toName,
loading: true,
type,
});
}
async afterSuccess() {
fscache().remove(fromPath, false);
if (fromBasepath === toBasepath) await this._afterSuccessSamePath();
else await this._afterSuccessDifferentPath();
}
async _afterSuccessSamePath() {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === toName) delete file.loading;
return file;
},
});
await fscache().update(fromBasepath, ({ files, ...rest }) => {
return {
files: files.map((file) => {
if (file.name === fromName) {
file.name = toName;
}
return file;
}),
...rest,
};
});
}
async _afterSuccessDifferentPath() {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === fromName) return null;
return file;
},
});
onDestroy(() => statePop(mutationFiles$, fromBasepath, fromName));
statePop(virtualFiles$, toBasepath, toName);
await fscache().update(fromBasepath, ({ files, ...rest }) => ({
files: files.filter((file) => file.name === fromName ? false : true),
...rest,
}))
await fscache().update(toBasepath, ({ files, ...rest }) => ({
files: files.concat([{
name: fromName,
time: new Date().getTime(),
type,
}]),
...rest,
}));
if (isDir(fromPath)) await fscache.remove(fromPath);
}
async afterError() {
statePop(mutationFiles$, fromBasepath, fromName);
if (fromBasepath !== toBasepath) {
statePop(virtualFiles$, toBasepath, toName);
}
return rxjs.EMPTY;
}
}
}
export function ls(path) {
return rxjs.pipe(
// case1: virtual files = additional files we want to see displayed in the UI
rxjs.switchMap(({ files, ...res }) => virtualFiles$.pipe(rxjs.mergeMap((virtualFiles) => {
const shouldContinue = !!(virtualFiles[path] && virtualFiles[path].length > 0);
if (!shouldContinue) return rxjs.of({ ...res, files });
return rxjs.of({
...res,
files: files.concat(virtualFiles[path]),
});
}))),
// case2: file mutation = update a file state, typically to add a loading state to an
// file or remove it entirely
rxjs.switchMap(({ files, ...res }) => mutationFiles$.pipe(rxjs.mergeMap((fns) => {
const shouldContinue = !!(fns[path] && fns[path].length > 0);
if (!shouldContinue) return rxjs.of({ ...res, files });
for (let i=files.length-1; i>=0; i--) {
for (let j=0; j<fns[path].length; j++) {
files[i] = fns[path][j].fn(files[i]);
if (!files[i]) {
files.splice(i, 1);
break;
}
}
}
return rxjs.of({ ...res, files });
}))),
);
}
function stateAdd(behavior, path, obj) {
let arr = behavior.value[path];
if (!arr) arr = [];
let alreadyKnown = false;
for (let i=0; i<arr.length; i++) {
if (arr[i].name === obj.name) {
alreadyKnown = true;
arr[i] = obj;
break;
}
}
if (!alreadyKnown) arr.push(obj);
behavior.next({
...behavior.value,
[path]: arr,
});
}
function statePop(behavior, path, filename) {
const arr = behavior.value[path];
if (!arr) return;
const newArr = arr.filter(({ name }) => name !== filename)
if (newArr.length === 0) {
const newState = { ...behavior.value };
delete newState[path];
behavior.next(newState);
return;
}
behavior.next({
...behavior.value,
[path]: newArr,
});
}
function removeLoading(behavior, path, filename) {
const arr = behavior.value[path];
if (!arr) return;
virtualFiles$.next({
...virtualFiles$.value,
[path]: arr.map((file) => {
if (file.name === filename) delete file.loading;
return file;
}),
});
}