From 355ec7ac578d9edc23422826f2a544deb00a389f Mon Sep 17 00:00:00 2001 From: "Felix W. Dekker" Date: Fri, 14 May 2021 18:02:04 +0200 Subject: [PATCH] Rewrite to TypeScript Fixes #48. --- Gruntfile.js | 19 +- package-lock.json | Bin 212116 -> 214491 bytes package.json | 8 +- src/main/js/DOM.js | 427 ----------------- src/main/js/DOM.ts | 501 +++++++++++++++++++ src/main/js/{Main.js => Main.ts} | 16 +- src/main/js/MediaWiki.js | 652 ------------------------- src/main/js/MediaWiki.ts | 796 +++++++++++++++++++++++++++++++ src/main/js/Shared.js | 20 - src/main/js/Shared.ts | 18 + tsconfig.json | 11 + 11 files changed, 1351 insertions(+), 1117 deletions(-) delete mode 100644 src/main/js/DOM.js create mode 100644 src/main/js/DOM.ts rename src/main/js/{Main.js => Main.ts} (95%) delete mode 100644 src/main/js/MediaWiki.js create mode 100644 src/main/js/MediaWiki.ts delete mode 100644 src/main/js/Shared.js create mode 100644 src/main/js/Shared.ts create mode 100644 tsconfig.json diff --git a/Gruntfile.js b/Gruntfile.js index 6d75545..bf1cbd9 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -16,12 +16,12 @@ module.exports = grunt => { }, focus: { dev: { - include: ["css", "html", "js"], + include: ["css", "html", "ts"], }, }, replace: { dev: { - src: ["./dist/*.html", "./dist/*.js"], + src: ["./dist/**/*.html", "./dist/**/*.js"], replacements: [ { from: "%%VERSION_NUMBER%%", @@ -31,7 +31,7 @@ module.exports = grunt => { overwrite: true, }, deploy: { - src: ["./dist/*.html", "./dist/*.js"], + src: ["./dist/**/*.html", "./dist/**/*.js"], replacements: [ { from: "%%VERSION_NUMBER%%", @@ -50,24 +50,25 @@ module.exports = grunt => { files: ["src/main/**/*.html"], tasks: ["copy:html"], }, - js: { - files: ["src/main/**/*.js"], + ts: { + files: ["src/main/**/*.ts"], tasks: ["webpack:dev", "replace:dev"], }, }, webpack: { options: { - entry: "./src/main/js/Main.js", + entry: "./src/main/js/Main.ts", module: { rules: [ { - test: /\.js$/, + test: /\.ts$/, + use: "ts-loader", exclude: /node_modules/, }, ], }, resolve: { - extensions: [".js"], + extensions: [".ts"], }, output: { filename: "bundle.js", @@ -99,6 +100,7 @@ module.exports = grunt => { "copy:html", // Compile JS "webpack:dev", + // Post "replace:dev", ]); grunt.registerTask("dev:server", ["dev", "focus:dev"]); @@ -110,6 +112,7 @@ module.exports = grunt => { "copy:html", // Compile JS "webpack:deploy", + // Post "replace:deploy", ]); diff --git a/package-lock.json b/package-lock.json index 3e634fa6c243004051ee478c1dba9d94ea84a1d6..7144ed2f5761be3f3b061861575c27403c7e41ea 100644 GIT binary patch delta 3603 zcmd6pS!^5E6^3z#Y9!g(qScZdS&|*c)`;YAxQMh8*d)bexQhEy!1a(E?#qzF9cY4} zE&Pz6wR9caHVFc`ND#M3V;Mp-K#X`nQJ|~sycp>Q+bJ5iXdT!^W7I`scPPnnANo=h zb^Jn{Irjo{{(sInACKSd`prw-#}0_(5@PAOJd&Bg!0EH!0KH{SoGs8XrZjzEa{90a z9=Wt*Te-n9T(p#8%ka^t5F++K{BY*#$BvC5+dxhO1n@~TimL8fy&TS z%0e~uS(DRV^t-Ez`am%s^VZ{}dLdyfumL(x7_4P;IU!XBWJw~Nfm7#Z;ir;5cys1` z__uT)Tpgd?c=uoMJ~(w}zps!mkX{AO+LB)HqDQK>+vQZ+oF}Ldo3+{%=7`^6*9Wv3 zhr&^cEjBCp8k1-BT+wO>HkhRghpP>Z3sI$RH!lui|ry@Aa_V(&DXzX2wf zbG@52JOx>-10KZnupr)p{_e*PY`HOtbO8+a)e~N+xoE*Fwty*!+nbemh0DsLiL^GF zFNQ)~hStp$h^jkUa~k56iYjSudMKNV%~MUI-Nbomh7Kwmc_x#^RdG6Ii)3X||3*0* zHasX{P^fC{Q%SUQ?TW_N!+*3L`~$UwA5U~-OBHdHjwYe(oe8vZ0&9a^C)6VkvW!$L-HOF3 zbo~qvdkU(Ug;3~oo^+*|uUR}EIqPErxtP>U#N*y)Z4k}{pauCFVK-lQqQA#e|fTb{Q!=xjE{mEnbNIpLwN$$8&2$h7fC zhrnIHpWO?3D3C)Jeh4N-a}qf`&1nWWCRb%jLY*6dSjZ!K54IQHYHRZ?78sKylvJCo z3Z2&}r8VBTDr}NhnSxY17xr_Zl1HnusPhd?J|fr2{3SM1ao|mj)1a)y4N6vJR@kD+ zP?%#9DOVz5SK?u}3O)wyn^Or-{C*eEp!3gz`O&RG736HiVvR%wwW1kcsbNoOy;Ykni#OFxS2dQ+ zsTLWQ;Ig?|-58(@`JzbkP;e8!ZVZUUb+@6t#%QNC%nbjA4yE6$$J83lTEn zVdtDV1EgIJ^pzymffkm)IQq^BFoKSr6n+lAi*+N6gQuTMGrU?PINdvdpOX=y4zg9S@G+(tA_zujzhakoS#0NV4UY*sX0OR!B%V zd1uDw$C+w2WDe(D8M>+Vx^#?w>mT*;! z>4YKLAX0iSrK`JrOqsEe){l*3Irw4EK^}epW*(1jhC8m<0O@b%^&fb0!=bm>iO8$Z8_y#dhq47QuUxQ6Y0oKR@}Hw=!p zL?XjQtdD1=!SseybZi^ekFJ(L&$?BOGuYD@(!Pt0@+(82XYbza(ND^!RF90eN@8Ov-(ez`5#;H#*l0P~wdbZ;sT^=FZHocr^0q(A7 sz3Fd}q7L-+x4__Xqvv5w|99e9(4n`1=YIv=XT4t})kr=zFCxId0Ld8NdH?_b delta 2701 zcmd6oe{5UT6~}e&vzw;4X_AIi1==_?CZ6P5d#9jXK<^XYI5tNE?$nN>jY?(1t=f#vfxULt+|;3h2b7P1*{nkOHVo<2gwi zkoa>e{PpfVI_IA6J@=f?yY%SpAAT%r9i}uY?c&$D&W9}a?md6uI|!DRQ5RXdB$en@ zS_45tgB{CGvWj;>a2IumtoHV_|BFWs!R4PJ6Iqo@h+NeTzhkIfguk~R{_4hef!t0> z!vX6;l*wA134Pd_%}nXl>3mXCa@nI4w;0N<`K-Q}_brfb?;nM8=TPtB#SL8@r4n+#@)Z0bw5^8RH`)uw zzlZN4%fFO{C#Gk#bl5nnQvKlTXD&pE|487?`P$k@SWA@Zc3Bhoy69XiH3|NL}PHrH!nN z-3jV(>hM;1_LJHJhX_9^iKwfw20tAS7#c}`C1`c)89Gu=rIXhAjK`MCRjgLdU7;%t zmd$X6>}*bL%{A-AN-e6%M?=P}uj-736V7yWu3^$@b-7|WRn+Ing};5SDA#oibi|oA zp08b@HQiLYQi3O*#BylKumqZa!Y1(Fs(P+d|iK&yk zGv!J~JXU|nFz-Wy?*_1GwUd(Jgheop>rmmKjpBe8rfRT8*8{V zET7;@lL4;ju9(fG7;UkXJFiIA(uc_9DAWG2Dbdq@!Nn;g)!9M~hJTHRVA+l&@OS{3 z_Dypg>vT<2cYQg=sI;*79KIXY&R}V{=;tPDT8*A|6hpcNcRex}&&|dolatJ}#_!c- zj7N=$z(ip_ZcC>e)P@5oE75=`@ zRAc0@eB>KCXg!bIaEk)z+RD`EY!&{ek0U1f%_Epe_5so0}{0R_h+& zKo3$#+N$+OHf@4^7+phx==gU8s(#cjeDXMY7YRKB=#w47r904!M6f83%q>iF=tp-7 zOMp~JcNY1dDhHZ+CGF~M+5mS@C=L4|( zN3>fw_6zjBbUSGmw0?CxbHp5vDy!kN(^q62g}Bk5(#({(S-z3d%!xCW<%=f1nF-LT zN?BQF3zNEl)#oj`s*E|#)=Ks%`(z?uuF=)0WH312^d^ILZ`B$Wdm;eeWT+qrPoi51 zzfS`1JcR~3bKOhiBi)@{Hw!auwJI>fq!x1UBY?iK#>B2s>d2x}R!4A$O5`(W(fNOP0n1?)S>)1g=PW<#oT zZ9%;Dm9DQ2ixz*v2yd*Tf5-$ziccT>3Q-6&SCHomFtq##zDIajfgiqIxHyjUF(^3k z9)UlNzuylhM#L$}J|m8Y{H)kwmnl2|<7cs&w2n&2lv4q#X?%d3aOU9Q89WIeN$?1~ zat-Z-%NY@v+>R&4~q+7Y}Jbz)rfy%9zJe7=YLtJSj+C=zc# z5EH&e0dor9wqc8%x*=kH>#WyLiDw-%QYM%?iFU*KL!yj{l`S{;?h00K=DfIaBj*TG zz*-(l;ng$Z&!P1K9^RG!s;AK|;lO$PQ*`n?-V36@KG;oA4;)j8rJxT|{To$)!a*@* zrv$J}(AI4tL)5Ua^bF4I5We+We1-zq-Ds!aR-kSKy(@U1aCH^09Tr0W!OL>tp`; - } - - /** - * 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 {Page[]} a list of all pages - * @param page {Page} the page to generate a label of - * @return {String} an appropriate label with icons for the given page - * @private - */ - _generateLabel(pages, page) { - const labelText = pages.some(it => it.link.lang === page.link.lang && !it.link.equals(page.link)) - ? page.link.toString() - : page.link.lang; - - return "" + - `` + - /**/`${labelText}` + - /**/` ` + - /**/`` + - /**/` ` + - /**/`` + - ``; - } - - /** - * Generates the head of the table generated by `#toTable`. - * - * @param network {InterlangNetwork} the network to generate the head for - * @return {String} the head of the table generated by `#toTable` - * @private - */ - _generateTableHead(network) { - return "" + - `` + - /**/`` + - /****/`` + - /****/`Source` + - /****/`Destination` + - /**/`` + - /**/`${network.pages.map(page => `${this._generateLabel(network.pages, page)}`)}` + - ``; - } - - /** - * Generates the body of the table generated by `#toTable`. - * - * @param network {InterlangNetwork} the network to generate the body for - * @return {String} the body of the table generated by `#toTable` - * @private - */ - _generateTableBody(network) { - const rows = network.pages.map(srcPage => { - const verdict = network.getPageVerdict(srcPage); - - const icons = verdict.self - .map(state => { - switch (state) { - case "perfect": - return this._createIcon("check", "Perfect 🙂", ["success"]); - case "not-found": - return this._createIcon("search", "Article does not exist 😕", ["error"]); - case "wrongly-ordered": - return this._createIcon("sort-alpha-asc", "Links are in the wrong order 😕", ["warning"]); - case "doubly-linked": - return this._createIcon("clone", "Links to the same wiki multiple times 😕", ["warning"]); - case "self-linked": - return this._createIcon("rotate-left", "Links to its own wiki 😕", ["warning"]); - case "unlinked": - return this._createIcon("chain-broken", "Misses one or more links 😕", ["error"]); - case "redirected": - return this._createIcon("mail-forward", "Links to a redirect 😕", ["warning"]); - case "wrongly-cased": - return this._createIcon("text-height", "Links with incorrect capitalisation 😕", ["warning"]); - default: - throw new Error(`Invalid page state '${state}'`); - } - }) - .map(it => `${it} `); - const label = this._generateLabel(network.pages, srcPage); - const cells = network.pages.map(dstPage => { - const linkState = verdict.pages.find(it => it.page.link.equals(dstPage.link)).verdict; - switch (linkState) { - case "linked": - return this._createIcon("check", "Linked 🙂", ["success"]); - case "self-linked": - return this._createIcon("rotate-left", "Links to its own wiki 😕", ["warning"]); - case "unlinked": - return this._createIcon("times", "Link is missing 😕", ["error"]); - case "self-unlinked": - return ``; - case "redirected": - return this._createIcon("mail-forward", "Links to a redirect 😕", ["warning"]); - case "wrongly-cased": - return this._createIcon("text-height", "Links with incorrect capitalisation 😕", ["warning"]); - default: - throw new Error(`Invalid link state '${linkState}'`); - } - }); - - return "" + - `` + - /**/`${icons}` + - /**/`${label}` + - /**/cells.map(it => `${it}`) + - ``; - }); - - return `${rows}`; - } - - /** - * Renders the the table describing the interlanguage network. - * - * @param id {String} the ID to assign to the table element - * @param network {InterlangNetwork} the network of pages to render - * @return {HTMLElement} the generated table - */ - render(id, network) { - const table = stringToHtml( - `` + - /**/this._generateTableHead(network) + - /**/this._generateTableBody(network) + - `
`, - "table" - ); - - // Add event handlers - table.querySelectorAll(".copyIcon").forEach(icon => { - 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; - } -} diff --git a/src/main/js/DOM.ts b/src/main/js/DOM.ts new file mode 100644 index 0000000..5cc1438 --- /dev/null +++ b/src/main/js/DOM.ts @@ -0,0 +1,501 @@ +// @ts-ignore +const {stringToHtml} = window.fwdekker; +import {InterlangNetwork, Page} 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`. + */ +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`. + */ +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 = " "; + 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 + * @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, title: string, classes: string[]): string { + return ``; + } + + /** + * 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 "" + + `` + + /**/`${labelText}` + + /**/` ` + + /**/`` + + /**/` ` + + /**/`` + + ``; + } + + /** + * 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 "" + + `` + + /**/`` + + /****/`` + + /****/`Source` + + /****/`Destination` + + /**/`` + + /**/`${network.pages.map(page => `${this.generateLabel(network.pages, page)}`)}` + + ``; + } + + /** + * 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 verdict = network.getPageVerdict(srcPage); + + const icons = verdict.self + .map(state => { + switch (state) { + case "perfect": + return InterlangTable.createIcon("check", "Perfect 🙂", ["success"]); + case "not-found": + return InterlangTable.createIcon("search", "Article does not exist 😕", ["error"]); + case "wrongly-ordered": + return InterlangTable.createIcon("sort-alpha-asc", "Links are in the wrong order 😕", ["warning"]); + case "doubly-linked": + return InterlangTable.createIcon("clone", "Links to the same wiki multiple times 😕", ["warning"]); + case "self-linked": + return InterlangTable.createIcon("rotate-left", "Links to its own wiki 😕", ["warning"]); + case "unlinked": + return InterlangTable.createIcon("chain-broken", "Misses one or more links 😕", ["error"]); + case "redirected": + return InterlangTable.createIcon("mail-forward", "Links to a redirect 😕", ["warning"]); + case "wrongly-cased": + return InterlangTable.createIcon("text-height", "Links with incorrect capitalisation 😕", ["warning"]); + default: + throw new Error(`Invalid page state '${state}'`); + } + }) + .map(it => `${it} `); + const label = this.generateLabel(network.pages, srcPage); + const cells = network.pages.map(dstPage => { + const linkState = verdict.pages.find(it => it.page.link.equals(dstPage.link))!.verdict; + switch (linkState) { + case "linked": + return InterlangTable.createIcon("check", "Linked 🙂", ["success"]); + case "self-linked": + return InterlangTable.createIcon("rotate-left", "Links to its own wiki 😕", ["warning"]); + case "unlinked": + return InterlangTable.createIcon("times", "Link is missing 😕", ["error"]); + case "self-unlinked": + return ``; + case "redirected": + return InterlangTable.createIcon("mail-forward", "Links to a redirect 😕", ["warning"]); + case "wrongly-cased": + return InterlangTable.createIcon("text-height", "Links with incorrect capitalisation 😕", ["warning"]); + default: + throw new Error(`Invalid link state '${linkState}'`); + } + }); + + return "" + + `` + + /**/`${icons}` + + /**/`${label}` + + /**/cells.map(it => `${it}`) + + ``; + }); + + return `${rows}`; + } + + /** + * 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( + `` + + /**/this.generateTableHead(network) + + /**/this.generateTableBody(network) + + `
`, + "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; + } +} diff --git a/src/main/js/Main.js b/src/main/js/Main.ts similarity index 95% rename from src/main/js/Main.js rename to src/main/js/Main.ts index 1197c00..37c7e11 100644 --- a/src/main/js/Main.js +++ b/src/main/js/Main.ts @@ -1,10 +1,11 @@ -// noinspection JSUnresolvedVariable +// @ts-ignore const {$, doAfterLoad, footer, header, nav} = window.fwdekker; import {ErrorHandler, InterlangTable, MessageHandler, ValidatableInput} from "./DOM"; import {discoverNetwork, InterlangNetwork, MediaWiki, MediaWikiManager} from "./MediaWiki"; // Contains global functions for debugging +// @ts-ignore window.ilc = {}; @@ -67,8 +68,8 @@ doAfterLoad(async () => { }); - let previousUrl = undefined; - let mwm = undefined; + let previousUrl: string | undefined; + let mwm: MediaWikiManager | undefined; const submit = async () => { localStorage.setItem("/tools/interlanguage-checker//api-url", urlInput.getValue()); @@ -107,7 +108,7 @@ doAfterLoad(async () => { // Discover discoverNetwork( - mwm, + mwm!, articleInput.getValue(), (level, message) => errorHandler.handle(level, message), it => messageHandler.handle("progress", it) @@ -120,7 +121,7 @@ doAfterLoad(async () => { form.textContent = ""; form.appendChild((new InterlangTable()).render("networkTable", network)); - switch (network.getVerdict()) { + switch (network.getNetworkVerdict()) { case "perfect": messageHandler.handle("complete", "A perfect network! 🙂"); break; @@ -155,13 +156,14 @@ doAfterLoad(async () => { // Read inputs from URL const currentParams = new URLSearchParams(window.location.search); if (currentParams.has("api")) { - urlInput.setValue(currentParams.get("api")); + urlInput.setValue(currentParams.get("api")!); articleInput.input.focus(); } - if (currentParams.has("article")) articleInput.setValue(currentParams.get("article")); + if (currentParams.has("article")) articleInput.setValue(currentParams.get("article")!); if (currentParams.has("api") && currentParams.has("article")) await submit(); // Set global debug function + // @ts-ignore window.ilc.getCurrentInputAsUrl = () => location.href.split("?")[0] + `?api=${encodeURI(urlInput.getValue())}` + diff --git a/src/main/js/MediaWiki.js b/src/main/js/MediaWiki.js deleted file mode 100644 index 276c269..0000000 --- a/src/main/js/MediaWiki.js +++ /dev/null @@ -1,652 +0,0 @@ -import {couldNotConnectMessage, mergeStates} from "./Shared"; - - -/** - * A data class for combining a language and page title to identify a page. - * - * This is only an _identifier_ of a page, not the page itself. For information on the page such as the links it - * contains, whether it's a redirect, etc., see the `Page` class. - * - * @property lang {string} the language of the wiki this page is of - * @property title {string} the title of the page - */ -export class InterlangLink { - /** - * Constructs a new `InterlangLink`. - * - * @param lang {string} the language of the wiki this page is of - * @param title {string} the title of the page - */ - constructor(lang, title) { - this.lang = lang; - this.title = title; - } - - - /** - * Returns `true` if and only if the given object equals this `InterlangLink`. - * - * @param other {*} the object to compare to this `InterlangLink` - * @returns {boolean} `true` if and only if the given object equals this `InterlangLink` - */ - equals(other) { - return other instanceof InterlangLink && this.lang === other.lang && this.title === other.title; - } - - /** - * Returns `true` if and only if the given object equals this `InterlangLink`, ignoring the case of the titles. - * - * @param other {*} the object to compare to this `InterlangLink` - * @returns {boolean} `true` if and only if the given object equals this `InterlangLink`, ignoring the case of the - * titles - */ - equalsIgnoringCase(other) { - return other instanceof InterlangLink && this.lang === other.lang - && this.title.toLowerCase() === other.title.toLowerCase(); - } - - /** - * Converts this `InterlangLink` to a string. - * - * @returns {string} the string representation of this `InterlangLink` - */ - toString() { - return `${this.lang}:${this.title}`; - } - - /** - * Returns a deep copy of this `InterlangLink`. - * - * @returns {InterlangLink} the deep copy - */ - copy() { - return new InterlangLink(this.lang, this.title); - } -} - -/** - * Redirects one `InterlangLink` to another. - * - * @property from [InterlangLink] the page that redirects - * @property to [InterlangLink] the page that is redirected to - */ -export class Redirect { - /** - * Constructs a new `Redirect`. - * - * @param from [InterlangLink] the page that redirects - * @param to [InterlangLink] the page that is redirected to - */ - constructor(from, to) { - this.from = from.copy(); - this.to = to.copy(); - } - - - /** - * Returns `true` if and only if the given object equals this `Redirect`. - * - * @param other {*} the object to compare to this `Redirect` - * @returns {boolean} `true` if and only if the given object equals this `Redirect` - */ - equals(other) { - return other instanceof Redirect && this.from.equals(other.from) && this.to.equals(other.to); - } - - /** - * Returns a deep copy of this `Redirect`. - * - * @returns {Redirect} the deep copy - */ - copy() { - return new Redirect(this.from, this.to); - } -} - -/** - * A map of interwiki links. - * - * Not implemented as a map but as a list of objects. Therefore, when there are duplicate keys, the original value is - * always retained. - * - * @property map {Array<{prefix: string, url: string}>} maps interwiki prefixes to URLs - */ -export class InterwikiMap { - /** - * Constructs a new interwiki map. - * - * @param map {Array<{prefix: string, url: string}>} the mapping from interwiki abbreviations to URLs to store in - * this map - */ - constructor(map) { - this.map = map.map(it => ({prefix: it.prefix, url: it.url.replace("http://", "https://")})); - } - - - /** - * Returns the URL for the given prefix, or `undefined` if the prefix could not be found. - * - * @param prefix {string} the prefix to return the URL of - * @returns {string} the URL for the given prefix, or `undefined` if the prefix could not be found - */ - getUrl(prefix) { - return this.map.find(it => it.prefix === prefix).url; - } - - /** - * Returns `true` if and only if this map has a URL for the given prefix. - * - * @param prefix {string} the prefix to check for - * @returns {boolean} `true` if and only if this map has a URL for the given prefix - */ - hasUrl(prefix) { - return this.map.find(it => it.prefix === prefix) !== undefined; - } - - /** - * Returns a deep copy of this `InterwikiMap`. - * - * @returns {InterwikiMap} the deep copy - */ - copy() { - return new InterwikiMap(this.map); - } -} - -/** - * Describes a page, i.e. what you get if you follow an `InterlangLink`. - * - * @property url {URL} the full URL at which this page is located - * @property link {InterlangLink} the interlanguage link describing the location of the page - * @property linksTo {InterlangLink[]} the interlanguage links contained in this page - * @property exists {boolean} `true` if and only if this page exists - */ -export class Page { - /** - * Constructs a new `Page`. - * - * @param url {URL} the full URL at which this page is located - * @param link {InterlangLink} the interlanguage link describing the location of the page - * @param langLinks {InterlangLink[]} the interlanguage links contained in this page - * @param exists {boolean} `true` if and only if this page exists - */ - constructor(url, link, langLinks, exists) { - this.url = new URL(url.toString()); - this.link = link.copy(); - this.langLinks = langLinks.map(it => it.copy()); - this.exists = exists; - } - - - /** - * Returns `true` if and only if this page's language links are sorted alphabetically. - * - * @returns {boolean} `true` if and only if this page's language links are sorted alphabetically - */ - langLinksAreOrdered() { - return this.langLinks.reduce((isSorted, langLink, i, self) => - i === 0 || (isSorted && self[i - 1].toString().localeCompare(langLink.toString()) <= 0), - true - ); - } - - /** - * Returns `true` if and only if this page has multiple links to the same language. - * - * @return {boolean} `true` if and only if this page has multiple links to the same language - */ - hasDoubleLinks() { - return this.langLinks.some(a => this.langLinks.filter(b => a.lang === b.lang).length > 1); - } - - - /** - * Returns a deep copy of this `Page`. - * - * @returns {Page} the deep copy - */ - copy() { - return new Page(this.url, this.link, this.langLinks, this.exists); - } -} - -/** - * A network of pages linking to each other. - * - * @property pages {Page[]} the pages linking to each other, sorted alphabetically - * @property redirects {Redirect[]} the redirects in the network - */ -export class InterlangNetwork { - /** - * Constructs a new `InterlangNetwork`. - * - * @param pages {Page[]} the pages linking to each other - * @param redirects {Redirect[]} the redirects in the network - */ - constructor(pages, redirects) { - this.pages = pages - .map(it => it.copy()) - .sort((a, b) => a.link.toString().localeCompare(b.link.toString())); - this.redirects = redirects.map(it => it.copy()); - } - - - /** - * Determines whether the given source links to the given destination, potentially through a redirect. - * - * @param source {Page} the source page of which to check the links - * @param destination {Page} the destination that could be linked to - * @returns {"linked"|"self-linked"|"unlinked"|"self-unlinked"|"redirected"} the status of the link - */ - getLinkVerdict(source, destination) { - const isSelfLangLink = source.link.lang === destination.link.lang; - - if (source.langLinks.some(it => it.equals(destination.link))) - return isSelfLangLink ? "self-linked" : "linked"; - - if (source.langLinks.some(it => it.equalsIgnoringCase(destination.link))) - return isSelfLangLink ? "self-linked" : "wrongly-cased"; - - if (source.langLinks.some(link => this.redirects.some(it => it.equals(new Redirect(link, destination.link))))) - return isSelfLangLink ? "self-linked" : "redirected"; - - return isSelfLangLink ? "self-unlinked" : "unlinked"; - } - - /** - * Analyzes the given source page and returns a verdict of its own state and of the state of its link to all other - * pages in this network. - * - * @param srcPage {Page} the page to give a verdict of - * @return verdict {Object} the verdict - * @return verdict.self {("perfect"|"not-found"|"wrongly-ordered"|"doubly-linked"|"self-linked"|"unlinked"| - * "redirected")[]} the verdict of the page in relation to the entire network - * @return verdict.pages {Object[]} the verdicts of the page in relation to each other article in the network - * @return verdict.pages[].page {Page} the page that the verdict is in relation to - * @return verdict.pages[].verdict {"linked"|"self-linked"|"unlinked"|"self-unlinked"|"redirected"} the verdict of - * the relation of the given page to this page - */ - getPageVerdict(srcPage) { - const pageStates = this.pages.map(dstPage => ({page: dstPage, verdict: this.getLinkVerdict(srcPage, dstPage)})); - - let selfStates = []; - if (!srcPage.exists) - selfStates.push("not-found"); - if (!srcPage.langLinksAreOrdered()) - selfStates.push("wrongly-ordered"); - if (srcPage.hasDoubleLinks()) - selfStates.push("doubly-linked"); - if (pageStates.some(({verdict}) => verdict === "self-linked")) - selfStates.push("self-linked"); - if (pageStates.some(({verdict}) => verdict === "unlinked")) - selfStates.push("unlinked"); - if (pageStates.some(({verdict}) => verdict === "redirected")) - selfStates.push("redirected"); - if (pageStates.some(({verdict}) => verdict === "wrongly-cased")) - selfStates.push("wrongly-cased"); - - if (selfStates.length === 0) - selfStates.push("perfect"); - - return {self: selfStates, pages: pageStates}; - } - - /** - * Returns a verdict on the network. - * - * @return {"perfect"|"flawed"|"broken"} a verdict on the network - */ - getVerdict() { - const states = ["broken", "flawed", "perfect"]; - return this.pages.reduce((state, page) => { - const verdict = this.getPageVerdict(page).self; - if (verdict.some(it => ["not-found", "unlinked"].includes(it))) - return mergeStates(states, state, "broken"); - if (verdict.some(it => ["wrongly-ordered", "doubly-linked", "self-linked", "redirected", "wrongly-cased"].includes(it))) - return mergeStates(states, state, "flawed"); - return mergeStates(states, state, "perfect"); - }, "perfect"); - } - - /** - * Returns a deep copy of this `InterlangNetwork`. - * - * @returns {InterlangNetwork} the deep copy - */ - copy() { - return new InterlangNetwork(this.pages, this.redirects); - } -} - - -/** - * Interacts with the API in an asynchronous manner. - * - * @property baseUrl {string} the origin of the wiki's API - * @property apiPath {string} the path relative to the wiki's API; starts with a `/` - * @property general {Object} the general information, retrieved from the API - * @property interwikiMap {InterwikiMap} the interwiki map of this wiki - * @property namespaces {Object.{number, Object}} the namespaces on this wiki - */ -export class MediaWiki { - /** - * Constructs a new MediaWiki object. - * - * @param apiUrl the url to the `api.php` file - */ - constructor(apiUrl) { - const urlObj = new URL(apiUrl); - this.origin = urlObj.origin; - this.apiPath = urlObj.pathname; - } - - /** - * Initializes this `MediaWiki` object with the necessary information from the API. - * - * @returns {MediaWiki} this `MediaWiki` object - */ - async init() { - const query = await this.getSiteInfo("general", "interwikimap", "namespaces"); - - // Add self to map - query.interwikimap.push({prefix: query.general.lang, url: query.general.server + query.general.articlepath}); - - // Set fields - this.general = query.general; - this.interwikiMap = new InterwikiMap(query.interwikimap); - this.namespaces = query.namespaces; - - return this; - } - - - /** - * Sends a request to the MediaWiki API and runs the given callback on the response. - * - * @param params {Object} the parameters to send to the API - * @return {Promise} the API's response - */ - request(params) { - const url = this.origin + this.apiPath + "?format=json&origin=*&" + new URLSearchParams(params).toString(); - console.debug(`Requesting from ${this.origin}${this.apiPath} with params`, params, "at", url); - return fetch(url) - .then(response => { - if (!response.ok) throw new Error(couldNotConnectMessage); - return response.json(); - }) - .catch(() => { - throw new Error(couldNotConnectMessage); - }); - } - - /** - * Requests all language links on the given article. - * - * @param title {string} the title of the article to return links of - * @return result {Object|undefined} the query result, or `undefined` if the article could not be found - * @return result.link {InterlangLink} the normalized, redirect-resolved link to the article - * @return result.langLinks {InterlangLink[]} the language links on the article - * @return result.redirects {Redirect[]} all redirects that were encountered, with double redirects removed - */ - getLangLinks(title) { - return this - .request({action: "parse", page: title, prop: "langlinks", redirects: ""}) - .then(response => { - if (response.error !== undefined) - return undefined; - - const langLinks = response.parse.langlinks.map(it => new InterlangLink(it.lang, it["*"])); - const redirects = response.parse.redirects - .map(it => new Redirect(this._toLink(it.from), this._toLink(it.to))) - .reduce((redirects, redirect, _, self) => { - // TODO Support triple redirects (#30) - const matches = self.filter(it => it.from.equals(redirect.to)); - if (matches.length > 1) - redirects.push(new Redirect(redirect.from, matches[0].to)); - else - redirects.push(redirect); - - return redirects; - }, []); - - return {link: this._toLink(response.parse.title), langLinks: langLinks, redirects: redirects}; - }); - } - - /** - * Returns this wiki's site information. - * - * @param props {...string} the site information properties to retrieve, such as "general" or "interwikimap" - * @returns {Object} the wiki's site information, with each property corresponding to an argument to this method - */ - getSiteInfo(...props) { - return this.request({action: "query", meta: "siteinfo", siprop: props.join("|")}) - .then(response => response.query); - } - - /** - * Normalizes the given link, adjusting its language to this wiki's language and replacing the link's namespace with - * the canonical namespace. - * - * @param link {InterlangLink} the link to normalize - * @returns {InterlangLink} the normalized link - */ - normalize(link) { - const normalLink = link.copy(); - normalLink.lang = this.general.lang; - - const titleParts = normalLink.title.split(':'); - if (titleParts.length < 2) return normalLink; - - titleParts[0] = Object.keys(this.namespaces).reduce((titlePart, namespaceId) => { - const namespace = this.namespaces[namespaceId]; - return titlePart === namespace["canonical"] ? namespace["*"] : titlePart - }, titleParts[0]); - normalLink.title = titleParts.join(':'); - - return normalLink; - } - - - /** - * Shorthand for converting a title to an `InterlangLink` of this wiki's language. - * - * @param title {string} the title of the article to generate a link for - * @returns {InterlangLink} the link to the article on this wiki - * @private - */ - _toLink(title) { - return new InterlangLink(this.general.lang, title); - } -} - -/** - * Manages a `MediaWiki` instance for different languages, caching retrieved information for re-use. - * - * @property mws {Object.} the cached `MediaWiki` instances - * @property articlePath {string} the path to articles, where `$1` indicates the article name - * @property apiPath {string} the path to `api.php` - * @property baseLang {string} the language of the base `MediaWiki`, where the exploration starts - */ -export class MediaWikiManager { - /** - * Constructs a new `MediaWikiManager`. - * - * The `#init` method **must** be called before invoking any other function. Behavior is undefined otherwise. - */ - constructor() { - this.mws = {}; - this._iwMap = new InterwikiMap([]); - } - - /** - * Initializes this `MediaWikiManager`. - * - * @param baseMw {MediaWiki} the `MediaWiki` that is used as a starting point - * @return {MediaWikiManager} this `MediaWikiManager` - */ - async init(baseMw) { - this.basePath = [...(baseMw.apiPath)] - .map((it, i) => it === baseMw.general.articlepath[i] ? it : "") - .join("") - .slice(0, -1); - this.articlePath = baseMw.general.articlepath.slice(this.basePath.length); - this.apiPath = baseMw.apiPath.slice(this.basePath.length); - this.baseLang = baseMw.general.lang; - - this.mws[baseMw.general.lang] = baseMw; - this._updateIwMap(); - - return this; - } - - - /** - * Returns the `MediaWiki` for the given language, creating and initializing it if necessary, or `undefined` if it - * could not be created. - * - * @param lang {string} the language of the `MediaWiki` to return - * @returns {MediaWiki} the `MediaWiki` for the given language, or `undefined` if it could not be created - */ - async getMwOrWait(lang) { - if (this.hasMw(lang)) - return this.mws[lang]; - - if (!this._iwMap.hasUrl(lang)) - return undefined; - - const url = this._iwMap.getUrl(lang); - let newMw; - try { - newMw = await new MediaWiki(url.slice(0, -this.articlePath.length) + this.apiPath).init(); - } catch (error) { - return undefined; - } - - if (this.hasMw(newMw.general.lang)) { - // Duplicate MW with different but equivalent language code; destroy new MW instance - this.mws[lang] = this.mws[newMw.general.lang]; - } else { - this.mws[newMw.general.lang] = newMw; - this.mws[lang] = newMw; - } - this._updateIwMap(); - - return this.mws[lang]; - } - - /** - * Returns the `MediaWiki` for the given language or `undefined` if it has not created that object. - * - * @param lang {string} the language of the `MediaWiki` to return - * @returns {MediaWiki} the `MediaWiki` for the given language or `undefined` if it has not created that object - */ - getMw(lang) { - return this.mws[lang]; - } - - /** - * Returns `true` if and only if this manager has a `MediaWiki` for the given language. - * - * @param lang {string} the language of the `MediaWiki` to check presence of - * @returns {boolean} `true` if and only if this manager has a `MediaWiki` for the given language - */ - hasMw(lang) { - return this.mws[lang] !== undefined; - } - - /** - * Returns the URL to the given article. - * - * @param link {InterlangLink} the link to return the URL of - * @returns {URL} the URL to the given article - */ - getArticlePath(link) { - return new URL(this._iwMap.getUrl(link.lang).replace("$1", link.title)); - } - - - /** - * Updates the `_iwMap` property with the entries in `MediaWiki` instances in this manager. - */ - _updateIwMap() { - const maps = Object.keys(this.mws).map(key => this.mws[key].interwikiMap.map); - this._iwMap = new InterwikiMap([].concat(...maps)); - } -} - -/** - * Discovers the interlanguage network, starting from the given link. - * - * @param mwm {MediaWikiManager} the manager to use for caching and resolving pages - * @param title {string} the title of the page to start traversing at - * @param [errorCb] {function("error"|"warning"|null, *): void} a function handling errors and warnings - * @param [progressCb] {function(*): void} a function handling progress updates - * @returns network {Object} the discovered network - * @returns network.pages {Page[]} the pages in the network - * @returns network.redirects {Redirect[]} the redirects in the network - */ -export const discoverNetwork = async function(mwm, title, errorCb, progressCb) { - const pages = []; - const redirects = []; - - const history = []; - const queue = [new InterlangLink(mwm.baseLang, title)]; - while (queue.length > 0) { - progressCb("Checking " + queue[queue.length - 1] + ""); - - let next = queue.pop(); - if (history.some(it => it.equals(next))) - continue; - - // Normalize - const nextMw = await mwm.getMwOrWait(next.lang); - if (nextMw === undefined) { - history.push(next); - pages.push(new Page(mwm.getArticlePath(next), next, [], false)); - 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?`); - continue; - } - } - - next = nextMw.normalize(next); - if (history.some(it => it.equals(next))) - continue; - else - history.push(next); - - // Fetch interlang links - const result = await nextMw.getLangLinks(next.title); - if (result === undefined) { - pages.push(new Page(mwm.getArticlePath(next), next, [], false)); - continue; - } - - // Follow redirects - if (!result.link.equals(next)) { - redirects.push(...(result.redirects)); - next = result.link; - if (history.some(it => it.equals(next))) - continue; - else - history.push(next); - } - - // Create `Page` object - pages.push(new Page(mwm.getArticlePath(next), next, result.langLinks, true)); - queue.push(...(result.langLinks)); - } - - // Normalize links - pages.forEach(page => { - page.langLinks = page.langLinks.map(langLink => { - const mw = mwm.getMw(langLink.lang); - return mw !== undefined ? mw.normalize(langLink) : langLink; - }); - }); - - return {pages: pages, redirects: redirects}; -} diff --git a/src/main/js/MediaWiki.ts b/src/main/js/MediaWiki.ts new file mode 100644 index 0000000..275cbb6 --- /dev/null +++ b/src/main/js/MediaWiki.ts @@ -0,0 +1,796 @@ +import {couldNotConnectMessage, mergeStates} from "./Shared"; + + +/** + * A data class for combining a language and page title to identify a page. + * + * This is only an _identifier_ of a page, not the page itself. For information on the page such as the links it + * contains, whether it's a redirect, etc., see the `Page` class. + */ +export class InterlangLink { + /** + * The language of the wiki this page is of. + */ + readonly lang: string; + /** + * The title of the page. + */ + readonly title: string; + + + /** + * Constructs a new interlanguage link. + * + * @param lang the language of the wiki this page is of + * @param title the title of the page + */ + constructor(lang: string, title: string) { + this.lang = lang; + this.title = title; + } + + + /** + * Returns `true` if and only if the given object equals this `InterlangLink`. + * + * @param other the object to compare to this `InterlangLink` + * @return `true` if and only if the given object equals this `InterlangLink` + */ + equals(other: any): boolean { + return other instanceof InterlangLink && this.lang === other.lang && this.title === other.title; + } + + /** + * Returns `true` if and only if the given object equals this `InterlangLink`, ignoring the case of the titles. + * + * @param other the object to compare to this `InterlangLink` + * @return `true` if and only if the given object equals this `InterlangLink`, ignoring the case of the titles + */ + equalsIgnoringCase(other: any) { + return other instanceof InterlangLink && this.lang === other.lang + && this.title.toLowerCase() === other.title.toLowerCase(); + } + + /** + * Converts this `InterlangLink` to a string. + * + * @return the string representation of this `InterlangLink` + */ + toString(): string { + return `${this.lang}:${this.title}`; + } + + /** + * Returns a deep copy of this `InterlangLink`. + * + * @return the deep copy + */ + copy(): InterlangLink { + return new InterlangLink(this.lang, this.title); + } +} + +/** + * Redirects one `InterlangLink` to another. + */ +export class Redirect { + /** + * The page that redirects. + */ + readonly from: InterlangLink; + /** + * The page that is redirected to. + */ + readonly to: InterlangLink; + + + /** + * Constructs a new `Redirect`. + * + * @param from the page that redirects + * @param to the page that is redirected to + */ + constructor(from: InterlangLink, to: InterlangLink) { + this.from = from.copy(); + this.to = to.copy(); + } + + + /** + * Returns `true` if and only if the given object equals this `Redirect`. + * + * @param other the object to compare to this `Redirect` + * @return `true` if and only if the given object equals this `Redirect` + */ + equals(other: any): boolean { + return other instanceof Redirect && this.from.equals(other.from) && this.to.equals(other.to); + } + + /** + * Returns a deep copy of this `Redirect`. + * + * This is a deep copy because the constructor performs copies of the received variables. + * + * @return the deep copy + */ + copy(): Redirect { + return new Redirect(this.from, this.to); + } +} + +/** + * A map of interwiki links. + * + * Not implemented as a map but as a list of objects. Therefore, when there are duplicate keys, the original value is + * always retained. + */ +// TODO: Replace entire class with a `Map` +export class InterwikiMap { + /** + * The mapping from interwiki abbreviations/prefixes to URLs. + */ + readonly map: Map; + + + /** + * Constructs a new interwiki map. + * + * @param map the mapping from interwiki abbreviations/prefixes to URLs + */ + constructor(map: { prefix: string, url: string }[]) { + this.map = new Map(); + map.forEach(({prefix, url}) => this.map.set(prefix, url.replace("http://", "https://"))); + } + + /** + * Constructs a new interwiki map from the given map. + * + * @param map the map to construct an interwiki map from + */ + static fromMap(map: Map): InterwikiMap { + return new InterwikiMap([...map.entries()].map(it => ({prefix: it[0], url: it[1]}))); + } + + + /** + * Returns the URL for the given prefix, or `undefined` if the prefix could not be found. + * + * @param prefix the prefix to return the URL of + * @return the URL for the given prefix, or `undefined` if the prefix could not be found + */ + getUrl(prefix: string): string | undefined { + return this.map.get(prefix); + } + + /** + * Returns `true` if and only if this map has a URL for the given prefix. + * + * @param prefix the prefix to check for + * @return `true` if and only if this map has a URL for the given prefix + */ + hasUrl(prefix: string): boolean { + return this.map.has(prefix); + } + + /** + * Returns a deep copy of this `InterwikiMap`. + * + * This is a deep copy because the constructor performs copies of the received variables. + * + * @return the deep copy + */ + copy(): InterwikiMap { + return InterwikiMap.fromMap(this.map); + } +} + +/** + * Describes a page, i.e. what you get if you follow an `InterlangLink`. + */ +export class Page { + /** + * The full URL at which this page is located. + */ + readonly url: URL; + /** + * The interlanguage link describing the location of the page. + */ + readonly link: InterlangLink; + /** + * The interlanguage links contained in this page. + */ + readonly langLinks: InterlangLink[]; + /** + * `true` if and only if this page exists. + */ + readonly exists: boolean; + + + /** + * Constructs a new page. + * + * @param url the full URL at which this page is located + * @param link the interlanguage link describing the location of the page + * @param langLinks the interlanguage links contained in this page + * @param exists `true` if and only if this page exists + */ + constructor(url: URL, link: InterlangLink, langLinks: InterlangLink[], exists: boolean) { + this.url = new URL(url.toString()); + this.link = link.copy(); + this.langLinks = langLinks.map(it => it.copy()); + this.exists = exists; + } + + + /** + * Returns `true` if and only if this page's language links are sorted alphabetically. + * + * @return `true` if and only if this page's language links are sorted alphabetically + */ + langLinksAreOrdered(): boolean { + return this.langLinks.reduce( + (isSorted: boolean, langLink: InterlangLink, i: number, self: InterlangLink[]) => + i === 0 || (isSorted && self[i - 1].toString().localeCompare(langLink.toString()) <= 0), + true + ); + } + + /** + * Returns `true` if and only if this page has multiple links to the same language. + * + * @return `true` if and only if this page has multiple links to the same language + */ + hasDoubleLinks(): boolean { + return this.langLinks.some(a => this.langLinks.filter(b => a.lang === b.lang).length > 1); + } + + + /** + * Returns a deep copy of this `Page`. + * + * This is a deep copy because the constructor performs copies of the received variables. + * + * @return the deep copy + */ + copy() { + return new Page(this.url, this.link, this.langLinks, this.exists); + } +} + +/** + * A network of pages linking to each other.y + */ +export class InterlangNetwork { + /** + * The alphabetically-sorted pages that have been discovered in the network. + */ + readonly pages: Page[]; + /** + * The redirects that have been discovered in the network. + */ + readonly redirects: Redirect[]; + + + /** + * Constructs a new `InterlangNetwork`. + * + * @param pages the pages linking to each other + * @param redirects the redirects in the network + */ + constructor(pages: Page[], redirects: Redirect[]) { + this.pages = pages + .map(it => it.copy()) + .sort((a, b) => a.link.toString().localeCompare(b.link.toString())); + this.redirects = redirects.map(it => it.copy()); + } + + + /** + * Determines whether the given source links to the given destination, potentially through a redirect. + * + * @param source the source page of which to check the links + * @param destination the destination that could be linked to + * @return the checker's verdict of the link + */ + getLinkVerdict(source: Page, destination: Page): LinkVerdict { + const isSelfLangLink = source.link.lang === destination.link.lang; + + if (source.langLinks.some(it => it.equals(destination.link))) + return isSelfLangLink ? "self-linked" : "linked"; + + if (source.langLinks.some(it => it.equalsIgnoringCase(destination.link))) + return isSelfLangLink ? "self-linked" : "wrongly-cased"; + + if (source.langLinks.some(link => this.redirects.some(it => it.equals(new Redirect(link, destination.link))))) + return isSelfLangLink ? "self-linked" : "redirected"; + + return isSelfLangLink ? "self-unlinked" : "unlinked"; + } + + /** + * Analyzes the given source page and returns a verdict of its own state and of the state of its link to all other + * pages in this network. + * + * @param srcPage the page to give a verdict of + * @return the checker's verdicts of the page and its outgoing links + */ + getPageVerdict(srcPage: Page): { self: PageVerdict[], pages: { page: Page, verdict: LinkVerdict }[] } { + const pageStates = this.pages.map(dstPage => ({page: dstPage, verdict: this.getLinkVerdict(srcPage, dstPage)})); + + let selfStates: PageVerdict[] = []; + if (!srcPage.exists) + selfStates.push("not-found"); + if (!srcPage.langLinksAreOrdered()) + selfStates.push("wrongly-ordered"); + if (srcPage.hasDoubleLinks()) + selfStates.push("doubly-linked"); + if (pageStates.some(({verdict}) => verdict === "self-linked")) + selfStates.push("self-linked"); + if (pageStates.some(({verdict}) => verdict === "unlinked")) + selfStates.push("unlinked"); + if (pageStates.some(({verdict}) => verdict === "redirected")) + selfStates.push("redirected"); + if (pageStates.some(({verdict}) => verdict === "wrongly-cased")) + selfStates.push("wrongly-cased"); + + if (selfStates.length === 0) + selfStates.push("perfect"); + + return {self: selfStates, pages: pageStates}; + } + + /** + * Returns a verdict on the network. + * + * @return a verdict on the network + */ + getNetworkVerdict(): NetworkVerdict { + const states: NetworkVerdict[] = ["broken", "flawed", "perfect"]; + return this.pages.reduce( + (state: NetworkVerdict, page: Page) => { + const verdict = this.getPageVerdict(page).self; + if (verdict.some(it => ["not-found", "unlinked"].includes(it))) + return mergeStates(states, state, "broken"); + if (verdict.some(it => ["wrongly-ordered", "doubly-linked", "self-linked", "redirected", "wrongly-cased"].includes(it))) + return mergeStates(states, state, "flawed"); + return mergeStates(states, state, "perfect"); + }, + "perfect" + ); + } + + /** + * Returns a deep copy of this `InterlangNetwork`. + * + * This is a deep copy because the constructor performs copies of the received variables. + * + * @return the deep copy + */ + copy(): InterlangNetwork { + return new InterlangNetwork(this.pages, this.redirects); + } +} + + +/** + * Interacts with the API in an asynchronous manner. + */ +export class MediaWiki { + /** + * The origin of the wiki's API URL. + */ + readonly origin: string; + /** + * The path relative to the wiki's API; starts with a `/`. + */ + readonly apiPath: string; + + /** + * The general information, retrieved from the API. + */ + general!: { articlepath: string, lang: string }; + /** + * The interwiki map of this wiki. + */ + interwikiMap!: InterwikiMap; + /** + * The namespaces on this wiki. + */ + namespaces!: Map; + + + /** + * Constructs a new MediaWiki object. + * + * The `#init` method **must** be called before invoking any other function. Behavior is undefined otherwise. + * + * @param apiUrl the url to the `api.php` file + */ + constructor(apiUrl: string) { + const urlObj = new URL(apiUrl); + this.origin = urlObj.origin; + this.apiPath = urlObj.pathname; + } + + /** + * Initializes this `MediaWiki` object with the necessary information from the API. + * + * @return this `MediaWiki` object + */ + async init(): Promise { + const query = await this.getSiteInfo("general", "interwikimap", "namespaces"); + + // Add self to map + query.interwikimap.push({prefix: query.general.lang, url: query.general.server + query.general.articlepath}); + + // Set fields + this.general = query.general; + this.interwikiMap = new InterwikiMap(query.interwikimap); + this.namespaces = query.namespaces; + + return this; + } + + + /** + * Sends a request to the MediaWiki API and runs the given callback on the response. + * + * @param params the parameters to send to the API + * @return the API's response + */ + request(params: { [key: string]: string }): Promise { + const url = this.origin + this.apiPath + "?format=json&origin=*&" + new URLSearchParams(params).toString(); + console.debug(`Requesting from ${this.origin}${this.apiPath} with params`, params, "at", url); + return fetch(url) + .then(response => { + if (!response.ok) throw new Error(couldNotConnectMessage); + return response.json(); + }) + .catch(() => { + throw new Error(couldNotConnectMessage); + }); + } + + /** + * Requests all language links on the given article. + * + * @param title the title of the article to return links of + * @return result the query result, or `undefined` if the article could not be found + */ + getLangLinks(title: string): Promise<{ link: InterlangLink, langLinks: InterlangLink[], redirects: Redirect[] } | undefined> { + return this + .request({action: "parse", page: title, prop: "langlinks", redirects: ""}) + .then(response => { + if (response.error !== undefined) + return undefined; + + const langLinks = response.parse.langlinks + .map((it: { lang: string, "*": string }) => new InterlangLink(it.lang, it["*"])); + const redirects = response.parse.redirects + .map((it: { from: string; to: string; }) => new Redirect(this.toLink(it.from), this.toLink(it.to))) + .reduce((redirects: Redirect[], redirect: Redirect, _: number, self: Redirect[]) => { + // TODO Support triple redirects (#30) + const matches = self.filter(it => it.from.equals(redirect.to)); + if (matches.length > 1) + redirects.push(new Redirect(redirect.from, matches[0].to)); + else + redirects.push(redirect); + + return redirects; + }, []); + + return {link: this.toLink(response.parse.title), langLinks: langLinks, redirects: redirects}; + }); + } + + /** + * Returns this wiki's site information. + * + * @param props the site information properties to retrieve, such as "general" or "interwikimap" + * @return the wiki's site information, with each property corresponding to an argument to this method + */ + getSiteInfo(...props: string[]): any { + return this.request({action: "query", meta: "siteinfo", siprop: props.join("|")}) + .then(response => response.query); + } + + /** + * Normalizes the given link, adjusting its language to this wiki's language and replacing the link's namespace with + * the canonical namespace. + * + * @param link the link to normalize + * @return the normalized link + */ + normalize(link: InterlangLink): InterlangLink { + const normalLang = this.general.lang; + + const titleParts = link.title.split(":"); + if (titleParts.length < 2) return new InterlangLink(normalLang, link.title); + + titleParts[0] = [...this.namespaces.values()].reduce( + (titlePart: string, namespace: { id: string, canonical: string, "*": string }) => { + return titlePart === namespace["canonical"] ? namespace["*"] : titlePart; + }, + titleParts[0] + ); + const normalTitle = titleParts.join(":"); + + return new InterlangLink(normalLang, normalTitle); + } + + + /** + * Shorthand for converting a title to an `InterlangLink` of this wiki's language. + * + * @param title the title of the article to generate a link for + * @return the link to the article on this wiki + * @private + */ + private toLink(title: string): InterlangLink { + return new InterlangLink(this.general.lang, title); + } +} + +/** + * Manages a `MediaWiki` instance for different languages, caching retrieved information for re-use. + */ +export class MediaWikiManager { + /** + * The combined interwiki map of all `MediaWiki` instances under management of this manager. + * + * @private + */ + private iwMap: InterwikiMap; + + /** + * The cached `MediaWiki` instances + */ + mws: Map; + /** + * The language of the base `MediaWiki`, where the exploration starts. + */ + baseLang!: string; + /** + * The path to articles, where `$1` indicates the article name. + */ + articlePath!: string; + /** + * The path to `api.php`. + */ + apiPath!: string; + + + /** + * Constructs a new MediaWiki manager. + * + * The `#init` method **must** be called before invoking any other function. Behavior is undefined otherwise. + */ + constructor() { + this.mws = new Map(); + this.iwMap = new InterwikiMap([]); + } + + /** + * Initializes this `MediaWikiManager`. + * + * @param baseMw the `MediaWiki` that is used as a starting point + * @return this `MediaWikiManager` + */ + async init(baseMw: MediaWiki): Promise { + const basePath = [...(baseMw.apiPath)] + .map((it, i) => it === baseMw.general.articlepath[i] ? it : "") + .join("") + .slice(0, -1); + + this.articlePath = baseMw.general.articlepath.slice(basePath.length); + this.apiPath = baseMw.apiPath.slice(basePath.length); + this.baseLang = baseMw.general.lang; + + this.mws.set(baseMw.general.lang, baseMw); + this.updateIwMap(); + + return this; + } + + + /** + * Returns the `MediaWiki` for the given language, creating and initializing it if necessary, or `undefined` if it + * could not be created. + * + * @param lang the language of the `MediaWiki` to return + * @return the `MediaWiki` for the given language, or `undefined` if it could not be created + */ + async getMwOrWait(lang: string): Promise { + if (this.hasMw(lang)) + return this.mws.get(lang); + + if (!this.iwMap.hasUrl(lang)) + return undefined; + + const url = this.iwMap.getUrl(lang); + if (url === undefined) return undefined; + + let newMw; + try { + newMw = await new MediaWiki(url.slice(0, -this.articlePath.length) + this.apiPath).init(); + } catch (error) { + return undefined; + } + + if (this.hasMw(newMw.general.lang)) { + // Duplicate MW with different but equivalent language code; destroy new MW instance + this.mws.set(lang, this.mws.get(newMw.general.lang)!); + } else { + this.mws.set(newMw.general.lang, newMw); + this.mws.set(lang, newMw); + } + this.updateIwMap(); + + return this.mws.get(lang); + } + + /** + * Returns the `MediaWiki` for the given language or `undefined` if it has not created that object. + * + * @param lang the language of the `MediaWiki` to return + * @return the `MediaWiki` for the given language or `undefined` if it has not created that object + */ + getMw(lang: string): MediaWiki | undefined { + return this.mws.get(lang); + } + + /** + * Returns `true` if and only if this manager has a `MediaWiki` for the given language. + * + * @param lang the language of the `MediaWiki` to check presence of + * @return `true` if and only if this manager has a `MediaWiki` for the given language + */ + hasMw(lang: string): boolean { + return this.mws.has(lang); + } + + /** + * Returns the URL to the given article. + * + * @param link the link to return the URL of + * @return the URL to the given article + */ + getArticlePath(link: InterlangLink): URL { + const articlePath = this.iwMap.getUrl(link.lang); + if (articlePath === undefined) throw Error(`Could not find article path for '${link}'.`); + + return new URL(articlePath.replace("$1", link.title)); + } + + + /** + * Updates the `_iwMap` property with the entries in `MediaWiki` instances in this manager. + * + * @private + */ + private updateIwMap(): void { + this.iwMap = InterwikiMap.fromMap( + [...this.mws.values()] + .map(mw => mw.interwikiMap.map) + .reduce((combined, map) => new Map([...combined, ...map]), new Map()) + ); + } +} + +/** + * Discovers the interlanguage network, starting from the given link. + * + * @param mwm the manager to use for caching and resolving pages + * @param title the title of the page to start traversing at + * @param errorCb a function handling errors and warnings + * @param progressCb a function handling progress updates + * @return the discovered network, including pages and redirects + */ +export const discoverNetwork = async function( + mwm: MediaWikiManager, + title: string, + errorCb: (level: "error" | "warning" | null, message: string) => void, + progressCb: (message: string) => void +): Promise<{ pages: Page[], redirects: Redirect[] }> { + const pages = []; + const redirects = []; + + const history: InterlangLink[] = []; + const queue: InterlangLink[] = [new InterlangLink(mwm.baseLang, title)]; + while (queue.length > 0) { + progressCb("Checking " + queue[queue.length - 1] + ""); + + let next = queue.pop()!; + if (history.some(it => it.equals(next))) + continue; + + // Normalize + const nextMw = await mwm.getMwOrWait(next.lang); + if (nextMw === undefined) { + history.push(next); + pages.push(new Page(mwm.getArticlePath(next), next, [], false)); + 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?`); + continue; + } + } + + next = nextMw.normalize(next); + if (history.some(it => it.equals(next))) + continue; + else + history.push(next); + + // Fetch interlang links + const result = await nextMw.getLangLinks(next.title); + if (result === undefined) { + pages.push(new Page(mwm.getArticlePath(next), next, [], false)); + continue; + } + + // Follow redirects + if (!result.link.equals(next)) { + redirects.push(...(result.redirects)); + next = result.link; + if (history.some(it => it.equals(next))) + continue; + else + history.push(next); + } + + // Create `Page` object + pages.push(new Page(mwm.getArticlePath(next), next, result.langLinks, true)); + queue.push(...(result.langLinks)); + } + + // Normalize links + pages.forEach(page => { + page.langLinks.map((langLink, idx, self) => { + const mw = mwm.getMw(langLink.lang); + // Update link in place using `self[idx] = ` + self[idx] = mw !== undefined ? mw.normalize(langLink) : langLink; + }); + }); + + return {pages: pages, redirects: redirects}; +}; + + +/** + * The verdict that the checker has of a link between two pages. + * + * The possible values are listed in decreasing order of importance, so that if a single link has multiple verdicts but + * only one can be displayed, the one with the highest importance will be displayed. + */ +type LinkVerdict = "linked" + | "self-linked" + | "unlinked" + | "self-unlinked" + | "redirected" + | "wrongly-cased"; + +/** + * The verdict that the checker has of a page. + * + * The possible values are listed in decreasing order of importance, so that if a single page has multiple verdicts but + * only one can be displayed, the one with the highest importance will be displayed. + */ +type PageVerdict = + "perfect" + | "not-found" + | "wrongly-ordered" + | "doubly-linked" + | "self-linked" + | "unlinked" + | "redirected" + | "wrongly-cased"; + +/** + * The verdict that the checker has of a network. + */ +type NetworkVerdict = + | "perfect" + | "flawed" + | "broken"; diff --git a/src/main/js/Shared.js b/src/main/js/Shared.js deleted file mode 100644 index 2a68d3d..0000000 --- a/src/main/js/Shared.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * The message that is displayed when the application fails to connect to the API. - * - * @type {string} - */ -export const couldNotConnectMessage = - "Could not to connect to API. Is the URL correct? Are you using a script blocker? " + - "See the About section for more information." - -/** - * Returns the status that has the lowest index in the given list of statuses. - * - * @param statuses {string[]} the statuses to look the given statuses up in - * @param status1 {string} the first status - * @param status2 {string} the second status - * @returns {string} the status that has the lowest index in the given list of statuses - */ -export const mergeStates = (statuses, status1, status2) => { - return statuses[Math.min(statuses.indexOf(status1), statuses.indexOf(status2))]; -}; diff --git a/src/main/js/Shared.ts b/src/main/js/Shared.ts new file mode 100644 index 0000000..ae0502e --- /dev/null +++ b/src/main/js/Shared.ts @@ -0,0 +1,18 @@ +/** + * 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? " + + "See the About section for more information."; + +/** + * Returns the status that has the lowest index in the given list of statuses. + * + * @param statuses the statuses to look the given statuses up in + * @param status1 the first status + * @param status2 the second status + * @return the status that has the lowest index in the given list of statuses + */ +export const mergeStates = (statuses: T[], status1: T, status2: T): T => { + return statuses[Math.min(statuses.indexOf(status1), statuses.indexOf(status2))]; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..69c22e6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es6", + "strict": true, + "rootDir": "./src/main/js/", + "outDir": "./dist/js/" + }, + "include": [ + "src/main/js/**/*.ts" + ] +}