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>
<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"> <details open id="about">
<summary><b>About</b></summary> <summary><b>About</b></summary>
<span> <p>
<a href="https://community.fandom.com/wiki/Help:Interlanguage_link">&#9099; Interlanguage links</a> <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. allow wikis to tell users where to find translations of articles.
Without the Without the
<a href="https://www.mediawiki.org/wiki/Extension:Interlanguage">&#9099; interlanguage extension</a>, <a href="https://www.mediawiki.org/wiki/Extension:Interlanguage" target="_blank">
interlanguage extension</a>,
each translation is responsible for maintaining its own outgoing links. 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 As the number of translations grows, the network of links becomes more <b>complex</b>, and the
number of errors grows.<br /> number of errors grows.
<br /> </p>
<p>
The <b>Interlanguage Checker</b> traverses the network of interlanguage links starting from a The <b>Interlanguage Checker</b> traverses the network of interlanguage links starting from a
given article and shows you that network in a table. 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> If there are missing or incorrect links, you can quickly spot them and <b>fix</b> them.
them.<br /> </p>
<br /> <p>
To use the tool, you should enter the link to the 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 <a href="https://www.mediawiki.org/wiki/API:Main_page" target="_blank">API of the wiki</a> you want
check. to check.
For <b>Wikimedia</b> wikis, this is <code>https://&lt;example.org&gt;/w/api.php</code>. 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 /> For <b>Fandom</b> wikis, this is <code>https://&lt;wiki&gt;.fandom.com/api.php</code>.
<br /> </p>
<p>
If the application <b>refuses to connect</b> to the API and you are certain the URL is correct, 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 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>. configuration of your <b>tracking blockers</b>.
These <b>external scripts</b> are necessary to provide support to older wikis that rely on 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 <a href="https://en.wikipedia.org/wiki/JSONP" target="_blank">JSONP requests</a> to interact with
API.<br /> the API.
<br /> </p>
<p>
If you need <b>help</b>, have a question, or found a bug, please 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> <a href="https://git.fwdekker.com/FWDekker/interlanguage-checker/issues/new" target="_blank">
or <a href="https://fallout.fandom.com/wiki/User_talk:FDekker">&#9099; leave a talk message</a>. open an issue</a>
</span> or
<a href="https://fallout.fandom.com/wiki/User_talk:FDekker" target="_blank">
leave a talk message</a>.
</p>
</details> </details>
<p></p>
</div>
</div>
</section>
<!-- Input --> <form id="inputs">
<section class="container"> <label for="url">API</label>
<div class="row"> <input id="url" type="url" placeholder="https://fallout.fandom.com/api.php" autocomplete="url"
<div class="column"> autofocus />
<form> <small id="url-hint" data-hint-for="url" data-hint="The URL to the wiki's api.php."></small>
<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"> <label for="article">Article</label>
Article&nbsp; <input id="article" type="text" placeholder="Master" />
<i class="fa fa-question-circle-o" title="The title of the article to check"></i> <small id="article-hint" data-hint-for="article" data-hint="The title of the article to check."></small>
</label>
<input id="article" type="text" /> <button id="submit">Check</button>
<br />
<button id="check" type="button">Check</button>
</form> </form>
</div>
</div> <hr />
<article class="status-card hidden" data-status-for="inputs">
<output></output>
</article>
</section> </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.");
// Initialize
if (urlInput.getValue() !== previousUrl) {
messageHandler.handle("progress", `Initializing <code>${urlInput.getValue()}</code>`);
try { try {
const mw = await new MediaWiki(urlInput.getValue()).init(); new URL(urlValue); // Throws exception if invalid
mwm = await new MediaWikiManager().init(mw);
} catch (error) { } catch (error) {
if (!(error instanceof Error)) try {
throw Error(`Error while processing initialization error:\n${error}`); const url = `http://${urlValue}`;
new URL(url); // Throws exception if invalid
messageHandler.handle("error", error.message); $("#url").value = url;
return; return "";
} catch {
return showInputInvalid(urlInput, (error as Error).message);
}
} }
previousUrl = urlInput.getValue(); if (articleValue.trim() === "")
return showInputInvalid(articleInput, "Enter the name of the article to check.");
// Initialize
if (urlValue !== previousUrl) {
showMessageBusy(form, `Initializing <code>${urlValue}</code>`);
try {
const mw = await new MediaWiki(urlValue).init();
mwm = await new MediaWikiManager().init(mw);
} catch (error) {
return showMessageError(form, (error as Error).message);
}
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.";
/** /**