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