// @ts-ignore const {stringToHtml} = window.fwdekker; import {InterlangNetwork, Page} from "./MediaWiki"; /** * An input that can be validated. */ export class ValidatableInput { /** * The validatable input. */ readonly input: HTMLInputElement; /** * Returns an empty string if the given input string is valid, and a string explaining why it is invalid otherwise. * * @private */ private readonly isValid: (input: string) => string; /** * Constructs a new validatable input. * * @param input the input that is validatable * @param isValid returns an empty string if the given input string is valid, and a string explaining why it is is * invalid otherwise */ constructor(input: HTMLInputElement, isValid: ((input: string) => string)) { this.input = input; this.isValid = isValid; } /** * Returns the value of the underlying input element. * * @return the value of the underlying input element */ getValue(): string { return this.input.value; } /** * Sets the value of the underlying input element. * * @param value the value to set */ setValue(value: string): void { this.input.value = value; } /** * Validates the input. * * @return an empty string if the input string is valid, and a string explaining why it is is invalid otherwise */ validate(): string { const validity = this.isValid(this.input.value); if (validity.length === 0) this.showSuccess(); else this.showError(); return validity; } /** * Marks the input as neither valid nor invalid. */ showBlank(): void { this.input.dataset["entered"] = "false"; this.input.setCustomValidity(""); } /** * Marks the input as invalid and moves focus to it. */ showError(): void { this.input.dataset["entered"] = "true"; this.input.setCustomValidity("Incorrect"); this.input.focus(); } /** * Marks the input as valid. */ showSuccess(): void { this.input.dataset["entered"] = "true"; this.input.setCustomValidity(""); } } /** * The types of error that can be displayed by an `ErrorHandler`. */ type ErrorLevel = "warning" | "error" | null; /** * Interacts with the DOM to delegate errors to the user. */ export class ErrorHandler { /** * The outer div that wraps around all displayed errors. * * @private */ private readonly outerDiv: HTMLDivElement; /** * Constructs a new error handler, inserting relevant new elements into the DOM. * * @param parent the element to insert elements into */ constructor(parent: HTMLElement) { this.outerDiv = document.createElement("div"); this.outerDiv.classList.add("errorOuter", "hidden"); parent.appendChild(this.outerDiv); } /** * Handles the displaying of the given error. * * @param level the level of message to display, determines the style of the text * @param message the message to display * @return this `ErrorHandler` */ handle(level: ErrorLevel, message: string): ErrorHandler { this.outerDiv.classList.remove("hidden"); const errorInner = document.createElement("div"); errorInner.classList.add("errorInner"); if (level !== null) errorInner.classList.add(level); errorInner.innerText = message; this.outerDiv.appendChild(errorInner); return this; } /** * Clears all errors from the DOM. * * @return this `ErrorHandler` */ clear(): ErrorHandler { this.outerDiv.classList.add("hidden"); this.outerDiv.innerHTML = ""; return this; } } /** * The types of message that can be displayed by a `MessageHandler`. */ type MessageLevel = "complete" | "progress" | "warning" | "error" | "neutral" | null; /** * Interacts with the DOM to delegate messages to the user. */ export class MessageHandler { /** * The outer div, wrapping around all of the handler's elements. * * @private */ private readonly outerDiv: HTMLDivElement; /** * The inner div, wrapping around the icon, spacing, and current message. * * @private */ private readonly innerDiv: HTMLDivElement; /** * A loading icon, optionally displayed before the current message. * * @private */ private readonly loadingIcon: HTMLElement; /** * Spacing between the loading icon and the current message. * * @private */ private readonly spacing: HTMLSpanElement; /** * The span containing the current message. * * @private */ private readonly textSpan: HTMLSpanElement; /** * The callback to be executed whenever a message is handler by this handler. * * @private */ private callback: ((level: MessageLevel, message: string) => void) | undefined; /** * The currently displayed message level. * * @private */ private currentLevel: MessageLevel | undefined; /** * Constructs a new `MessageHandler`, inserting relevant new elements into the DOM to interact with. * * @param parent the element to insert elements into */ constructor(parent: HTMLElement) { this.outerDiv = document.createElement("div"); this.outerDiv.classList.add("messageOuter", "hidden"); parent.appendChild(this.outerDiv); this.innerDiv = document.createElement("div"); this.innerDiv.classList.add("messageInner"); this.outerDiv.appendChild(this.innerDiv); this.loadingIcon = document.createElement("i"); this.loadingIcon.classList.add("fa", "fa-spinner", "fa-spin"); this.innerDiv.appendChild(this.loadingIcon); this.spacing = document.createElement("span"); this.spacing.innerHTML = " "; this.innerDiv.appendChild(this.spacing); this.textSpan = document.createElement("span"); this.innerDiv.appendChild(this.textSpan); this.callback = undefined; this.currentLevel = undefined; } /** * Handles the displaying of the given message. * * If no message is given, the current message and the loading icon are hidden. To display an empty message next to * the loading icon, give an empty string. * * @param level the level of message to display, or `null` if the entire message handler should be hidden * @param message the message to display * @return this `MessageHandler` */ handle(level: MessageLevel, message: string): MessageHandler { this.displayLevel(level); if (level === undefined) return this; // No need to handle the rest if (this.callback !== undefined) this.callback(level, message); this.textSpan.innerHTML = message; return this; } /** * Clears the message handler's contents and hides it. * * @return this `MessageHandler` */ clear(): MessageHandler { return this.handle(null, ""); } /** * Sets the callback to be executed whenever a message is handler by this handler. * * @param callback the function to execute whenever a message is handled * @return this `MessageHandler` */ setCallback(callback: ((level: MessageLevel, message: string) => void)): MessageHandler { this.callback = callback; return this; } /** * Changes the appearance of the message handler to that of the given level. * * @param level the level to change appearance to * @return this `MessageHandler` * @private */ private displayLevel(level: MessageLevel): MessageHandler { if (level === this.currentLevel) return this; this.currentLevel = level; this.outerDiv.classList.remove("hidden"); this.innerDiv.classList.remove("success", "warning", "error"); switch (level) { case "complete": this.innerDiv.classList.add("success"); this.toggleLoadingIcon(false); break; case "progress": this.toggleLoadingIcon(true); break; case "warning": this.innerDiv.classList.add("warning"); this.toggleLoadingIcon(false); break; case "error": this.innerDiv.classList.add("error"); this.toggleLoadingIcon(false); break; case "neutral": this.toggleLoadingIcon(false); break; default: this.outerDiv.classList.add("hidden"); break; } return this; } /** * Turns the loading icon on or off. * * @param state `true` if and only if the loading icon should be on * @return this `MessageHandler` * @private */ private toggleLoadingIcon(state: boolean): MessageHandler { if (state) { this.loadingIcon.classList.remove("hidden"); this.spacing.classList.remove("hidden"); } else { this.loadingIcon.classList.add("hidden"); this.spacing.classList.add("hidden"); } return this; } } /** * A network of interlanguage links. */ export class InterlangTable { /** * Generates an icon element with the given title and additional classes. * * @param icon the name of the icon to display * @param title the title of the icon, used for the `title` attribute * @param classes the additional classes to apply to the icon * @return an icon element with the given title and additional classes * @private */ private static createIcon(icon: string, title: string, classes: string[]): string { return ``; } /** * Returns an appropriate label for the given page. * * The label contains a link to the page and a few buttons to help the user interact with that page. The label's * appearance and contents depend both on the properties of the page (e.g. whether it exists and whether it's a * redirect page) and on the other pages in this network (e.g. whether it's the only page in its language). * * @param pages a list of all pages * @param page the page to generate a label of * @return an appropriate label with icons for the given page * @private */ private generateLabel(pages: Page[], page: Page): string { const labelText = pages.some(it => it.link.lang === page.link.lang && !it.link.equals(page.link)) ? page.link.toString() : page.link.lang; return "" + `` + /**/`${labelText}` + /**/` ` + /**/`` + /**/` ` + /**/`` + ``; } /** * Generates the head of the table generated by `#toTable`. * * @param network the network to generate the head for * @return the head of the table generated by `#toTable` * @private */ private generateTableHead(network: InterlangNetwork): string { return "" + `` + /**/`` + /****/`` + /****/`Source` + /****/`Destination` + /**/`` + /**/`${network.pages.map(page => `${this.generateLabel(network.pages, page)}`)}` + ``; } /** * Generates the body of the table generated by `#toTable`. * * @param network the network to generate the body for * @return the body of the table generated by `#toTable` * @private */ private generateTableBody(network: InterlangNetwork): string { const rows = network.pages.map(srcPage => { const verdict = network.getPageVerdict(srcPage); const icons = verdict.self .map(state => { switch (state) { case "perfect": return InterlangTable.createIcon("check", "Perfect 🙂", ["success"]); case "not-found": return InterlangTable.createIcon("search", "Article does not exist 😕", ["error"]); case "wrongly-ordered": return InterlangTable.createIcon("sort-alpha-asc", "Links are in the wrong order 😕", ["warning"]); case "doubly-linked": return InterlangTable.createIcon("clone", "Links to the same wiki multiple times 😕", ["warning"]); case "self-linked": return InterlangTable.createIcon("rotate-left", "Links to its own wiki 😕", ["warning"]); case "unlinked": return InterlangTable.createIcon("chain-broken", "Misses one or more links 😕", ["error"]); case "redirected": return InterlangTable.createIcon("mail-forward", "Links to a redirect 😕", ["warning"]); case "wrongly-cased": return InterlangTable.createIcon("text-height", "Links with incorrect capitalisation 😕", ["warning"]); default: throw new Error(`Invalid page state '${state}'`); } }) .map(it => `${it} `); const label = this.generateLabel(network.pages, srcPage); const cells = network.pages.map(dstPage => { const linkState = verdict.pages.find(it => it.page.link.equals(dstPage.link))!.verdict; switch (linkState) { case "linked": return InterlangTable.createIcon("check", "Linked 🙂", ["success"]); case "self-linked": return InterlangTable.createIcon("rotate-left", "Links to its own wiki 😕", ["warning"]); case "unlinked": return InterlangTable.createIcon("times", "Link is missing 😕", ["error"]); case "self-unlinked": return ``; case "redirected": return InterlangTable.createIcon("mail-forward", "Links to a redirect 😕", ["warning"]); case "wrongly-cased": return InterlangTable.createIcon("text-height", "Links with incorrect capitalisation 😕", ["warning"]); default: throw new Error(`Invalid link state '${linkState}'`); } }); return "" + `` + /**/`${icons}` + /**/`${label}` + /**/cells.map(it => `${it}`) + ``; }); return `${rows}`; } /** * Renders the the table describing the interlanguage network. * * @param id the ID to assign to the table element * @param network the network of pages to render * @return the generated table */ render(id: string, network: InterlangNetwork): HTMLElement { const table = stringToHtml( `` + /**/this.generateTableHead(network) + /**/this.generateTableBody(network) + `
`, "table" ) as HTMLElement; // Add event handlers table.querySelectorAll(".copyIcon").forEach(icon => { if (!(icon instanceof HTMLElement)) return; icon.addEventListener("click", () => { // noinspection JSIgnoredPromiseFromCall navigator.clipboard.writeText(`[[${icon.dataset.clipboarddata}]]`); icon.classList.replace("fa-clipboard", "fa-check"); setTimeout(() => icon.classList.replace("fa-check", "fa-clipboard"), 1000); }); }); return table; } }