Business Central Backup Automation

Automates the creation of backups of the Business Central database using Azure.

  1. // ==UserScript==
  2. // @name Business Central Backup Automation
  3. // @name:de Business Central Backup-Automatisierung
  4.  
  5. // @description Automates the creation of backups of the Business Central database using Azure.
  6. // @description:de Automatisierung des Erstellens von Backups von Business Central mittels Azure.
  7.  
  8. // @version 1.1.0
  9. // @author Rsge
  10. // @copyright 2025+, Jan G. (Rsge)
  11. // @license All rights reserved
  12. // @icon https://msweugwcas4004-a8arc8v.appservices.weu.businesscentral.dynamics.com/tenant/msweua1602t06326066/tab/92b102bf-7e05-4693-b322-e60777e7602f/Brand/Images/favicon.ico
  13.  
  14. // @namespace https://github.com/Rsge
  15. // @homepageURL https://github.com/Rsge/Business-Central-Auto-Backup
  16. // @supportURL https://github.com/Rsge/Business-Central-Auto-Backup/issues
  17.  
  18. // @match https://portal.azure.com/*
  19. // @match https://businesscentral.dynamics.com/*
  20.  
  21. // @run-at document-idle
  22. // @grant none
  23. // ==/UserScript==
  24.  
  25. (async function() {
  26. 'use strict';
  27.  
  28. // Constants
  29. const T = 1000;
  30. const LOC = window.location;
  31. const LINK_LABEL = "azureSasUrl";
  32. // Resources
  33. const ENV_IDX = 0; // Index (0-based) of environment to backup in list of BC Admin center
  34. const START_AUTOMATION_QUESTION = "Start backup automation?";
  35. const PASTE_SAS_URI_MSG = `<p>Sadly, automatic pasting of the SAS-URL doesn't seem possible.<br>
  36. The SAS-URL will be added to your clipboard.<br>
  37. Please paste it manually using <kbd><kbd>Strg</kbd>+<kbd>V</kbd></kbd>.<br>
  38. After pasting, the export will automatically be started immediately and the tab closed after 5 s.<br>
  39. It can then take around 15 minutes for the backup to shop up in Containers.</p>`;
  40.  
  41. // Variables
  42. let i;
  43.  
  44. // Basic functions
  45. function sleep(ms) {
  46. return new Promise(resolve => setTimeout(resolve, ms));
  47. }
  48. function getCookies() {
  49. return document.cookie.split(";")
  50. .map(function(cstr) {
  51. return cstr.trim().split("=");})
  52. .reduce(function(acc, curr) {
  53. acc[curr[0]] = curr[1];
  54. return acc;
  55. }, {});
  56. }
  57. function setCookie(key, value) {
  58. document.cookie = key + "=" + value + "; path=/";
  59. }
  60. function getXthElementByClassName(className, i = 0) {
  61. return document.getElementsByClassName(className)[i];
  62. }
  63. function getElementByClassNameAndTitle(className, title) {
  64. let items = Array.from(document.getElementsByClassName(className));
  65. const foundItem = items.find(
  66. e => e.title.startsWith(title)
  67. );
  68. return foundItem;
  69. }
  70. function getElementByClassNameAndText(className, text) {
  71. let items = Array.from(document.getElementsByClassName(className));
  72. const foundItem = items.find(
  73. e => e.textContent.startsWith(text)
  74. );
  75. return foundItem;
  76. }
  77. async function findClickWait(className, string, t, useText = false) {
  78. let element;
  79. if (useText) {
  80. element = getElementByClassNameAndText(className, string);
  81. } else {
  82. element = getElementByClassNameAndTitle(className, string);
  83. }
  84. if (element) {
  85. element.click();
  86. await sleep(t);
  87. return true;
  88. }
  89. return false;
  90. }
  91.  
  92. /* --------------------------------------------------- */
  93.  
  94. /* Custom dialog boxes */
  95.  
  96. // Base dialog
  97. async function cdialog(id, html) {
  98. // Create dialog HTML.
  99. let dialog = document.createElement("dialog");
  100. dialog.id = id;
  101. dialog.innerHTML = html;
  102. // Add dialog to site and show it.
  103. document.body.appendChild(dialog);
  104. dialog.showModal();
  105. // Wait for click on one of the buttons.
  106. return new Promise(function(resolve) {
  107. let buttons = dialog.getElementsByClassName("button")
  108. for (i = 0; i < buttons.length; i++) {
  109. let result = i == 0;
  110. buttons[i].addEventListener("click", function() {dialog.close(); resolve(result);});
  111. }
  112. });
  113. }
  114.  
  115. // Confirmation dialog
  116. async function yesNoDialog(msg) {
  117. return await cdialog("yesNoDialog", `<p>
  118. <label>${msg}</label>
  119. </p><p class="button-row">
  120. <button name="yesButton" class="button">Ja</button>
  121. <button name="noButton" class="button">Nein</button>
  122. </p>`);
  123. }
  124.  
  125. // Ok dialog
  126. async function okCancelDialog(msg) {
  127. return await cdialog("okCancelDialog", `<p>
  128. <label>${msg}</label>
  129. </p><p class="button-row">
  130. <button name="okButton" class="button">OK</button>
  131. <button name="cancelButton" class="button">Abbrechen</button>
  132. </p>`);
  133. }
  134.  
  135. /* --------------------------------------------------- */
  136.  
  137. /* Main */
  138. // Azure
  139. if (LOC.href.endsWith("azure.com/#home") &&
  140. await yesNoDialog(START_AUTOMATION_QUESTION)) {
  141. // Sidebar
  142. await findClickWait("fxs-topbar-sidebar-collapse-button", "Show portal menu", 0.5*T);
  143. // Storage accounts
  144. await findClickWait("fxs-sidebar-item-link", "Storage accounts", 3*T);
  145. // First storage account
  146. let accountCName = "fxc-gcflink-link"
  147. if (!getXthElementByClassName(accountCName)) {
  148. window.open("https://portal.azure.com/#blade/HubsExtension/BrowseResourceLegacy/resourceType/Microsoft.Storage%2FStorageAccounts", "_self");
  149. await sleep(3*T);
  150. }
  151. getXthElementByClassName(accountCName).click();
  152. await sleep(3*T);
  153. // Sidebar
  154. await findClickWait("fxs-topbar-sidebar-collapse-button", "Show portal menu", 0.5*T);
  155. // Shared access signature
  156. await findClickWait("fxc-menu-item", "Shared access signature", 4*T, true);
  157. // SAS form
  158. /// Checkboxes
  159. let ucFieldIDs = ["__field__6__", "__field__7__", "__field__8__", // Allowed services
  160. "__field__15__", "__field__16__", "__field__20__", "__field__21__"]; // Allowed permissions
  161. let cFieldIDs = ["__field__10__", "__field__11__", ]; // Allowed resource types
  162. for (let ucFieldID of ucFieldIDs) {
  163. let ucField = document.getElementById(ucFieldID);
  164. if (ucField.ariaChecked == true.toString()) {
  165. ucField.click();
  166. }
  167. }
  168. for (let cFieldID of cFieldIDs) {
  169. let cField = document.getElementById(cFieldID);
  170. if (cField.ariaChecked === false.toString()) {
  171. cField.click();
  172. }
  173. }
  174. /// Date
  175. let endDatePicker = getXthElementByClassName("azc-datePicker", 1);
  176. let datePanelOpener = endDatePicker.children[0].children[1];
  177. datePanelOpener.click();
  178. let datePanel = getXthElementByClassName("azc-datePanel");
  179. let todayBox = datePanel.getElementsByClassName("azc-datePanel-selected")[0];
  180. let weekArray = Array.from(todayBox.parentNode.children);
  181. let todayIdx = weekArray.indexOf(todayBox);
  182. let tomorrowBox = weekArray[todayIdx + 1];
  183. tomorrowBox.click();
  184. await sleep(T);
  185. /// Generate
  186. await findClickWait("fxc-simplebutton", "Generate SAS and connection string", 2*T, true);
  187. /// Copy
  188. let encLink = encodeURIComponent(getElementByClassNameAndTitle("azc-input azc-formControl", "https://").value);
  189. // Open BC
  190. let bcWindow = window.open("https://businesscentral.dynamics.com/?noSignUpCheck=1&" + LINK_LABEL + "=" + encLink)
  191. await sleep(T);
  192. // Sidebar
  193. await findClickWait("fxs-topbar-sidebar-collapse-button", "Show portal menu", 0.5*T);
  194. // Containers
  195. await findClickWait("fxc-menu-item", "Containers", 4*T, true);
  196. // First container
  197. getXthElementByClassName("ms-List-cell")?.children[0].children[1].children[0].children[0].children[1].click();
  198. await sleep(240*T);
  199. await findClickWait("azc-toolbarButton-label", "Refresh", T, true);
  200. } // BusinessCentral
  201. else if (LOC.host.startsWith("businesscentral")) {
  202. // Normal BC
  203. if (!LOC.pathname.endsWith("/admin")) {
  204. // Get SAS link from URL.
  205. let params = new URLSearchParams(LOC.search);
  206. const Link = encodeURIComponent(params.get(LINK_LABEL));
  207. // If no link found, exit for normal use.
  208. if (!Link) {
  209. return;
  210. }
  211. // Wait for loading of elements.
  212. let ranSettings = false;
  213. let ranAC = false;
  214. let observer = new MutationObserver(function(mutations) {
  215. mutations.forEach(function(mutation) {
  216. // "Disable" observer after Admin Center is clicked.
  217. if (ranAC) {
  218. return;
  219. }
  220. let node = mutation.addedNodes[0];
  221. if (node?.children != null && node.children.length > 0) {
  222. // Open settings dropdown.
  223. const SettingsButtonID = "O365_MainLink_Settings";
  224. if (!ranSettings && node.children[0].id == SettingsButtonID) {
  225. ranSettings = true;
  226. document.getElementById(SettingsButtonID).click();
  227. } // Open Admin Center (in new tab) and close current tab.
  228. // Also set a cookie with the SAS link for use in the Admin Center.
  229. else {
  230. let adminCenter = document.getElementById("AdminCenter")
  231. if (adminCenter) {
  232. ranAC = true;
  233. setCookie(LINK_LABEL, Link)
  234. adminCenter.click();
  235. window.close();
  236. }
  237. }
  238. }
  239. });
  240. });
  241. observer.observe(document.documentElement, {
  242. childList: true,
  243. subtree: true
  244. });
  245. } // BC Admin Center
  246. else {
  247. // Get SAS link from cookie.
  248. const Link = getCookies()[LINK_LABEL];
  249. // If no link found, exit for normal use.
  250. if (!Link || Link.length == 0) {
  251. return;
  252. }
  253. // Environments
  254. findClickWait("ms-Button ms-Button--action ms-Button--command", "Environments", 0);
  255. // Wait for loading of environments.
  256. let run = false;
  257. const EnvListClassName = "ms-List-page"
  258. let observer = new MutationObserver(function(mutations) {
  259. mutations.forEach(async function(mutation) {
  260. let node = mutation.addedNodes[0];
  261. //console.log(node);
  262. if (node?.children != null) {
  263. if (node.className == EnvListClassName) {
  264. // First environment
  265. let envList = getXthElementByClassName(EnvListClassName);
  266. let env = envList.children[ENV_IDX].children[0].children[0].children[0].children[0].children[0];
  267. env.click();
  268. await sleep(T);
  269. // Database dropdown
  270. await findClickWait("ms-Button ms-Button--commandBar ms-Button--hasMenu", "Database", 0.5*T);
  271. // Create export
  272. await findClickWait("ms-ContextualMenu-link", "Create database export");
  273. } else if (node.className.startsWith("ms-Layer ms-Layer--fixed")) {
  274. // Insert link
  275. let sasTxt = getElementByClassNameAndTitle("ms-TextField-field", "SAS URI from Azure");
  276. if (sasTxt) {
  277. let decLink = decodeURIComponent(Link);
  278. await sleep(T);
  279. if (await okCancelDialog(PASTE_SAS_URI_MSG)) {
  280. const inputHandler = async function(e) {
  281. if (e.target.value == decLink) {
  282. await sleep(T);
  283. await findClickWait("ms-Button ms-Button--primary", "Create", 5*T, true);
  284. window.close();
  285. }
  286. }
  287. sasTxt.addEventListener("input", inputHandler);
  288. navigator.clipboard.writeText(decLink);
  289. sasTxt.focus();
  290. }
  291. }
  292. }
  293. }
  294. });
  295. });
  296. observer.observe(document.documentElement, {
  297. childList: true,
  298. subtree: true
  299. });
  300. }
  301. }
  302. })();