interlanguage-checker/src/main/js/main.js

781 lines
24 KiB
JavaScript

import fetchJsonp from "fetch-jsonp";
/**
* A data class for combining a language and page title to identify a page.
*
* @property lang {string} the language of the wiki this page is of
* @property title {string} the title of the page
*/
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;
}
/**
* Converts this `InterlangLink` to a string.
*
* @returns {string} the string representation of this `InterlangLink`
*/
toString() {
return `${this.lang}:${this.title}`;
}
/**
* Converts a string obtained from the `#toString` method back into an `InterlangLink`.
*
* @param string {string} the string to convert to an `InterlangLink`
* @returns {InterlangLink} the `InterlangLink` obtained from the given string
*/
static fromString(string) {
const parts = string.split(/:(.+)/);
return new InterlangLink(parts[0], parts[1]);
}
}
/**
* A map of interwiki links.
*
* @property map {Object.<string, string>} maps interwiki abbreviations to URLs
*/
class InterwikiMap {
/**
* Constructs a new interwiki map.
*
* @param map {Object.<string, string>} the mapping from interwiki abbreviations to URLs to store in this map
*/
constructor(map) {
this.map = Object.assign({}, map);
}
/**
* Returns the URL for the given abbreviation, or `undefined` if the abbreviation could not be found.
*
* @param abbr {string} the abbreviation to return the URL of
* @returns {string} the URL for the given abbreviation, or `undefined` if the abbreviation could not be found
*/
getUrl(abbr) {
return this.map[abbr];
}
/**
* Returns `true` if and only if this map has a URL for the given abbreviation.
*
* @param abbr {string} the abbreviation to check for
* @returns {boolean} `true` if and only if this map has a URL for the given abbreviation
*/
hasUrl(abbr) {
return this.map[abbr] !== undefined;
}
/**
* Returns a new `InterwikiMap` with all entries from both this map and the given map.
*
* @param other {InterwikiMap} the map to merge with
* @return a new `InterwikiMap` with all entries from both this map and the given map
*/
mergeWith(other) {
const conflicts = Object.keys(this.map)
.filter(key => other.map.hasOwnProperty(key))
.filter(key => this.map[key] !== other.map[key]);
if (conflicts.length !== 0)
console.warn("iw map merge conflict(s)", conflicts);
return new InterwikiMap(Object.assign({}, this.map, other.map));
}
}
/**
* 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 `/`
*/
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;
}
/**
* 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) {
console.debug(`Requesting from ${this.origin}${this.apiPath} with params`, params);
return fetchJsonp(this.origin + this.apiPath + "?format=json&" + new URLSearchParams(params).toString())
.then(it => it.json())
.catch(() => {
throw new Error("Could not to connect to API. Is the URL correct?")
});
}
/**
* Requests all language links on the given pages.
*
* @param pages {string[]} an array of pages to return links of
* @param [limit] {number|"max"} the maximum number of links to returns over all pages, between 1 and 5000
* (inclusive); or "max" for the maximum
* @return {Promise<Object.<string, InterlangLink[]|undefined>>} the language links per page, which are
* `undefined` if the page could not be found
*/
getLangLinks(pages, limit) {
if (limit === undefined) limit = "max";
return this
.request({action: "query", prop: "langlinks", titles: pages.join("|"), lllimit: limit})
.then(response => response.query.pages)
.then(pages =>
Object.keys(pages).reduce((links, key) => {
let langlinks = undefined;
if (key >= 0)
langlinks = (pages[key].langlinks || []).map(it => new InterlangLink(it.lang, it["*"]));
links[pages[key].title] = langlinks;
return links;
}, {})
);
}
/**
* Returns this wiki's general information.
*
* @return {Object} this wiki's general information
*/
getGeneralInfo() {
return this.request({action: "query", meta: "siteinfo", siprop: "general"})
.then(response => response.query.general);
}
/**
* Requests this wiki's interwiki map.
*
* @return {Promise<InterwikiMap>} this wiki's interwiki map
*/
getIwMap() {
return this
.request({action: "query", meta: "siteinfo", siprop: "interwikimap"})
.then(response =>
response.query.interwikimap.reduce((map, mapping) => {
map[mapping["prefix"]] = mapping["url"];
return map;
}, {})
)
.then(map => new InterwikiMap(map));
}
}
/**
* 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
*/
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) {
const general = await baseMw.getGeneralInfo();
this.articlePath = "" + general.articlepath;
this.apiPath = baseMw.apiPath;
this.baseLang = general.lang;
this.mws[general.lang] = baseMw;
await this._importIwMap(baseMw);
return this;
}
/**
* Returns the `MediaWiki` for the given language, creating it if necessary, or `undefined` if it 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 getMw(lang) {
if (this.hasMw(lang))
return this.mws[lang];
if (!this._iwMap.hasUrl(lang))
return undefined;
const url = this._iwMap.getUrl(lang);
this.mws[lang] = new MediaWiki(url.slice(0, -this.articlePath.length) + this.apiPath);
await this._importIwMap(this.mws[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;
}
/**
* Imports the interwiki map from the given wiki, fetching it from the server and merging it into this manager's
* main interwiki map.
*
* @param mw {MediaWiki} the `MediaWiki` to import the interwiki map of
*/
async _importIwMap(mw) {
this._iwMap = this._iwMap.mergeWith(await mw.getIwMap());
}
/**
* Returns the article paths of all `MediaWiki`s known to this manager.
*
* @returns {Object.<string, string>} the article paths of all languages known to this manager
*/
getArticlePaths() {
return Object.keys(this.mws).reduce((paths, lang) => {
paths[lang] = this._iwMap.getUrl(lang).slice(0, -2);
return paths;
}, {});
}
}
/**
* A network of interlanguage links.
*/
class InterlangNetwork {
/**
* Constructs a new `InterlangNetwork`.
*
* @param mappings {Object.<string, InterlangLink[]>} a mapping from source page to all pages it links to
* @param missing {InterlangLink[]} list of articles that do not exist
* @param articlePaths = {Object.<string, string>} the article paths of each language
*/
constructor(mappings, missing, articlePaths) {
this._mappings = mappings;
this._missing = missing;
this._articlePaths = articlePaths;
}
/**
* Returns an appropriate label for the given link.
*
* The label itself is an `a` `HTMLElement` linking to the editor for the given link.
* The text in the label is just the language of the link if the given link is the only link with that language
* in this network, or the entire link otherwise.
*
* @param linkStr {string} the link to generate a label of
* @return {HTMLElement} an appropriate label for the given link
*/
_generateLabel(linkStr) {
const link = InterlangLink.fromString(linkStr);
const url = this._articlePaths[link.lang] + link.title;
const span = document.createElement("span");
if (this._missing.some(it => it.equals(link)))
span.classList.add("redLink");
const label = document.createElement("a");
label.href = url;
label.target = "_blank";
label.title = linkStr;
label.innerText =
Object.keys(this._mappings).filter(it => link.lang === InterlangLink.fromString(it).lang).length > 1
? linkStr
: link.lang;
span.appendChild(label);
const padding = document.createElement("span");
padding.innerHTML = "&nbsp;";
span.appendChild(padding);
const editIconLink = document.createElement("a");
editIconLink.href = url + "?action=edit";
editIconLink.target = "_blank";
editIconLink.title = "Edit";
new FontIcon("pencil").appendTo(editIconLink);
span.appendChild(editIconLink);
span.appendChild(padding.cloneNode(true));
const copyIconLink = document.createElement("a");
copyIconLink.href = "#";
copyIconLink.title = "Copy";
const copyIcon = new FontIcon("clipboard").appendTo(copyIconLink);
copyIconLink.addEventListener("click", () => {
navigator.clipboard.writeText(`[[${linkStr}]]`);
copyIcon.changeTo("check", 1000);
});
span.appendChild(copyIconLink);
return span;
}
/**
* Generates the head of the table generated by `#toTable`.
*
* @return {HTMLElement} the head of the table generated by `#toTable`
*/
_generateTableHead() {
const head = document.createElement("thead");
const topRow = document.createElement("tr");
const srcHead = document.createElement("th");
srcHead.innerText = "Source";
srcHead.rowSpan = 2;
srcHead.classList.add("sourceLabel");
topRow.appendChild(srcHead);
const dstHead = document.createElement("th");
dstHead.innerText = "Destination";
dstHead.colSpan = Object.keys(this._mappings).length;
topRow.appendChild(dstHead);
head.appendChild(topRow);
const bottomRow = document.createElement("tr");
Object.keys(this._mappings).sort().forEach(key => {
const cell = document.createElement("th");
cell.appendChild(this._generateLabel(key));
bottomRow.appendChild(cell);
});
head.appendChild(bottomRow);
return head;
}
/**
* Generates the body of the table generated by `#toTable`.
*
* @return {HTMLElement} the body of the table generated by `#toTable`
*/
_generateTableBody() {
const body = document.createElement("tbody");
Object.keys(this._mappings).sort().forEach(srcKey => {
const row = document.createElement("tr");
const label = document.createElement("th");
label.appendChild(this._generateLabel(srcKey));
label.classList.add("sourceLabel");
row.appendChild(label);
Object.keys(this._mappings).sort().forEach(dstKey => {
const cell = document.createElement("td");
if (srcKey !== dstKey) {
const isPresent = this._mappings[srcKey].some(it => it.equals(InterlangLink.fromString(dstKey)));
cell.innerText = isPresent ? "✓" : "✗";
cell.classList.add(isPresent ? "correct" : "incorrect");
}
row.appendChild(cell);
});
body.appendChild(row);
});
return body;
}
/**
* Generates an HTML table describing the interlanguage network.
*
* @return {HTMLElement} the generated table
*/
toTable() {
const table = document.createElement("table");
table.appendChild(this._generateTableHead());
table.appendChild(this._generateTableBody());
return table;
}
/**
* 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 [progressCb] {function(Object[]): void} a function handling progress updates
* @returns {Promise<InterlangNetwork>} a network of interlanguage links
*/
static async discoverNetwork(mwm, title, progressCb) {
const mappings = {};
const missing = [];
const history = [];
const queue = [new InterlangLink(mwm.baseLang, title)];
while (queue.length > 0) {
const next = queue.pop();
if (history.some(it => it.equals(next)))
continue;
progressCb("Checking", next);
history.push(next);
const nextMw = await mwm.getMw(next.lang);
await nextMw
.getLangLinks([next.title])
.then(langlinks => langlinks[next.title])
.then(langlinks => {
mappings[next.toString()] = [];
if (langlinks === undefined) {
missing.push(next);
return;
}
if (langlinks.length === 0)
return;
mappings[next.toString()] = langlinks;
queue.push(...langlinks);
});
}
return new InterlangNetwork(mappings, missing, mwm.getArticlePaths());
}
}
/**
* Interacts with the DOM to delegate messages to the user.
*/
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
* @param [id] {string} the id of the div containing the message
*/
constructor(parent, id) {
this._mainDiv = document.createElement("div");
this._mainDiv.style.display = "none";
this._mainDiv.classList.add("messageInner");
parent.appendChild(this._mainDiv);
this._loadingIcon = document.createElement("div");
this._loadingIcon.classList.add("lds-dual-ring");
this._mainDiv.appendChild(this._loadingIcon);
this._spacing = document.createElement("span");
this._spacing.innerHTML = "&nbsp;";
this._mainDiv.appendChild(this._spacing);
this._textSpan = document.createElement("span");
if (id !== undefined)
this._textSpan.id = id;
this._mainDiv.appendChild(this._textSpan);
this._callback = undefined;
}
/**
* Handles the displaying of the given messages.
*
* If no messages are 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 messages {...*} the messages to display, or no messages if the current message should be hidden
* @returns {MessageHandler} this `MessageHandler`
*/
handle(...messages) {
if (messages.length === 0) {
this._mainDiv.style.display = "none";
return this;
}
if (this._callback !== undefined)
this._callback(...messages);
this._textSpan.innerText = messages.join(" ");
this._mainDiv.style.display = "initial";
return this;
}
/**
* Calls `#handle` without any arguments.
*
* @returns {MessageHandler} this `MessageHandler`
*/
clear() {
return this.handle();
}
/**
* Sets the callback to be executed whenever messages are handler by this handler.
*
* @param callback {function(*[]): *} the function to execute whenever messages are handled
* @returns {MessageHandler} this `MessageHandler`
*/
setCallback(callback) {
this._callback = callback;
return this;
}
/**
* 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`
*/
toggleLoadingIcon(state) {
this._loadingIcon.style.display = state ? undefined : "none";
this._spacing.style.display = state ? undefined : "none";
return this;
}
}
/**
* An input that can be validated.
*
* @property input {HTMLElement} the input that is validatable
*/
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;
}
/**
* Validates the input.
*
* @return 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("");
}
}
/**
* A font-based icon.
*/
class FontIcon {
/**
* Constructs a new `FontIcon`.
*
* @param name {string} the name of the icon
*/
constructor(name) {
this._node = document.createElement("i");
this._node.classList.add("fa", `fa-${name}`);
this._name = name;
}
/**
* Appends this icon to the given parent node.
*
* @param parent {HTMLElement} the node to append the icon to
* @return {FontIcon} this `FontIcon`
*/
appendTo(parent) {
parent.appendChild(this._node);
return this;
}
/**
* Temporarily changes this icon to the given name.
*
* @param name {string} the temporary icon to display
* @param time {number} the number of milliseconds to display the other icon
*/
changeTo(name, time) {
this._node.classList.remove(`fa-${this._name}`);
this._node.classList.add(`fa-${name}`);
setTimeout(() => {
this._node.classList.remove(`fa-${name}`);
this._node.classList.add(`fa-${this._name}`);
}, time);
}
}
// noinspection JSUnresolvedFunction
doAfterLoad(async () => {
const urlInput = new ValidatableInput($("#url"), (value) => {
if (value.trim() === "")
return "URL must not be empty.";
try {
new URL(value); // Throws exception if invalid
return "";
} catch (error) {
return error.message;
}
});
const pageInput = new ValidatableInput($("#page"), (value) => {
return value.trim() === "" ? "Page must not be empty" : "";
});
const checkButton = $("#check");
const messages = $("#messages");
const progressHandler = new MessageHandler(messages)
.setCallback(() => errorHandler.clear())
.toggleLoadingIcon(true);
const errorHandler = new MessageHandler(messages, "errorMessage")
.setCallback((...messages) => {
progressHandler.clear();
console.error(...messages);
})
.toggleLoadingIcon(false);
let previousUrl = undefined;
let mwm = undefined;
const submit = async () => {
// Clean up
urlInput.showBlank();
pageInput.showBlank();
progressHandler.clear();
errorHandler.clear();
const oldTable = $("#networkTable");
if (oldTable !== null)
oldTable.parentNode.removeChild(oldTable);
// Validate
const urlValidity = urlInput.validate();
if (urlValidity !== "") return errorHandler.handle(urlValidity);
const pageValidity = pageInput.validate();
if (pageValidity !== "") return errorHandler.handle(pageValidity);
// Initialize
if (urlInput.getValue() !== previousUrl) {
progressHandler.handle("Initializing", urlInput.getValue());
try {
const mw = new MediaWiki(urlInput.getValue());
mwm = await new MediaWikiManager().init(mw);
} catch (error) {
errorHandler.handle(error);
return;
}
previousUrl = urlInput.getValue();
}
// Discover
InterlangNetwork
.discoverNetwork(mwm, pageInput.getValue(), progressHandler.handle.bind(progressHandler))
.then(it => {
progressHandler.handle("Creating table");
const newTable = it.toTable();
newTable.id = "networkTable";
const form = $("#networkTableForm");
form.textContent = "";
form.appendChild(newTable);
progressHandler.handle();
})
.catch(error => errorHandler.handle(error));
};
urlInput.input.addEventListener("keypress", (event) => {
if (event.key.toLowerCase() === "enter") submit();
});
pageInput.input.addEventListener("keypress", (event) => {
if (event.key.toLowerCase() === "enter") submit();
});
checkButton.addEventListener("click", () => submit());
});