388 lines
12 KiB
JavaScript
Executable File
388 lines
12 KiB
JavaScript
Executable File
import { api, describeError, onUnauthorized } from "./api.js";
|
|
import { element } from "./dom.js";
|
|
import { destinationSelect, setAutoExtractDefault, setupDownloadForm, updateSubfolderState } from "./download-form.js";
|
|
import { formatBytes } from "./format.js";
|
|
import { recentDestinations, renderHistory } from "./history.js";
|
|
import { isActive, matchesFilter, renderJobs, updateSpeeds } from "./jobs.js";
|
|
import { fillLibrarySelect } from "./libraries.js";
|
|
import { setupLogin } from "./login.js";
|
|
import { collectTransitions, notifyTransitions } from "./notify.js";
|
|
import { loadSettings, openSettings, setupSettings } from "./settings.js";
|
|
import { renderTree, topLevelSelection } from "./tree.js";
|
|
|
|
const pollIntervalMs = 1500;
|
|
const filterStorageKey = "ddc-job-filter";
|
|
const failuresBeforeBanner = 2;
|
|
|
|
const elements = {
|
|
loginView: document.getElementById("login-view"),
|
|
loginForm: document.getElementById("login-form"),
|
|
loginError: document.getElementById("login-error"),
|
|
appView: document.getElementById("app-view"),
|
|
logoutButton: document.getElementById("logout-button"),
|
|
settingsButton: document.getElementById("settings-button"),
|
|
diskStatus: document.getElementById("disk-status"),
|
|
connectionBanner: document.getElementById("connection-banner"),
|
|
jobFilter: document.getElementById("job-filter"),
|
|
clearCompletedButton: document.getElementById("clear-completed-button"),
|
|
jobList: document.getElementById("job-list"),
|
|
jobEmpty: document.getElementById("job-empty"),
|
|
detail: document.getElementById("job-detail"),
|
|
detailTitle: document.getElementById("detail-title"),
|
|
closeDetailButton: document.getElementById("close-detail-button"),
|
|
fileTree: document.getElementById("file-tree"),
|
|
selectionSummary: document.getElementById("selection-summary"),
|
|
librarySelect: document.getElementById("library-select"),
|
|
subfolderInput: document.getElementById("subfolder-input"),
|
|
cleanupCheckbox: document.getElementById("cleanup-checkbox"),
|
|
moveButton: document.getElementById("move-button"),
|
|
actionError: document.getElementById("action-error"),
|
|
history: {
|
|
title: document.getElementById("history-title"),
|
|
list: document.getElementById("history-list"),
|
|
empty: document.getElementById("history-empty"),
|
|
clearButton: document.getElementById("clear-history-button"),
|
|
},
|
|
};
|
|
|
|
const state = {
|
|
jobs: [],
|
|
destinations: [],
|
|
downloadPaths: [],
|
|
historyEntries: [],
|
|
selectedJobId: null,
|
|
treeStatus: null,
|
|
selection: new Set(),
|
|
pollTimer: null,
|
|
refreshing: false,
|
|
failedRefreshes: 0,
|
|
};
|
|
|
|
function showLogin() {
|
|
stopPolling();
|
|
setConnectionProblem(null);
|
|
closeDetail();
|
|
elements.appView.hidden = true;
|
|
elements.loginView.hidden = false;
|
|
}
|
|
|
|
async function showApp() {
|
|
elements.loginView.hidden = true;
|
|
elements.appView.hidden = false;
|
|
applyDefaults(await loadSettings());
|
|
await Promise.all([loadLibraries(), loadHistory()]);
|
|
await refresh();
|
|
startPolling();
|
|
}
|
|
|
|
function applyDefaults(settings) {
|
|
setAutoExtractDefault(settings.autoExtract);
|
|
elements.cleanupCheckbox.checked = settings.cleanupAfterMove;
|
|
state.downloadPaths = settings.downloadPaths || [];
|
|
renderDestinationSelects();
|
|
}
|
|
|
|
async function showSettings() {
|
|
try {
|
|
await openSettings();
|
|
} catch (error) {
|
|
window.alert(describeError(error));
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
stopPolling();
|
|
state.pollTimer = window.setInterval(refresh, pollIntervalMs);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (state.pollTimer !== null) window.clearInterval(state.pollTimer);
|
|
state.pollTimer = null;
|
|
}
|
|
|
|
async function loadLibraries() {
|
|
state.destinations = (await api.libraries()) || [];
|
|
renderDestinationSelects();
|
|
}
|
|
|
|
async function loadHistory() {
|
|
try {
|
|
state.historyEntries = (await api.history()) || [];
|
|
renderHistory(elements.history, state.historyEntries);
|
|
renderDestinationSelects();
|
|
} catch (error) {
|
|
if (error.status !== 401) console.error(error);
|
|
}
|
|
}
|
|
|
|
function renderDestinationSelects() {
|
|
const extras = { recentPaths: recentDestinations(state.historyEntries), savedPaths: state.downloadPaths };
|
|
fillLibrarySelect(elements.librarySelect, state.destinations, extras);
|
|
fillLibrarySelect(destinationSelect(), state.destinations, { ...extras, emptyLabel: "Temporary folder (move later)" });
|
|
updateSubfolderState();
|
|
}
|
|
|
|
async function refresh() {
|
|
if (state.refreshing) return;
|
|
state.refreshing = true;
|
|
try {
|
|
const [jobs, disk] = await Promise.all([api.jobs(), api.disk()]);
|
|
state.jobs = jobs || [];
|
|
updateSpeeds(state.jobs);
|
|
handleTransitions(collectTransitions(state.jobs));
|
|
elements.diskStatus.textContent = `${formatBytes(disk.availableBytes)} free`;
|
|
setConnectionProblem(null);
|
|
renderJobList();
|
|
await syncDetail();
|
|
} catch (error) {
|
|
if (error.status !== 401) {
|
|
console.error(error);
|
|
setConnectionProblem(error);
|
|
}
|
|
} finally {
|
|
state.refreshing = false;
|
|
}
|
|
}
|
|
|
|
function setConnectionProblem(error) {
|
|
if (!error) {
|
|
state.failedRefreshes = 0;
|
|
elements.connectionBanner.hidden = true;
|
|
return;
|
|
}
|
|
state.failedRefreshes += 1;
|
|
if (state.failedRefreshes < failuresBeforeBanner) return;
|
|
elements.connectionBanner.textContent = `${describeError(error)} Retrying automatically.`;
|
|
elements.connectionBanner.hidden = false;
|
|
}
|
|
|
|
function handleTransitions(transitions) {
|
|
notifyTransitions(transitions);
|
|
if (transitions.some((transition) => transition.previous === "moving")) loadHistory();
|
|
if (transitions.length > 0) {
|
|
loadLibraries().catch((error) => console.error(error));
|
|
}
|
|
}
|
|
|
|
function renderJobList() {
|
|
const filter = elements.jobFilter.value;
|
|
const visibleJobs = state.jobs.filter((job) => matchesFilter(job, filter));
|
|
elements.jobEmpty.hidden = visibleJobs.length > 0;
|
|
elements.jobEmpty.textContent = state.jobs.length === 0
|
|
? "No downloads yet. Paste a link above to start."
|
|
: "No downloads match this filter.";
|
|
elements.clearCompletedButton.hidden = !state.jobs.some((job) => job.status === "completed");
|
|
renderJobs(elements.jobList, visibleJobs, state.selectedJobId, {
|
|
onBrowse: openDetail,
|
|
onPause: (job) => runJobAction(() => api.pause(job.id)),
|
|
onResume: (job) => runJobAction(() => api.resume(job.id)),
|
|
onReorder: (job, direction) => runJobAction(() => api.reorder(job.id, direction)),
|
|
onDelete: deleteJob,
|
|
});
|
|
}
|
|
|
|
async function runJobAction(action) {
|
|
try {
|
|
await action();
|
|
await refresh();
|
|
} catch (error) {
|
|
window.alert(describeError(error));
|
|
}
|
|
}
|
|
|
|
function selectedJob() {
|
|
return state.jobs.find((job) => job.id === state.selectedJobId);
|
|
}
|
|
|
|
function openDetail(job) {
|
|
state.selectedJobId = job.id;
|
|
state.treeStatus = null;
|
|
state.selection.clear();
|
|
elements.actionError.textContent = "";
|
|
elements.detail.hidden = false;
|
|
renderJobList();
|
|
syncDetail();
|
|
loadLibraries().catch((error) => {
|
|
elements.actionError.textContent = describeError(error);
|
|
});
|
|
elements.detail.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
|
|
function closeDetail() {
|
|
state.selectedJobId = null;
|
|
state.treeStatus = null;
|
|
state.selection.clear();
|
|
elements.detail.hidden = true;
|
|
elements.fileTree.replaceChildren();
|
|
}
|
|
|
|
async function syncDetail() {
|
|
const job = selectedJob();
|
|
if (!job) {
|
|
if (state.selectedJobId) closeDetail();
|
|
return;
|
|
}
|
|
|
|
elements.detailTitle.textContent = job.fileName;
|
|
if (job.status === state.treeStatus) return;
|
|
state.treeStatus = job.status;
|
|
|
|
if (job.status === "ready") {
|
|
await loadTree(job.id);
|
|
return;
|
|
}
|
|
|
|
state.selection.clear();
|
|
updateSelectionSummary();
|
|
const text = job.status === "completed"
|
|
? "Files were moved and temporary files were removed."
|
|
: "Files will be available when this download is ready.";
|
|
elements.fileTree.replaceChildren(element("p", "muted", text));
|
|
}
|
|
|
|
async function loadTree(jobId) {
|
|
try {
|
|
const nodes = (await api.files(jobId)) || [];
|
|
state.selection.clear();
|
|
renderTree(elements.fileTree, nodes, state.selection, {
|
|
onSelectionChange: updateSelectionSummary,
|
|
onExtract: extractArchive,
|
|
});
|
|
updateSelectionSummary();
|
|
} catch (error) {
|
|
elements.actionError.textContent = describeError(error);
|
|
}
|
|
}
|
|
|
|
function updateSelectionSummary() {
|
|
const count = topLevelSelection(state.selection).length;
|
|
const job = selectedJob();
|
|
elements.selectionSummary.textContent = count === 0 ? "Nothing selected" : `${count} item(s) selected`;
|
|
elements.moveButton.disabled = count === 0 || !job || job.status !== "ready";
|
|
}
|
|
|
|
async function extractArchive(path) {
|
|
elements.actionError.textContent = "";
|
|
try {
|
|
await api.extract(state.selectedJobId, path);
|
|
await refresh();
|
|
} catch (error) {
|
|
elements.actionError.textContent = describeError(error);
|
|
}
|
|
}
|
|
|
|
async function moveSelection() {
|
|
elements.actionError.textContent = "";
|
|
const paths = topLevelSelection(state.selection);
|
|
if (paths.length === 0) return;
|
|
|
|
try {
|
|
await api.move(state.selectedJobId, {
|
|
paths,
|
|
destination: elements.librarySelect.value,
|
|
subfolder: elements.subfolderInput.value.trim(),
|
|
cleanup: elements.cleanupCheckbox.checked,
|
|
});
|
|
elements.subfolderInput.value = "";
|
|
await refresh();
|
|
} catch (error) {
|
|
elements.actionError.textContent = describeError(error);
|
|
}
|
|
}
|
|
|
|
async function deleteJob(job) {
|
|
const question = isActive(job)
|
|
? `Cancel and remove "${job.fileName}"?`
|
|
: `Remove "${job.fileName}" and its temporary files?`;
|
|
if (!window.confirm(question)) return;
|
|
|
|
try {
|
|
await api.remove(job.id);
|
|
if (job.id === state.selectedJobId) closeDetail();
|
|
await refresh();
|
|
} catch (error) {
|
|
window.alert(describeError(error));
|
|
}
|
|
}
|
|
|
|
async function clearCompleted() {
|
|
const completedJobs = state.jobs.filter((job) => job.status === "completed");
|
|
for (const job of completedJobs) {
|
|
try {
|
|
await api.remove(job.id);
|
|
if (job.id === state.selectedJobId) closeDetail();
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
await refresh();
|
|
}
|
|
|
|
async function clearHistory() {
|
|
if (!window.confirm("Clear the move history?")) return;
|
|
try {
|
|
await api.clearHistory();
|
|
await loadHistory();
|
|
} catch (error) {
|
|
window.alert(describeError(error));
|
|
}
|
|
}
|
|
|
|
function setupJobFilter() {
|
|
try {
|
|
const saved = localStorage.getItem(filterStorageKey);
|
|
if (saved && [...elements.jobFilter.options].some((option) => option.value === saved)) {
|
|
elements.jobFilter.value = saved;
|
|
}
|
|
} catch {
|
|
elements.jobFilter.value = "all";
|
|
}
|
|
elements.jobFilter.addEventListener("change", () => {
|
|
try {
|
|
localStorage.setItem(filterStorageKey, elements.jobFilter.value);
|
|
} catch {
|
|
console.warn("Could not remember the download filter.");
|
|
}
|
|
renderJobList();
|
|
});
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await api.logout();
|
|
} finally {
|
|
showLogin();
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
window.addEventListener("unhandledrejection", (event) => console.error("Unhandled error", event.reason));
|
|
onUnauthorized(showLogin);
|
|
setupLogin(elements.loginForm, elements.loginError, showApp);
|
|
setupDownloadForm(refresh);
|
|
setupJobFilter();
|
|
elements.clearCompletedButton.addEventListener("click", clearCompleted);
|
|
elements.history.clearButton.addEventListener("click", clearHistory);
|
|
elements.moveButton.addEventListener("click", moveSelection);
|
|
elements.closeDetailButton.addEventListener("click", () => {
|
|
closeDetail();
|
|
renderJobList();
|
|
});
|
|
elements.logoutButton.addEventListener("click", logout);
|
|
elements.settingsButton.addEventListener("click", showSettings);
|
|
setupSettings((settings) => {
|
|
applyDefaults(settings);
|
|
refresh();
|
|
});
|
|
|
|
try {
|
|
const session = await api.session();
|
|
if (session && session.authenticated) await showApp();
|
|
else showLogin();
|
|
} catch {
|
|
showLogin();
|
|
}
|
|
}
|
|
|
|
init();
|