From f725d4671a6152fcb09764ab0df580cc96497f40 Mon Sep 17 00:00:00 2001 From: Selis <12716288+ItsSelis@users.noreply.github.com> Date: Wed, 17 Sep 2025 21:45:34 +0200 Subject: [PATCH] Implements Autowiki (#18480) * Implements Autowiki * Disable autowiki for vending machines for now --- .github/workflows/autowiki.yml | 45 ++++++++++++++ code/_compile_options.dm | 4 ++ code/_helpers/text.dm | 4 ++ code/modules/autowiki/autowiki.dm | 33 ++++++++++ code/modules/autowiki/page/base.dm | 59 ++++++++++++++++++ code/modules/autowiki/page/techweb.dm | 68 +++++++++++++++++++++ code/modules/autowiki/page/vending.dm | 59 ++++++++++++++++++ code/modules/unit_tests/_unit_tests.dm | 1 + code/modules/unit_tests/autowiki.dm | 35 +++++++++++ tools/autowiki/autowiki.js | 85 ++++++++++++++++++++++++++ tools/autowiki/package.json | 10 +++ vorestation.dme | 3 + 12 files changed, 406 insertions(+) create mode 100644 .github/workflows/autowiki.yml create mode 100644 code/modules/autowiki/autowiki.dm create mode 100644 code/modules/autowiki/page/base.dm create mode 100644 code/modules/autowiki/page/techweb.dm create mode 100644 code/modules/autowiki/page/vending.dm create mode 100644 code/modules/unit_tests/autowiki.dm create mode 100644 tools/autowiki/autowiki.js create mode 100644 tools/autowiki/package.json diff --git a/.github/workflows/autowiki.yml b/.github/workflows/autowiki.yml new file mode 100644 index 00000000000..2f6cadbbf92 --- /dev/null +++ b/.github/workflows/autowiki.yml @@ -0,0 +1,45 @@ +name: Autowiki +on: + schedule: + - cron: "5 4 * * *" + workflow_dispatch: +permissions: + contents: read + +jobs: + autowiki: + runs-on: ubuntu-latest + steps: + - name: "Check for AUTOWIKI_USERNAME" + id: secrets_set + env: + ENABLER_SECRET: ${{ secrets.AUTOWIKI_USERNAME }} + run: | + unset SECRET_EXISTS + if [ -n "$ENABLER_SECRET" ]; then SECRET_EXISTS=true ; fi + echo "SECRETS_ENABLED=$SECRET_EXISTS" >> $GITHUB_OUTPUT + - name: Checkout + if: steps.secrets_set.outputs.SECRETS_ENABLED + uses: actions/checkout@v5 + - name: Install BYOND + if: steps.secrets_set.outputs.SECRETS_ENABLED + uses: ./.github/actions/restore_or_install_byond + - name: Install rust-g + if: steps.secrets_set.outputs.SECRETS_ENABLED + run: | + bash tools/ci/install_rust_g.sh + - name: Compile and generate Autowiki files + if: steps.secrets_set.outputs.SECRETS_ENABLED + run: | + source $HOME/BYOND/byond/bin/byondsetup + tools/build/build.sh --ci autowiki + - name: Run Autowiki + if: steps.secrets_set.outputs.SECRETS_ENABLED + env: + USERNAME: ${{ secrets.AUTOWIKI_USERNAME }} + PASSWORD: ${{ secrets.AUTOWIKI_PASSWORD }} + run: | + cd tools/autowiki + npm install + cd ../.. + node tools/autowiki/autowiki.js data/autowiki_edits.txt data/autowiki_files/ diff --git a/code/_compile_options.dm b/code/_compile_options.dm index bc43d51fa2b..872beb1c8ab 100644 --- a/code/_compile_options.dm +++ b/code/_compile_options.dm @@ -3,5 +3,9 @@ #define USE_CUSTOM_ERROR_HANDLER #endif +/// If this is uncommented, Autowiki will generate edits and shut down the server. +/// Prefer the autowiki build target instead. +// #define AUTOWIKI + // We do not have dreamlua implemented #define DISABLE_DREAMLUAU diff --git a/code/_helpers/text.dm b/code/_helpers/text.dm index 35d0441b112..09f048f62d6 100644 --- a/code/_helpers/text.dm +++ b/code/_helpers/text.dm @@ -788,3 +788,7 @@ GLOBAL_LIST_EMPTY(text_tag_cache) text2num(semver_regex.group[2]), text2num(semver_regex.group[3]), ) + +///Properly format a string of text by using replacetext() +/proc/format_text(text) + return replacetext(replacetext(text,"\proper ",""),"\improper ","") diff --git a/code/modules/autowiki/autowiki.dm b/code/modules/autowiki/autowiki.dm new file mode 100644 index 00000000000..f7a7d957c3e --- /dev/null +++ b/code/modules/autowiki/autowiki.dm @@ -0,0 +1,33 @@ +/// When the `AUTOWIKI` define is enabled, will generate an output file for tools/autowiki/autowiki.js to consume. +/// Autowiki code intentionally still *exists* even without the define, to ensure developers notice +/// when they break it immediately, rather than until CI or worse, call time. +#if defined(AUTOWIKI) || defined(UNIT_TESTS) +/proc/setup_autowiki() + Master.sleep_offline_after_initializations = FALSE + SSticker.OnRoundstart(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(generate_autowiki))) + SSticker.start_immediately = TRUE + CONFIG_SET(number/round_end_countdown, 0) + +/proc/generate_autowiki() + var/output = generate_autowiki_output() + rustg_file_write(output, "data/autowiki_edits.txt") + qdel(world) +#endif + +/// Returns a string of the autowiki output file +/proc/generate_autowiki_output() + var/total_output = "" + + for (var/datum/autowiki/autowiki_type as anything in subtypesof(/datum/autowiki)) + var/datum/autowiki/autowiki = new autowiki_type + var/output = autowiki.generate() + + if (!istext(output)) + CRASH("[autowiki_type] does not generate a proper output!") + + total_output += json_encode(list( + "title" = autowiki.page, + "text" = output, + )) + "\n" + + return total_output diff --git a/code/modules/autowiki/page/base.dm b/code/modules/autowiki/page/base.dm new file mode 100644 index 00000000000..ce32bfd4032 --- /dev/null +++ b/code/modules/autowiki/page/base.dm @@ -0,0 +1,59 @@ +/// A representation of an automated wiki page. +/datum/autowiki + /// The page on the wiki to be replaced. + /// This should never be a user-facing page, like "Guide to circuits". + /// It should always be a template that only Autowiki should touch. + /// For example: "Template:Autowiki/CircuitInfo". + var/page + +/// Override and return the new text of the page. +/// This proc can be impure, usually to call `upload_file`. +/datum/autowiki/proc/generate() + SHOULD_CALL_PARENT(FALSE) + CRASH("[type] does not implement generate()!") + +/// Generates an auto formatted template user. +/// Your autowiki should ideally be a *lot* of these. +/// It lets wiki editors edit it much easier later, without having to enter repo. +/// Parameters will be passed in by name. That means your template should expect +/// something that looks like `{{ Autowiki_Circuit|name=Combiner|description=This combines }}` +/// Lists, which must be array-like (no keys), will be turned into a flat list with their key and a number, +/// such that list("food" = list("fruit", "candy")) -> food1=fruit|food2=candy +/datum/autowiki/proc/include_template(name, parameters) + var/template_text = "{{[name]" + + var/list/prepared_parameters = list() + for (var/key in parameters) + var/value = parameters[key] + if (islist(value)) + for (var/index in 1 to length(value)) + prepared_parameters["[key][index]"] = "[value[index]]" + else + prepared_parameters[key] = value + + for (var/parameter_name in prepared_parameters) + template_text += "|[parameter_name]=" + template_text += "[prepared_parameters[parameter_name]]" + + template_text += "}}" + + return template_text + +/// Takes an icon and uploads it to Autowiki-name.png. +/// Do your best to make sure this is unique, so it doesn't clash with other autowiki icons. +/datum/autowiki/proc/upload_icon(icon/icon, name) + // Fuck you + if (IsAdminAdvancedProcCall()) + return + + var/static/uploaded_icons = list() + if(uploaded_icons["[name]"]) + CRASH("We tried uploading an icon, but the name \"[name]\" was already taken!") + + fcopy(icon, "data/autowiki_files/[name].png") + uploaded_icons["[name]"] = TRUE + +/// Escape a parameter such that it can be correctly put inside a wiki output +/datum/autowiki/proc/escape_value(parameter) + // | is a special character in MediaWiki, and must be escaped by...using another template. + return replacetextEx(parameter, "|", "{{!}}") diff --git a/code/modules/autowiki/page/techweb.dm b/code/modules/autowiki/page/techweb.dm new file mode 100644 index 00000000000..99b97667037 --- /dev/null +++ b/code/modules/autowiki/page/techweb.dm @@ -0,0 +1,68 @@ +/datum/autowiki/techweb + page = "Template:Autowiki/Content/Techweb" + +/datum/autowiki/techweb/generate() + var/output = "" + + for (var/node_id in sortList(SSresearch.techweb_nodes, GLOBAL_PROC_REF(sort_research_nodes))) + var/datum/techweb_node/node = SSresearch.techweb_nodes[node_id] + if (!node.show_on_wiki) + continue + + if (!valid_node(node)) + continue + + output += "\n\n" + include_template("Autowiki/TechwebEntry", list( + "name" = escape_value(node.display_name), + "description" = escape_value(node.description), + "prerequisites" = generate_prerequisites(node.prereq_ids), + "designs" = generate_designs(node.design_ids), + )) + + return output + +/datum/autowiki/techweb/proc/valid_node(datum/techweb_node/node) + return !node.experimental + +/datum/autowiki/techweb/proc/generate_designs(list/design_ids) + var/output = "" + + for (var/design_id in design_ids) + var/datum/design_techweb/design = SSresearch.techweb_designs[design_id] + output += include_template("Autowiki/TechwebEntryDesign", list( + "name" = escape_value(design.name), + "description" = escape_value(design.get_description()), + )) + + return output + +/datum/autowiki/techweb/proc/generate_prerequisites(list/prereq_ids) + var/output = "" + + for (var/prereq_id in prereq_ids) + var/datum/techweb_node/node = SSresearch.techweb_nodes[prereq_id] + output += include_template("Autowiki/TechwebEntryPrerequisite", list( + "name" = escape_value(node.display_name), + )) + + return output + +/datum/autowiki/techweb/experimental + page = "Template:Autowiki/Content/Techweb/Experimental" + +/datum/autowiki/techweb/experimental/valid_node(datum/techweb_node/node) + return node.experimental + +/proc/sort_research_nodes(node_id_a, node_id_b) + var/datum/techweb_node/node_a = SSresearch.techweb_nodes[node_id_a] + var/datum/techweb_node/node_b = SSresearch.techweb_nodes[node_id_b] + + var/prereq_difference = node_a.prereq_ids.len - node_b.prereq_ids.len + if (prereq_difference != 0) + return prereq_difference + + var/experiment_difference = node_a.required_experiments.len - node_b.required_experiments.len + if (experiment_difference != 0) + return experiment_difference + + return sorttext(node_b.display_name, node_a.display_name) diff --git a/code/modules/autowiki/page/vending.dm b/code/modules/autowiki/page/vending.dm new file mode 100644 index 00000000000..74dc575900b --- /dev/null +++ b/code/modules/autowiki/page/vending.dm @@ -0,0 +1,59 @@ +/datum/autowiki/vending + page = "Template:Autowiki/Content/VendingMachines" + +/datum/autowiki/vending/generate() + var/output = "" + + var/list/cached_products = list() + + // `powered()` checks if its in a null loc to say it's not powered. + // So we put it inside, something + var/obj/parent = new + + for (var/obj/machinery/vending/vending_type as anything in sortList(subtypesof(/obj/machinery/vending), GLOBAL_PROC_REF(cmp_typepaths_asc))) + var/obj/machinery/vending/parent_machine = type2parent(vending_type) + if(initial(parent_machine.name) == initial(vending_type.name)) + continue //Same name, likely just a slightly touched up subtype for specific maps. + var/obj/machinery/vending/vending_machine = new vending_type(parent) + vending_machine.use_power = FALSE + vending_machine.update_icon(UPDATE_ICON_STATE) + + // Technically won't match if product amounts change, but this isn't likely + var/products_cache_key = vending_machine.products.Join("-") + "&" + vending_machine.contraband.Join("-") + "&" + vending_machine.premium.Join("-") + + // In the future, this should show all vending machines that have the same products + if (products_cache_key in cached_products) + qdel(vending_machine) + continue + + cached_products += products_cache_key + + var/filename = SANITIZE_FILENAME(escape_value(format_text(vending_machine.name))) + + output += include_template("Autowiki/VendingMachine", list( + "icon" = escape_value(filename), + "name" = escape_value(format_text(vending_machine.name)), + "products" = format_product_list(vending_machine.products), + "contraband" = format_product_list(vending_machine.contraband), + "premium" = format_product_list(vending_machine.premium), + )) + + // It would be cool to make this support gifs someday, but not now + upload_icon(getFlatIcon(vending_machine, no_anim = TRUE), filename) + + qdel(vending_machine) + + qdel(parent) + + return output + +/datum/autowiki/vending/proc/format_product_list(list/product_list) + var/output = "" + + for (var/obj/product_path as anything in product_list) + output += include_template("Autowiki/VendingMachineProduct", list( + "name" = escape_value(capitalize(format_text(initial(product_path.name)))), + "amount" = product_list[product_path], + )) + + return output diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm index c1c0f345cb2..b8e8094626e 100644 --- a/code/modules/unit_tests/_unit_tests.dm +++ b/code/modules/unit_tests/_unit_tests.dm @@ -88,6 +88,7 @@ // BEGIN_INCLUDE #include "asset_smart_cache.dm" +#include "autowiki.dm" //#include "clothing_tests.dm" // FIXME #include "component_tests.dm" #include "cosmetic_tests.dm" diff --git a/code/modules/unit_tests/autowiki.dm b/code/modules/unit_tests/autowiki.dm new file mode 100644 index 00000000000..65ec2e228dd --- /dev/null +++ b/code/modules/unit_tests/autowiki.dm @@ -0,0 +1,35 @@ +/// Tests that all autowikis generate something without runtiming +/datum/unit_test/autowiki + +/datum/unit_test/autowiki/Run() + TEST_ASSERT(istext(generate_autowiki_output()), "generate_autowiki_output() did not finish successfully!") + +/// Test that `include_template` produces reasonable results +/datum/unit_test/autowiki_include_template + +/datum/unit_test/autowiki_include_template/Run() + var/datum/autowiki/autowiki_api = new + + TEST_ASSERT_EQUAL( \ + autowiki_api.include_template("Template"), \ + "{{Template}}", \ + "Basic template did not format correctly" \ + ) + + TEST_ASSERT_EQUAL( \ + autowiki_api.include_template("Template", list("name" = "Mothblocks")), \ + "{{Template|name=Mothblocks}}", \ + "Template with basic arguments did not format correctly" \ + ) + + TEST_ASSERT_EQUAL( \ + autowiki_api.include_template("Template", list("name" = autowiki_api.escape_value("P|peline"))), \ + "{{Template|name=P{{!}}peline}}", \ + "Template with escaped arguments did not format correctly" \ + ) + + TEST_ASSERT_EQUAL( \ + autowiki_api.include_template("Template", list("food" = list("fruit", "candy"))), \ + "{{Template|food1=fruit|food2=candy}}", \ + "Template with array arguments did not format correctly" \ + ) diff --git a/tools/autowiki/autowiki.js b/tools/autowiki/autowiki.js new file mode 100644 index 00000000000..eaec48094ae --- /dev/null +++ b/tools/autowiki/autowiki.js @@ -0,0 +1,85 @@ +const fs = require('fs').promises; +const MWBot = require('mwbot'); + +const { USERNAME, PASSWORD } = process.env; + +if (!USERNAME) { + console.error('USERNAME was not set.'); + process.exit(1); +} + +if (!PASSWORD) { + console.error('PASSWORD was not set.'); + process.exit(1); +} + +const PAGE_EDIT_FILENAME = process.argv[2]; + +if (!PAGE_EDIT_FILENAME) { + console.error('No filename specified to edit pages'); + process.exit(1); +} + +const FILE_EDIT_FILENAME = process.argv[3]; + +if (!FILE_EDIT_FILENAME) { + console.error('No filename specified to edit files'); + process.exit(1); +} + +async function main() { + console.log(`Reading from ${PAGE_EDIT_FILENAME}`); + const editFile = await (await fs.readFile(PAGE_EDIT_FILENAME, 'utf8')).split( + '\n', + ); + + console.log(`Logging in as ${USERNAME}`); + + const bot = new MWBot(); + + await bot.loginGetEditToken({ + apiUrl: 'https://wiki.vore-station.net/api.php', + username: USERNAME, + password: PASSWORD, + }); + + console.log('Logged in'); + + // This is not Promise.all as to not flood with a bunch of traffic at once + for (const editLine of editFile) { + if (editLine.length === 0) { + continue; + } + + let { title, text } = JSON.parse(editLine); + text = + 'This page is automated by Autowiki. Do NOT edit it manually.' + + text; + + console.log(`Editing ${title}...`); + await bot.edit(title, text, `Autowiki edit @ ${new Date().toISOString()}`); + } + + // Same here + for (const asset of await fs.readdir(FILE_EDIT_FILENAME)) { + const assetPath = `${FILE_EDIT_FILENAME}/${asset}`; + const assetName = `Autowiki-${asset}`; + + console.log(`Replacing ${assetName}...`); + await bot + .upload( + assetName, + assetPath, + `Autowiki upload @ ${new Date().toISOString()}`, + ) + .catch((error) => { + if (error.code === 'fileexists-no-change') { + console.log(`${assetName} is an exact duplicate`); + } else { + return Promise.reject(error); + } + }); + } +} + +main().catch(console.error); diff --git a/tools/autowiki/package.json b/tools/autowiki/package.json new file mode 100644 index 00000000000..281ab8fc827 --- /dev/null +++ b/tools/autowiki/package.json @@ -0,0 +1,10 @@ +{ + "name": "autowiki", + "version": "1.0.0", + "description": "Automatically publish generated pages to the virgo wiki", + "main": "autowiki.js", + "author": "Mothblocks", + "dependencies": { + "mwbot": "^2.0.0" + } +} diff --git a/vorestation.dme b/vorestation.dme index 885b9f3f99a..5b1f7b92d28 100644 --- a/vorestation.dme +++ b/vorestation.dme @@ -2168,6 +2168,9 @@ #include "code\modules\asset_cache\iconforge\universal_icon.dm" #include "code\modules\asset_cache\transports\asset_transport.dm" #include "code\modules\asset_cache\transports\webroot_transport.dm" +#include "code\modules\autowiki\autowiki.dm" +#include "code\modules\autowiki\page\base.dm" +#include "code\modules\autowiki\page\techweb.dm" #include "code\modules\awaymissions\bluespaceartillery.dm" #include "code\modules\awaymissions\corpse.dm" #include "code\modules\awaymissions\exile.dm"