parent
eacb38928e
commit
355ec7ac57
19
Gruntfile.js
19
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",
|
||||
]);
|
||||
|
||||
|
|
Binary file not shown.
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 `<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 {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 "" +
|
||||
`<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 {InterlangNetwork} the network to generate the head for
|
||||
* @return {String} the head of the table generated by `#toTable`
|
||||
* @private
|
||||
*/
|
||||
_generateTableHead(network) {
|
||||
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 {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}<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 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 `<span></span>`;
|
||||
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 "" +
|
||||
`<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 {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 id="${id}">` +
|
||||
/**/this._generateTableHead(network) +
|
||||
/**/this._generateTableBody(network) +
|
||||
`</table>`,
|
||||
"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;
|
||||
}
|
||||
}
|
|
@ -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 `<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;
|
||||
}
|
||||
}
|
|
@ -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())}` +
|
|
@ -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<Object>} the API's response
|
||||
*/
|
||||
request(params) {
|
||||
const url = this.origin + this.apiPath + "?format=json&origin=*&" + new URLSearchParams(params).toString();
|
||||
console.debug(`Requesting from ${this.origin}${this.apiPath} with params`, params, "at", url);
|
||||
return fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(couldNotConnectMessage);
|
||||
return response.json();
|
||||
})
|
||||
.catch(() => {
|
||||
throw new Error(couldNotConnectMessage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests all language links on the given article.
|
||||
*
|
||||
* @param title {string} the title of the article to return links of
|
||||
* @return result {Object|undefined} the query result, or `undefined` if the article could not be found
|
||||
* @return result.link {InterlangLink} the normalized, redirect-resolved link to the article
|
||||
* @return result.langLinks {InterlangLink[]} the language links on the article
|
||||
* @return result.redirects {Redirect[]} all redirects that were encountered, with double redirects removed
|
||||
*/
|
||||
getLangLinks(title) {
|
||||
return this
|
||||
.request({action: "parse", page: title, prop: "langlinks", redirects: ""})
|
||||
.then(response => {
|
||||
if (response.error !== undefined)
|
||||
return undefined;
|
||||
|
||||
const langLinks = response.parse.langlinks.map(it => new InterlangLink(it.lang, it["*"]));
|
||||
const redirects = response.parse.redirects
|
||||
.map(it => new Redirect(this._toLink(it.from), this._toLink(it.to)))
|
||||
.reduce((redirects, redirect, _, self) => {
|
||||
// TODO Support triple redirects (#30)
|
||||
const matches = self.filter(it => it.from.equals(redirect.to));
|
||||
if (matches.length > 1)
|
||||
redirects.push(new Redirect(redirect.from, matches[0].to));
|
||||
else
|
||||
redirects.push(redirect);
|
||||
|
||||
return redirects;
|
||||
}, []);
|
||||
|
||||
return {link: this._toLink(response.parse.title), langLinks: langLinks, redirects: redirects};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this wiki's site information.
|
||||
*
|
||||
* @param props {...string} the site information properties to retrieve, such as "general" or "interwikimap"
|
||||
* @returns {Object} the wiki's site information, with each property corresponding to an argument to this method
|
||||
*/
|
||||
getSiteInfo(...props) {
|
||||
return this.request({action: "query", meta: "siteinfo", siprop: props.join("|")})
|
||||
.then(response => response.query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the given link, adjusting its language to this wiki's language and replacing the link's namespace with
|
||||
* the canonical namespace.
|
||||
*
|
||||
* @param link {InterlangLink} the link to normalize
|
||||
* @returns {InterlangLink} the normalized link
|
||||
*/
|
||||
normalize(link) {
|
||||
const normalLink = link.copy();
|
||||
normalLink.lang = this.general.lang;
|
||||
|
||||
const titleParts = normalLink.title.split(':');
|
||||
if (titleParts.length < 2) return normalLink;
|
||||
|
||||
titleParts[0] = Object.keys(this.namespaces).reduce((titlePart, namespaceId) => {
|
||||
const namespace = this.namespaces[namespaceId];
|
||||
return titlePart === namespace["canonical"] ? namespace["*"] : titlePart
|
||||
}, titleParts[0]);
|
||||
normalLink.title = titleParts.join(':');
|
||||
|
||||
return normalLink;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shorthand for converting a title to an `InterlangLink` of this wiki's language.
|
||||
*
|
||||
* @param title {string} the title of the article to generate a link for
|
||||
* @returns {InterlangLink} the link to the article on this wiki
|
||||
* @private
|
||||
*/
|
||||
_toLink(title) {
|
||||
return new InterlangLink(this.general.lang, title);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a `MediaWiki` instance for different languages, caching retrieved information for re-use.
|
||||
*
|
||||
* @property mws {Object.<string, MediaWiki>} the cached `MediaWiki` instances
|
||||
* @property articlePath {string} the path to articles, where `$1` indicates the article name
|
||||
* @property apiPath {string} the path to `api.php`
|
||||
* @property baseLang {string} the language of the base `MediaWiki`, where the exploration starts
|
||||
*/
|
||||
export class MediaWikiManager {
|
||||
/**
|
||||
* Constructs a new `MediaWikiManager`.
|
||||
*
|
||||
* The `#init` method **must** be called before invoking any other function. Behavior is undefined otherwise.
|
||||
*/
|
||||
constructor() {
|
||||
this.mws = {};
|
||||
this._iwMap = new InterwikiMap([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes this `MediaWikiManager`.
|
||||
*
|
||||
* @param baseMw {MediaWiki} the `MediaWiki` that is used as a starting point
|
||||
* @return {MediaWikiManager} this `MediaWikiManager`
|
||||
*/
|
||||
async init(baseMw) {
|
||||
this.basePath = [...(baseMw.apiPath)]
|
||||
.map((it, i) => it === baseMw.general.articlepath[i] ? it : "")
|
||||
.join("")
|
||||
.slice(0, -1);
|
||||
this.articlePath = baseMw.general.articlepath.slice(this.basePath.length);
|
||||
this.apiPath = baseMw.apiPath.slice(this.basePath.length);
|
||||
this.baseLang = baseMw.general.lang;
|
||||
|
||||
this.mws[baseMw.general.lang] = baseMw;
|
||||
this._updateIwMap();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the `MediaWiki` for the given language, creating and initializing it if necessary, or `undefined` if it
|
||||
* could not be created.
|
||||
*
|
||||
* @param lang {string} the language of the `MediaWiki` to return
|
||||
* @returns {MediaWiki} the `MediaWiki` for the given language, or `undefined` if it could not be created
|
||||
*/
|
||||
async getMwOrWait(lang) {
|
||||
if (this.hasMw(lang))
|
||||
return this.mws[lang];
|
||||
|
||||
if (!this._iwMap.hasUrl(lang))
|
||||
return undefined;
|
||||
|
||||
const url = this._iwMap.getUrl(lang);
|
||||
let newMw;
|
||||
try {
|
||||
newMw = await new MediaWiki(url.slice(0, -this.articlePath.length) + this.apiPath).init();
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.hasMw(newMw.general.lang)) {
|
||||
// Duplicate MW with different but equivalent language code; destroy new MW instance
|
||||
this.mws[lang] = this.mws[newMw.general.lang];
|
||||
} else {
|
||||
this.mws[newMw.general.lang] = newMw;
|
||||
this.mws[lang] = newMw;
|
||||
}
|
||||
this._updateIwMap();
|
||||
|
||||
return this.mws[lang];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `MediaWiki` for the given language or `undefined` if it has not created that object.
|
||||
*
|
||||
* @param lang {string} the language of the `MediaWiki` to return
|
||||
* @returns {MediaWiki} the `MediaWiki` for the given language or `undefined` if it has not created that object
|
||||
*/
|
||||
getMw(lang) {
|
||||
return this.mws[lang];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if and only if this manager has a `MediaWiki` for the given language.
|
||||
*
|
||||
* @param lang {string} the language of the `MediaWiki` to check presence of
|
||||
* @returns {boolean} `true` if and only if this manager has a `MediaWiki` for the given language
|
||||
*/
|
||||
hasMw(lang) {
|
||||
return this.mws[lang] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL to the given article.
|
||||
*
|
||||
* @param link {InterlangLink} the link to return the URL of
|
||||
* @returns {URL} the URL to the given article
|
||||
*/
|
||||
getArticlePath(link) {
|
||||
return new URL(this._iwMap.getUrl(link.lang).replace("$1", link.title));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the `_iwMap` property with the entries in `MediaWiki` instances in this manager.
|
||||
*/
|
||||
_updateIwMap() {
|
||||
const maps = Object.keys(this.mws).map(key => this.mws[key].interwikiMap.map);
|
||||
this._iwMap = new InterwikiMap([].concat(...maps));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers the interlanguage network, starting from the given link.
|
||||
*
|
||||
* @param mwm {MediaWikiManager} the manager to use for caching and resolving pages
|
||||
* @param title {string} the title of the page to start traversing at
|
||||
* @param [errorCb] {function("error"|"warning"|null, *): void} a function handling errors and warnings
|
||||
* @param [progressCb] {function(*): void} a function handling progress updates
|
||||
* @returns network {Object} the discovered network
|
||||
* @returns network.pages {Page[]} the pages in the network
|
||||
* @returns network.redirects {Redirect[]} the redirects in the network
|
||||
*/
|
||||
export const discoverNetwork = async function(mwm, title, errorCb, progressCb) {
|
||||
const pages = [];
|
||||
const redirects = [];
|
||||
|
||||
const history = [];
|
||||
const queue = [new InterlangLink(mwm.baseLang, title)];
|
||||
while (queue.length > 0) {
|
||||
progressCb("Checking <code>" + queue[queue.length - 1] + "</code>");
|
||||
|
||||
let next = queue.pop();
|
||||
if (history.some(it => it.equals(next)))
|
||||
continue;
|
||||
|
||||
// Normalize
|
||||
const nextMw = await mwm.getMwOrWait(next.lang);
|
||||
if (nextMw === undefined) {
|
||||
history.push(next);
|
||||
pages.push(new Page(mwm.getArticlePath(next), next, [], false));
|
||||
if (history.length === 1)
|
||||
throw new Error(couldNotConnectMessage);
|
||||
else {
|
||||
errorCb("warning", `Could not connect to the wiki for language '${next.lang}'. Maybe the wiki no longer exists?`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
next = nextMw.normalize(next);
|
||||
if (history.some(it => it.equals(next)))
|
||||
continue;
|
||||
else
|
||||
history.push(next);
|
||||
|
||||
// Fetch interlang links
|
||||
const result = await nextMw.getLangLinks(next.title);
|
||||
if (result === undefined) {
|
||||
pages.push(new Page(mwm.getArticlePath(next), next, [], false));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Follow redirects
|
||||
if (!result.link.equals(next)) {
|
||||
redirects.push(...(result.redirects));
|
||||
next = result.link;
|
||||
if (history.some(it => it.equals(next)))
|
||||
continue;
|
||||
else
|
||||
history.push(next);
|
||||
}
|
||||
|
||||
// Create `Page` object
|
||||
pages.push(new Page(mwm.getArticlePath(next), next, result.langLinks, true));
|
||||
queue.push(...(result.langLinks));
|
||||
}
|
||||
|
||||
// Normalize links
|
||||
pages.forEach(page => {
|
||||
page.langLinks = page.langLinks.map(langLink => {
|
||||
const mw = mwm.getMw(langLink.lang);
|
||||
return mw !== undefined ? mw.normalize(langLink) : langLink;
|
||||
});
|
||||
});
|
||||
|
||||
return {pages: pages, redirects: redirects};
|
||||
}
|
|
@ -0,0 +1,796 @@
|
|||
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.
|
||||
*/
|
||||
export class InterlangLink {
|
||||
/**
|
||||
* The language of the wiki this page is of.
|
||||
*/
|
||||
readonly lang: string;
|
||||
/**
|
||||
* The title of the page.
|
||||
*/
|
||||
readonly title: string;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new interlanguage link.
|
||||
*
|
||||
* @param lang the language of the wiki this page is of
|
||||
* @param title the title of the page
|
||||
*/
|
||||
constructor(lang: string, title: string) {
|
||||
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`
|
||||
* @return `true` if and only if the given object equals this `InterlangLink`
|
||||
*/
|
||||
equals(other: any): boolean {
|
||||
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`
|
||||
* @return `true` if and only if the given object equals this `InterlangLink`, ignoring the case of the titles
|
||||
*/
|
||||
equalsIgnoringCase(other: any) {
|
||||
return other instanceof InterlangLink && this.lang === other.lang
|
||||
&& this.title.toLowerCase() === other.title.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this `InterlangLink` to a string.
|
||||
*
|
||||
* @return the string representation of this `InterlangLink`
|
||||
*/
|
||||
toString(): string {
|
||||
return `${this.lang}:${this.title}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep copy of this `InterlangLink`.
|
||||
*
|
||||
* @return the deep copy
|
||||
*/
|
||||
copy(): InterlangLink {
|
||||
return new InterlangLink(this.lang, this.title);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects one `InterlangLink` to another.
|
||||
*/
|
||||
export class Redirect {
|
||||
/**
|
||||
* The page that redirects.
|
||||
*/
|
||||
readonly from: InterlangLink;
|
||||
/**
|
||||
* The page that is redirected to.
|
||||
*/
|
||||
readonly to: InterlangLink;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `Redirect`.
|
||||
*
|
||||
* @param from the page that redirects
|
||||
* @param to the page that is redirected to
|
||||
*/
|
||||
constructor(from: InterlangLink, to: InterlangLink) {
|
||||
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`
|
||||
* @return `true` if and only if the given object equals this `Redirect`
|
||||
*/
|
||||
equals(other: any): boolean {
|
||||
return other instanceof Redirect && this.from.equals(other.from) && this.to.equals(other.to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep copy of this `Redirect`.
|
||||
*
|
||||
* This is a deep copy because the constructor performs copies of the received variables.
|
||||
*
|
||||
* @return the deep copy
|
||||
*/
|
||||
copy(): Redirect {
|
||||
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.
|
||||
*/
|
||||
// TODO: Replace entire class with a `Map`
|
||||
export class InterwikiMap {
|
||||
/**
|
||||
* The mapping from interwiki abbreviations/prefixes to URLs.
|
||||
*/
|
||||
readonly map: Map<string, string>;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new interwiki map.
|
||||
*
|
||||
* @param map the mapping from interwiki abbreviations/prefixes to URLs
|
||||
*/
|
||||
constructor(map: { prefix: string, url: string }[]) {
|
||||
this.map = new Map();
|
||||
map.forEach(({prefix, url}) => this.map.set(prefix, url.replace("http://", "https://")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new interwiki map from the given map.
|
||||
*
|
||||
* @param map the map to construct an interwiki map from
|
||||
*/
|
||||
static fromMap(map: Map<string, string>): InterwikiMap {
|
||||
return new InterwikiMap([...map.entries()].map(it => ({prefix: it[0], url: it[1]})));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the URL for the given prefix, or `undefined` if the prefix could not be found.
|
||||
*
|
||||
* @param prefix the prefix to return the URL of
|
||||
* @return the URL for the given prefix, or `undefined` if the prefix could not be found
|
||||
*/
|
||||
getUrl(prefix: string): string | undefined {
|
||||
return this.map.get(prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if and only if this map has a URL for the given prefix.
|
||||
*
|
||||
* @param prefix the prefix to check for
|
||||
* @return `true` if and only if this map has a URL for the given prefix
|
||||
*/
|
||||
hasUrl(prefix: string): boolean {
|
||||
return this.map.has(prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep copy of this `InterwikiMap`.
|
||||
*
|
||||
* This is a deep copy because the constructor performs copies of the received variables.
|
||||
*
|
||||
* @return the deep copy
|
||||
*/
|
||||
copy(): InterwikiMap {
|
||||
return InterwikiMap.fromMap(this.map);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a page, i.e. what you get if you follow an `InterlangLink`.
|
||||
*/
|
||||
export class Page {
|
||||
/**
|
||||
* The full URL at which this page is located.
|
||||
*/
|
||||
readonly url: URL;
|
||||
/**
|
||||
* The interlanguage link describing the location of the page.
|
||||
*/
|
||||
readonly link: InterlangLink;
|
||||
/**
|
||||
* The interlanguage links contained in this page.
|
||||
*/
|
||||
readonly langLinks: InterlangLink[];
|
||||
/**
|
||||
* `true` if and only if this page exists.
|
||||
*/
|
||||
readonly exists: boolean;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new page.
|
||||
*
|
||||
* @param url the full URL at which this page is located
|
||||
* @param link the interlanguage link describing the location of the page
|
||||
* @param langLinks the interlanguage links contained in this page
|
||||
* @param exists `true` if and only if this page exists
|
||||
*/
|
||||
constructor(url: URL, link: InterlangLink, langLinks: InterlangLink[], exists: boolean) {
|
||||
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.
|
||||
*
|
||||
* @return `true` if and only if this page's language links are sorted alphabetically
|
||||
*/
|
||||
langLinksAreOrdered(): boolean {
|
||||
return this.langLinks.reduce(
|
||||
(isSorted: boolean, langLink: InterlangLink, i: number, self: InterlangLink[]) =>
|
||||
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 `true` if and only if this page has multiple links to the same language
|
||||
*/
|
||||
hasDoubleLinks(): boolean {
|
||||
return this.langLinks.some(a => this.langLinks.filter(b => a.lang === b.lang).length > 1);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a deep copy of this `Page`.
|
||||
*
|
||||
* This is a deep copy because the constructor performs copies of the received variables.
|
||||
*
|
||||
* @return the deep copy
|
||||
*/
|
||||
copy() {
|
||||
return new Page(this.url, this.link, this.langLinks, this.exists);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A network of pages linking to each other.y
|
||||
*/
|
||||
export class InterlangNetwork {
|
||||
/**
|
||||
* The alphabetically-sorted pages that have been discovered in the network.
|
||||
*/
|
||||
readonly pages: Page[];
|
||||
/**
|
||||
* The redirects that have been discovered in the network.
|
||||
*/
|
||||
readonly redirects: Redirect[];
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `InterlangNetwork`.
|
||||
*
|
||||
* @param pages the pages linking to each other
|
||||
* @param redirects the redirects in the network
|
||||
*/
|
||||
constructor(pages: Page[], redirects: Redirect[]) {
|
||||
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 the source page of which to check the links
|
||||
* @param destination the destination that could be linked to
|
||||
* @return the checker's verdict of the link
|
||||
*/
|
||||
getLinkVerdict(source: Page, destination: Page): LinkVerdict {
|
||||
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 the page to give a verdict of
|
||||
* @return the checker's verdicts of the page and its outgoing links
|
||||
*/
|
||||
getPageVerdict(srcPage: Page): { self: PageVerdict[], pages: { page: Page, verdict: LinkVerdict }[] } {
|
||||
const pageStates = this.pages.map(dstPage => ({page: dstPage, verdict: this.getLinkVerdict(srcPage, dstPage)}));
|
||||
|
||||
let selfStates: PageVerdict[] = [];
|
||||
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 a verdict on the network
|
||||
*/
|
||||
getNetworkVerdict(): NetworkVerdict {
|
||||
const states: NetworkVerdict[] = ["broken", "flawed", "perfect"];
|
||||
return this.pages.reduce(
|
||||
(state: NetworkVerdict, page: Page) => {
|
||||
const verdict = this.getPageVerdict(page).self;
|
||||
if (verdict.some(it => ["not-found", "unlinked"].includes(it)))
|
||||
return mergeStates<NetworkVerdict>(states, state, "broken");
|
||||
if (verdict.some(it => ["wrongly-ordered", "doubly-linked", "self-linked", "redirected", "wrongly-cased"].includes(it)))
|
||||
return mergeStates<NetworkVerdict>(states, state, "flawed");
|
||||
return mergeStates<NetworkVerdict>(states, state, "perfect");
|
||||
},
|
||||
"perfect"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep copy of this `InterlangNetwork`.
|
||||
*
|
||||
* This is a deep copy because the constructor performs copies of the received variables.
|
||||
*
|
||||
* @return the deep copy
|
||||
*/
|
||||
copy(): InterlangNetwork {
|
||||
return new InterlangNetwork(this.pages, this.redirects);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Interacts with the API in an asynchronous manner.
|
||||
*/
|
||||
export class MediaWiki {
|
||||
/**
|
||||
* The origin of the wiki's API URL.
|
||||
*/
|
||||
readonly origin: string;
|
||||
/**
|
||||
* The path relative to the wiki's API; starts with a `/`.
|
||||
*/
|
||||
readonly apiPath: string;
|
||||
|
||||
/**
|
||||
* The general information, retrieved from the API.
|
||||
*/
|
||||
general!: { articlepath: string, lang: string };
|
||||
/**
|
||||
* The interwiki map of this wiki.
|
||||
*/
|
||||
interwikiMap!: InterwikiMap;
|
||||
/**
|
||||
* The namespaces on this wiki.
|
||||
*/
|
||||
namespaces!: Map<number, { id: string, canonical: string, "*": string }>;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new MediaWiki object.
|
||||
*
|
||||
* The `#init` method **must** be called before invoking any other function. Behavior is undefined otherwise.
|
||||
*
|
||||
* @param apiUrl the url to the `api.php` file
|
||||
*/
|
||||
constructor(apiUrl: string) {
|
||||
const urlObj = new URL(apiUrl);
|
||||
this.origin = urlObj.origin;
|
||||
this.apiPath = urlObj.pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes this `MediaWiki` object with the necessary information from the API.
|
||||
*
|
||||
* @return this `MediaWiki` object
|
||||
*/
|
||||
async init(): Promise<MediaWiki> {
|
||||
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 the parameters to send to the API
|
||||
* @return the API's response
|
||||
*/
|
||||
request(params: { [key: string]: string }): Promise<any> {
|
||||
const url = this.origin + this.apiPath + "?format=json&origin=*&" + new URLSearchParams(params).toString();
|
||||
console.debug(`Requesting from ${this.origin}${this.apiPath} with params`, params, "at", url);
|
||||
return fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(couldNotConnectMessage);
|
||||
return response.json();
|
||||
})
|
||||
.catch(() => {
|
||||
throw new Error(couldNotConnectMessage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests all language links on the given article.
|
||||
*
|
||||
* @param title the title of the article to return links of
|
||||
* @return result the query result, or `undefined` if the article could not be found
|
||||
*/
|
||||
getLangLinks(title: string): Promise<{ link: InterlangLink, langLinks: InterlangLink[], redirects: Redirect[] } | undefined> {
|
||||
return this
|
||||
.request({action: "parse", page: title, prop: "langlinks", redirects: ""})
|
||||
.then(response => {
|
||||
if (response.error !== undefined)
|
||||
return undefined;
|
||||
|
||||
const langLinks = response.parse.langlinks
|
||||
.map((it: { lang: string, "*": string }) => new InterlangLink(it.lang, it["*"]));
|
||||
const redirects = response.parse.redirects
|
||||
.map((it: { from: string; to: string; }) => new Redirect(this.toLink(it.from), this.toLink(it.to)))
|
||||
.reduce((redirects: Redirect[], redirect: Redirect, _: number, self: Redirect[]) => {
|
||||
// TODO Support triple redirects (#30)
|
||||
const matches = self.filter(it => it.from.equals(redirect.to));
|
||||
if (matches.length > 1)
|
||||
redirects.push(new Redirect(redirect.from, matches[0].to));
|
||||
else
|
||||
redirects.push(redirect);
|
||||
|
||||
return redirects;
|
||||
}, []);
|
||||
|
||||
return {link: this.toLink(response.parse.title), langLinks: langLinks, redirects: redirects};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this wiki's site information.
|
||||
*
|
||||
* @param props the site information properties to retrieve, such as "general" or "interwikimap"
|
||||
* @return the wiki's site information, with each property corresponding to an argument to this method
|
||||
*/
|
||||
getSiteInfo(...props: string[]): any {
|
||||
return this.request({action: "query", meta: "siteinfo", siprop: props.join("|")})
|
||||
.then(response => response.query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the given link, adjusting its language to this wiki's language and replacing the link's namespace with
|
||||
* the canonical namespace.
|
||||
*
|
||||
* @param link the link to normalize
|
||||
* @return the normalized link
|
||||
*/
|
||||
normalize(link: InterlangLink): InterlangLink {
|
||||
const normalLang = this.general.lang;
|
||||
|
||||
const titleParts = link.title.split(":");
|
||||
if (titleParts.length < 2) return new InterlangLink(normalLang, link.title);
|
||||
|
||||
titleParts[0] = [...this.namespaces.values()].reduce(
|
||||
(titlePart: string, namespace: { id: string, canonical: string, "*": string }) => {
|
||||
return titlePart === namespace["canonical"] ? namespace["*"] : titlePart;
|
||||
},
|
||||
titleParts[0]
|
||||
);
|
||||
const normalTitle = titleParts.join(":");
|
||||
|
||||
return new InterlangLink(normalLang, normalTitle);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shorthand for converting a title to an `InterlangLink` of this wiki's language.
|
||||
*
|
||||
* @param title the title of the article to generate a link for
|
||||
* @return the link to the article on this wiki
|
||||
* @private
|
||||
*/
|
||||
private toLink(title: string): InterlangLink {
|
||||
return new InterlangLink(this.general.lang, title);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a `MediaWiki` instance for different languages, caching retrieved information for re-use.
|
||||
*/
|
||||
export class MediaWikiManager {
|
||||
/**
|
||||
* The combined interwiki map of all `MediaWiki` instances under management of this manager.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private iwMap: InterwikiMap;
|
||||
|
||||
/**
|
||||
* The cached `MediaWiki` instances
|
||||
*/
|
||||
mws: Map<string, MediaWiki>;
|
||||
/**
|
||||
* The language of the base `MediaWiki`, where the exploration starts.
|
||||
*/
|
||||
baseLang!: string;
|
||||
/**
|
||||
* The path to articles, where `$1` indicates the article name.
|
||||
*/
|
||||
articlePath!: string;
|
||||
/**
|
||||
* The path to `api.php`.
|
||||
*/
|
||||
apiPath!: string;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new MediaWiki manager.
|
||||
*
|
||||
* The `#init` method **must** be called before invoking any other function. Behavior is undefined otherwise.
|
||||
*/
|
||||
constructor() {
|
||||
this.mws = new Map();
|
||||
this.iwMap = new InterwikiMap([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes this `MediaWikiManager`.
|
||||
*
|
||||
* @param baseMw the `MediaWiki` that is used as a starting point
|
||||
* @return this `MediaWikiManager`
|
||||
*/
|
||||
async init(baseMw: MediaWiki): Promise<MediaWikiManager> {
|
||||
const basePath = [...(baseMw.apiPath)]
|
||||
.map((it, i) => it === baseMw.general.articlepath[i] ? it : "")
|
||||
.join("")
|
||||
.slice(0, -1);
|
||||
|
||||
this.articlePath = baseMw.general.articlepath.slice(basePath.length);
|
||||
this.apiPath = baseMw.apiPath.slice(basePath.length);
|
||||
this.baseLang = baseMw.general.lang;
|
||||
|
||||
this.mws.set(baseMw.general.lang, baseMw);
|
||||
this.updateIwMap();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the `MediaWiki` for the given language, creating and initializing it if necessary, or `undefined` if it
|
||||
* could not be created.
|
||||
*
|
||||
* @param lang the language of the `MediaWiki` to return
|
||||
* @return the `MediaWiki` for the given language, or `undefined` if it could not be created
|
||||
*/
|
||||
async getMwOrWait(lang: string): Promise<MediaWiki | undefined> {
|
||||
if (this.hasMw(lang))
|
||||
return this.mws.get(lang);
|
||||
|
||||
if (!this.iwMap.hasUrl(lang))
|
||||
return undefined;
|
||||
|
||||
const url = this.iwMap.getUrl(lang);
|
||||
if (url === undefined) return undefined;
|
||||
|
||||
let newMw;
|
||||
try {
|
||||
newMw = await new MediaWiki(url.slice(0, -this.articlePath.length) + this.apiPath).init();
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.hasMw(newMw.general.lang)) {
|
||||
// Duplicate MW with different but equivalent language code; destroy new MW instance
|
||||
this.mws.set(lang, this.mws.get(newMw.general.lang)!);
|
||||
} else {
|
||||
this.mws.set(newMw.general.lang, newMw);
|
||||
this.mws.set(lang, newMw);
|
||||
}
|
||||
this.updateIwMap();
|
||||
|
||||
return this.mws.get(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `MediaWiki` for the given language or `undefined` if it has not created that object.
|
||||
*
|
||||
* @param lang the language of the `MediaWiki` to return
|
||||
* @return the `MediaWiki` for the given language or `undefined` if it has not created that object
|
||||
*/
|
||||
getMw(lang: string): MediaWiki | undefined {
|
||||
return this.mws.get(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if and only if this manager has a `MediaWiki` for the given language.
|
||||
*
|
||||
* @param lang the language of the `MediaWiki` to check presence of
|
||||
* @return `true` if and only if this manager has a `MediaWiki` for the given language
|
||||
*/
|
||||
hasMw(lang: string): boolean {
|
||||
return this.mws.has(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL to the given article.
|
||||
*
|
||||
* @param link the link to return the URL of
|
||||
* @return the URL to the given article
|
||||
*/
|
||||
getArticlePath(link: InterlangLink): URL {
|
||||
const articlePath = this.iwMap.getUrl(link.lang);
|
||||
if (articlePath === undefined) throw Error(`Could not find article path for '${link}'.`);
|
||||
|
||||
return new URL(articlePath.replace("$1", link.title));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the `_iwMap` property with the entries in `MediaWiki` instances in this manager.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private updateIwMap(): void {
|
||||
this.iwMap = InterwikiMap.fromMap(
|
||||
[...this.mws.values()]
|
||||
.map(mw => mw.interwikiMap.map)
|
||||
.reduce((combined, map) => new Map([...combined, ...map]), new Map())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers the interlanguage network, starting from the given link.
|
||||
*
|
||||
* @param mwm the manager to use for caching and resolving pages
|
||||
* @param title the title of the page to start traversing at
|
||||
* @param errorCb a function handling errors and warnings
|
||||
* @param progressCb a function handling progress updates
|
||||
* @return the discovered network, including pages and redirects
|
||||
*/
|
||||
export const discoverNetwork = async function(
|
||||
mwm: MediaWikiManager,
|
||||
title: string,
|
||||
errorCb: (level: "error" | "warning" | null, message: string) => void,
|
||||
progressCb: (message: string) => void
|
||||
): Promise<{ pages: Page[], redirects: Redirect[] }> {
|
||||
const pages = [];
|
||||
const redirects = [];
|
||||
|
||||
const history: InterlangLink[] = [];
|
||||
const queue: InterlangLink[] = [new InterlangLink(mwm.baseLang, title)];
|
||||
while (queue.length > 0) {
|
||||
progressCb("Checking <code>" + queue[queue.length - 1] + "</code>");
|
||||
|
||||
let next = queue.pop()!;
|
||||
if (history.some(it => it.equals(next)))
|
||||
continue;
|
||||
|
||||
// Normalize
|
||||
const nextMw = await mwm.getMwOrWait(next.lang);
|
||||
if (nextMw === undefined) {
|
||||
history.push(next);
|
||||
pages.push(new Page(mwm.getArticlePath(next), next, [], false));
|
||||
if (history.length === 1)
|
||||
throw new Error(couldNotConnectMessage);
|
||||
else {
|
||||
errorCb("warning", `Could not connect to the wiki for language '${next.lang}'. Maybe the wiki no longer exists?`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
next = nextMw.normalize(next);
|
||||
if (history.some(it => it.equals(next)))
|
||||
continue;
|
||||
else
|
||||
history.push(next);
|
||||
|
||||
// Fetch interlang links
|
||||
const result = await nextMw.getLangLinks(next.title);
|
||||
if (result === undefined) {
|
||||
pages.push(new Page(mwm.getArticlePath(next), next, [], false));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Follow redirects
|
||||
if (!result.link.equals(next)) {
|
||||
redirects.push(...(result.redirects));
|
||||
next = result.link;
|
||||
if (history.some(it => it.equals(next)))
|
||||
continue;
|
||||
else
|
||||
history.push(next);
|
||||
}
|
||||
|
||||
// Create `Page` object
|
||||
pages.push(new Page(mwm.getArticlePath(next), next, result.langLinks, true));
|
||||
queue.push(...(result.langLinks));
|
||||
}
|
||||
|
||||
// Normalize links
|
||||
pages.forEach(page => {
|
||||
page.langLinks.map((langLink, idx, self) => {
|
||||
const mw = mwm.getMw(langLink.lang);
|
||||
// Update link in place using `self[idx] = `
|
||||
self[idx] = mw !== undefined ? mw.normalize(langLink) : langLink;
|
||||
});
|
||||
});
|
||||
|
||||
return {pages: pages, redirects: redirects};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* The verdict that the checker has of a link between two pages.
|
||||
*
|
||||
* The possible values are listed in decreasing order of importance, so that if a single link has multiple verdicts but
|
||||
* only one can be displayed, the one with the highest importance will be displayed.
|
||||
*/
|
||||
type LinkVerdict = "linked"
|
||||
| "self-linked"
|
||||
| "unlinked"
|
||||
| "self-unlinked"
|
||||
| "redirected"
|
||||
| "wrongly-cased";
|
||||
|
||||
/**
|
||||
* The verdict that the checker has of a page.
|
||||
*
|
||||
* The possible values are listed in decreasing order of importance, so that if a single page has multiple verdicts but
|
||||
* only one can be displayed, the one with the highest importance will be displayed.
|
||||
*/
|
||||
type PageVerdict =
|
||||
"perfect"
|
||||
| "not-found"
|
||||
| "wrongly-ordered"
|
||||
| "doubly-linked"
|
||||
| "self-linked"
|
||||
| "unlinked"
|
||||
| "redirected"
|
||||
| "wrongly-cased";
|
||||
|
||||
/**
|
||||
* The verdict that the checker has of a network.
|
||||
*/
|
||||
type NetworkVerdict =
|
||||
| "perfect"
|
||||
| "flawed"
|
||||
| "broken";
|
|
@ -1,20 +0,0 @@
|
|||
/**
|
||||
* The message that is displayed when the application fails to connect to the API.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const couldNotConnectMessage =
|
||||
"Could not to connect to API. Is the URL correct? Are you using a script blocker? " +
|
||||
"See the <b>About</b> section for more information."
|
||||
|
||||
/**
|
||||
* Returns the status that has the lowest index in the given list of statuses.
|
||||
*
|
||||
* @param statuses {string[]} the statuses to look the given statuses up in
|
||||
* @param status1 {string} the first status
|
||||
* @param status2 {string} the second status
|
||||
* @returns {string} the status that has the lowest index in the given list of statuses
|
||||
*/
|
||||
export const mergeStates = (statuses, status1, status2) => {
|
||||
return statuses[Math.min(statuses.indexOf(status1), statuses.indexOf(status2))];
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* The message that is displayed when the application fails to connect to the API.
|
||||
*/
|
||||
export const couldNotConnectMessage: string =
|
||||
"Could not to connect to API. Is the URL correct? Are you using a script blocker? " +
|
||||
"See the <b>About</b> section for more information.";
|
||||
|
||||
/**
|
||||
* Returns the status that has the lowest index in the given list of statuses.
|
||||
*
|
||||
* @param statuses the statuses to look the given statuses up in
|
||||
* @param status1 the first status
|
||||
* @param status2 the second status
|
||||
* @return the status that has the lowest index in the given list of statuses
|
||||
*/
|
||||
export const mergeStates = <T>(statuses: T[], status1: T, status2: T): T => {
|
||||
return statuses[Math.min(statuses.indexOf(status1), statuses.indexOf(status2))];
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"strict": true,
|
||||
"rootDir": "./src/main/js/",
|
||||
"outDir": "./dist/js/"
|
||||
},
|
||||
"include": [
|
||||
"src/main/js/**/*.ts"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue