Files
cloudron-box/dashboard/src/js/filemanager.js
2023-02-24 23:40:15 +01:00

891 lines
30 KiB
JavaScript

'use strict';
require.config({ paths: { 'vs': '3rdparty/vs' }});
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ngDrag', 'ui.bootstrap', 'ui.bootstrap.contextMenu']);
angular.module('Application').filter('prettyOwner', function () {
return function (uid) {
if (uid === 0) return 'root';
if (uid === 33) return 'www-data';
if (uid === 1000) return 'cloudron';
if (uid === 1001) return 'git';
return uid;
};
});
// disable sce for footer https://code.angularjs.org/1.5.8/docs/api/ng/service/$sce
app.config(function ($sceProvider) {
$sceProvider.enabled(false);
});
app.filter('trustUrl', ['$sce', function ($sce) {
return function (recordingUrl) {
return $sce.trustAsResourceUrl(recordingUrl);
};
}]);
// https://stackoverflow.com/questions/25621321/angularjs-ng-drag
var ngDragEventDirectives = {};
angular.forEach(
'drag dragend dragenter dragexit dragleave dragover dragstart drop'.split(' '),
function(eventName) {
var directiveName = 'ng' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
ngDragEventDirectives[directiveName] = ['$parse', '$rootScope', function($parse/*, $rootScope */) {
return {
restrict: 'A',
compile: function($element, attr) {
var fn = $parse(attr[directiveName], null, true);
return function ngDragEventHandler(scope, element) {
element.on(eventName, function(event) {
var callback = function() {
fn(scope, {$event: event});
};
scope.$apply(callback);
});
};
}
};
}];
}
);
angular.module('ngDrag', []).directive(ngDragEventDirectives);
function sanitize(filePath) {
filePath = filePath.split('/').filter(function (a) { return !!a; }).reduce(function (a, v) {
if (v === '.'); // do nothing
else if (v === '..') a.pop();
else a.push(v);
return a;
}, []).map(function (p) {
// small detour to safely handle special characters and whitespace
return encodeURIComponent(decodeURIComponent(p));
}).join('/');
return filePath;
}
function isModalVisible() {
return !!document.getElementsByClassName('modal in').length;
}
var VIEW = {
LEFT: 'left',
RIGHT: 'right'
};
var OWNERS = [
{ name: 'cloudron', value: 1000 },
{ name: 'www-data', value: 33 },
{ name: 'git', value: 1001 },
{ name: 'root', value: 0 }
];
app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Client', function ($scope, $translate, $timeout, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
// expose enums
$scope.VIEW = VIEW;
$scope.OWNERS = OWNERS;
$scope.initialized = false;
$scope.status = null;
$scope.client = Client;
$scope.title = '';
$scope.backendId = search.id;
$scope.backendType = search.type;
$scope.volumes = [];
$scope.splitView = !!window.localStorage.splitView;
$scope.activeView = VIEW.LEFT;
$scope.viewerOpen = false;
$scope.clipboard = []; // holds cut or copied entries
$scope.clipboardCut = false; // if action is cut or copy
// add a hook for children to refresh both tree views
$scope.children = [];
$scope.registerChild = function (child) { $scope.children.push(child); };
$scope.refresh = function () {
$scope.children.forEach(function (child) {
child.onRefresh();
});
};
function collectFiles(entry, callback) {
var pathFrom = entry.pathFrom;
if (entry.isDirectory) {
Client.filesGet($scope.backendId, $scope.backendType, entry.fullFilePath, 'data', function (error, result) {
if (error) return callback(error);
if (!result.entries) return callback(new Error('not a folder'));
// amend fullFilePath
result.entries.forEach(function (e) {
e.fullFilePath = sanitize(entry.fullFilePath + '/' + e.fileName);
e.pathFrom = pathFrom; // we stash the original path for pasting
});
var collectedFiles = [];
async.eachLimit(result.entries, 5, function (entry, callback) {
collectFiles(entry, function (error, result) {
if (error) return callback(error);
collectedFiles = collectedFiles.concat(result);
callback();
});
}, function (error) {
if (error) return callback(error);
callback(null, collectedFiles);
});
});
return;
}
callback(null, [ entry ]);
}
// entries need to be an actual copy
$scope.actionCut = function (cwd, entries) {
$scope.clipboard = entries; //$scope.selected.slice();
$scope.clipboard.forEach(function (entry) {
entry.fullFilePath = sanitize(cwd + '/' + entry.fileName);
});
$scope.clipboardCut = true;
};
// entries need to be an actual copy
$scope.actionCopy = function (cwd, entries) {
$scope.clipboard = entries; //$scope.selected.slice();
$scope.clipboard.forEach(function (entry) {
entry.fullFilePath = sanitize(cwd + '/' + entry.fileName);
entry.pathFrom = cwd; // we stash the original path for pasting
});
$scope.clipboardCut = false;
};
$scope.actionPaste = function (cwd, destinationEntry) {
if ($scope.clipboardCut) {
// move files
async.eachLimit($scope.clipboard, 5, function (entry, callback) {
var newFilePath = sanitize(cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fileName);
// TODO this will overwrite files in destination!
Client.filesRename($scope.backendId, $scope.backendType, entry.fullFilePath, newFilePath, callback);
}, function (error) {
if (error) return Client.error(error);
// clear clipboard
$scope.clipboard = [];
$scope.refresh();
});
} else {
// copy files
// first collect all files recursively
var collectedFiles = [];
async.eachLimit($scope.clipboard, 5, function (entry, callback) {
collectFiles(entry, function (error, result) {
if (error) return callback(error);
collectedFiles = collectedFiles.concat(result);
callback();
});
}, function (error) {
if (error) return Client.error(error);
async.eachLimit(collectedFiles, 5, function (entry, callback) {
var newFilePath = sanitize(cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fullFilePath.slice(entry.pathFrom.length));
// This will NOT overwrite but finds a unique new name to copy to
// we prefix with a / to ensure we don't do relative target paths
Client.filesCopy($scope.backendId, $scope.backendType, entry.fullFilePath, '/' + newFilePath, callback);
}, function (error) {
if (error) return Client.error(error);
// clear clipboard
$scope.clipboard = [];
$scope.refresh();
});
});
}
};
// handle uploads
$scope.uploadStatus = {
error: null,
busy: false,
fileName: '',
count: 0,
countDone: 0,
size: 0,
done: 0,
percentDone: 0,
files: [],
targetFolder: ''
};
$scope.uploadFiles = function (files, targetFolder, overwrite) {
if (!files || !files.length) return;
overwrite = !!overwrite;
// prevent it from getting closed
$('#uploadModal').modal({
backdrop: 'static',
keyboard: false
});
$scope.uploadStatus.files = files;
$scope.uploadStatus.targetFolder = targetFolder;
$scope.uploadStatus.error = null;
$scope.uploadStatus.busy = true;
$scope.uploadStatus.count = files.length;
$scope.uploadStatus.countDone = 0;
$scope.uploadStatus.size = 0;
$scope.uploadStatus.sizeDone = 0;
$scope.uploadStatus.done = 0;
$scope.uploadStatus.percentDone = 0;
for (var i = 0; i < files.length; ++i) {
$scope.uploadStatus.size += files[i].size;
}
async.eachSeries(files, function (file, callback) {
var filePath = sanitize(targetFolder + '/' + (file.webkitRelativePath || file.name));
$scope.uploadStatus.fileName = file.name;
Client.filesUpload($scope.backendId, $scope.backendType, filePath, file, overwrite, function (loaded) {
$scope.uploadStatus.percentDone = ($scope.uploadStatus.done+loaded) * 100 / $scope.uploadStatus.size;
$scope.uploadStatus.sizeDone = loaded;
}, function (error) {
if (error) return callback(error);
$scope.uploadStatus.done += file.size;
$scope.uploadStatus.percentDone = $scope.uploadStatus.done * 100 / $scope.uploadStatus.size;
$scope.uploadStatus.countDone++;
callback();
});
}, function (error) {
$scope.uploadStatus.busy = false;
if (error && error.statusCode === 409) {
$scope.uploadStatus.error = 'exists';
return;
} else if (error) {
console.error(error);
$scope.uploadStatus.error = 'generic';
return;
}
$('#uploadModal').modal('hide');
$scope.uploadStatus.fileName = '';
$scope.uploadStatus.count = 0;
$scope.uploadStatus.size = 0;
$scope.uploadStatus.sizeDone = 0;
$scope.uploadStatus.done = 0;
$scope.uploadStatus.percentDone = 100;
$scope.uploadStatus.files = [];
$scope.uploadStatus.targetFolder = '';
$scope.refresh();
});
};
$scope.retryUpload = function (overwrite) {
$scope.uploadFiles($scope.uploadStatus.files, $scope.uploadStatus.targetFolder, !!overwrite);
};
// file and folder upload hooks, stashing $scope.uploadCwd for now
$scope.uploadCwd = '';
$('#uploadFileInput').on('change', function (e ) {
$scope.uploadFiles(e.target.files || [], $scope.uploadCwd, false);
});
$scope.onUploadFile = function (cwd) {
$scope.uploadCwd = cwd;
$('#uploadFileInput').click();
};
$('#uploadFolderInput').on('change', function (e ) {
$scope.uploadFiles(e.target.files || [], $scope.uploadCwd, false);
});
$scope.onUploadFolder = function (cwd) {
$scope.uploadCwd = cwd;
$('#uploadFolderInput').click();
};
// handle delete
$scope.deleteEntries = {
busy: false,
error: null,
cwd: '',
entries: [],
show: function (cwd, entries) {
$scope.deleteEntries.error = null;
$scope.deleteEntries.cwd = cwd;
$scope.deleteEntries.entries = entries;
$('#entriesDeleteModal').modal('show');
},
submit: function () {
$scope.deleteEntries.busy = true;
async.eachLimit($scope.deleteEntries.entries, 5, function (entry, callback) {
var filePath = sanitize($scope.deleteEntries.cwd + '/' + entry.fileName);
Client.filesRemove($scope.backendId, $scope.backendType, filePath, callback);
}, function (error) {
$scope.deleteEntries.busy = false;
if (error) return Client.error(error);
$scope.refresh();
$('#entriesDeleteModal').modal('hide');
});
}
};
// rename entry
$scope.renameEntry = {
busy: false,
error: null,
entry: null,
cwd: '',
newName: '',
show: function (cwd, entry) {
$scope.renameEntry.error = null;
$scope.renameEntry.cwd = cwd;
$scope.renameEntry.entry = entry;
$scope.renameEntry.newName = entry.fileName;
$scope.renameEntry.busy = false;
$('#renameEntryModal').modal('show');
},
submit: function () {
$scope.renameEntry.busy = true;
var oldFilePath = sanitize($scope.renameEntry.cwd + '/' + $scope.renameEntry.entry.fileName);
var newFilePath = sanitize(($scope.renameEntry.newName[0] === '/' ? '' : ($scope.renameEntry.cwd + '/')) + $scope.renameEntry.newName);
Client.filesRename($scope.backendId, $scope.backendType, oldFilePath, newFilePath, function (error) {
$scope.renameEntry.busy = false;
if (error) return Client.error(error);
$scope.refresh();
$('#renameEntryModal').modal('hide');
});
}
};
// chown entries
$scope.chownEntries = {
busy: false,
error: null,
entries: [],
newOwner: 0,
recursive: false,
showRecursiveOption: false,
show: function (cwd, entries) {
$scope.chownEntries.error = null;
$scope.chownEntries.cwd = cwd;
$scope.chownEntries.entries = entries;
// set default uid from first file
$scope.chownEntries.newOwner = entries[0].uid;
$scope.chownEntries.busy = false;
// default for directories is recursive
$scope.chownEntries.recursive = !!entries.find(function (entry) { return entry.isDirectory; });
$scope.chownEntries.showRecursiveOption = false;
$('#chownEntriesModal').modal('show');
},
submit: function () {
$scope.chownEntries.busy = true;
async.eachLimit($scope.chownEntries.entries, 5, function (entry, callback) {
var filePath = sanitize($scope.chownEntries.cwd + '/' + entry.fileName);
Client.filesChown($scope.backendId, $scope.backendType, filePath, $scope.chownEntries.newOwner, $scope.chownEntries.recursive, callback);
}, function (error) {
$scope.chownEntries.busy = false;
if (error) return Client.error(error);
$scope.refresh();
$('#chownEntriesModal').modal('hide');
});
}
};
// new file
$scope.newFile = {
busy: false,
error: null,
cwd: '',
name: '',
show: function (cwd) {
$scope.newFile.error = null;
$scope.newFile.name = '';
$scope.newFile.busy = false;
$scope.newFile.cwd = cwd;
$scope.newFileForm.$setUntouched();
$scope.newFileForm.$setPristine();
$('#newFileModal').modal('show');
},
submit: function () {
$scope.newFile.busy = true;
$scope.newFile.error = null;
var filePath = sanitize($scope.newFile.cwd + '/' + $scope.newFile.name);
Client.filesUpload($scope.backendId, $scope.backendType, filePath, new File([], $scope.newFile.name), false, function () {}, function (error) {
$scope.newFile.busy = false;
if (error && error.statusCode === 409) return $scope.newFile.error = 'exists';
if (error) return Client.error(error);
$scope.refresh();
$('#newFileModal').modal('hide');
});
}
};
// new folder
$scope.newFolder = {
busy: false,
error: null,
cwd: '',
name: '',
show: function (cwd) {
$scope.newFolder.error = null;
$scope.newFolder.name = '';
$scope.newFolder.busy = false;
$scope.newFolder.cwd = cwd;
$scope.newFolderForm.$setUntouched();
$scope.newFolderForm.$setPristine();
$('#newFolderModal').modal('show');
},
submit: function () {
$scope.newFolder.busy = true;
$scope.newFolder.error = null;
var filePath = sanitize($scope.newFolder.cwd + '/' + $scope.newFolder.name);
Client.filesCreateDirectory($scope.backendId, $scope.backendType, filePath, function (error) {
$scope.newFolder.busy = false;
if (error && error.statusCode === 409) return $scope.newFolder.error = 'exists';
if (error) return Client.error(error);
$scope.refresh();
$('#newFolderModal').modal('hide');
});
}
};
// extract archives
$scope.extractStatus = {
error: null,
busy: false,
fileName: ''
};
$scope.extractEntry = function (cwd, entry) {
var filePath = sanitize(cwd + '/' + entry.fileName);
if (entry.isDirectory) return;
// prevent it from getting closed
$('#extractModal').modal({
backdrop: 'static',
keyboard: false
});
$scope.extractStatus.fileName = entry.fileName;
$scope.extractStatus.error = null;
$scope.extractStatus.busy = true;
Client.filesExtract($scope.backendId, $scope.backendType, filePath, function (error) {
$scope.extractStatus.busy = false;
if (error) {
console.error(error);
$scope.extractStatus.error = $translate.instant('filemanager.extract.error', error.message);
return;
}
$('#extractModal').modal('hide');
$scope.refresh();
});
};
// split view handling
$scope.toggleSplitView = function () {
$scope.splitView = !$scope.splitView;
if (!$scope.splitView) {
$scope.activeView = VIEW.LEFT;
delete window.localStorage.splitView;
} else {
window.localStorage.splitView = true;
}
};
$scope.setActiveView = function (view) {
$scope.activeView = view;
};
// monaco text editor
var LANGUAGES = [];
require(['vs/editor/editor.main'], function() { LANGUAGES = monaco.languages.getLanguages(); });
function getLanguage(filename) {
var ext = '.' + filename.split('.').pop();
var language = LANGUAGES.find(function (l) {
if (!l.extensions) return false;
return !!l.extensions.find(function (e) { return e === ext; });
}) || '';
return language ? language.id : '';
}
$scope.textEditor = {
busy: false,
cwd: null,
entry: null,
editor: null,
unsaved: false,
visible: false,
show: function (cwd, entry) {
$scope.textEditor.cwd = cwd;
$scope.textEditor.entry = entry;
$scope.textEditor.busy = false;
$scope.textEditor.unsaved = false;
$scope.textEditor.visible = true;
// clear model if any
if ($scope.textEditor.editor && $scope.textEditor.editor.getModel()) $scope.textEditor.editor.setModel(null);
$scope.viewerOpen = true;
// document.getElementById('textEditorModal').style['display'] = 'flex';
var filePath = sanitize($scope.textEditor.cwd + '/' + entry.fileName);
var language = getLanguage(entry.fileName);
Client.filesGet($scope.backendId, $scope.backendType, filePath, 'data', function (error, result) {
if (error) return Client.error(error);
if (!$scope.textEditor.editor) {
$timeout(function () {
$scope.textEditor.editor = monaco.editor.create(document.getElementById('textEditorContainer'), {
value: result,
language: language,
theme: 'vs-dark'
});
$scope.textEditor.editor.getModel().onDidChangeContent(function () { $scope.textEditor.unsaved = true; });
}, 200);
} else {
$scope.textEditor.editor.setModel(monaco.editor.createModel(result, language));
$scope.textEditor.editor.getModel().onDidChangeContent(function () { $scope.textEditor.unsaved = true; }); // have to re-attach whenever model changes
}
});
},
save: function (callback) {
$scope.textEditor.busy = true;
var newContent = $scope.textEditor.editor.getValue();
var filePath = sanitize($scope.textEditor.cwd + '/' + $scope.textEditor.entry.fileName);
var file = new File([newContent], 'file');
Client.filesUpload($scope.backendId, $scope.backendType, filePath, file, true, function () {}, function (error) {
if (error) return Client.error(error);
$scope.refresh();
$timeout(function () {
$scope.textEditor.unsaved = false;
$scope.textEditor.busy = false;
if (typeof callback === 'function') return callback();
}, 1000);
});
},
close: function () {
$scope.textEditor.visible = false;
$scope.viewerOpen = false;
$('#textEditorCloseModal').modal('hide');
},
onClose: function () {
$scope.textEditor.visible = false;
$scope.viewerOpen = false;
$('#textEditorCloseModal').modal('hide');
},
saveAndClose: function () {
$scope.textEditor.save(function () {
$scope.textEditor.onClose();
});
},
maybeClose: function () {
if (!$scope.textEditor.unsaved) return $scope.textEditor.onClose();
$('#textEditorCloseModal').modal('show');
},
};
// restart app or mail logic
$scope.restartBusy = false;
$scope.onRestartApp = function () {
$scope.restartBusy = true;
function waitUntilRestarted(callback) {
Client.getApp($scope.backendId, function (error, result) {
if (error) return callback(error);
if (result.installationState === ISTATES.INSTALLED) return callback();
setTimeout(waitUntilRestarted.bind(null, callback), 2000);
});
}
Client.restartApp($scope.backendId, function (error) {
if (error) console.error('Failed to restart app.', error);
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartBusy = false;
});
});
};
$scope.onRestartMail = function () {
$scope.restartBusy = true;
function waitUntilRestarted(callback) {
Client.getService('mail', function (error, result) {
if (error) return callback(error);
if (result.status === 'active') return callback();
setTimeout(waitUntilRestarted.bind(null, callback), 2000);
});
}
Client.restartService('mail', function (error) {
if (error) console.error('Failed to restart mail.', error);
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartBusy = false;
});
});
};
// init code
function fetchVolumesInfo(mounts) {
$scope.volumes = [];
async.each(mounts, function (mount, callback) {
Client.getVolume(mount.volumeId, function (error, result) {
if (error) return callback(error);
$scope.volumes.push(result);
callback();
});
}, function (error) {
if (error) console.error('Failed to fetch volumes info.', error);
});
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
window.location.href = '/';
return;
}
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
$scope.status = status;
console.log('Running filemanager version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
if (error) return Client.initError(error, init);
var getter;
if ($scope.backendType === 'app') {
getter = Client.getApp.bind(Client, $scope.backendId);
} else if ($scope.backendType === 'volume') {
getter = Client.getVolume.bind(Client, $scope.backendId);
} else if ($scope.backendType === 'mail') {
getter = function (next) { next(null, null); };
}
getter(function (error, result) {
if (error) {
$scope.initialized = true;
return;
}
// fine to do async
if ($scope.backendType === 'app') fetchVolumesInfo(result.mounts || []);
switch ($scope.backendType) {
case 'app':
$scope.title = result.label || result.fqdn;
$scope.rootDirLabel = '/app/data/';
$scope.applicationLink = 'https://' + result.fqdn;
break;
case 'volume':
$scope.title = result.name;
$scope.rootDirLabel = result.hostPath;
break;
case 'mail':
$scope.title = 'mail';
$scope.rootDirLabel = 'mail';
break;
}
window.document.title = $scope.title + ' - ' + $translate.instant('filemanager.title');
// now mark the Client to be ready
Client.setReady();
// openPath('');
$scope.initialized = true;
});
});
});
}
init();
// toplevel key input handling
window.addEventListener('keydown', function (event) {
if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 's') {
if (!$scope.textEditor.visible) return;
event.preventDefault();
$scope.$apply($scope.textEditor.save);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'c') {
if ($scope.textEditor.visible) return;
if ($scope.selected.length === 0) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionCopy);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'x') {
if ($scope.textEditor.visible) return;
if ($scope.selected.length === 0) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionCut);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'v') {
if ($scope.textEditor.visible) return;
if ($scope.clipboard.length === 0) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionPaste);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'a') {
if ($scope.textEditor.visible) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionSelectAll);
} else if(event.key === 'Escape') {
if ($scope.textEditor.visible) return $scope.$apply($scope.textEditor.maybeClose);
else $scope.$apply(function () { $scope.selected = []; });
}
});
// setup all the dialog focus handling
['newFileModal', 'newFolderModal', 'renameEntryModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
});
// selects filename (without extension)
['renameEntryModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
var elem = $(this).find('[autofocus]:first');
var text = elem.val();
elem[0].setSelectionRange(0, text.indexOf('.'));
});
});
}]);