Use Webpack for development
This commit is contained in:
parent
5b87e9f8c1
commit
bfaa542fa1
|
@ -0,0 +1 @@
|
|||
package-lock.json binary
|
|
@ -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
|
|
@ -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"]);
|
||||
};
|
999
index.html
999
index.html
|
@ -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 = " ";
|
||||
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 = " ";
|
||||
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>
|
Binary file not shown.
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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 = " ";
|
||||
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 = " ";
|
||||
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());
|
||||
});
|
Loading…
Reference in New Issue