diff --git a/Gruntfile.js b/Gruntfile.js
index 6d75545..bf1cbd9 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -16,12 +16,12 @@ module.exports = grunt => {
},
focus: {
dev: {
- include: ["css", "html", "js"],
+ include: ["css", "html", "ts"],
},
},
replace: {
dev: {
- src: ["./dist/*.html", "./dist/*.js"],
+ src: ["./dist/**/*.html", "./dist/**/*.js"],
replacements: [
{
from: "%%VERSION_NUMBER%%",
@@ -31,7 +31,7 @@ module.exports = grunt => {
overwrite: true,
},
deploy: {
- src: ["./dist/*.html", "./dist/*.js"],
+ src: ["./dist/**/*.html", "./dist/**/*.js"],
replacements: [
{
from: "%%VERSION_NUMBER%%",
@@ -50,24 +50,25 @@ module.exports = grunt => {
files: ["src/main/**/*.html"],
tasks: ["copy:html"],
},
- js: {
- files: ["src/main/**/*.js"],
+ ts: {
+ files: ["src/main/**/*.ts"],
tasks: ["webpack:dev", "replace:dev"],
},
},
webpack: {
options: {
- entry: "./src/main/js/Main.js",
+ entry: "./src/main/js/Main.ts",
module: {
rules: [
{
- test: /\.js$/,
+ test: /\.ts$/,
+ use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
- extensions: [".js"],
+ extensions: [".ts"],
},
output: {
filename: "bundle.js",
@@ -99,6 +100,7 @@ module.exports = grunt => {
"copy:html",
// Compile JS
"webpack:dev",
+ // Post
"replace:dev",
]);
grunt.registerTask("dev:server", ["dev", "focus:dev"]);
@@ -110,6 +112,7 @@ module.exports = grunt => {
"copy:html",
// Compile JS
"webpack:deploy",
+ // Post
"replace:deploy",
]);
diff --git a/package-lock.json b/package-lock.json
index 3e634fa..7144ed2 100644
Binary files a/package-lock.json and b/package-lock.json differ
diff --git a/package.json b/package.json
index 781032b..6ff0542 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "interlanguage-checker",
- "version": "1.12.0",
+ "version": "1.13.0",
"description": "Check the consistency of MediaWiki interlanguage links in a simple overview.",
"author": "Felix W. Dekker",
"browser": "dist/bundle.js",
@@ -24,7 +24,9 @@
"grunt-focus": "^1.0.0",
"grunt-text-replace": "^0.4.0",
"grunt-webpack": "^4.0.3",
- "webpack": "^5.36.0",
- "webpack-cli": "^4.6.0"
+ "ts-loader": "^9.1.2",
+ "typescript": "^4.2.4",
+ "webpack": "^5.37.0",
+ "webpack-cli": "^4.7.0"
}
}
diff --git a/src/main/js/DOM.js b/src/main/js/DOM.js
deleted file mode 100644
index a2b5b85..0000000
--- a/src/main/js/DOM.js
+++ /dev/null
@@ -1,427 +0,0 @@
-// noinspection JSUnresolvedVariable
-const {stringToHtml} = window.fwdekker;
-
-
-/**
- * An input that can be validated.
- *
- * @property input {HTMLElement} the input that is validatable
- */
-export class ValidatableInput {
- /**
- * Constructs a new validatable input.
- *
- * @param input {HTMLInputElement} the input that is validatable
- * @param isValid {function(string): string} returns an empty string if the given input string is valid, and a
- * string explaining why it is is invalid otherwise
- */
- constructor(input, isValid) {
- this.input = input;
- this._isValid = isValid;
- }
-
-
- /**
- * Returns the value of the underlying input element.
- *
- * @return {String} the value of the underlying input element
- */
- getValue() {
- return this.input.value;
- }
-
- /**
- * Sets the value of the underlying input element.
- *
- * @param value {string} the value to set
- */
- setValue(value) {
- this.input.value = value;
- }
-
- /**
- * Validates the input.
- *
- * @return {String} an empty string if the input string is valid, and a string explaining why it is is invalid
- * otherwise
- */
- validate() {
- 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() {
- this.input.dataset["entered"] = "false";
- this.input.setCustomValidity("");
- }
-
- /**
- * Marks the input as invalid and moves focus to it.
- */
- showError() {
- this.input.dataset["entered"] = "true";
- this.input.setCustomValidity("Incorrect");
- this.input.focus();
- }
-
- /**
- * Marks the input as valid.
- */
- showSuccess() {
- this.input.dataset["entered"] = "true";
- this.input.setCustomValidity("");
- }
-}
-
-/**
- * Interacts with the DOM to delegate errors to the user.
- */
-export class ErrorHandler {
- /**
- * Constructs a new `ErrorHandler`, inserting relevant new elements into the DOM to interact with.
- *
- * @param parent {HTMLElement} the element to insert elements into
- * @param [id] {string} the id of the div containing the errors
- */
- constructor(parent, id) {
- this._outerDiv = document.createElement("div");
- this._outerDiv.classList.add("errorOuter", "hidden");
- parent.appendChild(this._outerDiv);
- }
-
-
- /**
- * Handles the displaying of the given error.
- *
- * @param [level] {"warning"|"error"|null} the level of message to display, determines the style of the text
- * @param [message] {*} the message to display
- * @returns {ErrorHandler} this `ErrorHandler`
- */
- handle(level, message) {
- 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.
- *
- * @returns {ErrorHandler} this `ErrorHandler`
- */
- clear() {
- this._outerDiv.classList.add("hidden");
- this._outerDiv.innerHTML = "";
-
- return this;
- }
-}
-
-/**
- * Interacts with the DOM to delegate messages to the user.
- */
-export class MessageHandler {
- /**
- * Constructs a new `MessageHandler`, inserting relevant new elements into the DOM to interact with.
- *
- * @param parent {HTMLElement} the element to insert elements into
- */
- constructor(parent) {
- 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._currentLevel = undefined;
- this._callback = 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] {"complete"|"progress"|"warning"|"error"|"neutral"|undefined} the level of message to display, or
- * `undefined` if the entire message handler should be hidden
- * @param [message] {*} the message to display
- * @returns {MessageHandler} this `MessageHandler`
- */
- handle(level, message) {
- 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;
- }
-
- /**
- * Calls `#handle` without any arguments.
- *
- * @returns {MessageHandler} this `MessageHandler`
- */
- clear() {
- return this.handle();
- }
-
- /**
- * Sets the callback to be executed whenever a message is handler by this handler.
- *
- * @param callback {function("complete"|"progress"|"warning"|"error"|"neutral", [*]): *} the function to execute
- * whenever a message is handled
- * @returns {MessageHandler} this `MessageHandler`
- */
- setCallback(callback) {
- this._callback = callback;
- return this;
- }
-
-
- /**
- * Changes the appearance of the message handler to that of the given level.
- *
- * @param level {"complete"|"progress"|"warning"|"error"|"neutral"} the level to change appearance to
- * @private
- */
- _displayLevel(level) {
- if (level === this._currentLevel) return;
- 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;
- }
- }
-
- /**
- * Turns the loading icon on or off.
- *
- * @param state {boolean} `true` if and only if the loading icon should be on
- * @returns {MessageHandler} this `MessageHandler`
- * @private
- */
- _toggleLoadingIcon(state) {
- 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.
- *
- * @property pages {Page[]} the pages in the network
- */
-export class InterlangTable {
- /**
- * Generates an icon element with the given title and additional classes.
- *
- * @param icon {string} the name of the icon to display
- * @param title {string} the title of the icon, used for the `title` attribute
- * @param [classes] {string[]} the additional classes to apply to the icon
- * @return {String} an icon element with the given title and additional classes
- * @private
- */
- _createIcon(icon, title, classes) {
- 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 {Page[]} a list of all pages
- * @param page {Page} the page to generate a label of
- * @return {String} an appropriate label with icons for the given page
- * @private
- */
- _generateLabel(pages, page) {
- 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 {InterlangNetwork} the network to generate the head for
- * @return {String} the head of the table generated by `#toTable`
- * @private
- */
- _generateTableHead(network) {
- return "" +
- `` +
- /**/`
` +
- /****/`
` +
- /****/`
Source
` +
- /****/`
Destination
` +
- /**/`
` +
- /**/`
${network.pages.map(page => `
${this._generateLabel(network.pages, page)}
`)}
` +
- ``;
- }
-
- /**
- * Generates the body of the table generated by `#toTable`.
- *
- * @param network {InterlangNetwork} the network to generate the body for
- * @return {String} the body of the table generated by `#toTable`
- * @private
- */
- _generateTableBody(network) {
- const rows = network.pages.map(srcPage => {
- const verdict = network.getPageVerdict(srcPage);
-
- const icons = verdict.self
- .map(state => {
- switch (state) {
- case "perfect":
- return this._createIcon("check", "Perfect 🙂", ["success"]);
- case "not-found":
- return this._createIcon("search", "Article does not exist 😕", ["error"]);
- case "wrongly-ordered":
- return this._createIcon("sort-alpha-asc", "Links are in the wrong order 😕", ["warning"]);
- case "doubly-linked":
- return this._createIcon("clone", "Links to the same wiki multiple times 😕", ["warning"]);
- case "self-linked":
- return this._createIcon("rotate-left", "Links to its own wiki 😕", ["warning"]);
- case "unlinked":
- return this._createIcon("chain-broken", "Misses one or more links 😕", ["error"]);
- case "redirected":
- return this._createIcon("mail-forward", "Links to a redirect 😕", ["warning"]);
- case "wrongly-cased":
- return this._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 this._createIcon("check", "Linked 🙂", ["success"]);
- case "self-linked":
- return this._createIcon("rotate-left", "Links to its own wiki 😕", ["warning"]);
- case "unlinked":
- return this._createIcon("times", "Link is missing 😕", ["error"]);
- case "self-unlinked":
- return ``;
- case "redirected":
- return this._createIcon("mail-forward", "Links to a redirect 😕", ["warning"]);
- case "wrongly-cased":
- return this._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 {String} the ID to assign to the table element
- * @param network {InterlangNetwork} the network of pages to render
- * @return {HTMLElement} the generated table
- */
- render(id, network) {
- const table = stringToHtml(
- `
`,
- "table"
- );
-
- // Add event handlers
- table.querySelectorAll(".copyIcon").forEach(icon => {
- 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;
- }
-}
diff --git a/src/main/js/DOM.ts b/src/main/js/DOM.ts
new file mode 100644
index 0000000..5cc1438
--- /dev/null
+++ b/src/main/js/DOM.ts
@@ -0,0 +1,501 @@
+// @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(
+ `
`,
+ "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;
+ }
+}
diff --git a/src/main/js/Main.js b/src/main/js/Main.ts
similarity index 95%
rename from src/main/js/Main.js
rename to src/main/js/Main.ts
index 1197c00..37c7e11 100644
--- a/src/main/js/Main.js
+++ b/src/main/js/Main.ts
@@ -1,10 +1,11 @@
-// noinspection JSUnresolvedVariable
+// @ts-ignore
const {$, doAfterLoad, footer, header, nav} = window.fwdekker;
import {ErrorHandler, InterlangTable, MessageHandler, ValidatableInput} from "./DOM";
import {discoverNetwork, InterlangNetwork, MediaWiki, MediaWikiManager} from "./MediaWiki";
// Contains global functions for debugging
+// @ts-ignore
window.ilc = {};
@@ -67,8 +68,8 @@ doAfterLoad(async () => {
});
- let previousUrl = undefined;
- let mwm = undefined;
+ let previousUrl: string | undefined;
+ let mwm: MediaWikiManager | undefined;
const submit = async () => {
localStorage.setItem("/tools/interlanguage-checker//api-url", urlInput.getValue());
@@ -107,7 +108,7 @@ doAfterLoad(async () => {
// Discover
discoverNetwork(
- mwm,
+ mwm!,
articleInput.getValue(),
(level, message) => errorHandler.handle(level, message),
it => messageHandler.handle("progress", it)
@@ -120,7 +121,7 @@ doAfterLoad(async () => {
form.textContent = "";
form.appendChild((new InterlangTable()).render("networkTable", network));
- switch (network.getVerdict()) {
+ switch (network.getNetworkVerdict()) {
case "perfect":
messageHandler.handle("complete", "A perfect network! 🙂");
break;
@@ -155,13 +156,14 @@ doAfterLoad(async () => {
// Read inputs from URL
const currentParams = new URLSearchParams(window.location.search);
if (currentParams.has("api")) {
- urlInput.setValue(currentParams.get("api"));
+ urlInput.setValue(currentParams.get("api")!);
articleInput.input.focus();
}
- if (currentParams.has("article")) articleInput.setValue(currentParams.get("article"));
+ if (currentParams.has("article")) articleInput.setValue(currentParams.get("article")!);
if (currentParams.has("api") && currentParams.has("article")) await submit();
// Set global debug function
+ // @ts-ignore
window.ilc.getCurrentInputAsUrl = () =>
location.href.split("?")[0] +
`?api=${encodeURI(urlInput.getValue())}` +
diff --git a/src/main/js/MediaWiki.js b/src/main/js/MediaWiki.js
deleted file mode 100644
index 276c269..0000000
--- a/src/main/js/MediaWiki.js
+++ /dev/null
@@ -1,652 +0,0 @@
-import {couldNotConnectMessage, mergeStates} from "./Shared";
-
-
-/**
- * A data class for combining a language and page title to identify a page.
- *
- * This is only an _identifier_ of a page, not the page itself. For information on the page such as the links it
- * contains, whether it's a redirect, etc., see the `Page` class.
- *
- * @property lang {string} the language of the wiki this page is of
- * @property title {string} the title of the page
- */
-export class InterlangLink {
- /**
- * Constructs a new `InterlangLink`.
- *
- * @param lang {string} the language of the wiki this page is of
- * @param title {string} the title of the page
- */
- constructor(lang, title) {
- this.lang = lang;
- this.title = title;
- }
-
-
- /**
- * Returns `true` if and only if the given object equals this `InterlangLink`.
- *
- * @param other {*} the object to compare to this `InterlangLink`
- * @returns {boolean} `true` if and only if the given object equals this `InterlangLink`
- */
- equals(other) {
- return other instanceof InterlangLink && this.lang === other.lang && this.title === other.title;
- }
-
- /**
- * Returns `true` if and only if the given object equals this `InterlangLink`, ignoring the case of the titles.
- *
- * @param other {*} the object to compare to this `InterlangLink`
- * @returns {boolean} `true` if and only if the given object equals this `InterlangLink`, ignoring the case of the
- * titles
- */
- equalsIgnoringCase(other) {
- return other instanceof InterlangLink && this.lang === other.lang
- && this.title.toLowerCase() === other.title.toLowerCase();
- }
-
- /**
- * Converts this `InterlangLink` to a string.
- *
- * @returns {string} the string representation of this `InterlangLink`
- */
- toString() {
- return `${this.lang}:${this.title}`;
- }
-
- /**
- * Returns a deep copy of this `InterlangLink`.
- *
- * @returns {InterlangLink} the deep copy
- */
- copy() {
- return new InterlangLink(this.lang, this.title);
- }
-}
-
-/**
- * Redirects one `InterlangLink` to another.
- *
- * @property from [InterlangLink] the page that redirects
- * @property to [InterlangLink] the page that is redirected to
- */
-export class Redirect {
- /**
- * Constructs a new `Redirect`.
- *
- * @param from [InterlangLink] the page that redirects
- * @param to [InterlangLink] the page that is redirected to
- */
- constructor(from, to) {
- this.from = from.copy();
- this.to = to.copy();
- }
-
-
- /**
- * Returns `true` if and only if the given object equals this `Redirect`.
- *
- * @param other {*} the object to compare to this `Redirect`
- * @returns {boolean} `true` if and only if the given object equals this `Redirect`
- */
- equals(other) {
- return other instanceof Redirect && this.from.equals(other.from) && this.to.equals(other.to);
- }
-
- /**
- * Returns a deep copy of this `Redirect`.
- *
- * @returns {Redirect} the deep copy
- */
- copy() {
- return new Redirect(this.from, this.to);
- }
-}
-
-/**
- * A map of interwiki links.
- *
- * Not implemented as a map but as a list of objects. Therefore, when there are duplicate keys, the original value is
- * always retained.
- *
- * @property map {Array<{prefix: string, url: string}>} maps interwiki prefixes to URLs
- */
-export class InterwikiMap {
- /**
- * Constructs a new interwiki map.
- *
- * @param map {Array<{prefix: string, url: string}>} the mapping from interwiki abbreviations to URLs to store in
- * this map
- */
- constructor(map) {
- this.map = map.map(it => ({prefix: it.prefix, url: it.url.replace("http://", "https://")}));
- }
-
-
- /**
- * Returns the URL for the given prefix, or `undefined` if the prefix could not be found.
- *
- * @param prefix {string} the prefix to return the URL of
- * @returns {string} the URL for the given prefix, or `undefined` if the prefix could not be found
- */
- getUrl(prefix) {
- return this.map.find(it => it.prefix === prefix).url;
- }
-
- /**
- * Returns `true` if and only if this map has a URL for the given prefix.
- *
- * @param prefix {string} the prefix to check for
- * @returns {boolean} `true` if and only if this map has a URL for the given prefix
- */
- hasUrl(prefix) {
- return this.map.find(it => it.prefix === prefix) !== undefined;
- }
-
- /**
- * Returns a deep copy of this `InterwikiMap`.
- *
- * @returns {InterwikiMap} the deep copy
- */
- copy() {
- return new InterwikiMap(this.map);
- }
-}
-
-/**
- * Describes a page, i.e. what you get if you follow an `InterlangLink`.
- *
- * @property url {URL} the full URL at which this page is located
- * @property link {InterlangLink} the interlanguage link describing the location of the page
- * @property linksTo {InterlangLink[]} the interlanguage links contained in this page
- * @property exists {boolean} `true` if and only if this page exists
- */
-export class Page {
- /**
- * Constructs a new `Page`.
- *
- * @param url {URL} the full URL at which this page is located
- * @param link {InterlangLink} the interlanguage link describing the location of the page
- * @param langLinks {InterlangLink[]} the interlanguage links contained in this page
- * @param exists {boolean} `true` if and only if this page exists
- */
- constructor(url, link, langLinks, exists) {
- this.url = new URL(url.toString());
- this.link = link.copy();
- this.langLinks = langLinks.map(it => it.copy());
- this.exists = exists;
- }
-
-
- /**
- * Returns `true` if and only if this page's language links are sorted alphabetically.
- *
- * @returns {boolean} `true` if and only if this page's language links are sorted alphabetically
- */
- langLinksAreOrdered() {
- return this.langLinks.reduce((isSorted, langLink, i, self) =>
- i === 0 || (isSorted && self[i - 1].toString().localeCompare(langLink.toString()) <= 0),
- true
- );
- }
-
- /**
- * Returns `true` if and only if this page has multiple links to the same language.
- *
- * @return {boolean} `true` if and only if this page has multiple links to the same language
- */
- hasDoubleLinks() {
- return this.langLinks.some(a => this.langLinks.filter(b => a.lang === b.lang).length > 1);
- }
-
-
- /**
- * Returns a deep copy of this `Page`.
- *
- * @returns {Page} the deep copy
- */
- copy() {
- return new Page(this.url, this.link, this.langLinks, this.exists);
- }
-}
-
-/**
- * A network of pages linking to each other.
- *
- * @property pages {Page[]} the pages linking to each other, sorted alphabetically
- * @property redirects {Redirect[]} the redirects in the network
- */
-export class InterlangNetwork {
- /**
- * Constructs a new `InterlangNetwork`.
- *
- * @param pages {Page[]} the pages linking to each other
- * @param redirects {Redirect[]} the redirects in the network
- */
- constructor(pages, redirects) {
- this.pages = pages
- .map(it => it.copy())
- .sort((a, b) => a.link.toString().localeCompare(b.link.toString()));
- this.redirects = redirects.map(it => it.copy());
- }
-
-
- /**
- * Determines whether the given source links to the given destination, potentially through a redirect.
- *
- * @param source {Page} the source page of which to check the links
- * @param destination {Page} the destination that could be linked to
- * @returns {"linked"|"self-linked"|"unlinked"|"self-unlinked"|"redirected"} the status of the link
- */
- getLinkVerdict(source, destination) {
- const isSelfLangLink = source.link.lang === destination.link.lang;
-
- if (source.langLinks.some(it => it.equals(destination.link)))
- return isSelfLangLink ? "self-linked" : "linked";
-
- if (source.langLinks.some(it => it.equalsIgnoringCase(destination.link)))
- return isSelfLangLink ? "self-linked" : "wrongly-cased";
-
- if (source.langLinks.some(link => this.redirects.some(it => it.equals(new Redirect(link, destination.link)))))
- return isSelfLangLink ? "self-linked" : "redirected";
-
- return isSelfLangLink ? "self-unlinked" : "unlinked";
- }
-
- /**
- * Analyzes the given source page and returns a verdict of its own state and of the state of its link to all other
- * pages in this network.
- *
- * @param srcPage {Page} the page to give a verdict of
- * @return verdict {Object} the verdict
- * @return verdict.self {("perfect"|"not-found"|"wrongly-ordered"|"doubly-linked"|"self-linked"|"unlinked"|
- * "redirected")[]} the verdict of the page in relation to the entire network
- * @return verdict.pages {Object[]} the verdicts of the page in relation to each other article in the network
- * @return verdict.pages[].page {Page} the page that the verdict is in relation to
- * @return verdict.pages[].verdict {"linked"|"self-linked"|"unlinked"|"self-unlinked"|"redirected"} the verdict of
- * the relation of the given page to this page
- */
- getPageVerdict(srcPage) {
- const pageStates = this.pages.map(dstPage => ({page: dstPage, verdict: this.getLinkVerdict(srcPage, dstPage)}));
-
- let selfStates = [];
- if (!srcPage.exists)
- selfStates.push("not-found");
- if (!srcPage.langLinksAreOrdered())
- selfStates.push("wrongly-ordered");
- if (srcPage.hasDoubleLinks())
- selfStates.push("doubly-linked");
- if (pageStates.some(({verdict}) => verdict === "self-linked"))
- selfStates.push("self-linked");
- if (pageStates.some(({verdict}) => verdict === "unlinked"))
- selfStates.push("unlinked");
- if (pageStates.some(({verdict}) => verdict === "redirected"))
- selfStates.push("redirected");
- if (pageStates.some(({verdict}) => verdict === "wrongly-cased"))
- selfStates.push("wrongly-cased");
-
- if (selfStates.length === 0)
- selfStates.push("perfect");
-
- return {self: selfStates, pages: pageStates};
- }
-
- /**
- * Returns a verdict on the network.
- *
- * @return {"perfect"|"flawed"|"broken"} a verdict on the network
- */
- getVerdict() {
- const states = ["broken", "flawed", "perfect"];
- return this.pages.reduce((state, page) => {
- const verdict = this.getPageVerdict(page).self;
- if (verdict.some(it => ["not-found", "unlinked"].includes(it)))
- return mergeStates(states, state, "broken");
- if (verdict.some(it => ["wrongly-ordered", "doubly-linked", "self-linked", "redirected", "wrongly-cased"].includes(it)))
- return mergeStates(states, state, "flawed");
- return mergeStates(states, state, "perfect");
- }, "perfect");
- }
-
- /**
- * Returns a deep copy of this `InterlangNetwork`.
- *
- * @returns {InterlangNetwork} the deep copy
- */
- copy() {
- return new InterlangNetwork(this.pages, this.redirects);
- }
-}
-
-
-/**
- * Interacts with the API in an asynchronous manner.
- *
- * @property baseUrl {string} the origin of the wiki's API
- * @property apiPath {string} the path relative to the wiki's API; starts with a `/`
- * @property general {Object} the general information, retrieved from the API
- * @property interwikiMap {InterwikiMap} the interwiki map of this wiki
- * @property namespaces {Object.{number, Object}} the namespaces on this wiki
- */
-export class MediaWiki {
- /**
- * Constructs a new MediaWiki object.
- *
- * @param apiUrl the url to the `api.php` file
- */
- constructor(apiUrl) {
- const urlObj = new URL(apiUrl);
- this.origin = urlObj.origin;
- this.apiPath = urlObj.pathname;
- }
-
- /**
- * Initializes this `MediaWiki` object with the necessary information from the API.
- *
- * @returns {MediaWiki} this `MediaWiki` object
- */
- async init() {
- const query = await this.getSiteInfo("general", "interwikimap", "namespaces");
-
- // Add self to map
- query.interwikimap.push({prefix: query.general.lang, url: query.general.server + query.general.articlepath});
-
- // Set fields
- this.general = query.general;
- this.interwikiMap = new InterwikiMap(query.interwikimap);
- this.namespaces = query.namespaces;
-
- return this;
- }
-
-
- /**
- * Sends a request to the MediaWiki API and runs the given callback on the response.
- *
- * @param params {Object} the parameters to send to the API
- * @return {Promise