Upgrade template to v3

Fixes #46. Fixes #49. Fixes #51.
This commit is contained in:
Florine W. Dekker 2022-11-22 20:27:44 +01:00
parent abf499c9cf
commit cc39bf2535
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
9 changed files with 340 additions and 689 deletions

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{ {
"name": "interlanguage-checker", "name": "interlanguage-checker",
"version": "1.13.6", "version": "1.13.7",
"description": "Check the consistency of MediaWiki interlanguage links in a simple overview.", "description": "Check the consistency of MediaWiki interlanguage links in a simple overview.",
"author": "Florine W. Dekker", "author": "Florine W. Dekker",
"browser": "dist/bundle.js", "browser": "dist/bundle.js",
@ -16,17 +16,17 @@
"deploy": "grunt deploy" "deploy": "grunt deploy"
}, },
"devDependencies": { "devDependencies": {
"grunt": "^1.4.1", "grunt": "^1.5.3",
"grunt-cli": "^1.4.3", "grunt-cli": "^1.4.3",
"grunt-contrib-clean": "^2.0.0", "grunt-contrib-clean": "^2.0.1",
"grunt-contrib-copy": "^1.0.0", "grunt-contrib-copy": "^1.0.0",
"grunt-contrib-watch": "^1.1.0", "grunt-contrib-watch": "^1.1.0",
"grunt-focus": "^1.0.0", "grunt-focus": "^1.0.0",
"grunt-text-replace": "^0.4.0", "grunt-text-replace": "^0.4.0",
"grunt-webpack": "^5.0.0", "grunt-webpack": "^5.0.0",
"ts-loader": "^9.2.6", "ts-loader": "^9.4.1",
"typescript": "^4.5.5", "typescript": "^4.9.3",
"webpack": "^5.69.1", "webpack": "^5.75.0",
"webpack-cli": "^4.9.2" "webpack-cli": "^5.0.0"
} }
} }

View File

@ -20,12 +20,12 @@ summary {
/*** /***
* Table * Table
**/ **/
#networkTableForm { #network-table-form {
/* Center table */ /* Center table */
width: 100%; width: 100%;
} }
#networkTable { #network-table {
/* Center table */ /* Center table */
margin: 0 auto; margin: 0 auto;
@ -36,27 +36,32 @@ summary {
/* Text alignment */ /* Text alignment */
#networkTable th.sourceLabel, #networkTable td.sourceLabel { #network-table th.sourceLabel,
#network-table td.sourceLabel {
text-align: right; text-align: right;
} }
#networkTable th:not(.sourceLabel), #networkTable td:not(.sourceLabel) { #network-table th:not(.sourceLabel),
#network-table td:not(.sourceLabel) {
text-align: center; text-align: center;
} }
/* Borders */ /* Borders */
#networkTable th, #networkTable td { #network-table th,
#network-table td {
border-right: 1px solid var(--table-border-color); border-right: 1px solid var(--table-border-color);
border-bottom: 1px solid var(--table-border-color); border-bottom: 1px solid var(--table-border-color);
} }
#networkTable tr:last-child { #network-table tr:last-child {
border-bottom: none; border-bottom: none;
} }
#networkTable th:first-child, #networkTable td:first-child, #network-table th:first-child,
#networkTable th:last-child, #networkTable td:last-child { #network-table td:first-child,
#network-table th:last-child,
#network-table td:last-child {
/* Undo Milligram padding because it looks bad with column borders */ /* Undo Milligram padding because it looks bad with column borders */
padding-left: 1.5rem; padding-left: 1.5rem;
padding-right: 1.5rem; padding-right: 1.5rem;
@ -64,26 +69,31 @@ summary {
/* Table colors */ /* Table colors */
#networkTable tbody tr:nth-child(odd) { #network-table tbody tr:nth-child(odd) {
background-color: var(--table-row-color); background-color: var(--table-row-color);
} }
#networkTable th a i { #network-table th a i {
font-size: 0.9em; font-size: 0.9em;
font-weight: normal; font-weight: normal;
} }
#networkTable a { #network-table a {
cursor: pointer; cursor: pointer;
} }
#network-table a::after {
display: none;
}
/* Shared colors */ /* Shared colors */
.redLink a { .red-link a {
color: var(--fandom-redlink); color: var(--fandom-redlink);
} }
span.success, i.success { span.success,
i.success {
color: var(--success-color); color: var(--success-color);
} }
@ -92,7 +102,8 @@ div.success {
background-color: var(--success-bg-color); background-color: var(--success-bg-color);
} }
span.error, i.error { span.error,
i.error {
color: var(--error-color); color: var(--error-color);
} }
@ -101,7 +112,8 @@ div.error {
background-color: var(--error-bg-color); background-color: var(--error-bg-color);
} }
span.warning, i.warning { span.warning,
i.warning {
color: var(--warning-color); color: var(--warning-color);
} }
@ -110,7 +122,8 @@ div.warning {
background-color: var(--warning-bg-color); background-color: var(--warning-bg-color);
} }
span.info, i.info { span.info,
i.info {
color: var(--info-color); color: var(--info-color);
} }
@ -123,20 +136,23 @@ div.info {
/*** /***
* Messages, errors, etc. * Messages, errors, etc.
**/ **/
#errors, #messages { #errors,
#messages {
width: 100%; width: 100%;
text-align: center; text-align: center;
} }
.errorOuter, .messageOuter { .error-outer,
.message-outer {
display: inline-block; display: inline-block;
} }
.errorInner { .error-inner {
margin-bottom: 1em; margin-bottom: 1em;
} }
.errorInner, .messageInner { .error-inner,
.message-inner {
padding: 1em; padding: 1em;
border-width: 1px; border-width: 1px;

View File

@ -8,18 +8,25 @@
<meta name="description" content="Check the consistency of MediaWiki interlanguage links in a simple overview." /> <meta name="description" content="Check the consistency of MediaWiki interlanguage links in a simple overview." />
<meta name="theme-color" content="#0033cc" /> <meta name="theme-color" content="#0033cc" />
<meta name="fwd:auto:show-main" />
<meta name="fwd:nav:target" content="#nav" />
<meta name="fwd:nav:highlight-path" content="/Tools/Interlanguage Checker/" />
<meta name="fwd:footer:target" content="#footer" />
<meta name="fwd:footer:vcs-url" content="https://git.fwdekker.com/tools/interlanguage-checker/" />
<meta name="fwd:footer:version" content="v%%VERSION_NUMBER%%" />
<meta name="fwd:validation:load-forms" />
<title>Interlanguage Checker | FWDekker</title> <title>Interlanguage Checker | FWDekker</title>
<link rel="stylesheet" href="https://static.fwdekker.com/fonts/roboto/roboto.css" />
<link rel="stylesheet" href="https://static.fwdekker.com/fonts/fork-awesome/1.x.x/fork-awesome.css" /> <link rel="stylesheet" href="https://static.fwdekker.com/fonts/fork-awesome/1.x.x/fork-awesome.css" />
<link rel="stylesheet" href="https://static.fwdekker.com/lib/template/2.x.x/template.css" /> <link rel="stylesheet" href="https://static.fwdekker.com/lib/template/3.x.x/template.css?v=%%VERSION_NUMBER%%" />
<!--suppress HtmlUnknownTarget --> <!--suppress HtmlUnknownTarget -->
<link rel="stylesheet" href="main.css?v=%%VERSION_NUMBER%%" /> <link rel="stylesheet" href="main.css?v=%%VERSION_NUMBER%%" />
<script async src="https://stats.fwdekker.com/count.js" <script async src="https://stats.fwdekker.com/count.js"
data-goatcounter="https://stats.fwdekker.com/count"></script> data-goatcounter="https://stats.fwdekker.com/count"></script>
</head> </head>
<body> <body>
<noscript> <noscript class="fwd-js-notice">
<img src="https://stats.fwdekker.com/count?p=/tools/interlanguage-checker/" alt="Counting pixel" /> <img src="https://stats.fwdekker.com/count?p=/tools/interlanguage-checker/" alt="Counting pixel" />
<p> <p>
@ -28,92 +35,92 @@
instructions on how to enable JavaScript in your web browser</a>. instructions on how to enable JavaScript in your web browser</a>.
</p> </p>
</noscript> </noscript>
<nav id="nav"></nav>
<main class="hidden"> <main class="hidden">
<div id="nav"></div> <div role="document">
<div id="contents">
<div id="header"></div>
<!-- About -->
<section class="container"> <section class="container">
<div class="row"> <header class="fwd-header">
<div class="column"> <hgroup>
<details open id="about"> <h1><a href=".">Interlanguage Checker</a></h1>
<summary><b>About</b></summary> <h2>Check the consistency of MediaWiki interlanguage links in a simple overview.</h2>
<span> </hgroup>
<a href="https://community.fandom.com/wiki/Help:Interlanguage_link">&#9099; Interlanguage links</a> </header>
allow wikis to tell users where to find translations of articles.
Without the <details open id="about">
<a href="https://www.mediawiki.org/wiki/Extension:Interlanguage">&#9099; interlanguage extension</a>, <summary><b>About</b></summary>
each translation is responsible for maintaining its own outgoing links. <p>
As the number of translations grows, the network of links becomes more <b>complex</b>, and the <a href="https://community.fandom.com/wiki/Help:Interlanguage_link" target="_blank">
number of errors grows.<br /> Interlanguage links</a>
<br /> allow wikis to tell users where to find translations of articles.
The <b>Interlanguage Checker</b> traverses the network of interlanguage links starting from a Without the
given article and shows you that network in a table. <a href="https://www.mediawiki.org/wiki/Extension:Interlanguage" target="_blank">
If there are missing or incorrect links, you can quickly spot them and <b>fix</b> interlanguage extension</a>,
them.<br /> each translation is responsible for maintaining its own outgoing links.
<br /> As the number of translations grows, the network of links becomes more <b>complex</b>, and the
To use the tool, you should enter the link to the number of errors grows.
<a href="https://www.mediawiki.org/wiki/API:Main_page">&#9099; API of the wiki</a> you want to </p>
check. <p>
For <b>Wikimedia</b> wikis, this is <code>https://&lt;example.org&gt;/w/api.php</code>. The <b>Interlanguage Checker</b> traverses the network of interlanguage links starting from a
For <b>Fandom</b> wikis, this is <code>https://&lt;wiki&gt;.fandom.com/api.php</code>.<br /> given article and shows you that network in a table.
<br /> If there are missing or incorrect links, you can quickly spot them and <b>fix</b> them.
If the application <b>refuses to connect</b> to the API and you are certain the URL is correct, </p>
make sure that you allow scripts to be executed from the API you have entered by checking the <p>
configuration of your <b>tracking blockers</b>. To use the tool, you should enter the link to the
These <b>external scripts</b> are necessary to provide support to older wikis that rely on <a href="https://www.mediawiki.org/wiki/API:Main_page" target="_blank">API of the wiki</a> you want
<a href="https://en.wikipedia.org/wiki/JSONP">&#9099; JSONP requests</a> to interact with the to check.
API.<br /> For <b>Wikimedia</b> wikis, this is <code>https://&lt;example.org&gt;/w/api.php</code>.
<br /> For <b>Fandom</b> wikis, this is <code>https://&lt;wiki&gt;.fandom.com/api.php</code>.
If you need <b>help</b>, have a question, or found a bug, please </p>
<a href="https://git.fwdekker.com/FWDekker/interlanguage-checker/issues/new">&#9099; open an issue</a> <p>
or <a href="https://fallout.fandom.com/wiki/User_talk:FDekker">&#9099; leave a talk message</a>. If the application <b>refuses to connect</b> to the API and you are certain the URL is correct,
</span> make sure that you allow scripts to be executed from the API you have entered by checking the
</details> configuration of your <b>tracking blockers</b>.
<p></p> These <b>external scripts</b> are necessary to provide support to older wikis that rely on
</div> <a href="https://en.wikipedia.org/wiki/JSONP" target="_blank">JSONP requests</a> to interact with
</div> the API.
</p>
<p>
If you need <b>help</b>, have a question, or found a bug, please
<a href="https://git.fwdekker.com/FWDekker/interlanguage-checker/issues/new" target="_blank">
open an issue</a>
or
<a href="https://fallout.fandom.com/wiki/User_talk:FDekker" target="_blank">
leave a talk message</a>.
</p>
</details>
<form id="inputs">
<label for="url">API</label>
<input id="url" type="url" placeholder="https://fallout.fandom.com/api.php" autocomplete="url"
autofocus />
<small id="url-hint" data-hint-for="url" data-hint="The URL to the wiki's api.php."></small>
<label for="article">Article</label>
<input id="article" type="text" placeholder="Master" />
<small id="article-hint" data-hint-for="article" data-hint="The title of the article to check."></small>
<button id="submit">Check</button>
</form>
<hr />
<article class="status-card hidden" data-status-for="inputs">
<output></output>
</article>
</section> </section>
<!-- Input -->
<section class="container">
<div class="row">
<div class="column">
<form>
<label for="url">
API&nbsp;
<i class="fa fa-question-circle-o" title="The URL to the wiki's api.php"></i>
</label>
<input id="url" type="url" autofocus />
<label for="article">
Article&nbsp;
<i class="fa fa-question-circle-o" title="The title of the article to check"></i>
</label>
<input id="article" type="text" />
<br />
<button id="check" type="button">Check</button>
</form>
</div>
</div>
</section>
<!-- Output -->
<section> <!-- No `container` class, to allow use of whole width --> <section> <!-- No `container` class, to allow use of whole width -->
<hr /> <form id="network-table-form">
<div id="errors"></div> <table id="network-table"></table>
<div id="messages"></div>
<hr />
<form id="networkTableForm">
<table id="networkTable"></table>
</form> </form>
</section> </section>
<section class="container">
<footer id="footer"></footer>
</section>
</div> </div>
<div id="footer"></div>
</main> </main>
<script src="https://static.fwdekker.com/lib/template/2.x.x/template.js"></script> <script src="https://static.fwdekker.com/lib/template/3.x.x/template.js?v=%%VERSION_NUMBER%%"></script>
<!--suppress HtmlUnknownTarget --> <!--suppress HtmlUnknownTarget -->
<script src="bundle.js?v=%%VERSION_NUMBER%%"></script> <script src="bundle.js?v=%%VERSION_NUMBER%%"></script>
</body> </body>

View File

@ -1,471 +0,0 @@
// @ts-ignore
const {stringToHtml} = window.fwdekker;
import {InterlangNetwork, LinkVerdict, Page, PageVerdict} from "./MediaWiki";
/**
* An input that can be validated.
*/
export class ValidatableInput {
/**
* The validatable input.
*/
readonly input: HTMLInputElement;
/**
* Returns an empty string if the given input string is valid, and a string explaining why it is invalid otherwise.
*
* @private
*/
private readonly isValid: (input: string) => string;
/**
* Constructs a new validatable input.
*
* @param input the input that is validatable
* @param isValid returns an empty string if the given input string is valid, and a string explaining why it is is
* invalid otherwise
*/
constructor(input: HTMLInputElement, isValid: ((input: string) => string)) {
this.input = input;
this.isValid = isValid;
}
/**
* Returns the value of the underlying input element.
*
* @return the value of the underlying input element
*/
getValue(): string {
return this.input.value;
}
/**
* Sets the value of the underlying input element.
*
* @param value the value to set
*/
setValue(value: string): void {
this.input.value = value;
}
/**
* Validates the input.
*
* @return an empty string if the input string is valid, and a string explaining why it is is invalid otherwise
*/
validate(): string {
const validity = this.isValid(this.input.value);
if (validity.length === 0) this.showSuccess();
else this.showError();
return validity;
}
/**
* Marks the input as neither valid nor invalid.
*/
showBlank(): void {
this.input.dataset["entered"] = "false";
this.input.setCustomValidity("");
}
/**
* Marks the input as invalid and moves focus to it.
*/
showError(): void {
this.input.dataset["entered"] = "true";
this.input.setCustomValidity("Incorrect");
this.input.focus();
}
/**
* Marks the input as valid.
*/
showSuccess(): void {
this.input.dataset["entered"] = "true";
this.input.setCustomValidity("");
}
}
/**
* The types of error that can be displayed by an `ErrorHandler`.
*/
export type ErrorLevel = "warning" | "error" | null;
/**
* Interacts with the DOM to delegate errors to the user.
*/
export class ErrorHandler {
/**
* The outer div that wraps around all displayed errors.
*
* @private
*/
private readonly outerDiv: HTMLDivElement;
/**
* Constructs a new error handler, inserting relevant new elements into the DOM.
*
* @param parent the element to insert elements into
*/
constructor(parent: HTMLElement) {
this.outerDiv = document.createElement("div");
this.outerDiv.classList.add("errorOuter", "hidden");
parent.appendChild(this.outerDiv);
}
/**
* Handles the displaying of the given error.
*
* @param level the level of message to display, determines the style of the text
* @param message the message to display
* @return this `ErrorHandler`
*/
handle(level: ErrorLevel, message: string): ErrorHandler {
this.outerDiv.classList.remove("hidden");
const errorInner = document.createElement("div");
errorInner.classList.add("errorInner");
if (level !== null) errorInner.classList.add(level);
errorInner.innerText = message;
this.outerDiv.appendChild(errorInner);
return this;
}
/**
* Clears all errors from the DOM.
*
* @return this `ErrorHandler`
*/
clear(): ErrorHandler {
this.outerDiv.classList.add("hidden");
this.outerDiv.innerHTML = "";
return this;
}
}
/**
* The types of message that can be displayed by a `MessageHandler`.
*/
export type MessageLevel = "complete" | "progress" | "warning" | "error" | "neutral" | null;
/**
* Interacts with the DOM to delegate messages to the user.
*/
export class MessageHandler {
/**
* The outer div, wrapping around all of the handler's elements.
*
* @private
*/
private readonly outerDiv: HTMLDivElement;
/**
* The inner div, wrapping around the icon, spacing, and current message.
*
* @private
*/
private readonly innerDiv: HTMLDivElement;
/**
* A loading icon, optionally displayed before the current message.
*
* @private
*/
private readonly loadingIcon: HTMLElement;
/**
* Spacing between the loading icon and the current message.
*
* @private
*/
private readonly spacing: HTMLSpanElement;
/**
* The span containing the current message.
*
* @private
*/
private readonly textSpan: HTMLSpanElement;
/**
* The callback to be executed whenever a message is handler by this handler.
*
* @private
*/
private callback: ((level: MessageLevel, message: string) => void) | undefined;
/**
* The currently displayed message level.
*
* @private
*/
private currentLevel: MessageLevel | undefined;
/**
* Constructs a new `MessageHandler`, inserting relevant new elements into the DOM to interact with.
*
* @param parent the element to insert elements into
*/
constructor(parent: HTMLElement) {
this.outerDiv = document.createElement("div");
this.outerDiv.classList.add("messageOuter", "hidden");
parent.appendChild(this.outerDiv);
this.innerDiv = document.createElement("div");
this.innerDiv.classList.add("messageInner");
this.outerDiv.appendChild(this.innerDiv);
this.loadingIcon = document.createElement("i");
this.loadingIcon.classList.add("fa", "fa-spinner", "fa-spin");
this.innerDiv.appendChild(this.loadingIcon);
this.spacing = document.createElement("span");
this.spacing.innerHTML = "&nbsp;";
this.innerDiv.appendChild(this.spacing);
this.textSpan = document.createElement("span");
this.innerDiv.appendChild(this.textSpan);
this.callback = undefined;
this.currentLevel = undefined;
}
/**
* Handles the displaying of the given message.
*
* If no message is given, the current message and the loading icon are hidden. To display an empty message next to
* the loading icon, give an empty string.
*
* @param level the level of message to display, or `null` if the entire message handler should be hidden
* @param message the message to display
* @return this `MessageHandler`
*/
handle(level: MessageLevel, message: string): MessageHandler {
this.displayLevel(level);
if (level === undefined) return this; // No need to handle the rest
if (this.callback !== undefined) this.callback(level, message);
this.textSpan.innerHTML = message;
return this;
}
/**
* Clears the message handler's contents and hides it.
*
* @return this `MessageHandler`
*/
clear(): MessageHandler {
return this.handle(null, "");
}
/**
* Sets the callback to be executed whenever a message is handler by this handler.
*
* @param callback the function to execute whenever a message is handled
* @return this `MessageHandler`
*/
setCallback(callback: ((level: MessageLevel, message: string) => void)): MessageHandler {
this.callback = callback;
return this;
}
/**
* Changes the appearance of the message handler to that of the given level.
*
* @param level the level to change appearance to
* @return this `MessageHandler`
* @private
*/
private displayLevel(level: MessageLevel): MessageHandler {
if (level === this.currentLevel) return this;
this.currentLevel = level;
this.outerDiv.classList.remove("hidden");
this.innerDiv.classList.remove("success", "warning", "error");
switch (level) {
case "complete":
this.innerDiv.classList.add("success");
this.toggleLoadingIcon(false);
break;
case "progress":
this.toggleLoadingIcon(true);
break;
case "warning":
this.innerDiv.classList.add("warning");
this.toggleLoadingIcon(false);
break;
case "error":
this.innerDiv.classList.add("error");
this.toggleLoadingIcon(false);
break;
case "neutral":
this.toggleLoadingIcon(false);
break;
default:
this.outerDiv.classList.add("hidden");
break;
}
return this;
}
/**
* Turns the loading icon on or off.
*
* @param state `true` if and only if the loading icon should be on
* @return this `MessageHandler`
* @private
*/
private toggleLoadingIcon(state: boolean): MessageHandler {
if (state) {
this.loadingIcon.classList.remove("hidden");
this.spacing.classList.remove("hidden");
} else {
this.loadingIcon.classList.add("hidden");
this.spacing.classList.add("hidden");
}
return this;
}
}
/**
* A network of interlanguage links.
*/
export class InterlangTable {
/**
* Generates an icon element with the given title and additional classes.
*
* @param icon the name of the icon to display, or `null` if an empty span should be returned
* @param title the title of the icon, used for the `title` attribute
* @param classes the additional classes to apply to the icon
* @return an icon element with the given title and additional classes
* @private
*/
private static createIcon(icon: string | null, title: string, classes: string[]): string {
if (icon === null) return `<span></span>`;
return `<i class="fa fa-${icon} ${(classes || []).join(" ")}" title="${title}"></i>`;
}
/**
* Returns an appropriate label for the given page.
*
* The label contains a link to the page and a few buttons to help the user interact with that page. The label's
* appearance and contents depend both on the properties of the page (e.g. whether it exists and whether it's a
* redirect page) and on the other pages in this network (e.g. whether it's the only page in its language).
*
* @param pages a list of all pages
* @param page the page to generate a label of
* @return an appropriate label with icons for the given page
* @private
*/
private generateLabel(pages: Page[], page: Page): string {
const labelText = pages.some(it => it.link.lang === page.link.lang && !it.link.equals(page.link))
? page.link.toString()
: page.link.lang;
return "" +
`<span class="${page.exists ? "" : "redLink"}">` +
/**/`<a href="${page.url}" target="_blank" title="${page.link}">${labelText}</a>` +
/**/`<span> </span>` +
/**/`<a href="${page.url}?action=edit" target="_blank" title="Edit"><i class="fa fa-pencil"></i></a>` +
/**/`<span> </span>` +
/**/`<a title="Copy"><i class="fa fa-clipboard copyIcon" data-clipboarddata="${page.link}"></i></a>` +
`</span>`;
}
/**
* Generates the head of the table generated by `#toTable`.
*
* @param network the network to generate the head for
* @return the head of the table generated by `#toTable`
* @private
*/
private generateTableHead(network: InterlangNetwork): string {
return "" +
`<thead>` +
/**/`<tr>` +
/****/`<th rowspan="2"></th>` +
/****/`<th class="sourceLabel" rowspan="2">Source</th>` +
/****/`<th colspan="${network.pages.length}">Destination</th>` +
/**/`</tr>` +
/**/`<tr>${network.pages.map(page => `<th>${this.generateLabel(network.pages, page)}</th>`)}</tr>` +
`</thead>`;
}
/**
* Generates the body of the table generated by `#toTable`.
*
* @param network the network to generate the body for
* @return the body of the table generated by `#toTable`
* @private
*/
private generateTableBody(network: InterlangNetwork): string {
const rows = network.pages.map(srcPage => {
const {self: selfVerdict, links: linkVerdicts} = network.getPageVerdict(srcPage);
const icons = selfVerdict
.map(state => {
const props = PageVerdict.props[state];
return InterlangTable.createIcon(props.icon, props.message, props.style);
})
.map(it => `${it}<span> </span>`);
const label = this.generateLabel(network.pages, srcPage);
const cells = network.pages.map(dstPage => {
const linkState = linkVerdicts.get(dstPage.link)!;
const props = LinkVerdict.props[linkState];
return InterlangTable.createIcon(props.icon, props.message, props.style);
});
return "" +
`<tr>` +
/**/`<th>${icons}</th>` +
/**/`<th class="sourceLabel">${label}</th>` +
/**/cells.map(it => `<td>${it}</td>`) +
`</tr>`;
});
return `<tbody>${rows}</tbody>`;
}
/**
* Renders the the table describing the interlanguage network.
*
* @param id the ID to assign to the table element
* @param network the network of pages to render
* @return the generated table
*/
render(id: string, network: InterlangNetwork): HTMLElement {
const table = stringToHtml(
`<table id="${id}">` +
/**/this.generateTableHead(network) +
/**/this.generateTableBody(network) +
`</table>`,
"table"
) as HTMLElement;
// Add event handlers
table.querySelectorAll(".copyIcon").forEach(icon => {
if (!(icon instanceof HTMLElement)) return;
icon.addEventListener("click", () => {
// noinspection JSIgnoredPromiseFromCall
navigator.clipboard.writeText(`[[${icon.dataset.clipboarddata}]]`);
icon.classList.replace("fa-clipboard", "fa-check");
setTimeout(() => icon.classList.replace("fa-check", "fa-clipboard"), 1000);
});
});
return table;
}
}

View File

@ -0,0 +1,136 @@
const {stringToHtml} = (window as any).fwdekker;
import {InterlangNetwork, LinkVerdict, Page, PageVerdict} from "./MediaWiki";
/**
* A network of interlanguage links.
*/
export class InterlangTable {
/**
* Generates an icon element with the given title and additional classes.
*
* @param icon the name of the icon to display, or `null` if an empty span should be returned
* @param title the title of the icon, used for the `title` attribute
* @param classes the additional classes to apply to the icon
* @return an icon element with the given title and additional classes
* @private
*/
private static createIcon(icon: string | null, title: string, classes: string[]): string {
if (icon === null) return `<span></span>`;
return `<i class="fa fa-${icon} ${(classes || []).join(" ")}" title="${title}"></i>`;
}
/**
* Returns an appropriate label for the given page.
*
* The label contains a link to the page and a few buttons to help the user interact with that page. The label's
* appearance and contents depend both on the properties of the page (e.g. whether it exists and whether it's a
* redirect page) and on the other pages in this network (e.g. whether it's the only page in its language).
*
* @param pages a list of all pages
* @param page the page to generate a label of
* @return an appropriate label with icons for the given page
* @private
*/
private generateLabel(pages: Page[], page: Page): string {
const labelText = pages.some(it => it.link.lang === page.link.lang && !it.link.equals(page.link))
? page.link.toString()
: page.link.lang;
return "" +
`<span class="${page.exists ? "" : "red-link"}">` +
/**/`<a href="${page.url}" target="_blank" title="${page.link}">${labelText}</a>` +
/**/`<span> </span>` +
/**/`<a href="${page.url}?action=edit" target="_blank" title="Edit"><i class="fa fa-pencil"></i></a>` +
/**/`<span> </span>` +
/**/`<a title="Copy"><i class="fa fa-clipboard copy-icon" data-clipboarddata="${page.link}"></i></a>` +
`</span>`;
}
/**
* Generates the head of the table generated by `#toTable`.
*
* @param network the network to generate the head for
* @return the head of the table generated by `#toTable`
* @private
*/
private generateTableHead(network: InterlangNetwork): string {
return "" +
`<thead>` +
/**/`<tr>` +
/****/`<th rowspan="2"></th>` +
/****/`<th class="source-label" rowspan="2">Source</th>` +
/****/`<th colspan="${network.pages.length}">Destination</th>` +
/**/`</tr>` +
/**/`<tr>${network.pages.map(page => `<th>${this.generateLabel(network.pages, page)}</th>`)}</tr>` +
`</thead>`;
}
/**
* Generates the body of the table generated by `#toTable`.
*
* @param network the network to generate the body for
* @return the body of the table generated by `#toTable`
* @private
*/
private generateTableBody(network: InterlangNetwork): string {
const rows = network.pages.map(srcPage => {
const {self: selfVerdict, links: linkVerdicts} = network.getPageVerdict(srcPage);
const icons = selfVerdict
.map(state => {
const props = PageVerdict.props[state];
return InterlangTable.createIcon(props.icon, props.message, props.style);
})
.map(it => `${it}<span> </span>`);
const label = this.generateLabel(network.pages, srcPage);
const cells = network.pages.map(dstPage => {
const linkState = linkVerdicts.get(dstPage.link)!;
const props = LinkVerdict.props[linkState];
return InterlangTable.createIcon(props.icon, props.message, props.style);
});
return "" +
`<tr>` +
/**/`<th>${icons}</th>` +
/**/`<th class="source-label">${label}</th>` +
/**/cells.map(it => `<td>${it}</td>`) +
`</tr>`;
});
return `<tbody>${rows}</tbody>`;
}
/**
* Renders the table describing the interlanguage network.
*
* @param id the ID to assign to the table element
* @param network the network of pages to render
* @return the generated table
*/
render(id: string, network: InterlangNetwork): HTMLElement {
const table = stringToHtml(
`<table id="${id}">` +
/**/this.generateTableHead(network) +
/**/this.generateTableBody(network) +
`</table>`,
"table"
) as HTMLElement;
// Add event handlers
table.querySelectorAll(".copy-icon").forEach(icon => {
if (!(icon instanceof HTMLElement)) return;
icon.addEventListener("click", () => {
// noinspection JSIgnoredPromiseFromCall
navigator.clipboard.writeText(`[[${icon.dataset.clipboarddata}]]`);
icon.classList.replace("fa-clipboard", "fa-check");
setTimeout(() => icon.classList.replace("fa-check", "fa-clipboard"), 1000);
});
});
return table;
}
}

View File

@ -1,28 +1,16 @@
// @ts-ignore const {$, doAfterLoad} = (window as any).fwdekker;
const {$, doAfterLoad, footer, header, nav} = window.fwdekker; const {
import {ErrorHandler, InterlangTable, MessageHandler, ValidatableInput} from "./DOM"; clearFormValidity, showInputInvalid, showMessageBusy, showMessageError, showMessageInfo, showMessageType
} = (window as any).fwdekker.validation;
import {InterlangTable} from "./InterlangTable";
import {discoverNetwork, InterlangNetwork, MediaWiki, MediaWikiManager, NetworkVerdict} from "./MediaWiki"; import {discoverNetwork, InterlangNetwork, MediaWiki, MediaWikiManager, NetworkVerdict} from "./MediaWiki";
// Contains global functions for debugging // Contains global functions for debugging
// @ts-ignore (window as any).ilc = {};
window.ilc = {};
// Set up template
doAfterLoad(() => {
$("#nav").appendChild(nav("/Tools/Interlanguage Checker/"));
$("#header").appendChild(header({
title: "Interlanguage Checker",
description: "Check the consistency of MediaWiki interlanguage links in a simple overview"
}));
$("#footer").appendChild(footer({
vcsURL: "https://git.fwdekker.com/tools/interlanguage-checker/",
version: "v%%VERSION_NUMBER%%"
}));
$("main").classList.remove("hidden");
});
// Handle "About" toggle // Handle "About" toggle
doAfterLoad(() => { doAfterLoad(() => {
const about = $("#about"); const about = $("#about");
@ -31,138 +19,109 @@ doAfterLoad(() => {
about.addEventListener("toggle", () => localStorage.setItem(key, "" + !!about.open)); about.addEventListener("toggle", () => localStorage.setItem(key, "" + !!about.open));
const storedState = localStorage.getItem(key); const storedState = localStorage.getItem(key);
if (storedState === null) about.open = true; about.open = storedState === null || storedState === "true";
else about.open = storedState === "true";
}); });
// Handle input // Handle input
doAfterLoad(async () => { doAfterLoad(async () => {
const urlInput = new ValidatableInput($("#url"), (value) => { const form = $("#inputs");
if (value.trim() === "") const urlInput = $("#url");
return "API URL must not be empty."; const articleInput = $("#article");
try {
new URL(value); // Throws exception if invalid
return "";
} catch (error) {
try {
// noinspection HttpUrlsUsage
const url = `http://${value}`;
new URL(url); // Throws exception if invalid
$("#url").value = url;
return "";
} catch {
if (!(error instanceof Error))
throw Error(`Error while processing connection error:\n${error}`);
return error.message;
}
}
});
const articleInput = new ValidatableInput($("#article"), (value) => {
return value.trim() === "" ? "Article must not be empty" : "";
});
const checkButton = $("#check");
const errorHandler = new ErrorHandler($("#errors"));
const messageHandler = new MessageHandler($("#messages"))
.setCallback((level, message) => {
if (level === "error") console.error(message);
});
// Form submission
let previousUrl: string | undefined; let previousUrl: string | undefined;
let mwm: MediaWikiManager | undefined; let mwm: MediaWikiManager | undefined;
const submit = async () => { const submit = async () => {
localStorage.setItem("/tools/interlanguage-checker//api-url", urlInput.getValue()); localStorage.setItem("/tools/interlanguage-checker//api-url", urlInput.value);
// Clean up // Clean up
urlInput.showBlank(); clearFormValidity(form);
articleInput.showBlank(); $("#network-table")?.remove();
errorHandler.clear();
messageHandler.clear();
const oldTable = $("#networkTable");
if (oldTable !== null)
oldTable.parentNode.removeChild(oldTable);
// Validate // Validate
const urlValidity = urlInput.validate(); const urlValue = urlInput.value;
if (urlValidity !== "") return messageHandler.handle("error", urlValidity); const articleValue = articleInput.value;
const articleValidity = articleInput.validate(); if (urlValue.trim() === "")
if (articleValidity !== "") return messageHandler.handle("error", articleValidity); return showInputInvalid(urlInput, "Enter the API URL.");
try {
new URL(urlValue); // Throws exception if invalid
} catch (error) {
try {
const url = `http://${urlValue}`;
new URL(url); // Throws exception if invalid
$("#url").value = url;
return "";
} catch {
return showInputInvalid(urlInput, (error as Error).message);
}
}
if (articleValue.trim() === "")
return showInputInvalid(articleInput, "Enter the name of the article to check.");
// Initialize // Initialize
if (urlInput.getValue() !== previousUrl) { if (urlValue !== previousUrl) {
messageHandler.handle("progress", `Initializing <code>${urlInput.getValue()}</code>`); showMessageBusy(form, `Initializing <code>${urlValue}</code>`);
try { try {
const mw = await new MediaWiki(urlInput.getValue()).init(); const mw = await new MediaWiki(urlValue).init();
mwm = await new MediaWikiManager().init(mw); mwm = await new MediaWikiManager().init(mw);
} catch (error) { } catch (error) {
if (!(error instanceof Error)) return showMessageError(form, (error as Error).message);
throw Error(`Error while processing initialization error:\n${error}`);
messageHandler.handle("error", error.message);
return;
} }
previousUrl = urlInput.getValue(); previousUrl = urlValue;
} }
// Discover // Discover
discoverNetwork( discoverNetwork(
mwm!, mwm!,
articleInput.getValue(), articleInput.value,
(level, message) => errorHandler.handle(level, message), (type, message) => showMessageType(form, message, type),
it => messageHandler.handle("progress", it) it => showMessageType(form, it, "busy")
) )
.then(it => new InterlangNetwork(it.pages, it.redirects)) .then(it => new InterlangNetwork(it.pages, it.redirects))
.then(network => { .then(network => {
messageHandler.handle("progress", "Creating table"); showMessageType(form, "Creating table", "busy");
const form = $("#networkTableForm"); const tableForm = $("#network-table-form");
form.textContent = ""; tableForm.textContent = "";
form.appendChild((new InterlangTable()).render("networkTable", network)); tableForm.appendChild((new InterlangTable()).render("network-table", network));
const props = NetworkVerdict.props[network.getNetworkVerdict()]; const props = NetworkVerdict.props[network.getNetworkVerdict()];
messageHandler.handle(props.style, props.message); showMessageType(form, props.message, props.type);
}) })
.catch(error => messageHandler.handle("error", error)); .catch(error => showMessageError(form, error));
}; };
form.addEventListener("submit", (event: SubmitEvent) => {
urlInput.input.addEventListener("keypress", (event) => { event.preventDefault();
if (event.key.toLowerCase() === "enter") submit(); submit();
}); });
articleInput.input.addEventListener("keypress", (event) => {
if (event.key.toLowerCase() === "enter") submit();
});
checkButton.addEventListener("click", () => submit());
urlInput.input.focus() // Default focus urlInput.focus(); // Default focus
// Read inputs from cookies // Read inputs from cookies
const apiUrl = localStorage.getItem("/tools/interlanguage-checker//api-url"); const apiUrl = localStorage.getItem("/tools/interlanguage-checker//api-url");
if (apiUrl !== null && apiUrl.trim() !== "") { if (apiUrl !== null && apiUrl.trim() !== "") {
urlInput.setValue(apiUrl); urlInput.value = apiUrl;
articleInput.input.focus(); articleInput.focus();
} }
// Read inputs from URL // Read inputs from URL
const currentParams = new URLSearchParams(window.location.search); const currentParams = new URLSearchParams(window.location.search);
if (currentParams.has("api")) { if (currentParams.has("api")) {
urlInput.setValue(currentParams.get("api")!); urlInput.value = currentParams.get("api")!;
articleInput.input.focus(); articleInput.focus();
} }
if (currentParams.has("article")) articleInput.setValue(currentParams.get("article")!); if (currentParams.has("article")) articleInput.value = currentParams.get("article")!;
if (currentParams.has("api") && currentParams.has("article")) await submit(); if (currentParams.has("api") && currentParams.has("article")) await submit();
// Set global debug function // Set global debug function
// @ts-ignore (window as any).ilc.getCurrentInputAsUrl = () =>
window.ilc.getCurrentInputAsUrl = () =>
location.href.split("?")[0] + location.href.split("?")[0] +
`?api=${encodeURI(urlInput.getValue())}` + `?api=${encodeURI(urlInput.value)}` +
`&article=${encodeURI(articleInput.getValue())}`; `&article=${encodeURI(articleInput.value)}`;
}); });

View File

@ -1,4 +1,3 @@
import {MessageLevel} from "./DOM";
import {couldNotConnectMessage, mergeMaps, mergeSets} from "./Shared"; import {couldNotConnectMessage, mergeMaps, mergeSets} from "./Shared";
@ -621,7 +620,7 @@ export class MediaWikiManager {
export const discoverNetwork = async function( export const discoverNetwork = async function(
mwm: MediaWikiManager, mwm: MediaWikiManager,
title: string, title: string,
errorCb: (level: "error" | "warning" | null, message: string) => void, errorCb: (type: "error" | "warning" | null, message: string) => void,
progressCb: (message: string) => void progressCb: (message: string) => void
): Promise<{ pages: Page[], redirects: Redirect[] }> { ): Promise<{ pages: Page[], redirects: Redirect[] }> {
const pages = []; const pages = [];
@ -630,7 +629,7 @@ export const discoverNetwork = async function(
const history: InterlangLink[] = []; const history: InterlangLink[] = [];
const queue: InterlangLink[] = [new InterlangLink(mwm.baseLang, title)]; const queue: InterlangLink[] = [new InterlangLink(mwm.baseLang, title)];
while (queue.length > 0) { while (queue.length > 0) {
progressCb("Checking <code>" + queue[queue.length - 1] + "</code>"); progressCb("Checking <code>" + queue[queue.length - 1] + "</code>.");
let next = queue.pop()!; let next = queue.pop()!;
if (history.some(it => it.equals(next))) if (history.some(it => it.equals(next)))
@ -644,7 +643,10 @@ export const discoverNetwork = async function(
if (history.length === 1) if (history.length === 1)
throw new Error(couldNotConnectMessage); throw new Error(couldNotConnectMessage);
else { else {
errorCb("warning", `Could not connect to the wiki for language '${next.lang}'. Maybe the wiki no longer exists?`); errorCb(
"warning",
`Could not connect to the wiki for language '${next.lang}'. Maybe the wiki no longer exists?`
);
continue; continue;
} }
} }
@ -765,17 +767,19 @@ export namespace NetworkVerdict {
export const props = { export const props = {
"perfect": { "perfect": {
message: "A perfect network! 🙂", message: "A perfect network! 🙂",
style: "complete" as MessageLevel type: "success"
}, },
"flawed": { "flawed": {
message: "The network is complete but flawed 😕<br />" + message:
"The network is complete but flawed 😕<br />" +
"Hover over an icon in the left column for more information.", "Hover over an icon in the left column for more information.",
style: "warning" as MessageLevel type: "warning"
}, },
"broken": { "broken": {
message: "The network is broken 😞<br />" + message:
"The network is broken 😞<br />" +
"Hover over an icon in the left column for more information.", "Hover over an icon in the left column for more information.",
style: "warning" as MessageLevel type: "warning"
}, },
}; };

View File

@ -2,7 +2,7 @@
* The message that is displayed when the application fails to connect to the API. * The message that is displayed when the application fails to connect to the API.
*/ */
export const couldNotConnectMessage: string = export const couldNotConnectMessage: string =
"Could not to connect to API. Is the URL correct? Are you using a script blocker? " + "Could not connect to API. Is the URL correct? Are you using a script blocker? " +
"See the <b>About</b> section for more information."; "See the <b>About</b> section for more information.";
/** /**