Implementing console log in card template for AnkiDroid

I’ve been banging my head for a while at implementing some complex js for a card template and really needed the ability to debug why things worked on desktop but on AnkiDroid. :rage:

So, I implemented a custom consoleLog function just for doing that. :sweat_smile: : I hope other people will find this helpful. I’ve tested it on AnkiDroid but not AnkiMobile.

Custom consoleLog function

Add this toin your card template to debug things in AnkiDroid.

<script>
  // consoleLogMessages is an array of html strings which are the individual logs the custom console will show
  // To prevent lag, the number of stored messages is limited to 100
  globalThis.consoleLogMessages = globalThis.consoleLogMessages || [];
  // consoleLogMessageCount is only used for a pretty counter of the log
  // It's necessary due to the limiting of consoleLogMessages, otherwise
  // the index arg in .map() could be used.
  globalThis.consoleLogMessageCount = globalThis.consoleLogMessageCount || 0;


  // Update console content with current consoleLogMessages
  // This only needs to be done when showing the console
  function renderLogMessages() {
    const consoleElement = document.getElementById('custom-console');
    const startingMsgCount = Math.max(globalThis.consoleLogMessageCount - globalThis.consoleLogMessages.length, 0);
    // Limit stored messages to the last 100 to limit lag
    globalThis.consoleLogMessages = (globalThis.consoleLogMessages || []).slice(-100);
    consoleElement.innerHTML = ''
      + globalThis.consoleLogMessages.map((msg, i) => {
        const msgNUm = startingMsgCount + i + 1;
        const digitCount = parseInt(Math.log10(msgNUm));
        let zeroes = "";
        for (let j = 0; j < 4 - digitCount; j++) {
          zeroes += "0";
        }
        return `<div style="margin-bottom: 2px; border-bottom: 1px solid black;"><span style="float: right; font-size: 0.7em; color: #858585;">${zeroes}${msgNUm}</span>${msg}</div>`
      })
        .join('');
    // Auto-scroll to bottom
    consoleElement.scrollTop = consoleElement.scrollHeight;
    return consoleElement;
  }

  // Init function for inserting the two elements needed for the console into the card
  // the console itself and a toggle for showing/hiding the console
  function insertCustomLogElements() {
    // Create custom console element
    let customConsole = document.getElementById('custom-console');
    if (!customConsole) {
      customConsole = document.createElement('div');

      customConsole.id = 'custom-console';
      customConsole.style.cssText = 'position: absolute; top: 25px; right: 0; width:80%; max-height: 500px; overflow-y: auto; background-color: #333; color: #fff; font-size: 0.5em; font-weight: normal; border: 1px solid #ccc; padding: 10px; z-index: 1000; display: none; text-align: left;';
      customConsole.innerHTML = (globalThis.consoleLogMessages || []).join('');

      document.body.insertBefore(customConsole, document.body.firstChild);
    }

    // Create toggle button for console
    let toggleConsoleBtn = document.getElementById('toggle-console-btn');
    if (!toggleConsoleBtn) {
      toggleConsoleBtn = document.createElement('button');
      toggleConsoleBtn.id = 'toggle-console-btn';
      toggleConsoleBtn.style.cssText = 'position: absolute; top: 0; right: 0; background-color: #007bff; color: #fff; border: none; cursor: pointer; z-index: 1001;margin: 0;';
      if (globalThis.ankiPlatform === "desktop") {
        toggleConsoleBtn.style.cssText += " width: 15px; height: 15px; padding: 0px 0px 2px 0px;";
      } else {
        toggleConsoleBtn.style.cssText += " width: 25px; height: 25px; padding: 4px 4px 6px 4px;";
      }
      toggleConsoleBtn.innerHTML = '&#9660;'; // Downward arrow by default

      // Toggle visibility of the custom console
      toggleConsoleBtn.addEventListener('click', function toggleConsoleVisiblity() {
        const customConsoleCur = document.getElementById('custom-console');
        customConsoleCur.innerHTML = (globalThis.consoleLogMessages || []).join(''); // Update console with all logged messages
        customConsoleCur.scrollTop = customConsoleCur.scrollHeight; // Auto-scroll to bottom

        const toggleBtn = document.getElementById('toggle-console-btn');
        if (customConsoleCur.style.display === 'none') {
          customConsoleCur.style.display = 'block';
          toggleBtn.innerHTML = '&#9650;'; // Upward arrow when expanded
          // Console will be shown, so render the messages
          renderLogMessages();
        } else {
          customConsoleCur.style.display = 'none';
          toggleBtn.innerHTML = '&#9660;'; // Downward arrow when collapsed
        }
      })
      document.body.insertBefore(toggleConsoleBtn, document.body.firstChild);
    }
  }

  // Function to log messages to the custom console and store them in consoleLogMessages
  function consoleLog(...args) {
    // Also log to normal console, when testing changes to the console code on desktop
    // console.log(...args);
    globalThis.consoleLogMessageCount = globalThis.consoleLogMessageCount || 0;
    globalThis.consoleLogMessageCount++;
    var logHtml = '';
    args.forEach((arg, i) => {
      // Wrap first arg in span so that it's on the same line as the msg counter
      const wrapper = i === 0 ? 'span' : 'div';
      if (arg instanceof Error) {
        // Render stack trace for Error objects in red
        logHtml += `<${wrapper} style="color: #FF6B6B;">${arg.toString()}</${wrapper}>`;
        if (arg.stack) {
          logHtml += `<${wrapper} style="color: #FF6B6B;">${arg.stack.replace(/\n/g, '<br>')}</${wrapper}>`;
        }
      } else if ((typeof arg === 'object' && arg !== null) || typeof arg === 'function') {
        // Render objects and functions in light blue
        logHtml += `<${wrapper} style="color: #7FDBFF;">${stringifyLimited(arg)}</${wrapper}>`;
      } else if (typeof arg === 'string') {
        // Render other types of data as plain text in white
        logHtml += `<${wrapper} style="color: #FFFFFF;">${arg}</${wrapper}>`;
      } else {
        // null, undefined, numbers in gray
        logHtml += `<${wrapper} style="color: #AAAAAA;">${arg}</${wrapper}>`;
      }
    });

    // Using straight-up JSON.stringify on objects and arrays created logs that were 
    // way too long. The normal browser limits rendering objects and arrays to just 
    // the first level so this does the same. You can't expand the stuff like in a normal
    // console though
    function stringifyLimited(obj, depth = 1) {
      let res = '<span>'
      if (depth < 2) {
        if (Array.isArray(obj)) {
          res += '['
          obj.forEach((el, i) => {
            res += `<div>${[i]}: ${stringifyLimited(el, depth + 1)}</div>`
          })
          res += ']';
        } else if (typeof obj === 'object' && obj !== null) {
          res += '{'
          Object.entries(obj).forEach(([key, val]) => {
            res += `<div>${[key]}: ${stringifyLimited(val, depth + 1)}</div>`
          })
          res += '}';
        } else if (typeof obj === 'function') {
          res += `ƒ <i>${obj.name}()<i>`;
        } else {
          res += JSON.stringify(obj);
        }
      } else {
        if (Array.isArray(obj)) {
          res += `Array[${obj.length}]`;
        } else if (typeof obj === 'object' && obj !== null) {
          res += `Object[${Object.keys(obj).length}]`;
        } else if (typeof obj === 'function') {
          res += `ƒ <i>${obj.name}()</i>`;
        } else {
          res += JSON.stringify(obj);
        }
      }
      res += '</span>';
      return res;
    }

    // Just in case, ensure consoleLogMessages is initialized here too
    globalThis.consoleLogMessages = globalThis.consoleLogMessages || [];
    globalThis.consoleLogMessages.push(logHtml); // Store the log message as HTML string
    // If the console is open while a log is added, we need to render it
    const customConsoleCur = document.getElementById('custom-console');
    if (customConsoleCur.style.display === 'block') {
      renderLogMessages();
    }
  };

</script>

<script>
  if (globalThis.ankiPlatform === "desktop") {
    // Replace the custom consoleLog with normal console.log on desktop
    // Regardless of whether we're on desktop or mobile, we will call consoleLog and not console.log
    consoleLog = console.log;
  } else {
    // If not on desktop, insert the custom console log elements so we can use it
    insertCustomLogElements();
  }
  consoleLog("ankiPlatform", globalThis.ankiPlatform);
  consoleLog("AnkiDroidJS", globalThis.AnkiDroidJS);
  var UA = navigator.userAgent;

  var isMobile = /Android/i.test(UA);
  var isAndroidWebview = /wv/i.test(UA);
  consoleLog("UA", UA)
  consoleLog("isMobile", isMobile)
  consoleLog("isAndroidWebview", isAndroidWebview);
</script>

Cross-platform script loader with error logging

Here’s how I’m using the consoleLog function to debug imports on AnkiDroid using my version of the cross-platform custom script loader by kleinerpirat:

<script>
  // Wrap the whole thing in a try-catch block to debug issues with the importer itself
  try {
    function getAnkiPrefix() {
      return globalThis.ankiPlatform === "desktop"
        ? ""
        : globalThis.AnkiDroidJS
          ? "https://appassets.androidplatform.net"
          : ".";
    }

    var cardScripts = [
      {
        name: "Card info",
        url: "_cardInfo.js",
        init: (moduleObj) => {
          globalThis.addCardInfo = moduleObj.addCardInfo;
          globalThis.addCardInfo();
        }
      },
    ];

    // init first time ExternalScriptsLoaded
    if (!globalThis.ExternalScriptsLoaded) {
      // We'll keep track of the load status of each individual script so that
      // upon any script finishing loading, we can check if everything's done and
      // run the functions that should wait until everything's loaded
      globalThis.ExternalScriptsLoaded = cardScripts.reduce((dict, curScript) => {
        dict[curScript.name] = false;
        return dict;
      }, {})
    }

    // Functions to run after all scripts are loaded
    var onScriptsLoadedFuncs = [
      () => {
        // The cardInfo adding is done in the script's init function
        // on desktop the script is only loaded once, so we need to also have this
        // so that the cardInfo is loaded for new reviews as well
        if (globalThis.addCardInfo) globalThis.addCardInfo();
      },
    ];

    function onScriptsLoaded() {
      for (func of onScriptsLoadedFuncs) {
        if (typeof func === 'function') func();
      }
    }

    var allScriptsLoaded = () => Object.values(globalThis.ExternalScriptsLoaded).every(isLoaded => isLoaded);

    // guard to prevent further imports on Desktop & AnkiMobile
    if (!allScriptsLoaded()) {
      for (script of cardScripts) {
        loadScript(script);
      }
    } else {
      onScriptsLoaded();
    }

    async function loadScript(script) {
      const scriptPromise = import(`${getAnkiPrefix()}/${script.url}`);
      consoleLog(`Loading ${script.name}.`);
      scriptPromise
        .then(
          (moduleObj) => {
            consoleLog(`Loaded ${script.name}.`);
            // If the script exports a myCustomExport object, it'll be available in moduleObject.myCustomExport,
            // which the init script can then set to globalThis to store it persistently between reviews
            // This only works in desktop though, AnkiDroid resets the globalThis object
            // between front and back and reviews
            if (script.init) script.init(moduleObj);
            // Set this script as loaded
            globalThis.ExternalScriptsLoaded[script.name] = true;
            // If all scripts have finished loading, run final setup
            // Since loading is async, we don't know when we'll get to this,
            // thus it's checked on every script load
            if (allScriptsLoaded()) {
              onScriptsLoaded();
            }

          },
          (error) =>
            consoleLog(`An error occured while loading ${script.name}:`, error)
        )
        .catch((error) =>
          consoleLog(`An error occured while executing ${script.name}:`, error)
        );

      if (globalThis.onUpdateHook) {
        onUpdateHook.push(() => scriptPromise);
      }
    }
  } catch (e) {
    // An error in the importer code itself happened
    consoleLog(e);
  }
</script>

Card Info file

And here is the _cardInfo.js imported above. I use this in multiple templates so it’s useful to have as a separate file like this.

export function addCardInfo() {
    const UA = navigator.userAgent;

    const isMobile = /Android/i.test(UA);
    const isAndroidWebview = /wv/i.test(UA);
    console.log(UA, isMobile, isAndroidWebview);

    // Create a div.card-info element that will contain the card info
    let cardInfo = document.getElementById("card-info");
    if (!cardInfo) {
        cardInfo = document.createElement("div");
        cardInfo.id = "card-info";
        cardInfo.style.fontFamily = "arial, serif";
        cardInfo.style.fontWeight = "normal";
        cardInfo.style.fontSize = "1rem";
        cardInfo.style.color = "gray";
        // Append as the first child of the body
        document.body.insertBefore(cardInfo, document.body.firstChild);
    }

    const setInfoInSpan = (id, text) => {
        // Append span with id="id" into the card-info div
        // Does it already exist?
        let span = document.getElementById(id);
        if (!span) {
            span = document.createElement("span");
            span.id = id;
            // Add margin
            span.style.marginRight = "1.5rem";
            cardInfo.appendChild(span);
        }
        span.innerHTML = text;
    }

    const setReps = (repsNum = 0) => {
        setInfoInSpan("reps", "reps: " + repsNum);
    };
    const setFactor = (fctNum = 0) => {
        setInfoInSpan("ease", "ease: " + fctNum / 10 + "%");
    };
    const setIvl = (ivlNum = 0) => {
        setInfoInSpan("ivl", "ivl: " + ivlNum + "d");
    };
    if (globalThis.AnkiDroidJS) {
        const jsApi = { version: "0.0.1", developer: "https://github.com/<your_github_username_here>" };

        const apiStatus = AnkiDroidJS.init(JSON.stringify(jsApi));

        setReps(AnkiDroidJS.ankiGetCardReps());
        setFactor(AnkiDroidJS.ankiGetCardFactor());
        setIvl(AnkiDroidJS.ankiGetCardInterval());
    } else if (globalThis.ankiPlatform === 'desktop') {
        // On desktop, we'll use AnkiDroid API equivalent functions added by this addon:
        // https://ankiweb.net/shared/info/1490471827
        pycmd("AnkiJS.ankiGetCardReps()", setReps);
        pycmd("AnkiJS.ankiGetCardFactor()", setFactor);
        pycmd("AnkiJS.ankiGetCardInterval()", setIvl);
    }
}
2 Likes

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.