502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
// @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 `<i class="fa fa-${icon} ${(classes || []).join(" ")}" title="${title}"></i>`;
|
|
}
|
|
|
|
/**
|
|
* 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 "" +
|
|
`<span class="${page.exists ? "" : "redLink"}">` +
|
|
/**/`<a href="${page.url}" target="_blank" title="${page.link}">${labelText}</a>` +
|
|
/**/`<span> </span>` +
|
|
/**/`<a href="${page.url}?action=edit" target="_blank" title="Edit"><i class="fa fa-pencil"></i></a>` +
|
|
/**/`<span> </span>` +
|
|
/**/`<a title="Copy"><i class="fa fa-clipboard copyIcon" data-clipboarddata="${page.link}"></i></a>` +
|
|
`</span>`;
|
|
}
|
|
|
|
/**
|
|
* 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 "" +
|
|
`<thead>` +
|
|
/**/`<tr>` +
|
|
/****/`<th rowspan="2"></th>` +
|
|
/****/`<th class="sourceLabel" rowspan="2">Source</th>` +
|
|
/****/`<th colspan="${network.pages.length}">Destination</th>` +
|
|
/**/`</tr>` +
|
|
/**/`<tr>${network.pages.map(page => `<th>${this.generateLabel(network.pages, page)}</th>`)}</tr>` +
|
|
`</thead>`;
|
|
}
|
|
|
|
/**
|
|
* 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}<span> </span>`);
|
|
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 `<span></span>`;
|
|
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 "" +
|
|
`<tr>` +
|
|
/**/`<th>${icons}</th>` +
|
|
/**/`<th class="sourceLabel">${label}</th>` +
|
|
/**/cells.map(it => `<td>${it}</td>`) +
|
|
`</tr>`;
|
|
});
|
|
|
|
return `<tbody>${rows}</tbody>`;
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
`<table id="${id}">` +
|
|
/**/this.generateTableHead(network) +
|
|
/**/this.generateTableBody(network) +
|
|
`</table>`,
|
|
"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;
|
|
}
|
|
}
|