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",
"version": "1.13.6",
"version": "1.13.7",
"description": "Check the consistency of MediaWiki interlanguage links in a simple overview.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",
@ -16,17 +16,17 @@
"deploy": "grunt deploy"
},
"devDependencies": {
"grunt": "^1.4.1",
"grunt": "^1.5.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-watch": "^1.1.0",
"grunt-focus": "^1.0.0",
"grunt-text-replace": "^0.4.0",
"grunt-webpack": "^5.0.0",
"ts-loader": "^9.2.6",
"typescript": "^4.5.5",
"webpack": "^5.69.1",
"webpack-cli": "^4.9.2"
"ts-loader": "^9.4.1",
"typescript": "^4.9.3",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.0"
}
}

View File

@ -20,12 +20,12 @@ summary {
/***
* Table
**/
#networkTableForm {
#network-table-form {
/* Center table */
width: 100%;
}
#networkTable {
#network-table {
/* Center table */
margin: 0 auto;
@ -36,27 +36,32 @@ summary {
/* Text alignment */
#networkTable th.sourceLabel, #networkTable td.sourceLabel {
#network-table th.sourceLabel,
#network-table td.sourceLabel {
text-align: right;
}
#networkTable th:not(.sourceLabel), #networkTable td:not(.sourceLabel) {
#network-table th:not(.sourceLabel),
#network-table td:not(.sourceLabel) {
text-align: center;
}
/* Borders */
#networkTable th, #networkTable td {
#network-table th,
#network-table td {
border-right: 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;
}
#networkTable th:first-child, #networkTable td:first-child,
#networkTable th:last-child, #networkTable td:last-child {
#network-table th:first-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 */
padding-left: 1.5rem;
padding-right: 1.5rem;
@ -64,26 +69,31 @@ summary {
/* Table colors */
#networkTable tbody tr:nth-child(odd) {
#network-table tbody tr:nth-child(odd) {
background-color: var(--table-row-color);
}
#networkTable th a i {
#network-table th a i {
font-size: 0.9em;
font-weight: normal;
}
#networkTable a {
#network-table a {
cursor: pointer;
}
#network-table a::after {
display: none;
}
/* Shared colors */
.redLink a {
.red-link a {
color: var(--fandom-redlink);
}
span.success, i.success {
span.success,
i.success {
color: var(--success-color);
}
@ -92,7 +102,8 @@ div.success {
background-color: var(--success-bg-color);
}
span.error, i.error {
span.error,
i.error {
color: var(--error-color);
}
@ -101,7 +112,8 @@ div.error {
background-color: var(--error-bg-color);
}
span.warning, i.warning {
span.warning,
i.warning {
color: var(--warning-color);
}
@ -110,7 +122,8 @@ div.warning {
background-color: var(--warning-bg-color);
}
span.info, i.info {
span.info,
i.info {
color: var(--info-color);
}
@ -123,20 +136,23 @@ div.info {
/***
* Messages, errors, etc.
**/
#errors, #messages {
#errors,
#messages {
width: 100%;
text-align: center;
}
.errorOuter, .messageOuter {
.error-outer,
.message-outer {
display: inline-block;
}
.errorInner {
.error-inner {
margin-bottom: 1em;
}
.errorInner, .messageInner {
.error-inner,
.message-inner {
padding: 1em;
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="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>
<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/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 -->
<link rel="stylesheet" href="main.css?v=%%VERSION_NUMBER%%" />
<script async src="https://stats.fwdekker.com/count.js"
data-goatcounter="https://stats.fwdekker.com/count"></script>
</head>
<body>
<noscript>
<noscript class="fwd-js-notice">
<img src="https://stats.fwdekker.com/count?p=/tools/interlanguage-checker/" alt="Counting pixel" />
<p>
@ -28,92 +35,92 @@
instructions on how to enable JavaScript in your web browser</a>.
</p>
</noscript>
<nav id="nav"></nav>
<main class="hidden">
<div id="nav"></div>
<div id="contents">
<div id="header"></div>
<!-- About -->
<div role="document">
<section class="container">
<div class="row">
<div class="column">
<details open id="about">
<summary><b>About</b></summary>
<span>
<a href="https://community.fandom.com/wiki/Help:Interlanguage_link">&#9099; Interlanguage links</a>
allow wikis to tell users where to find translations of articles.
Without the
<a href="https://www.mediawiki.org/wiki/Extension:Interlanguage">&#9099; interlanguage extension</a>,
each translation is responsible for maintaining its own outgoing links.
As the number of translations grows, the network of links becomes more <b>complex</b>, and the
number of errors grows.<br />
<br />
The <b>Interlanguage Checker</b> traverses the network of interlanguage links starting from a
given article and shows you that network in a table.
If there are missing or incorrect links, you can quickly spot them and <b>fix</b>
them.<br />
<br />
To use the tool, you should enter the link to the
<a href="https://www.mediawiki.org/wiki/API:Main_page">&#9099; API of the wiki</a> you want to
check.
For <b>Wikimedia</b> wikis, this is <code>https://&lt;example.org&gt;/w/api.php</code>.
For <b>Fandom</b> wikis, this is <code>https://&lt;wiki&gt;.fandom.com/api.php</code>.<br />
<br />
If the application <b>refuses to connect</b> to the API and you are certain the URL is correct,
make sure that you allow scripts to be executed from the API you have entered by checking the
configuration of your <b>tracking blockers</b>.
These <b>external scripts</b> are necessary to provide support to older wikis that rely on
<a href="https://en.wikipedia.org/wiki/JSONP">&#9099; JSONP requests</a> to interact with the
API.<br />
<br />
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">&#9099; open an issue</a>
or <a href="https://fallout.fandom.com/wiki/User_talk:FDekker">&#9099; leave a talk message</a>.
</span>
</details>
<p></p>
</div>
</div>
<header class="fwd-header">
<hgroup>
<h1><a href=".">Interlanguage Checker</a></h1>
<h2>Check the consistency of MediaWiki interlanguage links in a simple overview.</h2>
</hgroup>
</header>
<details open id="about">
<summary><b>About</b></summary>
<p>
<a href="https://community.fandom.com/wiki/Help:Interlanguage_link" target="_blank">
Interlanguage links</a>
allow wikis to tell users where to find translations of articles.
Without the
<a href="https://www.mediawiki.org/wiki/Extension:Interlanguage" target="_blank">
interlanguage extension</a>,
each translation is responsible for maintaining its own outgoing links.
As the number of translations grows, the network of links becomes more <b>complex</b>, and the
number of errors grows.
</p>
<p>
The <b>Interlanguage Checker</b> traverses the network of interlanguage links starting from a
given article and shows you that network in a table.
If there are missing or incorrect links, you can quickly spot them and <b>fix</b> them.
</p>
<p>
To use the tool, you should enter the link to the
<a href="https://www.mediawiki.org/wiki/API:Main_page" target="_blank">API of the wiki</a> you want
to check.
For <b>Wikimedia</b> wikis, this is <code>https://&lt;example.org&gt;/w/api.php</code>.
For <b>Fandom</b> wikis, this is <code>https://&lt;wiki&gt;.fandom.com/api.php</code>.
</p>
<p>
If the application <b>refuses to connect</b> to the API and you are certain the URL is correct,
make sure that you allow scripts to be executed from the API you have entered by checking the
configuration of your <b>tracking blockers</b>.
These <b>external scripts</b> are necessary to provide support to older wikis that rely on
<a href="https://en.wikipedia.org/wiki/JSONP" target="_blank">JSONP requests</a> to interact with
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>
<!-- 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 -->
<hr />
<div id="errors"></div>
<div id="messages"></div>
<hr />
<form id="networkTableForm">
<table id="networkTable"></table>
<form id="network-table-form">
<table id="network-table"></table>
</form>
</section>
<section class="container">
<footer id="footer"></footer>
</section>
</div>
<div id="footer"></div>
</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 -->
<script src="bundle.js?v=%%VERSION_NUMBER%%"></script>
</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, footer, header, nav} = window.fwdekker;
import {ErrorHandler, InterlangTable, MessageHandler, ValidatableInput} from "./DOM";
const {$, doAfterLoad} = (window as any).fwdekker;
const {
clearFormValidity, showInputInvalid, showMessageBusy, showMessageError, showMessageInfo, showMessageType
} = (window as any).fwdekker.validation;
import {InterlangTable} from "./InterlangTable";
import {discoverNetwork, InterlangNetwork, MediaWiki, MediaWikiManager, NetworkVerdict} from "./MediaWiki";
// Contains global functions for debugging
// @ts-ignore
window.ilc = {};
(window as any).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
doAfterLoad(() => {
const about = $("#about");
@ -31,138 +19,109 @@ doAfterLoad(() => {
about.addEventListener("toggle", () => localStorage.setItem(key, "" + !!about.open));
const storedState = localStorage.getItem(key);
if (storedState === null) about.open = true;
else about.open = storedState === "true";
about.open = storedState === null || storedState === "true";
});
// Handle input
doAfterLoad(async () => {
const urlInput = new ValidatableInput($("#url"), (value) => {
if (value.trim() === "")
return "API URL must not be empty.";
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);
});
const form = $("#inputs");
const urlInput = $("#url");
const articleInput = $("#article");
// Form submission
let previousUrl: string | undefined;
let mwm: MediaWikiManager | undefined;
const submit = async () => {
localStorage.setItem("/tools/interlanguage-checker//api-url", urlInput.getValue());
localStorage.setItem("/tools/interlanguage-checker//api-url", urlInput.value);
// Clean up
urlInput.showBlank();
articleInput.showBlank();
errorHandler.clear();
messageHandler.clear();
const oldTable = $("#networkTable");
if (oldTable !== null)
oldTable.parentNode.removeChild(oldTable);
clearFormValidity(form);
$("#network-table")?.remove();
// Validate
const urlValidity = urlInput.validate();
if (urlValidity !== "") return messageHandler.handle("error", urlValidity);
const urlValue = urlInput.value;
const articleValue = articleInput.value;
const articleValidity = articleInput.validate();
if (articleValidity !== "") return messageHandler.handle("error", articleValidity);
if (urlValue.trim() === "")
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
if (urlInput.getValue() !== previousUrl) {
messageHandler.handle("progress", `Initializing <code>${urlInput.getValue()}</code>`);
if (urlValue !== previousUrl) {
showMessageBusy(form, `Initializing <code>${urlValue}</code>`);
try {
const mw = await new MediaWiki(urlInput.getValue()).init();
const mw = await new MediaWiki(urlValue).init();
mwm = await new MediaWikiManager().init(mw);
} catch (error) {
if (!(error instanceof Error))
throw Error(`Error while processing initialization error:\n${error}`);
messageHandler.handle("error", error.message);
return;
return showMessageError(form, (error as Error).message);
}
previousUrl = urlInput.getValue();
previousUrl = urlValue;
}
// Discover
discoverNetwork(
mwm!,
articleInput.getValue(),
(level, message) => errorHandler.handle(level, message),
it => messageHandler.handle("progress", it)
articleInput.value,
(type, message) => showMessageType(form, message, type),
it => showMessageType(form, it, "busy")
)
.then(it => new InterlangNetwork(it.pages, it.redirects))
.then(network => {
messageHandler.handle("progress", "Creating table");
showMessageType(form, "Creating table", "busy");
const form = $("#networkTableForm");
form.textContent = "";
form.appendChild((new InterlangTable()).render("networkTable", network));
const tableForm = $("#network-table-form");
tableForm.textContent = "";
tableForm.appendChild((new InterlangTable()).render("network-table", network));
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));
};
urlInput.input.addEventListener("keypress", (event) => {
if (event.key.toLowerCase() === "enter") submit();
form.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
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
const apiUrl = localStorage.getItem("/tools/interlanguage-checker//api-url");
if (apiUrl !== null && apiUrl.trim() !== "") {
urlInput.setValue(apiUrl);
articleInput.input.focus();
urlInput.value = apiUrl;
articleInput.focus();
}
// Read inputs from URL
const currentParams = new URLSearchParams(window.location.search);
if (currentParams.has("api")) {
urlInput.setValue(currentParams.get("api")!);
articleInput.input.focus();
urlInput.value = currentParams.get("api")!;
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();
// Set global debug function
// @ts-ignore
window.ilc.getCurrentInputAsUrl = () =>
(window as any).ilc.getCurrentInputAsUrl = () =>
location.href.split("?")[0] +
`?api=${encodeURI(urlInput.getValue())}` +
`&article=${encodeURI(articleInput.getValue())}`;
`?api=${encodeURI(urlInput.value)}` +
`&article=${encodeURI(articleInput.value)}`;
});

View File

@ -1,4 +1,3 @@
import {MessageLevel} from "./DOM";
import {couldNotConnectMessage, mergeMaps, mergeSets} from "./Shared";
@ -621,7 +620,7 @@ export class MediaWikiManager {
export const discoverNetwork = async function(
mwm: MediaWikiManager,
title: string,
errorCb: (level: "error" | "warning" | null, message: string) => void,
errorCb: (type: "error" | "warning" | null, message: string) => void,
progressCb: (message: string) => void
): Promise<{ pages: Page[], redirects: Redirect[] }> {
const pages = [];
@ -630,7 +629,7 @@ export const discoverNetwork = async function(
const history: InterlangLink[] = [];
const queue: InterlangLink[] = [new InterlangLink(mwm.baseLang, title)];
while (queue.length > 0) {
progressCb("Checking <code>" + queue[queue.length - 1] + "</code>");
progressCb("Checking <code>" + queue[queue.length - 1] + "</code>.");
let next = queue.pop()!;
if (history.some(it => it.equals(next)))
@ -644,7 +643,10 @@ export const discoverNetwork = async function(
if (history.length === 1)
throw new Error(couldNotConnectMessage);
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;
}
}
@ -765,17 +767,19 @@ export namespace NetworkVerdict {
export const props = {
"perfect": {
message: "A perfect network! 🙂",
style: "complete" as MessageLevel
type: "success"
},
"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.",
style: "warning" as MessageLevel
type: "warning"
},
"broken": {
message: "The network is broken 😞<br />" +
message:
"The network is broken 😞<br />" +
"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.
*/
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.";
/**