downthemall/windows/select.ts
2019-08-20 16:41:37 +02:00

728 lines
17 KiB
TypeScript

"use strict";
// License: MIT
import { VirtualTable } from "../uikit/lib/table";
import ModalDialog from "../uikit/lib/modal";
import { ContextMenu } from "../uikit/lib/contextmenu";
import { iconForPath } from "../lib/windowutils";
import { _, localize } from "../lib/i18n";
import { Prefs } from "../lib/prefs";
import { MASK, FASTFILTER } from "../lib/recentlist";
import { WindowState } from "./windowstate";
import { Dropdown } from "./dropdown";
import { Keys } from "./keys";
import { Icons } from "./icons";
import { sort, naturalCaseCompare } from "../lib/sorting";
import { hookButton } from "../lib/manager/renamer";
import { CellTypes } from "../uikit/lib/constants";
import { runtime } from "../lib/browser";
const PORT = runtime.connect(null, { name: "select" });
const $ = document.querySelector.bind(document);
const TREE_CONFIG_VERSION = 1;
const COL_CHECK = 0;
const COL_DOWNLOAD = 1;
const COL_TITLE = 2;
const COL_DESC = 3;
const COL_MASK = 4;
const COL_REFERRER = 5;
const ICON_BASE_SIZE = 16;
const NUM_FILTER_CLASSES = 8;
let Table: SelectionTable;
let Mask: Dropdown;
let FastFilter: Dropdown;
type DELTAS = {deltaLinks: any[]; deltaMedia: any[]};
function matched(item: any) {
return item && item.matched && item.matched !== "unmanual";
}
class PausedModalDialog extends ModalDialog {
get content() {
const content = $("#paused-template").content.cloneNode(true);
localize(content);
return content;
}
shown() {
this.focusDefault();
}
get buttons() {
return [
{
title: _("remember"),
value: "ok",
default: true,
dismiss: false
},
{
title: _("add-paused-once"),
value: "once",
default: false,
dismiss: false
},
{
title: _("cancel"),
value: "cancel",
default: false,
dismiss: true
}
];
}
}
class CheckClasser extends Map<string, string> {
gen: IterableIterator<string>;
constructor(numClasses: number) {
super();
this.gen = (function *() {
for (;;) {
for (let c = 0; c < numClasses; ++c) {
yield `filter-${c + 1}`;
}
}
})();
this.set("manual", "filter-manual");
this.set("fast", "filter-fast");
}
"get"(key: string) {
let result = super.get(key);
if (typeof result !== "string") {
result = this.gen.next().value;
super.set(key, result);
}
return result;
}
}
class SelectionTable extends VirtualTable {
checkClasser: CheckClasser;
icons: Icons;
links: any[];
media: any[];
type: string;
items: any[];
status: any;
linksTab: any;
mediaTab: any;
linksFilters: any;
mediaFilters: any;
contextMenu: ContextMenu;
sortcol: any;
sortasc: boolean;
keyfns: Map<string, (item: any) => any>;
constructor(treeConfig: any, type: string, links: any[], media: any[]) {
if (type === "links" && !links.length) {
type = "media";
}
else if (type === "media" && !media.length) {
type = "links";
}
super("#items", treeConfig, TREE_CONFIG_VERSION);
this.checkClasser = new CheckClasser(NUM_FILTER_CLASSES);
this.icons = new Icons($("#icons"));
this.links = links;
this.media = media;
this.type = type;
this.items = (this as any)[type];
this.status = $("#statusItems");
this.linksTab = $("#linksTab");
if (!links.length) {
this.linksTab.classList.add("disabled");
}
else {
this.linksTab.addEventListener(
"click", this.switchTab.bind(this, "links"));
}
this.mediaTab = $("#mediaTab");
if (!media.length) {
this.mediaTab.classList.add("disabled");
}
else {
this.mediaTab.addEventListener(
"click", this.switchTab.bind(this, "media"));
}
this.linksFilters = $("#linksFilters");
this.mediaFilters = $("#mediaFilters");
localize($("#table-context").content);
this.contextMenu = new ContextMenu("#table-context");
Keys.adoptContext(this.contextMenu);
this.sortcol = null;
this.sortasc = true;
this.keyfns = new Map([
["colDownload", item => item.usable],
["colTitle", item => [item.title, item.usable]],
["colDescription", item => [item.description, item.usable]],
["colMask", item => [item.mask, item.usable]],
]);
this.on("config-changed", () => {
Prefs.set("tree-config-select", JSON.stringify(this));
});
this.on("column-clicked", colid => {
const keyfn = this.keyfns.get(colid);
if (!keyfn) {
return false;
}
sort(this.links, keyfn, naturalCaseCompare);
sort(this.media, keyfn, naturalCaseCompare);
const elem = document.querySelector<HTMLElement>(`#${colid}`);
const oldelem = (this.sortcol && document.querySelector<HTMLElement>(`#${this.sortcol}`));
if (this.sortcol === colid && this.sortasc) {
this.links.reverse();
this.media.reverse();
this.sortasc = false;
}
else {
this.sortcol = colid;
this.sortasc = true;
}
if (oldelem) {
oldelem.dataset.sortdir = "";
}
if (elem) {
elem.dataset.sortdir = this.sortasc ? "asc" : "desc";
}
this.invalidate();
return true;
});
this.on(" -keypress", () => this.checkSelection());
this.contextMenu.on("ctx-check-selected", () => {
this.checkSelection("manual");
});
this.contextMenu.on("ctx-uncheck-selected", () => {
this.checkSelection("unmanual");
});
this.contextMenu.on("ctx-toggle-selected", () => {
this.checkSelection("toggle");
});
Keys.on("ACCEL-KeyA", (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (target.localName === "input") {
return false;
}
this.selectAll();
return true;
});
Keys.on("ACCEL-KeyF", () => {
this.selectChecked();
return true;
});
Keys.on("ACCEL-KeyI", () => {
this.selectToggle();
return true;
});
Keys.on("ACCEL-KeyO", () => {
this.openSelection();
return true;
});
this.contextMenu.on("ctx-mask", async() => {
if (this.selection.empty) {
return;
}
let oldmask = "";
for (const r of this.selection) {
const m = this.items[r].mask;
if (oldmask && m !== oldmask) {
oldmask = "";
break;
}
oldmask = m;
}
try {
Keys.suppressed = true;
const newmask = await ModalDialog.prompt(
"Renaming mask", "Set new renaming mask", oldmask);
for (const r of this.selection) {
this.items[r].mask = newmask;
this.invalidateRow(r);
}
}
catch (ex) {
console.warn("mask dismissed", ex);
}
finally {
Keys.suppressed = false;
}
});
this.contextMenu.on("dismissed", () => this.table.focus());
this.on("contextmenu", (tree, event) => {
if (!this.selection.empty) {
this.contextMenu.show(event);
}
return true;
});
this.init();
this.switchTab(type);
}
get checkedIndexes() {
const rv: number[] = [];
this.items.forEach(function (item, idx) {
if (item.matched && item.matched !== "unmanual") {
rv.push(idx);
}
});
return rv;
}
get rowCount() {
return this.items.length;
}
checkSelection(state?: string) {
if (this.selection.empty) {
return false;
}
for (const rowid of this.selection) {
const item = this.items[rowid];
if (!state) {
state = matched(item) ? "unmanual" : "manual";
}
let ns;
switch (state) {
case "toggle":
ns = matched(item) ? "unmanual" : "manual";
break;
default:
ns = state;
}
item.matched = ns;
this.invalidateRow(rowid);
}
this.updateStatus();
return true;
}
selectAll() {
this.selection.add(0, this.rowCount - 1);
}
selectChecked() {
this.selection.clear();
let min = null;
for (const ci of this.checkedIndexes) {
this.selection.add(ci);
min = min === null ? ci : Math.min(min, ci);
}
if (min !== null) {
this.scrollIntoView(min);
}
}
selectToggle() {
this.selection.toggle(0, this.rowCount - 1);
}
openSelection() {
const items = this.items.filter((i, idx) => this.selection.contains(idx));
if (!items.length) {
if (this.focusRow < 0) {
return;
}
items.push(this.items[this.focusRow]);
}
PORT.postMessage({
msg: "openUrls",
urls: items.map(e => e.url)
});
}
applyDeltaTo(delta: any[], items: any[]) {
const active = items === this.items;
for (const d of delta) {
const {idx = -1, matched = null} = d;
if (idx < 0) {
continue;
}
const item = items[idx];
if (!item) {
continue;
}
if (item.matched === matched) {
continue;
}
if (matched !== "fast" &&
(item.matched === "manual" || item.matched === "unmanual")) {
// Skip manually selected items
continue;
}
item.matched = matched;
if (active) {
this.invalidateRow(idx);
}
}
}
applyDeltas({deltaLinks = [], deltaMedia = []}: DELTAS) {
this.applyDeltaTo(deltaLinks, this.links);
this.applyDeltaTo(deltaMedia, this.media);
this.updateStatus();
}
switchTab(type: string) {
this.type = type;
const isLinks = type === "links";
this.linksTab.classList[isLinks ? "add" : "remove"]("active");
this.mediaTab.classList[!isLinks ? "add" : "remove"]("active");
this.linksFilters.classList[isLinks ? "add" : "remove"]("active");
this.mediaFilters.classList[!isLinks ? "add" : "remove"]("active");
this.items = (this as any)[type];
this.selection.clear();
this.invalidate();
this.updateStatus();
}
updateStatus() {
const selected = this.checkedIndexes.length;
if (!selected) {
this.status.textContent = _("noitems.label");
}
else {
this.status.textContent = _("numitems.label", [selected]);
}
}
getRowClasses(rowid: number) {
const item = this.items[rowid];
if (!item || !matched(item)) {
return null;
}
return ["filtered", this.checkClasser.get(item.matched)];
}
getCellIcon(rowid: number, colid: number) {
const item = this.items[rowid];
if (item && colid === COL_DOWNLOAD) {
return this.icons.get(iconForPath(item.url, ICON_BASE_SIZE));
}
return null;
}
getCellType(rowid: number, colid: number) {
switch (colid) {
case COL_CHECK:
return CellTypes.TYPE_CHECK;
default:
return CellTypes.TYPE_TEXT;
}
}
getDownloadText(idx: number) {
const item = this.items[idx];
if (!item) {
return "";
}
if (item.fileName) {
return `${item.usable} (${item.fileName})`;
}
return item.usable;
}
getText(prop: string, idx: number) {
const item = this.items[idx];
if (!item || !(prop in item) || !item[prop]) {
return "";
}
return item[prop];
}
getMaskText(idx: number) {
const item = this.items[idx];
if (item) {
return item.mask;
}
return _("mask.default");
}
getCellText(rowid: number, colid: number) {
switch (colid) {
case COL_DOWNLOAD:
return this.getDownloadText(rowid);
case COL_TITLE:
return this.getText("title", rowid);
case COL_DESC:
return this.getText("description", rowid);
case COL_REFERRER:
return this.getText("usableReferrer", rowid);
case COL_MASK:
return this.getMaskText(rowid);
default:
return "";
}
}
getCellCheck(rowid: number, colid: number) {
if (colid === COL_CHECK) {
return matched(this.items[rowid]);
}
return false;
}
setCellCheck(rowid: number, colid: number, value: boolean) {
this.items[rowid].matched = value ? "manual" : "unmanual";
this.invalidateRow(rowid);
this.updateStatus();
}
}
async function download(paused = false) {
try {
const mask = Mask.value;
if (!mask) {
throw new Error("error.invalidMask");
}
const items = Table.checkedIndexes;
if (!items.length) {
throw new Error("error.noItemsSelected");
}
if (paused && !(await Prefs.get("add-paused"))) {
try {
Keys.suppressed = true;
const remember = await new PausedModalDialog().show();
if (remember === "ok") {
await Prefs.set("add-paused", true);
await Prefs.save();
}
}
catch (ex) {
return;
}
finally {
Keys.suppressed = false;
}
}
else if (!paused) {
await Prefs.set("add-paused", false);
}
PORT.postMessage({
msg: "queue",
type: Table.type,
items,
paused,
mask,
maskOnce: $("#maskOnceCheck").checked,
fast: FastFilter.value,
fastOnce: $("#fastOnceCheck").checked,
});
}
catch (ex) {
const not = $("#notification");
const msg = _(ex.message || ex);
not.textContent = msg || ex.message || ex;
not.style.display = "block";
}
}
class Filter {
active: any;
container: any;
elem: HTMLLabelElement;
label: any;
checkElem: HTMLInputElement;
id: any;
constructor(container: HTMLElement, raw: any, active = false) {
Object.assign(this, raw);
this.active = active;
this.container = container;
this.elem = document.createElement("label");
this.elem.classList.add("filter");
this.elem.setAttribute("title", this.elem.textContent = this.label);
this.checkElem = document.createElement("input");
this.checkElem.setAttribute("type", "checkbox");
this.checkElem.checked = active;
this.elem.insertBefore(this.checkElem, this.elem.firstChild);
this.container.appendChild(this.elem);
this.checkElem.addEventListener("change", this.changed.bind(this));
}
changed() {
PORT.postMessage({
msg: "filter-changed",
id: this.id,
value: this.checkElem.checked
});
}
}
function setFiltersInternal(
desc: string, filters: any[], active: Set<string>) {
const container = $(desc);
container.textContent = "";
for (let filter of filters) {
filter = new Filter(container, filter, active.has(filter.id));
}
}
function setFilters(filters: any) {
const {linkFilters = [], mediaFilters = [], activeFilters = []} = filters;
const active: Set<string> = new Set(activeFilters);
setFiltersInternal("#linksFilters", linkFilters, active);
setFiltersInternal("#mediaFilters", mediaFilters, active);
}
function cancel() {
PORT.postMessage("cancel");
return true;
}
async function init() {
await Promise.all([MASK.init(), FASTFILTER.init()]);
Mask = new Dropdown("#mask", MASK.values);
FastFilter = new Dropdown("#fast", FASTFILTER.values);
FastFilter.on("changed", () => {
PORT.postMessage({
msg: "fast-filter",
fastFilter: FastFilter.value
});
});
}
const LOADED = new Promise(resolve => {
addEventListener("load", function dom() {
removeEventListener("load", dom);
resolve();
});
});
addEventListener("DOMContentLoaded", function dom() {
removeEventListener("DOMContentLoaded", dom);
init().catch(console.error);
localize(document.documentElement);
$("#donate").addEventListener("click", () => {
PORT.postMessage({
msg: "donate",
});
});
$("#statusPrefs").addEventListener("click", () => {
PORT.postMessage({
msg: "prefs",
});
});
$("#btnDownload").addEventListener("click", () => download(false));
$("#btnPaused").addEventListener("click", () => download(true));
$("#btnCancel").addEventListener(
"click", cancel);
$("#fastDisableOthers").addEventListener("change", () => {
PORT.postMessage({
msg: "onlyfast",
fast: $("#fastDisableOthers").checked
});
});
Keys.on("Enter", "Return", () => {
download(false);
return true;
});
Keys.on("ACCEL-Enter", "ACCEL-Return", () => {
download(true);
return true;
});
PORT.onMessage.addListener(async (msg: any) => {
try {
await LOADED;
switch (msg.msg) {
case "items": {
const {type = "links", links = [], media = []} = msg.data;
const treeConfig = JSON.parse(
await Prefs.get("tree-config-select", "{}"));
requestAnimationFrame(() => {
Table = new SelectionTable(treeConfig, type, links, media);
});
return;
}
case "filters":
setFilters(msg.data);
return;
case "item-delta":
requestAnimationFrame(() => {
Table.applyDeltas(msg.data);
});
return;
default:
throw Error("Unhandled message");
}
}
catch (ex) {
console.error("Failed to process message", msg, ex);
}
});
Keys.on("Escape", cancel);
hookButton($("#maskButton"));
});
addEventListener("contextmenu", event => {
event.preventDefault();
event.stopPropagation();
return false;
});
addEventListener("beforeunload", function() {
PORT.disconnect();
});
new WindowState(PORT);