596 lines
24 KiB
JavaScript
596 lines
24 KiB
JavaScript
// upload.js — drag-drop file upload into the current scope.
|
|
//
|
|
// Active only in server mode and only at paths where the cascade
|
|
// declares drop_target: true (see zddc/internal/zddc/lookups.go
|
|
// DropTargetAt + defaults.zddc.yaml). The loader captures the
|
|
// X-ZDDC-Drop-Target response header on every directory listing
|
|
// fetch and stamps state.scopeDropTarget; this module just reads it.
|
|
//
|
|
// At scopes where drop_target is false (or unset), the handlers
|
|
// stay armed but ignore drops silently — no visible drop-zone
|
|
// overlay. An operator can flip working/staging/incoming on or
|
|
// extend the cascade to mark additional dirs as drop targets via
|
|
// .zddc; the client follows automatically without code change.
|
|
//
|
|
// Wire model:
|
|
// - dragenter on the document raises a counter; first-enter shows
|
|
// the overlay.
|
|
// - dragleave decrements; reaching zero hides the overlay.
|
|
// - drop short-circuits: prevent default, PUT each file under the
|
|
// current state.currentPath, surface per-file toast results,
|
|
// refetch the listing on completion.
|
|
//
|
|
// The PUT uses fetch(`<currentPath><filename>`, method: 'PUT'). The
|
|
// server's authorizeAction enforces write ACL on the parent; a 403
|
|
// surfaces as an error toast and the rest of the batch proceeds.
|
|
//
|
|
// Per-file size cap (UPLOAD_MAX_BYTES): files larger than the cap
|
|
// are rejected client-side with a clear toast — the server would
|
|
// accept them in chunks but browse's v1 PUT is a single body, and
|
|
// dropping a 4 GB CAD bundle into the browser tab as a Blob is a
|
|
// poor experience. Operators with larger uploads should use a
|
|
// dedicated client (zddc-cli or the cache/mirror downstream).
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (!window.app || !window.app.modules) return;
|
|
|
|
var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file
|
|
var state = window.app.state;
|
|
var enterCount = 0;
|
|
var overlayEl = null;
|
|
|
|
function ensureOverlay() {
|
|
if (overlayEl) return overlayEl;
|
|
overlayEl = document.createElement('div');
|
|
overlayEl.className = 'upload-overlay';
|
|
overlayEl.setAttribute('aria-hidden', 'true');
|
|
overlayEl.innerHTML =
|
|
'<div class="upload-overlay__panel">'
|
|
+ '<div class="upload-overlay__icon">⤴</div>'
|
|
+ '<div class="upload-overlay__title">Drop to upload</div>'
|
|
+ '<div class="upload-overlay__path" id="uploadOverlayPath"></div>'
|
|
+ '</div>';
|
|
document.body.appendChild(overlayEl);
|
|
return overlayEl;
|
|
}
|
|
|
|
function currentScopeAllows() {
|
|
if (!state || state.source !== 'server') return false;
|
|
// state.scopeDropTarget is set by the loader on every listing
|
|
// fetch from the X-ZDDC-Drop-Target response header; it's a
|
|
// boolean read of the cascade's effective drop_target flag at
|
|
// the current path. Defaults to false when the header is
|
|
// absent (older server or non-server response).
|
|
return !!state.scopeDropTarget;
|
|
}
|
|
|
|
function showOverlay() {
|
|
var el = ensureOverlay();
|
|
var pathEl = el.querySelector('#uploadOverlayPath');
|
|
if (pathEl) pathEl.textContent = state.currentPath || '/';
|
|
el.classList.add('is-active');
|
|
}
|
|
|
|
function hideOverlay() {
|
|
if (overlayEl) overlayEl.classList.remove('is-active');
|
|
}
|
|
|
|
function dragHasFiles(e) {
|
|
if (!e.dataTransfer || !e.dataTransfer.types) return false;
|
|
var types = e.dataTransfer.types;
|
|
for (var i = 0; i < types.length; i++) {
|
|
if (types[i] === 'Files') return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Join a directory path and a relative path safely. dir is expected
|
|
// to be /-prefixed and may or may not have a trailing /; rel is a
|
|
// forward-slash relative path (no leading /). Each segment is
|
|
// URI-encoded so spaces and friends survive the round trip.
|
|
function joinUrl(dir, rel) {
|
|
var base = dir || '/';
|
|
if (!base.endsWith('/')) base += '/';
|
|
return base + rel.split('/').map(encodeURIComponent).join('/');
|
|
}
|
|
|
|
async function uploadOne(file, destDir, relPath) {
|
|
if (file.size > UPLOAD_MAX_BYTES) {
|
|
return {
|
|
file: file,
|
|
ok: false,
|
|
status: 0,
|
|
message: 'too large (max ' + Math.round(UPLOAD_MAX_BYTES / 1024 / 1024) + ' MiB)'
|
|
};
|
|
}
|
|
try {
|
|
var resp = await fetch(joinUrl(destDir, relPath), {
|
|
method: 'PUT',
|
|
body: file,
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': file.type || 'application/octet-stream'
|
|
}
|
|
});
|
|
return {
|
|
file: file,
|
|
ok: resp.ok,
|
|
status: resp.status,
|
|
message: resp.ok ? '' : ('HTTP ' + resp.status)
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
file: file,
|
|
ok: false,
|
|
status: 0,
|
|
message: (e && e.message) ? e.message : 'network error'
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Folder-upload helpers (webkitGetAsEntry recursion) ─────────────────
|
|
// Browsers expose dropped folders only through the entries API.
|
|
// walkEntry flattens a tree into [{ relPath, file }] so uploadOne
|
|
// can PUT each file individually. The server's PUT auto-creates
|
|
// intermediate directories, so no explicit mkdir is needed.
|
|
|
|
function readAllEntries(reader) {
|
|
return new Promise(function (resolve, reject) {
|
|
var collected = [];
|
|
function loop() {
|
|
reader.readEntries(function (batch) {
|
|
if (batch.length === 0) return resolve(collected);
|
|
collected = collected.concat(batch);
|
|
loop();
|
|
}, reject);
|
|
}
|
|
loop();
|
|
});
|
|
}
|
|
|
|
function entryToFile(entry) {
|
|
return new Promise(function (resolve, reject) {
|
|
entry.file(resolve, reject);
|
|
});
|
|
}
|
|
|
|
async function walkEntry(entry, prefix, out) {
|
|
if (entry.isFile) {
|
|
try {
|
|
var f = await entryToFile(entry);
|
|
out.push({ relPath: prefix + entry.name, file: f });
|
|
} catch (_e) { /* skip unreadable file */ }
|
|
} else if (entry.isDirectory) {
|
|
var reader = entry.createReader();
|
|
var kids = await readAllEntries(reader);
|
|
for (var i = 0; i < kids.length; i++) {
|
|
await walkEntry(kids[i], prefix + entry.name + '/', out);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract { relPath, file } pairs from a DataTransfer. Uses
|
|
// webkitGetAsEntry when available (so folder uploads work);
|
|
// falls back to dataTransfer.files for cases where entries
|
|
// aren't exposed (some browsers / cross-origin).
|
|
async function collectUploads(dt) {
|
|
var out = [];
|
|
if (dt.items && dt.items.length) {
|
|
var entries = [];
|
|
for (var i = 0; i < dt.items.length; i++) {
|
|
var item = dt.items[i];
|
|
if (item.kind !== 'file') continue;
|
|
var entry = typeof item.webkitGetAsEntry === 'function'
|
|
? item.webkitGetAsEntry()
|
|
: null;
|
|
if (entry) {
|
|
entries.push(entry);
|
|
} else {
|
|
var f = item.getAsFile();
|
|
if (f) out.push({ relPath: f.name, file: f });
|
|
}
|
|
}
|
|
for (var j = 0; j < entries.length; j++) {
|
|
await walkEntry(entries[j], '', out);
|
|
}
|
|
if (out.length) return out;
|
|
}
|
|
if (dt.files) {
|
|
for (var k = 0; k < dt.files.length; k++) {
|
|
out.push({ relPath: dt.files[k].name, file: dt.files[k] });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Run a batch of uploads against an arbitrary destination directory.
|
|
// Surfaces per-file errors as toasts; refreshes the tree afterward
|
|
// so newly-uploaded entries appear. Returns { ok, fail } counts.
|
|
async function uploadBatch(uploads, destDir) {
|
|
var note = window.zddc && window.zddc.toast;
|
|
if (note) {
|
|
note('Uploading ' + uploads.length + ' item'
|
|
+ (uploads.length === 1 ? '' : 's') + '…', 'info');
|
|
}
|
|
var ok = 0, fail = 0;
|
|
for (var i = 0; i < uploads.length; i++) {
|
|
var u = uploads[i];
|
|
var res = await uploadOne(u.file, destDir, u.relPath);
|
|
if (res.ok) ok++;
|
|
else {
|
|
fail++;
|
|
if (note) {
|
|
note('Upload failed: ' + u.relPath + ' — ' + res.message, 'error');
|
|
}
|
|
}
|
|
}
|
|
if (note) {
|
|
if (fail === 0) {
|
|
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's')
|
|
+ ' → ' + destDir, 'success');
|
|
} else if (ok === 0) {
|
|
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
|
|
} else {
|
|
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
|
|
}
|
|
}
|
|
return { ok: ok, fail: fail };
|
|
}
|
|
|
|
// Comment upload: PUT each dropped file's bytes to the target URL.
|
|
// The server detects the virtual <workflow>/received/ context and
|
|
// rewrites the destination to <workflow>/<base>+C<n><suffix>, surfacing
|
|
// the resolved path in X-ZDDC-Resolved-Path so the status line can
|
|
// tell the user where the bytes landed.
|
|
async function uploadCommentToTarget(targetURL, dataTransfer) {
|
|
var note = window.zddc && window.zddc.toast;
|
|
var files = [];
|
|
if (dataTransfer.files && dataTransfer.files.length) {
|
|
for (var k = 0; k < dataTransfer.files.length; k++) {
|
|
files.push(dataTransfer.files[k]);
|
|
}
|
|
}
|
|
if (files.length === 0) {
|
|
if (note) note('No files to upload.', 'warning');
|
|
return;
|
|
}
|
|
var ok = 0;
|
|
var lastResolved = '';
|
|
for (var i = 0; i < files.length; i++) {
|
|
var f = files[i];
|
|
if (f.size > UPLOAD_MAX_BYTES) {
|
|
if (note) note('Skipped (too large): ' + f.name, 'error');
|
|
continue;
|
|
}
|
|
try {
|
|
var resp = await fetch(targetURL, {
|
|
method: 'PUT',
|
|
body: f,
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': f.type || 'application/octet-stream' }
|
|
});
|
|
if (resp.ok) {
|
|
ok++;
|
|
var hdr = resp.headers.get('X-ZDDC-Resolved-Path') || '';
|
|
if (hdr) lastResolved = hdr;
|
|
} else if (note) {
|
|
note('Comment upload failed (' + resp.status + ')', 'error');
|
|
}
|
|
} catch (e) {
|
|
if (note) note('Comment upload error: ' + (e && e.message), 'error');
|
|
}
|
|
}
|
|
if (note && ok > 0) {
|
|
var msg = 'Saved ' + ok + ' comment' + (ok === 1 ? '' : 's');
|
|
if (lastResolved) msg += ' — last at ' + lastResolved;
|
|
note(msg, 'success');
|
|
}
|
|
// Reload the current listing so the new +Cn file appears in the
|
|
// tree. Best-effort.
|
|
try {
|
|
var ev = window.app.modules.events;
|
|
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
|
|
} catch (_e) { /* refresh is best-effort */ }
|
|
}
|
|
|
|
// ── Write-permission escalation (FS-API mode) ──────────────────────────
|
|
// The local folder is picked read-only (showDirectoryPicker mode:read)
|
|
// so browsing never prompts. The first mutation escalates to readwrite
|
|
// via the FS-Access permission prompt; granting on the picked root
|
|
// covers every descendant handle. Must run under a user gesture — every
|
|
// caller is reached from a click/menu action. No-op in server mode or
|
|
// on browsers without the permission API.
|
|
async function ensureWritable() {
|
|
if (state.source !== 'fs') return;
|
|
var root = state.rootHandle;
|
|
if (!root || typeof root.requestPermission !== 'function') return;
|
|
var opts = { mode: 'readwrite' };
|
|
if ((await root.queryPermission(opts)) === 'granted') return;
|
|
if ((await root.requestPermission(opts)) === 'granted') return;
|
|
throw new Error('Write permission denied — grant edit access to the folder when prompted.');
|
|
}
|
|
|
|
// handleForDir resolves a directory PATH (FS-API mode) to its
|
|
// FileSystemDirectoryHandle: the picked root for the current scope,
|
|
// else the matching expanded node's handle. Returns null if unknown.
|
|
function handleForDir(dirPath) {
|
|
var tree = window.app.modules.tree;
|
|
if (!dirPath.endsWith('/')) dirPath += '/';
|
|
if (dirPath === state.currentPath) return state.rootHandle;
|
|
var noSlash = dirPath.replace(/\/$/, '');
|
|
var hit = null;
|
|
state.nodes.forEach(function (n) {
|
|
if (hit || !n.isDir || !n.handle) return;
|
|
if (tree && tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
|
|
});
|
|
return hit ? hit.handle : null;
|
|
}
|
|
|
|
// ── Create-new helpers ────────────────────────────────────────────────
|
|
// Server mode: PUT for files (empty/template body) and POST +
|
|
// X-ZDDC-Op: mkdir for directories; the server's ACL is the source of
|
|
// truth. FS-API mode: create directly in the picked tree via
|
|
// getDirectoryHandle/getFileHandle({create:true}) + createWritable —
|
|
// limited only by the filesystem permission the user granted.
|
|
|
|
async function makeDir(parentDir, name) {
|
|
if (state.source === 'server') {
|
|
var url = joinUrl(parentDir, name);
|
|
if (!url.endsWith('/')) url += '/';
|
|
var resp = await fetch(url, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'X-ZDDC-Op': 'mkdir' }
|
|
});
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
return;
|
|
}
|
|
var parent = handleForDir(parentDir);
|
|
if (!parent) throw new Error('No directory handle for ' + parentDir);
|
|
await ensureWritable();
|
|
await parent.getDirectoryHandle(name, { create: true });
|
|
}
|
|
|
|
async function makeFile(parentDir, name, body, contentType) {
|
|
if (state.source === 'server') {
|
|
var resp = await fetch(joinUrl(parentDir, name), {
|
|
method: 'PUT',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': contentType || 'application/octet-stream' },
|
|
body: body == null ? '' : body
|
|
});
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
return;
|
|
}
|
|
var parent = handleForDir(parentDir);
|
|
if (!parent) throw new Error('No directory handle for ' + parentDir);
|
|
await ensureWritable();
|
|
var fh = await parent.getFileHandle(name, { create: true });
|
|
var w = await fh.createWritable();
|
|
await w.write(body == null ? '' : body);
|
|
await w.close();
|
|
}
|
|
|
|
// ── Delete + rename ─────────────────────────────────────────────────────
|
|
// Both run through the same FS Access API + file-API endpoints used
|
|
// by the create helpers above:
|
|
// - Server mode: DELETE / POST X-ZDDC-Op: move. ACL is enforced
|
|
// server-side; a 403/405 surfaces as an error toast.
|
|
// - FS-API mode: FileSystemHandle.remove({recursive:true}) and
|
|
// .move(newName) — both are Chromium-110+ features. We feature-
|
|
// detect at the handle level; callers see a clear "not supported"
|
|
// error message if the browser is too old.
|
|
|
|
function pathForNode(node) {
|
|
var tree = window.app.modules.tree;
|
|
return tree ? tree.pathFor(node) : '';
|
|
}
|
|
|
|
function isZipMember(node) {
|
|
if (node.handle && node.handle.isZipEntry) return true;
|
|
if (node.url && state.source === 'server' && /\.zip\//i.test(node.url)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// True when this node's write API is reachable. The server can
|
|
// still refuse the action on ACL grounds; this only gates the
|
|
// menu's disabled-state for the cases where there's clearly no
|
|
// write target at all.
|
|
function canMutate(node) {
|
|
if (!node || node.virtual) return false;
|
|
if (isZipMember(node)) return false;
|
|
if (state.source === 'server') return true;
|
|
if (node.handle && typeof node.handle.remove === 'function') return true;
|
|
return false;
|
|
}
|
|
|
|
async function removeNode(node) {
|
|
if (!node) throw new Error('no node');
|
|
if (isZipMember(node)) {
|
|
throw new Error('Cannot delete a file inside a zip archive.');
|
|
}
|
|
if (node.virtual) {
|
|
throw new Error('Virtual folder — nothing on disk to delete.');
|
|
}
|
|
if (state.source === 'server') {
|
|
var url = pathForNode(node);
|
|
if (node.isDir && !url.endsWith('/')) url += '/';
|
|
var resp = await fetch(url, {
|
|
method: 'DELETE',
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
if (resp.status === 403) throw new Error('Permission denied (403).');
|
|
if (resp.status === 405) throw new Error('Delete not allowed for this entry.');
|
|
throw new Error('HTTP ' + resp.status);
|
|
}
|
|
return;
|
|
}
|
|
// FS-API path. FileSystemHandle.remove() is Chromium 110+
|
|
// (browsers that didn't ship it expose no equivalent — the
|
|
// legacy removeEntry() lives on the PARENT directory handle
|
|
// and we don't retain ancestor handles).
|
|
if (node.handle && typeof node.handle.remove === 'function') {
|
|
await ensureWritable();
|
|
await node.handle.remove({ recursive: !!node.isDir });
|
|
return;
|
|
}
|
|
throw new Error('Delete not supported by this browser in offline mode.');
|
|
}
|
|
|
|
async function renameNode(node, newName) {
|
|
if (!node) throw new Error('no node');
|
|
if (!newName) throw new Error('Name required.');
|
|
if (newName === node.name) return;
|
|
if (isZipMember(node)) {
|
|
throw new Error('Cannot rename a file inside a zip archive.');
|
|
}
|
|
if (node.virtual) {
|
|
throw new Error('Virtual folder — nothing on disk to rename.');
|
|
}
|
|
if (state.source === 'server') {
|
|
var src = pathForNode(node);
|
|
if (node.isDir && !src.endsWith('/')) src += '/';
|
|
// Destination = same parent, new basename.
|
|
var lastSlash = src.replace(/\/$/, '').lastIndexOf('/');
|
|
var parent = lastSlash >= 0 ? src.substring(0, lastSlash + 1) : '/';
|
|
var dst = parent + encodeURIComponent(newName) + (node.isDir ? '/' : '');
|
|
var resp = await fetch(src, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'X-ZDDC-Op': 'move',
|
|
'X-ZDDC-Destination': dst
|
|
}
|
|
});
|
|
if (!resp.ok) {
|
|
if (resp.status === 403) throw new Error('Permission denied (403).');
|
|
if (resp.status === 409) throw new Error('A file with that name already exists.');
|
|
throw new Error('HTTP ' + resp.status);
|
|
}
|
|
return;
|
|
}
|
|
// FS-API: handle.move(newName) is Chromium 110+.
|
|
if (node.handle && typeof node.handle.move === 'function') {
|
|
await ensureWritable();
|
|
await node.handle.move(newName);
|
|
return;
|
|
}
|
|
throw new Error('Rename not supported by this browser in offline mode.');
|
|
}
|
|
|
|
// Refresh either the root listing (when the upload targeted the
|
|
// current scope) or just one folder node's children (when the
|
|
// upload targeted a subfolder via a per-row drop).
|
|
async function refreshAfterUpload(targetDir) {
|
|
var loader = window.app.modules.loader;
|
|
var tree = window.app.modules.tree;
|
|
if (!loader || !tree) return;
|
|
if (state.currentPath && targetDir === state.currentPath) {
|
|
try {
|
|
var es = await loader.fetchServerChildren(state.currentPath);
|
|
tree.setRoot(es);
|
|
tree.render();
|
|
} catch (_e) { /* swallow */ }
|
|
return;
|
|
}
|
|
// Find any tree node whose path matches targetDir and reload
|
|
// its children. Walks state.nodes flat — n is small enough for
|
|
// a linear scan.
|
|
var dirNoSlash = (targetDir || '').replace(/\/$/, '');
|
|
var hit = null;
|
|
state.nodes.forEach(function (n) {
|
|
if (hit || !n.isDir) return;
|
|
if (tree.pathFor(n).replace(/\/$/, '') === dirNoSlash) hit = n;
|
|
});
|
|
if (hit && hit.expanded) {
|
|
try {
|
|
var raw = await loader.fetchServerChildren(targetDir);
|
|
tree.setChildren(hit.id, raw);
|
|
tree.render();
|
|
} catch (_e) { /* swallow */ }
|
|
}
|
|
}
|
|
|
|
// Document-level drop: targets the currently-viewed scope. The
|
|
// per-row drop (events.js) calls uploadToDir directly with a
|
|
// different destination.
|
|
async function handleDrop(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
enterCount = 0;
|
|
hideOverlay();
|
|
|
|
if (!currentScopeAllows()) return;
|
|
var dt = e.dataTransfer;
|
|
if (!dt) return;
|
|
var uploads = await collectUploads(dt);
|
|
if (!uploads.length) return;
|
|
await uploadBatch(uploads, state.currentPath);
|
|
await refreshAfterUpload(state.currentPath);
|
|
}
|
|
|
|
// Public entry for per-row drops or programmatic uploads. destDir
|
|
// must be a server path (/-prefixed, slash-terminated optional).
|
|
async function uploadToDir(destDir, dataTransfer) {
|
|
var uploads = await collectUploads(dataTransfer);
|
|
if (!uploads.length) return { ok: 0, fail: 0 };
|
|
var res = await uploadBatch(uploads, destDir);
|
|
await refreshAfterUpload(destDir);
|
|
return res;
|
|
}
|
|
|
|
function onEnter(e) {
|
|
if (!dragHasFiles(e)) return;
|
|
enterCount++;
|
|
if (enterCount === 1 && currentScopeAllows()) {
|
|
showOverlay();
|
|
}
|
|
}
|
|
|
|
function onLeave(e) {
|
|
if (!dragHasFiles(e)) return;
|
|
enterCount = Math.max(0, enterCount - 1);
|
|
if (enterCount === 0) hideOverlay();
|
|
}
|
|
|
|
function onOver(e) {
|
|
if (!dragHasFiles(e)) return;
|
|
// preventDefault on dragover is required for drop to fire.
|
|
e.preventDefault();
|
|
if (e.dataTransfer && currentScopeAllows()) {
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
} else if (e.dataTransfer) {
|
|
e.dataTransfer.dropEffect = 'none';
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
document.addEventListener('dragenter', onEnter, false);
|
|
document.addEventListener('dragleave', onLeave, false);
|
|
document.addEventListener('dragover', onOver, false);
|
|
document.addEventListener('drop', handleDrop, false);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
window.app.modules.upload = {
|
|
currentScopeAllows: currentScopeAllows,
|
|
uploadToDir: uploadToDir,
|
|
uploadCommentToTarget: uploadCommentToTarget,
|
|
makeDir: makeDir,
|
|
makeFile: makeFile,
|
|
removeNode: removeNode,
|
|
renameNode: renameNode,
|
|
canMutate: canMutate,
|
|
ensureWritable: ensureWritable,
|
|
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
|
|
};
|
|
})();
|