diff --git a/bin/add-edb-ids b/bin/add-edb-ids
index cbb1f07..84ccfa4 100755
--- a/bin/add-edb-ids
+++ b/bin/add-edb-ids
@@ -47,7 +47,8 @@ const pages = (await Promise.all(Object.entries(categoryTypes).map(async ([categ
console.log('Processing', pages.length, 'items\n');
-for (const {title, type} of pages) {
+for (const page of pages) {
+ const {title, type} = page;
// this runs serially with an artificial delay between requests to decrease
// the chance of sqenix sending ninjas to my house
await new Promise(resolve => setTimeout(resolve, 1000));
@@ -73,7 +74,7 @@ for (const {title, type} of pages) {
let updatedText;
try {
updatedText = insertInfoboxEDBID(originalText, edbID);
- diff(originalText, updatedText);
+ diff(originalText, updatedText, page);
} catch (error) {
console.log('Error inserting parameter:', error);
console.group('Bad page content:');
diff --git a/bin/add-gt-ids b/bin/add-gt-ids
index f6988be..f450704 100755
--- a/bin/add-gt-ids
+++ b/bin/add-gt-ids
@@ -1,8 +1,5 @@
#!/usr/bin/env -S node --env-file-if-exists=.env
-console.log(process.env);
-process.exit(1);
-
import {findItemGTID} from '../lib/api/xivapi.js';
import {getMediawikiClient} from '../lib/config.js';
import {diff} from '../lib/util/diff.js';
@@ -38,10 +35,11 @@ const mw = await getMediawikiClient();
// TODO: update this to work with the new maintenance category hierarchy
// Get pages in the "Missing internal ID" category from the main article namespace
-const itemPagesWithoutGTIDs = await mw.listCategoryPages('Category:Missing internal ID', [0], +process.env.LIMIT || 500);
-console.log('Processing', itemPagesWithoutGTIDs.length, 'item pages from [[Category:Missing internal ID]]\n');
+const itemPagesWithoutGTIDs = await mw.listCategoryPages('Category:Items with no internal ID specified', [0], +process.env.LIMIT || 500);
+console.log('Processing', itemPagesWithoutGTIDs.length, 'item pages from [[Category:Items with no internal ID specified]]\n');
-for (const {title} of itemPagesWithoutGTIDs) {
+for (const page of itemPagesWithoutGTIDs) {
+ const {title} = page;
console.log('Page:', title);
// look up on XIVAPI
let gtID;
@@ -68,7 +66,7 @@ for (const {title} of itemPagesWithoutGTIDs) {
let updatedText;
try {
updatedText = insertInfoboxGTBID(originalText, gtID);
- diff(originalText, updatedText);
+ diff(originalText, updatedText, page);
} catch (error) {
console.log('Error inserting parameter:', error);
console.group('Bad page content:');
diff --git a/bin/one-time/add-quest-sync-info b/bin/one-time/add-quest-sync-info
new file mode 100755
index 0000000..bf63da9
--- /dev/null
+++ b/bin/one-time/add-quest-sync-info
@@ -0,0 +1,60 @@
+#!/usr/bin/env -S node --env-file-if-exists=.env
+// Adds `quest-sync` parameters to quest infoboxes that don't have them
+
+import { findEDBQuestSync } from '../../lib/api/lodestone.js';
+import {getMediawikiClient} from '../../lib/config.js';
+import {diff} from '../../lib/util/diff.js';
+import { addParameterBesideExistingParameter } from '../../lib/util/template-parameters.js';
+
+const mw = await getMediawikiClient();
+
+const allquestcats = `
+Category:Main Scenario quests
+Category:Side Story quests
+Category:Guildleves
+Category:Sidequests
+Category:other quests
+Category:Daily quests
+Category:Feature quests
+Category:Repeatable Feature quests
+Category:Quasi-quests
+Category:Feature quasi-quests
+Category:Quests with no type specified
+`.split('\n').filter(s => s);
+console.log(allquestcats)
+
+const pages = (await Promise.all(allquestcats.map(category => mw.listCategoryPages(category, [0], 'max')))).flat();
+
+console.log(pages.length);
+
+function insertQuestSync (pageContent) {
+ if (pageContent.includes('quest-sync')) {
+ throw new Error('Page already contains a `quest-sync` parameter');
+ }
+ let result = addParameterBesideExistingParameter(pageContent, 'quest-sync', 'true', 'after', 'level');
+ if (result == null) throw new Error('Failed to add parameter');
+ return result;
+}
+
+const start = parseInt(process.env.START ?? '0', 10);
+if (start) {
+ console.log('Starting from item index', start);
+ pages.splice(0, start);
+}
+
+for (const [i, {title}] of Object.entries(pages)) {
+ console.log(start+parseInt(i), title);
+ const content = await mw.readPage(title);
+ const hasQuestSync = await findEDBQuestSync(title.replace(' (Quest)', ''));
+ if (!hasQuestSync) continue;
+ let newContent;
+ try {
+ newContent = insertQuestSync(content);
+ } catch (error) {
+ console.error(error);
+ continue;
+ }
+ diff(content, newContent);
+
+ await mw.editPage(title, newContent, 'Add quest-sync parameter', true);
+}
diff --git a/bin/one-time/flavor-text-color-fix b/bin/one-time/flavor-text-color-fix
new file mode 100755
index 0000000..984fa3d
--- /dev/null
+++ b/bin/one-time/flavor-text-color-fix
@@ -0,0 +1,52 @@
+#!/usr/bin/env -S node --env-file-if-exists=.env
+// Fixes flavor text formatting on files robogurgum created for patches 7.3/7.31
+// see https://discord.com/channels/312060255469305856/1412850908668100810
+
+import {getMediawikiClient} from '../../lib/config.js';
+import {diff} from '../../lib/util/diff.js';
+
+const mw = await getMediawikiClient();
+
+function fixPageContent (pageContent) {
+ const flavorTextRegexp = /\| flavor-text = ([\s\S]*?)\n\|/;
+
+ let match = pageContent.match(flavorTextRegexp);
+ if (!match) return pageContent;
+ let flavorText = match[1];
+ if (!flavorText.trim()) return pageContent;
+
+ let newFlavorText = flavorText
+ // linebreaks with
+ // - no
or
etc before or after
+ // - no {{action fact}} after (that renders a line break at the start)
+ .replace(/(?)\n(?!
)(?!{{action fact|\s*$)/gi, '
\n')
+ // spans that set color via inline style instead of using {{colorize}}
+ .replace(/([^<]+)<\/span>/g, '`$1`')
+ .replace(/([^<]+)<\/span>/g, '``$1``')
+ .replace(/([^<]+)<\/span>/g, '```$1```')
+ // convert lunar/phaenna credit bonuses to use {{action fact}}, but only
+ // when we can be sure it won't add extra line breaks
+ .replace(
+ /
(\n?)`([\w\d _-]+)(?<=Credit Bonus)(?::`|`:) +([^\n<]+)/g,
+ '$1{{action fact|$2|$3}}',
+ )
+ // flavor-text is automatically colorized, remove redundant invocations
+ .replace(/{{colorize\|([^|{}]+)}}/g, '$1');
+
+ return pageContent.replace(flavorTextRegexp, `| flavor-text = ${newFlavorText}\n|`);
+}
+
+const pages = await mw.listUserContribs('RoboGurgum', {namespaces: [0], show: 'new', limit: 1000});
+for (const page of pages) {
+ const {title} = page;
+ const pageContent = await mw.readPage(title);
+ let fixedContent = fixPageContent(pageContent);
+ if (fixedContent === pageContent) {
+ console.log('#', title, ' No change\n');
+ continue;
+ }
+ diff(pageContent, fixedContent, page);
+ console.log();
+
+ await mw.editPage(title, fixedContent, 'Fix flavor text formatting', true);
+}
diff --git a/lib/api/mediawiki.js b/lib/api/mediawiki.js
index 5dc2b7b..2c3d017 100644
--- a/lib/api/mediawiki.js
+++ b/lib/api/mediawiki.js
@@ -27,9 +27,26 @@ export class MediaWikiClient {
constructor (wikiURL, {killPage}) {
this.wikiURL = wikiURL;
this.killPage = killPage;
+ this.lastKillPageCheck = 0; // first check is triggered after login
this.fetch = makeFetchCookie(fetch);
}
+ /** Checks the kill page if needed, killing the process if not empty. */
+ async tryKillPageCheck () {
+ if (!this.killPage) return;
+ // wait at least 10 seconds since the last check
+ if (Date.now() > this.lastKillPageCheck + 10 * 1000) return;
+ this.lastKillPageCheck = Date.now();
+
+ const response = await this.fetch(`${this.wikiURL}/index.php?action=raw&title=${encodeURIComponent(this.killPage)}`);
+ const content = await response.text();
+ if (content.trim()) {
+ console.error('*** Kill page is not empty; stopping ***\n');
+ console.error(content);
+ process.exit(1);
+ }
+ };
+
/**
* Makes a GET request against `index.php`.
* @param {Record} params Query string parameters
@@ -71,6 +88,7 @@ export class MediaWikiClient {
* @returns {Promise}
*/
async fetchApiPost (params, options = {}) {
+ await this.tryKillPageCheck();
const response = await this.fetch(`${this.wikiURL}/api.php`, {
...options,
method: 'POST',
@@ -128,20 +146,6 @@ export class MediaWikiClient {
if (body.login.result === 'Failed') {
throw new Error(body.login.reason);
}
-
- if (this.killPage) {
- // start the kill page check loop as soon as we're logged in
- const checkKillPage = async () => {
- let content = await this.readPage(this.killPage);
- if (content.trim()) {
- console.error('*** Kill page is not empty; stopping ***\n');
- console.error(content);
- process.exit(1);
- }
- setTimeout(checkKillPage, 30 * 1000); // every 30 seconds
- };
- checkKillPage();
- }
}
/**
@@ -239,4 +243,28 @@ export class MediaWikiClient {
});
return body.query.categorymembers;
}
+
+ /**
+ * Gets the list of a user's contributions.
+ * @param {string} username Name of the user whose contribs should be fetched
+ * @param {object} options
+ * @param {number | number[] | '*'} [options.namespaces] List of namespaces from which to return results
+ * @param {number} [options.limit] Maximum number of items to return
+ * @param {string} [options.show] See the documentation of `ucshow` at https://www.mediawiki.org/wiki/API:Usercontribs
+ * @returns {Promise<{pageid: string; revid: string; timestamp: string; title: string}[]>}
+ */
+ async listUserContribs (username, {namespaces = '*', limit = 50, show}) {
+ if (Array.isArray(namespaces)) {
+ namespaces = namespaces.join('|');
+ }
+ const body = await this.fetchApiGet({
+ action: 'query',
+ list: 'usercontribs',
+ ucuser: username,
+ uclimit: limit,
+ ucnamespace: namespaces,
+ ucshow: show,
+ });
+ return body.query.usercontribs;
+ }
}
diff --git a/lib/util/diff.js b/lib/util/diff.js
index d2e2991..eaa2fc3 100644
--- a/lib/util/diff.js
+++ b/lib/util/diff.js
@@ -13,17 +13,26 @@ const toBase64 = str => Buffer.from(str, 'utf-8').toString('base64');
* terrible terrible terrible string diff helper for debugging
* @param {string} a
* @param {string} b
+ * @param {object} page Page object from the API, used to generate diff labels
*/
-export function diff (a, b) {
+export function diff (a, b, page) {
+ let labels = null;
+ if (page) { // generate diff labels based on
+ labels = [
+ `${page.title} (${page.revid ? `revision ${page.revid}` : 'current revision'}${page.timestamp ? ` from ${page.timestamp}` : ''})`,
+ `${page.title} (pending edit)`,
+ ];
+ }
// base64 input strings before passing to shell to avoid escaping issues
// https://stackoverflow.com/a/60221847
- // use tail to cut off useless file info lines
- execSync(String.raw`bash <<- EOF
+ // use tail to cut off file info lines and re-add with fake filenames/dates
+ // this function is so extra now holy shit
+ execSync(String.raw`bash <<- '_EOF_'
diff --color=always -u \
- <(echo "${toBase64(a)}" | base64 -d) \
- <(echo "${toBase64(b)}" | base64 -d) \
- | tail -n +3
- EOF`, {
+ ${labels ? `--label="$(echo '${toBase64(labels[0])}' | base64 -d)" ` : ''}<(echo '${toBase64(a)}' | base64 -d) \
+ ${labels ? `--label="$(echo '${toBase64(labels[1])}' | base64 -d)" ` : ''}<(echo '${toBase64(b)}' | base64 -d) \
+ ${labels ? `|| true` : `| tail -n +3` /* cut off header if no labels */}
+ _EOF_`, {
// display result directly in terminal
stdio: 'inherit',
});