Use Webpack for development

This commit is contained in:
Florine W. Dekker 2020-04-11 11:41:44 +02:00
parent 5b87e9f8c1
commit bfaa542fa1
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
9 changed files with 1226 additions and 999 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
package-lock.json binary

104
.gitignore vendored Normal file
View File

@ -0,0 +1,104 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port

97
Gruntfile.js Normal file
View File

@ -0,0 +1,97 @@
const path = require('path');
module.exports = grunt => {
grunt.initConfig({
pkg: grunt.file.readJSON("package.json"),
clean: {
default: ["build/"]
},
copy: {
html: {
files: [{expand: true, cwd: "src/main/", src: "**/*.html", dest: "build/"}]
},
css: {
files: [{expand: true, cwd: "src/main/", src: "**/*.css", dest: "build/"}]
}
},
replace: {
dev: {
src: ["./build/*.html", "./build/*.js"],
replacements: [
{
from: "%%VERSION_NUMBER%%",
to: "<%= pkg.version %>+" + new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")
}
],
overwrite: true
},
deploy: {
src: ["./build/*.html", "./build/*.js"],
replacements: [
{
from: "%%VERSION_NUMBER%%",
to: "<%= pkg.version %>"
}
],
overwrite: true
}
},
webpack: {
options: {
entry: "./src/main/js/main.js",
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".js"],
},
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "build/"),
}
},
dev: {
mode: "development",
devtool: "inline-source-map"
},
deploy: {
mode: "production"
}
}
});
grunt.loadNpmTasks("grunt-contrib-clean");
grunt.loadNpmTasks("grunt-contrib-copy");
grunt.loadNpmTasks("grunt-text-replace");
grunt.loadNpmTasks("grunt-webpack");
grunt.registerTask("dev", [
// Pre
"clean",
// Copy files
"copy:html",
"copy:css",
// Compile
"webpack:dev",
// Post
"replace:dev"
]);
grunt.registerTask("deploy", [
// Pre
"clean",
// Copy files
"copy:html",
"copy:css",
// Compile JS
"webpack:deploy",
// Post
"replace:deploy"
]);
grunt.registerTask("default", ["dev"]);
};

View File

@ -1,999 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="author" content="Felix W. Dekker" />
<meta name="application-name" content="Interlanguage Checker" />
<meta name="description" content="Check the consistency of MediaWiki interlanguage links in a simple overview." />
<meta name="theme-color" content="#0033cc" />
<title>Interlanguage Checker | FWDekker</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic"
crossorigin="anonymous" />
<link rel="stylesheet" href="https://static.fwdekker.com/css/milligram-bundle.min.css" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fork-awesome/1.1.7/css/fork-awesome.min.css"
integrity="sha256-gsmEoJAws/Kd3CjuOQzLie5Q3yshhvmo7YNtBG7aaEY=" crossorigin="anonymous" />
<!--suppress CssUnresolvedCustomProperty, CssUnusedSymbol -->
<style>
:root {
--error-color: red;
--success-color: green;
}
/***
* Table
**/
#networkTableForm {
/* Center table */
width: 100%;
}
#networkTable {
/* Center table */
margin: 0 auto;
/* Make table as small as possible */
width: auto;
table-layout: fixed;
}
/* Text alignment */
#networkTable th.sourceLabel, #networkTable td.sourceLabel {
text-align: right;
}
#networkTable th:not(.sourceLabel), #networkTable td:not(.sourceLabel) {
text-align: center;
}
/* Borders */
#networkTable th, #networkTable td {
border-right: 1px solid var(--kpxc-table-border-color);
}
#networkTable tr:last-child {
border-bottom: none;
}
#networkTable th:first-child, #networkTable td:first-child,
#networkTable th:last-child, #networkTable td:last-child {
/* Undo Milligram padding because it looks bad with column borders */
padding-left: 1.5rem;
padding-right: 1.5rem;
}
/* Colors */
#networkTable tbody tr:nth-child(odd) {
background-color: var(--kpxc-table-hover-color);
}
#networkTable td.correct {
color: var(--success-color);
}
#networkTable td.incorrect {
color: var(--error-color);
}
#networkTable th a i {
font-size: 0.9em;
font-weight: normal;
}
/***
* Loading icon
*
* From https://loading.io/css/.
**/
.lds-dual-ring {
display: inline-block;
width: 1em;
height: 1em;
}
.lds-dual-ring:after {
content: " ";
display: block;
width: 1em;
height: 1em;
border-radius: 50%;
border: 1px solid;
border-color: #000 transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/***
* Messages, errors, etc.
**/
#messages {
width: 100%;
text-align: center;
}
.messageInner {
display: inline-block;
}
#errorMessage {
color: var(--error-color);
}
input[data-entered=true]:invalid {
border-color: var(--error-color);
color: var(--error-color);
}
.redLink a {
color: #ba0000;
}
</style>
</head>
<body>
<main class="wrapper">
<!-- Header -->
<header class="header">
<section class="container">
<h1>Interlanguage Checker</h1>
<noscript>
<span style="color: red; font-weight: bold;">
This website does not function if JavaScript is disabled.
Please check the <a href="https://www.enable-javascript.com/">
instructions on how to enable JavaScript in your web browser</a>.
</span>
</noscript>
<blockquote>
<p><em>Check the consistency of MediaWiki interlanguage links in a simple overview.</em></p>
</blockquote>
</section>
</header>
<!-- Content -->
<section class="container">
<!-- Input -->
<div class="row">
<div class="column">
<form>
<label for="url">URL</label>
<input id="url" type="url" value="https://fallout.fandom.com/api.php" />
<label for="page">Page</label>
<input id="page" type="text" autofocus />
<br />
<button id="check" type="button">Check</button>
</form>
</div>
</div>
</section>
<!-- Output -->
<section> <!-- No `container` class to allow use of whole page -->
<hr />
<div id="messages"></div>
<form id="networkTableForm">
<table id="networkTable"></table>
</form>
<hr />
</section>
<!-- Footer -->
<footer class="footer">
<section class="container">
Made by <a href="https://fwdekker.com/">Felix W. Dekker</a>.
Licensed under the
<a href="https://git.fwdekker.com/FWDekker/interlanguage-checker/src/branch/master/LICENSE">MIT License</a>.
Source code available on <a href="https://git.fwdekker.com/FWDekker/interlanguage-checker/">git</a>.
<div style="float: right;">v1.2.2</div>
</section>
</footer>
</main>
<!-- Scripts -->
<script src="https://static.fwdekker.com/js/common.js" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fetch-jsonp/1.1.3/fetch-jsonp.min.js"
integrity="sha256-1ar8IuE0nRpUw1CRhDsyndspfpqMu5tQTPRaKA6Rk+E=" crossorigin="anonymous"></script>
<script>
"use strict";
/**
* 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());
});
</script>
</body>
</html>

BIN
package-lock.json generated Normal file

Binary file not shown.

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "interlanguage-checker",
"version": "1.2.3",
"description": "Check the consistency of MediaWiki interlanguage links in a simple overview.",
"author": "Felix W. Dekker",
"browser": "bundle.js",
"repository": {
"type": "git",
"url": "git@git.fwdekker.com:FWDekker/interlanguage-checker.git"
},
"private": true,
"scripts": {
"clean": "grunt clean",
"dev": "grunt dev",
"deploy": "grunt deploy"
},
"dependencies": {
"fetch-jsonp": "^1.1.3"
},
"devDependencies": {
"grunt": "^1.1.0",
"grunt-cli": "^1.3.2",
"grunt-contrib-clean": "^2.0.0",
"grunt-contrib-copy": "^1.0.0",
"grunt-text-replace": "^0.4.0",
"grunt-webpack": "^3.1.3",
"webpack": "^4.42.1",
"webpack-cli": "^3.3.11"
}
}

126
src/main/css/main.css Normal file
View File

@ -0,0 +1,126 @@
:root {
--error-color: red;
--success-color: green;
}
/***
* Table
**/
#networkTableForm {
/* Center table */
width: 100%;
}
#networkTable {
/* Center table */
margin: 0 auto;
/* Make table as small as possible */
width: auto;
table-layout: fixed;
}
/* Text alignment */
#networkTable th.sourceLabel, #networkTable td.sourceLabel {
text-align: right;
}
#networkTable th:not(.sourceLabel), #networkTable td:not(.sourceLabel) {
text-align: center;
}
/* Borders */
#networkTable th, #networkTable td {
border-right: 1px solid var(--kpxc-table-border-color);
}
#networkTable tr:last-child {
border-bottom: none;
}
#networkTable th:first-child, #networkTable td:first-child,
#networkTable th:last-child, #networkTable td:last-child {
/* Undo Milligram padding because it looks bad with column borders */
padding-left: 1.5rem;
padding-right: 1.5rem;
}
/* Colors */
#networkTable tbody tr:nth-child(odd) {
background-color: var(--kpxc-table-hover-color);
}
#networkTable td.correct {
color: var(--success-color);
}
#networkTable td.incorrect {
color: var(--error-color);
}
#networkTable th a i {
font-size: 0.9em;
font-weight: normal;
}
/***
* Loading icon
*
* From https://loading.io/css/.
**/
.lds-dual-ring {
display: inline-block;
width: 1em;
height: 1em;
}
.lds-dual-ring:after {
content: " ";
display: block;
width: 1em;
height: 1em;
border-radius: 50%;
border: 1px solid;
border-color: #000 transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/***
* Messages, errors, etc.
**/
#messages {
width: 100%;
text-align: center;
}
.messageInner {
display: inline-block;
}
#errorMessage {
color: var(--error-color);
}
input[data-entered=true]:invalid {
border-color: var(--error-color);
color: var(--error-color);
}
.redLink a {
color: #ba0000;
}

88
src/main/index.html Normal file
View File

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="author" content="Felix W. Dekker" />
<meta name="application-name" content="Interlanguage Checker" />
<meta name="description" content="Check the consistency of MediaWiki interlanguage links in a simple overview." />
<meta name="theme-color" content="#0033cc" />
<title>Interlanguage Checker | FWDekker</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic"
crossorigin="anonymous" />
<link rel="stylesheet" href="https://static.fwdekker.com/css/milligram-bundle.min.css" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fork-awesome/1.1.7/css/fork-awesome.min.css"
integrity="sha256-gsmEoJAws/Kd3CjuOQzLie5Q3yshhvmo7YNtBG7aaEY=" crossorigin="anonymous" />
<link rel="stylesheet" href="css/main.css" />
</head>
<body>
<main class="wrapper">
<!-- Header -->
<header class="header">
<section class="container">
<h1>Interlanguage Checker</h1>
<noscript>
<span style="color: red; font-weight: bold;">
This website does not function if JavaScript is disabled.
Please check the <a href="https://www.enable-javascript.com/">
instructions on how to enable JavaScript in your web browser</a>.
</span>
</noscript>
<blockquote>
<p><em>Check the consistency of MediaWiki interlanguage links in a simple overview.</em></p>
</blockquote>
</section>
</header>
<!-- Content -->
<section class="container">
<!-- Input -->
<div class="row">
<div class="column">
<form>
<label for="url">URL</label>
<input id="url" type="url" value="https://fallout.fandom.com/api.php" />
<label for="page">Page</label>
<input id="page" type="text" autofocus />
<br />
<button id="check" type="button">Check</button>
</form>
</div>
</div>
</section>
<!-- Output -->
<section> <!-- No `container` class to allow use of whole page -->
<hr />
<div id="messages"></div>
<form id="networkTableForm">
<table id="networkTable"></table>
</form>
<hr />
</section>
<!-- Footer -->
<footer class="footer">
<section class="container">
Made by <a href="https://fwdekker.com/">Felix W. Dekker</a>.
Licensed under the
<a href="https://git.fwdekker.com/FWDekker/interlanguage-checker/src/branch/master/LICENSE">MIT License</a>.
Source code available on <a href="https://git.fwdekker.com/FWDekker/interlanguage-checker/">git</a>.
<div style="float: right;">v%%VERSION_NUMBER%%</div>
</section>
</footer>
</main>
<!-- Scripts -->
<script src="https://static.fwdekker.com/js/common.js" crossorigin="anonymous"></script>
<script src="bundle.js"></script>
</body>
</html>

780
src/main/js/main.js Normal file
View File

@ -0,0 +1,780 @@
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());
});