"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"; import { $ } from "./winutil"; const PORT = runtime.connect(null, { name: "select" }); 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 cleaErrors() { const not = $("#notification"); not.textContent = ""; not.style.display = "none"; } function matched(item: any) { return item && item.matched && item.matched !== "unmanual"; } class PausedModalDialog extends ModalDialog { get content() { const tmpl = $("#paused-template"); const content = tmpl.content.cloneNode(true) as DocumentFragment; 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 { gen: IterableIterator; 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: HTMLElement; linksTab: HTMLElement; mediaTab: HTMLElement; linksFilters: HTMLElement; mediaFilters: HTMLElement; contextMenu: ContextMenu; sortcol: number | null; sortasc: boolean; keyfns: Map 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") as HTMLStyleElement); 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") as HTMLTemplateElement).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(`#${colid}`); const oldelem = (this.sortcol && document.querySelector(`#${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]); } cleaErrors(); } 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; } } 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) { 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 = 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); Mask.on("changed", cleaErrors); 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);