Duolingo: Type more, tap less

Replace tapping exercises with full-sentence typing

// ==UserScript==
// @name         Duolingo: Type more, tap less
// @description  Replace tapping exercises with full-sentence typing
// @namespace    neobrain
// @author       neobrain
// @match        https://*.duolingo.com/*
// @grant        GM.XMLHTTPRequest
// @grant        GM.log
// @run-at       document-start
// @require      https://update.greasyfork.org/scripts/472943.js?version=1239323
// @license      MIT
// @version      0.5
// ==/UserScript==

middleMan.addHook("*/sessions", {
    requestHandler(request) {
      GM.log("Outgoing request: ", request);
    },
    async responseHandler(request, response, error) {
        GM.log("Incoming response: ", response, error);

        let reduxState;
        try {
          reduxState = JSON.parse(localStorage.getItem("duo.state")).state.redux
        } catch {
          // NOTE: This is now a base64-encoded gz archive

          let str = localStorage.getItem("duo.state");

          const str2 = atob(str.slice(1, -1)); // Remove wrapping quotes
          const ds = new DecompressionStream("gzip");
          let uint8Array = new Uint8Array(str2.split("").map(c => c.charCodeAt(0)));
          let readableStream = new ReadableStream({
              start(controller) {
                  controller.enqueue(uint8Array);
                  controller.close();
              }
          });

          let outstream = readableStream.pipeThrough(ds);

          let result = "";
          const reader = outstream.getReader();
          const decoder = new TextDecoder('utf-8');
          while (true) {
            let value = await reader.read();
            result += decoder.decode(value.value);
            if (value.done) {
              break;
            }
          }

          reduxState = JSON.parse(result).state.redux;
        }
        const learningLanguage = reduxState.user.learningLanguage;
        const fromLanguage = reduxState.user.fromLanguage;

        const data = await response?.json() ?? { errorMessage: error.message };
        for (let challenge of data.challenges) {
            // Speak exercises always use tapping. Turn this into a translate exercise instead
            // Disabled for now since translate exercises won't have voice output :(
            if (false && challenge.type === "speak") {
              GM.log("Patching speak challenge to translate: ", challenge);
              challenge.type = "translate";

              challenge.correctSolutions = [ challenge.solutionTranslation ];
              delete challenge.solutionTranslation;

              challenge.targetLanguage = challenge.grader.langage;
              challenge.sourceLanguage = challenge.grader.langage === learningLanguage ? fromLanguage : learningLanguage;
            }

            // Alternative that turns it into a "listen => translate" exercise. Hard to distinguish from pure listening exercises though!
            if (false && challenge.type === "speak") {
              GM.log("Patching speak challenge to listenTap: ", challenge);
              challenge.type = "listenTap";

              challenge.grader.language = "fr";

              challenge.correctTokens = challenge.tokens;
              challenge.choices = challenge.tokens.filter(a => "tts" in a).map(a => { return { text: a.value, tts: a.tts } });
              challenge.choices.push({ text: "Tap input not available. Switch to keyboard input." });
            }

            // Translate exercises use tapping only when translating to the student language.
            // Trick the frontend into allowing free-form text by swapping the languages
            if (challenge.type === "translate" && challenge.grader.language == fromLanguage) {
                GM.log(`Patching challenge from ${learningLanguage}->${fromLanguage} to ${fromLanguage}->${learningLanguage}:`, challenge);
                challenge.grader.language = learningLanguage;
                challenge.sourceLanguage = fromLanguage;
                // NOTE: The frontend uses challenge.targetLanguage for the "Write this in X" text, so we leave it unchanged
            }
        }
        return Response.json(data);
    }
});