angular
  .module("anonymous-github", [
    "ngRoute",
    "ngSanitize",
    "ui.ace",
    "ngPDFViewer",
    "htmlDoc",
    "pascalprecht.translate",
    "admin",
  ])
  .config([
    "$routeProvider",
    "$locationProvider",
    "$translateProvider",
    function ($routeProvider, $locationProvider, $translateProvider) {
      $translateProvider.useStaticFilesLoader({
        prefix: "/i18n/locale-",
        suffix: ".json",
      });

      $translateProvider.preferredLanguage("en");

      $routeProvider
        .when("/", {
          templateUrl: "/partials/home.htm",
          controller: "homeController",
          title: "Anonymous GitHub – Share the code, not the author",
        })
        .when("/dashboard", {
          templateUrl: "/partials/dashboard.htm",
          controller: "unifiedDashboardController",
          title: "Your anonymizations – Anonymous GitHub",
        })
        .when("/pr-dashboard", {
          redirectTo: "/dashboard",
        })
        .when("/anonymize/:repoId?", {
          templateUrl: "/partials/anonymize.htm",
          controller: "anonymizeController",
          title: "New anonymization – Anonymous GitHub",
        })
        .when("/pull-request-anonymize/:pullRequestId?", {
          templateUrl: "/partials/anonymize.htm",
          controller: "anonymizeController",
          title: "Anonymize a pull request – Anonymous GitHub",
        })
        .when("/gist-anonymize/:gistId?", {
          templateUrl: "/partials/anonymize.htm",
          controller: "anonymizeController",
          title: "Anonymize a gist – Anonymous GitHub",
        })
        .when("/status/:repoId", {
          templateUrl: "/partials/status.htm",
          controller: "statusController",
          title: "Repository status – Anonymous GitHub",
        })
        .when("/conferences", {
          templateUrl: "/partials/conferences.htm",
          controller: "conferencesController",
          title: "Your conferences – Anonymous GitHub",
        })
        .when("/conference/new", {
          templateUrl: "/partials/newConference.htm",
          controller: "newConferenceController",
          title: "New conference – Anonymous GitHub",
        })
        .when("/conference/:conferenceId/edit", {
          templateUrl: "/partials/newConference.htm",
          controller: "newConferenceController",
          title: "Edit conference – Anonymous GitHub",
        })
        .when("/conference/:conferenceId", {
          templateUrl: "/partials/conference.htm",
          controller: "conferenceController",
          title: "Conference – Anonymous GitHub",
        })
        .when("/faq", {
          templateUrl: "/partials/faq.htm",
          controller: "faqController",
          title: "FAQ – Anonymous GitHub",
        })
        .when("/profile", {
          templateUrl: "/partials/profile.htm",
          controller: "profileController",
          title: "Your settings – Anonymous GitHub",
        })
        .when("/claim", {
          templateUrl: "/partials/claim.htm",
          controller: "claimController",
          title: "Claim an anonymization – Anonymous GitHub",
        })
        .when("/pr/:pullRequestId/:path*?", {
          templateUrl: "/partials/pullRequest.htm",
          controller: "pullRequestController",
          title: "Anonymous pull request – Anonymous GitHub",
          reloadOnUrl: false,
        })
        .when("/gist/:gistId/:path*?", {
          templateUrl: "/partials/gist.htm",
          controller: "gistController",
          title: "Anonymous gist – Anonymous GitHub",
          reloadOnUrl: false,
        })
        .when("/r/:repoId/:path*?", {
          templateUrl: "/partials/explorer.htm",
          controller: "exploreController",
          title: "Anonymous repository – Anonymous GitHub",
          reloadOnUrl: false,
        })
        .when("/repository/:repoId/:path*?", {
          templateUrl: "/partials/explorer.htm",
          controller: "exploreController",
          title: "Anonymous repository – Anonymous GitHub",
          reloadOnUrl: false,
        })
        .when("/admin/", {
          templateUrl: "/partials/admin/overview.htm",
          controller: "overviewAdminController",
          title: "Admin · Overview – Anonymous GitHub",
        })
        .when("/admin/repositories", {
          templateUrl: "/partials/admin/repositories.htm",
          controller: "repositoriesAdminController",
          title: "Admin · Repositories – Anonymous GitHub",
        })
        .when("/admin/users", {
          templateUrl: "/partials/admin/users.htm",
          controller: "usersAdminController",
          title: "Admin · Users – Anonymous GitHub",
        })
        .when("/admin/users/:username", {
          templateUrl: "/partials/admin/user.htm",
          controller: "userAdminController",
          title: "Admin · User details – Anonymous GitHub",
        })
        .when("/admin/conferences", {
          templateUrl: "/partials/admin/conferences.htm",
          controller: "conferencesAdminController",
          title: "Admin · Conferences – Anonymous GitHub",
        })
        .when("/admin/queues", {
          templateUrl: "/partials/admin/queues.htm",
          controller: "queuesAdminController",
          title: "Admin · Queues – Anonymous GitHub",
        })
        .when("/admin/errors", {
          templateUrl: "/partials/admin/errors.htm",
          controller: "errorsAdminController",
          title: "Admin · Errors – Anonymous GitHub",
        })
        .when("/404", {
          templateUrl: "/partials/404.htm",
          title: "Page not found – Anonymous GitHub",
        })
        .otherwise({
          templateUrl: "/partials/404.htm",
          title: "Page not found – Anonymous GitHub",
        });
      $locationProvider.html5Mode(true);
    },
  ])
  // Paths served by express, not by the Angular router. html5Mode makes
  // Angular swallow every same-origin anchor click and resolve it against the
  // route table, so a link to /w/<repoId>/ — the one an anonymized README
  // points at for the repository's GitHub Page — fell through to .otherwise()
  // and rendered the 404 partial. Reloading worked because that bypassed
  // Angular and reached the server. Give these URLs back to the browser as a
  // real navigation.
  .run([
    "$rootScope",
    "$window",
    function ($rootScope, $window) {
      const serverPaths = /^\/(w|api|github)(\/|$)/;
      $rootScope.$on("$locationChangeStart", function (event, next) {
        let url;
        try {
          url = new URL(next, $window.location.href);
        } catch (e) {
          return;
        }
        if (url.origin !== $window.location.origin) return;
        if (!serverPaths.test(url.pathname)) return;
        // Reverts $location to the previous URL so the address bar stays
        // consistent until the browser leaves the page.
        event.preventDefault();
        $window.location.assign(url.href);
      });
    },
  ])
  .filter("humanFileSize", function () {
    return humanFileSize;
  })
  .filter("bigNum", function () {
    return function bigNum(v) {
      const n = Number(v) || 0;
      const abs = Math.abs(n);
      if (abs < 1000) return String(n);
      if (abs < 10000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
      if (abs < 1000000) return Math.round(n / 1000) + "k";
      if (abs < 10000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
      return Math.round(n / 1000000) + "M";
    };
  })
  .filter("humanTime", function () {
    return function humanTime(seconds) {
      if (!seconds) {
        return "never";
      }
      if (seconds instanceof Date)
        seconds = Math.round((Date.now() - seconds) / 1000);
      if (typeof seconds == "string" || typeof seconds == "number")
        seconds = Math.round((Date.now() - new Date(seconds)) / 1000);
      var suffix = seconds < 0 ? "from now" : "ago";

      // more than 2 days ago display Date
      if (Math.abs(seconds) > 2 * 60 * 60 * 24) {
        const now = new Date();
        now.setSeconds(now.getSeconds() - seconds);
        return "on " + now.toLocaleDateString();
      }

      seconds = Math.abs(seconds);

      var times = [
        seconds / 60 / 60 / 24 / 365, // years
        seconds / 60 / 60 / 24 / 30, // months
        seconds / 60 / 60 / 24 / 7, // weeks
        seconds / 60 / 60 / 24, // days
        seconds / 60 / 60, // hours
        seconds / 60, // minutes
        seconds, // seconds
      ];
      var names = ["year", "month", "week", "day", "hour", "minute", "second"];

      for (var i = 0; i < names.length; i++) {
        var time = Math.floor(times[i]);
        var name = names[i];
        if (time > 1) name += "s";

        if (time >= 1) return time + " " + name + " " + suffix;
      }
      return "0 seconds " + suffix;
    };
  })
  .filter("title", function () {
    return function (str) {
      if (!str) return str;

      str = str.toLowerCase();
      var words = str.split(" ");

      var capitalized = words.map(function (word) {
        return word.charAt(0).toUpperCase() + word.substring(1, word.length);
      });
      return capitalized.join(" ");
    };
  })
  .filter("statusMsg", function () {
    return function (msg) {
      if (!msg) return msg;
      var m = msg.match(/^rate_limited:(\d+)$/);
      if (m) {
        var remaining = Math.max(0, Math.ceil((parseInt(m[1], 10) - Date.now()) / 1000));
        if (remaining <= 0) return "Rate limited — resuming soon";
        var min = Math.floor(remaining / 60);
        var sec = remaining % 60;
        return "Rate limited — retrying in " + (min > 0 ? min + "m " + sec + "s" : sec + "s");
      }
      return msg;
    };
  })
  .filter("diff", [
    "$sce",
    function ($sce) {
      const esc = (s) =>
        s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

      function flushFile(out, file) {
        if (!file) return;
        const headerName =
          file.newPath && file.newPath !== "/dev/null"
            ? file.newPath
            : file.oldPath || "";
        const status =
          file.oldPath === "/dev/null"
            ? "added"
            : file.newPath === "/dev/null"
            ? "deleted"
            : file.oldPath && file.newPath && file.oldPath !== file.newPath
            ? "renamed"
            : "modified";
        out.push('<div class="diff-file-block">');
        out.push(
          '<div class="diff-file-header"><span class="diff-file-icon"><i class="far fa-file-code"></i></span>' +
            '<span class="diff-file-name">' +
            esc(headerName) +
            "</span>" +
            '<span class="diff-file-status diff-file-status-' +
            status +
            '">' +
            status +
            "</span></div>"
        );
        if (file.lines.length) {
          out.push('<table class="diff-file-table"><tbody>');
          for (const line of file.lines) {
            out.push(
              '<tr class="diff-row diff-row-' +
                line.kind +
                '">' +
                '<td class="diff-gutter diff-gutter-old">' +
                (line.oldNo || "") +
                "</td>" +
                '<td class="diff-gutter diff-gutter-new">' +
                (line.newNo || "") +
                "</td>" +
                '<td class="diff-sign">' +
                (line.kind === "add"
                  ? "+"
                  : line.kind === "remove"
                  ? "-"
                  : line.kind === "hunk"
                  ? "@"
                  : "") +
                "</td>" +
                '<td class="diff-code">' +
                esc(line.text) +
                "</td>" +
                "</tr>"
            );
          }
          out.push("</tbody></table>");
        }
        out.push("</div>");
      }

      return function (str) {
        if (!str) return str;
        const out = [];
        let file = null;
        let oldNo = 0;
        let newNo = 0;
        const ensureFile = () => {
          if (!file) file = { oldPath: "", newPath: "", lines: [] };
          return file;
        };
        const startNewFileIfNeeded = () => {
          if (file && (file.lines.length || file.oldPath || file.newPath)) {
            flushFile(out, file);
            file = null;
          }
        };
        const lines = str.split("\n");
        for (let i = 0; i < lines.length; i++) {
          const ln = lines[i];
          if (ln.startsWith("diff --git")) {
            startNewFileIfNeeded();
            ensureFile();
            continue;
          }
          if (ln.startsWith("--- ")) {
            // New file boundary if the previous file already had lines.
            if (file && file.lines.length) startNewFileIfNeeded();
            ensureFile().oldPath = ln.replace(/^--- (a\/)?/, "").trim();
            continue;
          }
          if (ln.startsWith("+++ ")) {
            ensureFile().newPath = ln.replace(/^\+\+\+ (b\/)?/, "").trim();
            continue;
          }
          if (
            ln.startsWith("index ") ||
            ln.startsWith("similarity index") ||
            ln.startsWith("rename ") ||
            ln.startsWith("new file mode") ||
            ln.startsWith("deleted file mode") ||
            ln.startsWith("Binary files")
          ) {
            continue;
          }
          if (ln.startsWith("@@")) {
            const m = ln.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
            if (m) {
              oldNo = parseInt(m[1], 10);
              newNo = parseInt(m[2], 10);
            }
            ensureFile().lines.push({ kind: "hunk", oldNo: "", newNo: "", text: ln });
            continue;
          }
          if (!file) continue;
          if (ln.startsWith("+")) {
            file.lines.push({ kind: "add", oldNo: "", newNo: newNo, text: ln.slice(1) });
            newNo++;
          } else if (ln.startsWith("-")) {
            file.lines.push({ kind: "remove", oldNo: oldNo, newNo: "", text: ln.slice(1) });
            oldNo++;
          } else {
            file.lines.push({ kind: "ctx", oldNo: oldNo, newNo: newNo, text: ln.startsWith(" ") ? ln.slice(1) : ln });
            oldNo++;
            newNo++;
          }
        }
        flushFile(out, file);
        return $sce.trustAsHtml(out.join(""));
      };
    },
  ])
  .directive("gistFile", [
    "$location",
    "$timeout",
    "$sce",
    function ($location, $timeout, $sce) {
      // Map GitHub `language` and file extensions to Prism aliases. Prism
      // only ships a handful of grammars (js/py/r/julia/markup); unknown
      // classes still render as readable <pre><code>.
      const langAliases = {
        javascript: "javascript",
        js: "javascript",
        typescript: "javascript",
        ts: "javascript",
        jsx: "javascript",
        tsx: "javascript",
        python: "python",
        py: "python",
        ipynb: "json",
        r: "r",
        julia: "julia",
        html: "markup",
        xml: "markup",
        svg: "markup",
        json: "json",
        yaml: "yaml",
        yml: "yaml",
        bash: "bash",
        sh: "bash",
        shell: "bash",
        css: "css",
        scss: "css",
        c: "c",
        "c++": "cpp",
        cpp: "cpp",
        java: "java",
        go: "go",
        rust: "rust",
        ruby: "ruby",
        php: "php",
        sql: "sql",
        diff: "diff",
      };
      function ext(filename) {
        const i = (filename || "").lastIndexOf(".");
        return i < 0 ? "" : filename.slice(i + 1).toLowerCase();
      }
      function langFor(file) {
        const fromLang =
          file && file.language && langAliases[file.language.toLowerCase()];
        if (fromLang) return fromLang;
        const fromExt = langAliases[ext(file && file.filename)];
        return fromExt || "none";
      }
      function kind(file) {
        const e = ext(file && file.filename);
        if (e === "md" || e === "markdown" || (file && file.language === "Markdown"))
          return "md";
        return "code";
      }
      return {
        restrict: "E",
        scope: { file: "=", terms: "=", options: "=" },
        template:
          '<div ng-if="kind === \'md\'"><markdown content="file.content" terms="terms" options="options"></markdown></div>' +
          '<pre ng-if="kind === \'code\'" class="line-numbers"><code class="{{prismClass}}" ng-bind="file.content"></code></pre>',
        link: function (scope, elem) {
          function update() {
            if (!scope.file) return;
            scope.kind = kind(scope.file);
            scope.prismClass = "language-" + langFor(scope.file);
            // Re-run Prism after the new <code> lands in the DOM.
            $timeout(() => {
              const codes = elem[0].querySelectorAll("pre code");
              codes.forEach((c) => {
                if (window.Prism) Prism.highlightElement(c);
              });
            }, 50);
          }
          scope.$watch("file", update);
          scope.$watch("file.content", update);
          scope.$watch("terms", update);
          scope.$watch("options", update, true);
        },
      };
    },
  ])
  .directive("markdown", [
    "$location",
    function ($location) {
      return {
        restrict: "E",
        scope: {
          terms: "=",
          options: "=",
          content: "=",
        },
        link: function (scope, elem, attrs) {
          function update() {
            elem.html(renderMD(scope.content, $location.url() + "/../"));
          }
          scope.$watch(attrs.terms, update);
          scope.$watch("terms", update);
          scope.$watch("options", update);
          scope.$watch("content", update);
        },
      };
    },
  ])
  .directive("tree", [
    function () {
      return {
        restrict: "E",
        scope: { file: "=", parent: "@", searchQuery: "=", searchResults: "=" },
        controller: [
          "$element",
          "$scope",
          "$routeParams",
          "$compile",
          function ($element, $scope, $routeParams, $compile) {
            $scope.repoId = document.location.pathname.split("/")[2];

            $scope.opens = {};

            if ($routeParams.path) {
              let accumulatedPath = "";
              $routeParams.path.split("/").forEach((f) => {
                $scope.opens[accumulatedPath + "/" + f] = true;
                accumulatedPath = accumulatedPath + "/" + f;
              });
            }

            const toArray = function (arr) {
              const output = [];
              const keys = { "": { child: output } };
              function ensurePath(path) {
                if (keys[path]) return;
                const segments = path.split("/");
                let acc = "";
                for (let i = 0; i < segments.length; i++) {
                  const parent = acc;
                  acc = acc ? acc + "/" + segments[i] : segments[i];
                  if (!keys[acc]) {
                    const dir = { name: segments[i], child: [] };
                    keys[acc] = dir;
                    keys[parent].child.push(dir);
                  }
                }
              }
              for (let file of arr) {
                if (file.path && !keys[file.path]) {
                  ensurePath(file.path);
                }
                let current = keys[file.path || ""].child;
                let fPath = `${file.path}/${file.name}`;
                if (fPath.startsWith("/")) {
                  fPath = fPath.substring(1);
                }
                if (file.size != null) {
                  current.push({
                    name: file.name,
                    size: file.size,
                    sha: file.sha,
                  });
                } else {
                  if (!keys[fPath]) {
                    const dir = {
                      name: file.name,
                      child: [],
                    };
                    keys[fPath] = dir;
                    current.push(dir);
                  }
                }
              }
              return output;
            };

            const sortFiles = (f1, f2) => {
              const f1d = !!f1.child;
              const f2d = !!f2.child;
              if (f1d && f2d) {
                return f1.name.localeCompare(f2.name);
              }
              if (f1d) {
                return -1;
              }
              if (f2d) {
                return 1;
              }
              return f1.name.localeCompare(f2.name);
            };

            function getFileCount(folderPath) {
              const counts = $scope.$parent.fileCounts;
              if (!counts) return 0;
              const normalized = folderPath.startsWith("/")
                ? folderPath.substring(1)
                : folderPath;
              return counts[normalized] || 0;
            }

            function isTruncated(folderPath) {
              const truncated =
                ($scope.$parent.options &&
                  $scope.$parent.options.truncatedFolders) ||
                [];
              if (!truncated.length) return false;
              const normalized = folderPath.startsWith("/")
                ? folderPath.substring(1)
                : folderPath;
              return truncated.indexOf(normalized) !== -1;
            }

            function escapeHtml(str) {
              return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
            }

            // Escape a value for safe interpolation into a single-quoted
            // AngularJS expression string (e.g. ng-click="openFolder('...')")
            // that itself sits inside a double-quoted HTML attribute which is
            // later $compile()d. Backslash/quote are escaped at the Angular
            // string level; &<>" are HTML-encoded for the attribute. Without
            // this a file name like `');$emit(...)//` would break out of the
            // expression string and execute (DOM XSS, CWE-79).
            function escapeNgString(str) {
              return String(str)
                .replace(/\\/g, "\\\\")
                .replace(/'/g, "\\'")
                .replace(/&/g, "&amp;")
                .replace(/</g, "&lt;")
                .replace(/>/g, "&gt;")
                .replace(/"/g, "&quot;");
            }

            function buildSearchFilter() {
              const results = $scope.searchResults;
              if (!results || !results.length) return null;
              const matchPaths = new Set();
              const matchFolders = new Set();
              for (const f of results) {
                const full = f.path ? `${f.path}/${f.name}` : f.name;
                matchPaths.add(full);
                // Also collect all ancestor folders
                if (f.path) {
                  const segments = f.path.split("/").filter(Boolean);
                  let acc = "";
                  for (const seg of segments) {
                    acc = acc ? `${acc}/${seg}` : seg;
                    matchFolders.add(acc);
                  }
                }
              }
              return { paths: matchPaths, folders: matchFolders };
            }

            function nodeMatchesFilter(node, parentPath, filterSet) {
              if (!filterSet) return true;
              const path = parentPath
                ? `${parentPath}/${node.name}`
                : node.name;
              if (!node.child) {
                return filterSet.paths.has(path);
              }
              // Show folder if it's an ancestor of a match or contains matches
              if (filterSet.folders.has(path)) return true;
              return node.child.some((c) =>
                nodeMatchesFilter(c, path, filterSet)
              );
            }

            function generate(current, parentPath, filterSet) {
              if (!current) return "";
              current = current.sort(sortFiles);
              let output = "<ul>";
              for (let f of current) {
                if (filterSet && !nodeMatchesFilter(f, parentPath ? parentPath.substring(1) : "", filterSet)) {
                  continue;
                }
                let dir = !!f.child;
                let name = f.name;
                let size = f.size;
                let collapsed = f;
                if (dir) {
                  let test = name;
                  let inner = f.child;
                  while (inner && inner.length == 1) {
                    test += "/" + inner[0].name;
                    size = inner[0].size;
                    inner = inner[0].child;
                  }
                  name = test;
                  collapsed = inner ? { child: inner } : f;
                  if (size != null && size >= 0) {
                    dir = false;
                  }
                }
                const sizeTitle = size != null ? `Size: ${humanFileSize(size || 0)}` : "";
                const path = `${parentPath}/${name}`;
                const fileCount = dir ? getFileCount(path) : 0;

                const isOpen = filterSet ? ($scope.opens[path] !== false) : $scope.opens[path];
                const cssClasses = ["file"];
                if (dir) {
                  cssClasses.push("folder");
                }
                if (isOpen) {
                  cssClasses.push("open");
                }
                if ($scope.isActive(path)) {
                  cssClasses.push("active");
                }
                const truncated = dir && isTruncated(path);
                if (truncated) {
                  cssClasses.push("truncated");
                }

                const ngPath = escapeNgString(path);
                output += `<li class="${cssClasses.join(
                  " "
                )}" ng-class="{active: isActive('${ngPath}'), open: ${filterSet ? "opens['" + ngPath + "'] !== false" : "opens['" + ngPath + "']"}}" title="${escapeHtml(sizeTitle)}">`;
                if (dir) {
                  output += `<a ng-click="openFolder('${ngPath}', $event)"><span class="tree-toggle"></span><span class="tree-icon-folder"></span><span class="tree-name">${escapeHtml(name)}</span>`;
                  if (truncated) {
                    output += `<span class="truncated-warning" title="{{ 'WARNINGS.folder_truncated' | translate }}"><i class="fas fa-exclamation-triangle"></i></span>`;
                  }
                  if (fileCount > 0) {
                    output += `<span class="tree-count">${fileCount}</span>`;
                  }
                  output += `</a>`;
                } else {
                  const needsSpacer = parentPath !== "";
                  output += `<a href='/r/${$scope.repoId}${encodePathForUrl(
                    path
                  )}'>${needsSpacer ? '<span class="tree-spacer"></span>' : ''}<span class="tree-icon-file"></span><span class="tree-name">${escapeHtml(name)}</span></a>`;
                }
                if (isOpen && collapsed.child) {
                  const children = collapsed.child;
                  if (children.length > 1) {
                    output += generate(children, path, filterSet);
                  } else if (dir) {
                    let inner = children;
                    while (inner && inner.length == 1) {
                      inner = inner[0].child;
                    }
                    output += generate(inner, path, filterSet);
                  }
                }
                output += "</li>";
              }
              return output + "</ul>";
            }

            function display() {
              $element.html("");
              const filterSet = $scope.searchQuery ? buildSearchFilter() : null;
              let output;
              if (filterSet !== null && filterSet.paths.size === 0) {
                output = '<div class="tree-search-empty">No files found</div>';
              } else {
                output = generate(toArray($scope.file).sort(sortFiles), "", filterSet);
              }
              $compile(output)($scope, (clone) => {
                $element.append(clone);
                restoreFocus();
              });
            }

            function expandAllFolders(nodes, parentPath) {
              if (!nodes) return;
              for (const f of nodes) {
                if (!f.child || f.child.length === 0) continue;
                const path = `${parentPath}/${f.name}`;
                if (!(path in $scope.opens)) {
                  $scope.opens[path] = true;
                }
                expandAllFolders(f.child, path);
              }
            }

            $scope.$watch(
              "file",
              (newValue) => {
                if (newValue == null) return;
                if (newValue.length == 0) {
                  return $element.html("Empty repository");
                }
                expandAllFolders(toArray(newValue), "");
                display();
              },
              true
            );

            $scope.$watch("searchResults", (newVal, oldVal) => {
              if (newVal === oldVal) return;
              if ($scope.file && $scope.file.length) {
                display();
              }
            });

            $scope.$watch("searchQuery", (newVal, oldVal) => {
              if (newVal === oldVal) return;
              if (!newVal && $scope.file && $scope.file.length) {
                display();
              }
            });

            $scope.isActive = function (name) {
              return $routeParams.path == name.substring(1);
            };

            $scope.openFolder = function (folder, event) {
              var currentlyOpen = $scope.opens[folder];
              if (currentlyOpen === undefined && $scope.searchQuery) {
                currentlyOpen = true;
              }
              $scope.opens[folder] = !currentlyOpen;
              const li = event.target.closest("li");
              const childUl = li ? li.querySelector(":scope > ul") : null;
              const needsLoad =
                childUl == null ||
                childUl.children.length === 0;
              if (needsLoad) {
                $scope.$parent.getFiles(folder.substring(1));
              }
            };

            var focusedPath = $routeParams.path ? "/" + $routeParams.path : null;

            function getVisibleLinks() {
              return Array.from($element[0].querySelectorAll("li > a"));
            }

            function getFocusedLink() {
              return $element[0].querySelector("a.tree-focused");
            }

            function getLinkPath(link) {
              if (!link) return null;
              var href = link.getAttribute("href");
              if (href) {
                var prefix = "/r/" + $scope.repoId;
                return href.indexOf(prefix) === 0 ? decodeURIComponent(href.substring(prefix.length)) : null;
              }
              var onclick = link.getAttribute("ng-click");
              if (onclick) {
                var m = onclick.match(/openFolder\('([^']+)'/);
                return m ? m[1] : null;
              }
              return null;
            }

            function findLinkByPath(path) {
              if (!path) return null;
              var links = getVisibleLinks();
              for (var i = 0; i < links.length; i++) {
                if (getLinkPath(links[i]) === path) return links[i];
              }
              return null;
            }

            function setFocus(link) {
              var prev = getFocusedLink();
              if (prev) prev.classList.remove("tree-focused");
              if (link) {
                link.classList.add("tree-focused");
                link.scrollIntoView({ block: "nearest" });
                focusedPath = getLinkPath(link);
              } else {
                focusedPath = null;
              }
            }

            function restoreFocus() {
              if (!focusedPath) return;
              var link = findLinkByPath(focusedPath);
              if (link) {
                link.classList.add("tree-focused");
                $element[0].focus();
              }
            }

            $element[0].setAttribute("tabindex", "0");

            $element[0].addEventListener("keydown", function (e) {
              var links = getVisibleLinks();
              if (!links.length) return;
              var focused = getFocusedLink();
              var idx = focused ? links.indexOf(focused) : -1;

              if (e.key === "ArrowDown") {
                e.preventDefault();
                var next = idx < links.length - 1 ? idx + 1 : 0;
                setFocus(links[next]);
              } else if (e.key === "ArrowUp") {
                e.preventDefault();
                var prev = idx > 0 ? idx - 1 : links.length - 1;
                setFocus(links[prev]);
              } else if (e.key === "ArrowRight") {
                e.preventDefault();
                if (!focused) return;
                var li = focused.closest("li");
                if (li && li.classList.contains("folder")) {
                  if (!li.classList.contains("open")) {
                    focused.click();
                  } else {
                    var childLink = li.querySelector(":scope > ul > li > a");
                    if (childLink) setFocus(childLink);
                  }
                }
              } else if (e.key === "ArrowLeft") {
                e.preventDefault();
                if (!focused) return;
                var li = focused.closest("li");
                if (li && li.classList.contains("folder") && li.classList.contains("open")) {
                  focused.click();
                } else {
                  var parentLi = li && li.parentElement ? li.parentElement.closest("li.folder") : null;
                  if (parentLi) {
                    var parentLink = parentLi.querySelector(":scope > a");
                    if (parentLink) setFocus(parentLink);
                  }
                }
              } else if (e.key === "Enter") {
                e.preventDefault();
                if (focused) focused.click();
              }
            });

            $element[0].addEventListener("click", function (e) {
              var link = e.target.closest("a");
              if (link && $element[0].contains(link)) {
                setFocus(link);
              }
            });
          },
        ],
      };
    },
  ])
  .directive("notebook", [
    function () {
      return {
        restrict: "E",
        scope: { file: "=" },
        controller: [
          "$element",
          "$scope",
          "$http",
          function ($element, $scope, $http) {
            function renderNotebookJSON(json) {
              const notebook = nb.parse(json);
              try {
                $element.html("");
                // notebook.render() turns notebook JSON (markdown cells, cell
                // outputs) into HTML without sanitising it — a malicious
                // notebook could embed <script>/onerror handlers that execute
                // in the viewer's browser (XSS, CWE-79). Run the rendered
                // output through DOMPurify before inserting it.
                const rendered = notebook.render();
                $element.html(DOMPurify.sanitize(rendered));
                Prism.highlightAll();
              } catch (error) {
                $element.html("Unable to render the notebook.");
              }
            }
            function render() {
              if ($scope.$parent.content) {
                try {
                  renderNotebookJSON(JSON.parse($scope.$parent.content));
                } catch (error) {
                  $element.html(
                    "Unable to render the notebook invalid notebook format."
                  );
                }
              } else if ($scope.file) {
                $http
                  .get($scope.file.download_url)
                  .then((res) => renderNotebookJSON(res.data));
              }
            }
            $scope.$watch("file", (v) => {
              render();
            });
            render();
          },
        ],
      };
    },
  ])
  .directive("loc", [
    function () {
      return {
        restrict: "E",
        scope: { stats: "=" },
        template:
          "<div class='lang' ng-repeat='lang in elements' title='{{lang.lang|title}}: {{lang.loc | number}} lines' data-toggle='tooltip' data-placement='bottom'  style='width:{{lang.loc*100/total}}%;background:{{lang.color}};'></div>",
        controller: [
          "$scope",
          function ($scope) {
            function render() {
              $scope.elements = [];
              $scope.total = 0;
              for (let lang in $scope.stats) {
                const loc = $scope.stats[lang].code;
                if (!loc) {
                  continue;
                }
                $scope.total += loc;
                $scope.elements.push({
                  lang,
                  loc,
                  color: langColors[lang],
                });
              }
              setTimeout(() => {
                $('[data-toggle="tooltip"]').tooltip();
              }, 100);
            }

            $scope.$watch("stats", (v) => {
              render();
            });
            render();
          },
        ],
      };
    },
  ])
  .controller("mainController", [
    "$scope",
    "$http",
    "$location",
    "$timeout",
    function ($scope, $http, $location, $timeout) {
      $scope.title = "Main";
      $scope.user = { status: "connection" };
      $scope.site_options;

      $scope.toasts = [];

      $scope.removeToast = function (toast) {
        const index = $scope.toasts.indexOf(toast);
        if (index === -1) return;
        $scope.toasts.splice(index, 1);
      };

      // Auto-dismiss toasts after a fixed delay so they don't pile up across
      // navigations (e.g. the "README not found" toast re-fired every time the
      // edit screen was reopened — see #246). Long-running operations that
      // mutate the toast (remove/refresh) will simply disappear once the
      // delay elapses; users can re-check status from the dashboard.
      $scope.addToast = function (toast) {
        $scope.toasts.push(toast);
        $timeout(function () {
          $scope.removeToast(toast);
        }, 8000);
        return toast;
      };

      $scope.path = $location.url();
      $scope.paths = $location.path().substring(1).split("/");

      $scope.darkMode = function (on) {
        localStorage.setItem("darkMode", on);
        $scope.isDarkMode = on;
        const darkPrismLink = "/css/prism-okaidia.css";
        const lightPrismLink = "/css/prism.css";
        if (on) {
          $("body").addClass("dark-mode");
          let link = document.createElement("link");
          link.href = darkPrismLink;
          link.rel = "stylesheet";
          document.head.append(link);
          $(`link[href='${lightPrismLink}']`).remove();
        } else {
          $("body").removeClass("dark-mode");
          let link = document.createElement("link");
          link.href = lightPrismLink;
          link.rel = "stylesheet";
          document.head.append(link);
          $(`link[href='${darkPrismLink}']`).remove();
        }
        // Update Ko-fi floating button to match theme
        var kofiBtn = document.querySelector(".floatingchat-container-wrap-mo498 .floating-chat-kofi-text-container-wrap");
        if (kofiBtn) {
          kofiBtn.style.backgroundColor = on ? "#FAF9F6" : "#1A1815";
          kofiBtn.style.color = on ? "#1A1815" : "#FAF9F6";
        }
        $scope.$broadcast("dark-mode", on);
      };

      $scope.darkMode(localStorage.getItem("darkMode") == "true");

      function getUser() {
        $http.get("/api/user").then(
          (res) => {
            if (res) $scope.user = res.data;
          },
          () => {
            $scope.user = null;
          }
        );
      }
      getUser();

      function getOptions() {
        $http.get("/api/options").then(
          (res) => {
            if (res) $scope.site_options = res.data;
          },
          () => {
            $scope.site_options = null;
          }
        );
      }
      getOptions();

      function getMessage() {
        $http.get("/api/message").then(
          (res) => {
            if (res) $scope.generalMessage = res.data;
          },
          () => {
            $scope.generalMessage = null;
          }
        );
      }
      getMessage();

      function changedUrl(_, current) {
        if (current) {
          $scope.title = current.title;
        }
        $scope.path = $location.url();
        $scope.paths = $location.path().substring(1).split("/");
      }

      $scope.$on("$routeChangeSuccess", changedUrl);
      $scope.$on("$routeUpdate", changedUrl);
    },
  ])
  .controller("faqController", ["$scope", "$http", function ($scope, $http) {}])
  .controller("profileController", [
    "$scope",
    "$http",
    function ($scope, $http) {
      $scope.terms = "";
      $scope.options = {
        expirationMode: "remove",
        update: false,
        image: true,
        pdf: true,
        notebook: true,
        loc: true,
        link: true,
      };

      function getDefault() {
        $http.get("/api/user/default").then((res) => {
          const data = res.data;
          if (data.terms) {
            $scope.terms = data.terms.join("\n");
          }
          $scope.options = Object.assign({}, $scope.options, data.options);
        });
      }
      getDefault();

      $scope.saveDefault = () => {
        const params = {
          terms: $scope.terms.trim().split("\n"),
          options: $scope.options,
        };
        $http.post("/api/user/default", params).then(
          () => {
            getDefault();
            $scope.message = "Saved";
          },
          (error) => {
            $translate("ERRORS." + error.data.error).then((translation) => {
              $scope.error = translation;
            }, console.error);
          }
        );
      };

      $scope.deleteAccount = () => {
        if (
          !confirm(
            "Delete your account? All your anonymized repositories, gists, and pull requests will be removed, and your personal data will be erased. This cannot be undone."
          )
        )
          return;
        $scope.deletingAccount = true;
        $http.delete("/api/user").then(
          () => {
            window.location.href = "/";
          },
          () => {
            $scope.deletingAccount = false;
            $scope.deleteError =
              "Unable to delete the account. Please try again.";
          }
        );
      };
    },
  ])
  .controller("claimController", [
    "$scope",
    "$http",
    "$location",
    function ($scope, $http, $location) {
      $scope.repoId = null;
      $scope.repoUrl = null;
      $scope.claim = () => {
        $http
          .post("/api/repo/claim", {
            repoId: $scope.repoId,
            repoUrl: $scope.repoUrl,
          })
          .then(
            (res) => {
              $location.url("/dashboard");
            },
            (err) => {
              $scope.error = err.data;
              $scope.claimForm.repoUrl.$setValidity("not_found", false);
              $scope.claimForm.repoId.$setValidity("not_found", false);
            }
          );
      };
    },
  ])
  .controller("homeController", [
    "$scope",
    "$http",
    "$location",
    function ($scope, $http, $location) {
      if ($scope.user && !$scope.user.status) {
        $location.url("/dashboard");
      }
      $scope.$watch("user.status", () => {
        if ($scope.user && !$scope.user.status) {
          $location.url("/dashboard");
        }
      });

      $scope.cards = [
        { key: "repositories", total: 0, label: "repositories anonymized" },
        { key: "users", total: 0, label: "researchers" },
        { key: "pageViews", total: 0, label: "page views" },
        { key: "pullRequests", total: 0, label: "pull requests" },
      ];
      function getStat() {
        $http.get("/api/stat/").then((res) => {
          $scope.stat = res.data;
          $scope.cards[0].total = res.data.nbRepositories;
          $scope.cards[1].total = res.data.nbUsers;
          $scope.cards[2].total = res.data.nbPageViews;
          $scope.cards[3].total = res.data.nbPullRequests;
        });
      }
      getStat();

      function buildSeriesView(series) {
        const view = {
          series: series,
          bars: [],
          viewW: 100,
          deltaToday: 0,
          pctChange: 0,
          pctAbs: 0,
          isUp: true,
        };
        if (!series || series.length < 2) return view;
        // Bars represent the *daily increment* (today - yesterday), not the
        // cumulative total. The big number above the chart shows the total.
        const deltas = new Array(series.length - 1);
        for (let i = 1; i < series.length; i++) {
          deltas[i - 1] = series[i] - series[i - 1];
        }
        const n = deltas.length;
        const max = Math.max.apply(null, deltas);
        const min = Math.min.apply(null, deltas);
        // Anchor scale to zero so visually small days look small even when all
        // deltas are positive; only fall back to min when there are negatives.
        const base = Math.min(0, min);
        const range = max - base || 1;
        view.viewW = n * 2;
        view.bars = new Array(n);
        for (let i = 0; i < n; i++) {
          const norm = (deltas[i] - base) / range;
          const h = Math.max(1.5, norm * 34);
          view.bars[i] = {
            x: (i * 2 + 0.25).toFixed(2),
            y: (36 - h).toFixed(2),
            w: "1.5",
            h: h.toFixed(2),
          };
        }
        view.deltaToday = deltas[n - 1];
        if (n >= 2) {
          const prior = deltas[n - 2];
          if (prior) {
            view.pctChange = ((view.deltaToday - prior) / prior) * 100;
          }
        }
        view.pctAbs = Math.round(Math.abs(view.pctChange));
        view.isUp = view.pctChange >= 0;
        return view;
      }

      $scope.history = {
        repositories: buildSeriesView([]),
        users: buildSeriesView([]),
        pageViews: buildSeriesView([]),
        pullRequests: buildSeriesView([]),
      };
      $http.get("/api/stat/history?days=60").then((res) => {
        const rows = res.data || [];
        $scope.history = {
          repositories: buildSeriesView(rows.map((r) => r.nbRepositories || 0)),
          users: buildSeriesView(rows.map((r) => r.nbUsers || 0)),
          pageViews: buildSeriesView(rows.map((r) => r.nbPageViews || 0)),
          pullRequests: buildSeriesView(rows.map((r) => r.nbPullRequests || 0)),
        };
      });
    },
  ])
  .controller("unifiedDashboardController", [
    "$scope",
    "$http",
    "$location",
    function ($scope, $http, $location) {
      $scope.$on("$routeChangeStart", function () {
        $('[data-toggle="tooltip"]').tooltip("dispose");
      });
      $scope.$watch("user.status", () => {
        if ($scope.user == null) {
          $location.url("/");
        }
      });
      if ($scope.user == null) {
        $location.url("/");
      }

      setTimeout(() => {
        $('[data-toggle="tooltip"]').tooltip();
      }, 250);

      $scope.items = [];
      $scope.search = "";

      const dashboardPrefsKey = "dashboard.filterPrefs";
      const dashboardPrefDefaults = {
        typeFilter: "all",
        filters: { status: { ready: true, expired: true, removed: false } },
        orderBy: "-anonymizeDate",
      };
      const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {};
      $scope.typeFilter = savedDashboardPrefs.typeFilter || dashboardPrefDefaults.typeFilter;
      $scope.filters = {
        status: Object.assign(
          {},
          dashboardPrefDefaults.filters.status,
          (savedDashboardPrefs.filters && savedDashboardPrefs.filters.status) || {}
        ),
      };
      $scope.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy;

      $scope.$watchGroup(
        ["typeFilter", "orderBy"],
        () => {
          saveFilterPrefs(dashboardPrefsKey, {
            typeFilter: $scope.typeFilter,
            filters: $scope.filters,
            orderBy: $scope.orderBy,
          });
        }
      );
      $scope.$watch(
        "filters",
        () => {
          saveFilterPrefs(dashboardPrefsKey, {
            typeFilter: $scope.typeFilter,
            filters: $scope.filters,
            orderBy: $scope.orderBy,
          });
        },
        true
      );

      function getQuota() {
        $http.get("/api/user/quota").then((res) => {
          $scope.quota = res.data;
          $scope.quota.storage.percent = $scope.quota.storage.total
            ? ($scope.quota.storage.used * 100) / $scope.quota.storage.total
            : 100;
          $scope.quota.file.percent = $scope.quota.file.total
            ? ($scope.quota.file.used * 100) / $scope.quota.file.total
            : 100;
          $scope.quota.repository.percent = $scope.quota.repository.total
            ? ($scope.quota.repository.used * 100) /
              $scope.quota.repository.total
            : 100;
        }, console.error);
      }
      getQuota();

      let loadedRepos = null;
      let loadedPRs = null;
      let loadedGists = null;

      function mergeItems() {
        $scope.items = (loadedRepos || [])
          .concat(loadedPRs || [])
          .concat(loadedGists || []);
      }

      function loadAll() {
        loadedRepos = null;
        loadedPRs = null;
        loadedGists = null;
        $http.get("/api/user/anonymized_repositories").then(
          (res) => {
            loadedRepos = res.data.map((repo) => {
              if (!repo.pageView) repo.pageView = 0;
              if (!repo.lastView) repo.lastView = "";
              repo.options.terms = repo.options.terms.filter((f) => f);
              repo._type = "repo";
              repo._id = repo.repoId;
              repo._name = repo.repoId;
              repo._source = repo.source.fullName;
              repo._editUrl = "/anonymize/" + repo.repoId;
              repo._viewUrl = "/r/" + repo.repoId + "/";
              return repo;
            });
            mergeItems();
          },
          (err) => { console.error(err); }
        );
        $http.get("/api/user/anonymized_pull_requests").then(
          (res2) => {
            loadedPRs = res2.data.map((pr) => {
              if (!pr.pageView) pr.pageView = 0;
              if (!pr.lastView) pr.lastView = "";
              pr.options.terms = pr.options.terms.filter((f) => f);
              pr._type = "pr";
              pr._id = pr.pullRequestId;
              pr._name = pr.pullRequestId;
              pr._source = pr.source.repositoryFullName + "#" + pr.source.pullRequestId;
              pr._editUrl = "/pull-request-anonymize/" + pr.pullRequestId;
              pr._viewUrl = "/pr/" + pr.pullRequestId + "/";
              return pr;
            });
            mergeItems();
          },
          (err) => { console.error(err); }
        );
        $http.get("/api/user/anonymized_gists").then(
          (res3) => {
            loadedGists = res3.data.map((g) => {
              if (!g.pageView) g.pageView = 0;
              if (!g.lastView) g.lastView = "";
              g.options.terms = (g.options.terms || []).filter((f) => f);
              g._type = "gist";
              g._id = g.gistId;
              g._name = g.gistId;
              g._source = g.source.gistId;
              g._editUrl = "/gist-anonymize/" + g.gistId;
              g._viewUrl = "/gist/" + g.gistId + "/";
              return g;
            });
            mergeItems();
          },
          (err) => { console.error(err); }
        );
      }
      loadAll();

      function waitRepoToBeReady(repoId, callback) {
        $http.get("/api/repo/" + repoId).then((res) => {
          for (const item of $scope.items) {
            if (item._type === "repo" && item.repoId == repoId) {
              item.status = res.data.status;
              break;
            }
          }
          if (
            res.data.status == "ready" ||
            res.data.status == "error" ||
            res.data.status == "removed" ||
            res.data.status == "expired"
          ) {
            callback(res.data);
            return;
          }
          setTimeout(() => waitRepoToBeReady(repoId, callback), 2500);
        });
      }

      const labelOf = (t) =>
        t === "repo" ? "repository" : t === "gist" ? "gist" : "pull request";
      const apiBaseOf = (t) =>
        t === "repo" ? "/api/repo" : t === "gist" ? "/api/gist" : "/api/pr";

      $scope.removeItem = (item) => {
        const label = labelOf(item._type);
        if (confirm(`Are you sure that you want to remove the ${label} ${item._id}?`)) {
          const toast = {
            title: `Removing ${item._id}...`,
            date: new Date(),
            body: `The ${label} ${item._id} is going to be removed.`,
          };
          $scope.addToast(toast);
          const endpoint = `${apiBaseOf(item._type)}/${item._id}`;
          $http.delete(endpoint).then(
            () => {
              if (item._type === "repo") {
                waitRepoToBeReady(item._id, () => {
                  toast.title = `${item._id} is removed.`;
                  toast.body = `The ${label} ${item._id} is removed.`;
                  $scope.$apply();
                });
              } else {
                toast.title = `${item._id} is removed.`;
                toast.body = `The ${label} ${item._id} is removed.`;
                loadAll();
              }
            },
            (error) => {
              toast.title = `Error during the removal of ${item._id}.`;
              toast.body = error.body;
              loadAll();
            }
          );
        }
      };

      $scope.refreshItem = (item) => {
        const label = labelOf(item._type);
        const toast = {
          title: `Refreshing ${item._id}...`,
          date: new Date(),
          body: `The ${label} ${item._id} is going to be refreshed.`,
        };
        $scope.addToast(toast);
        const endpoint = `${apiBaseOf(item._type)}/${item._id}/refresh`;
        $http.post(endpoint).then(
          () => {
            if (item._type === "repo") {
              waitRepoToBeReady(item._id, () => {
                toast.title = `${item._id} is refreshed.`;
                toast.body = `The ${label} ${item._id} is refreshed.`;
                $scope.$apply();
              });
            } else {
              toast.title = `${item._id} is refreshed.`;
              toast.body = `The ${label} ${item._id} is refreshed.`;
              loadAll();
            }
          },
          (error) => {
            toast.title = `Error during the refresh of ${item._id}.`;
            toast.body = error.body;
            loadAll();
          }
        );
      };

      $scope.extendItem = (item) => {
        const label = labelOf(item._type);
        const toast = {
          title: `Extending ${item._id}...`,
          date: new Date(),
          body: `The expiration of ${label} ${item._id} is going to be extended by 6 months.`,
        };
        $scope.addToast(toast);
        const endpoint = `${apiBaseOf(item._type)}/${item._id}/extend`;
        $http.post(endpoint).then(
          () => {
            if (item._type === "repo") {
              waitRepoToBeReady(item._id, () => {
                toast.title = `${item._id} is extended.`;
                toast.body = `The expiration of ${label} ${item._id} is extended by 6 months.`;
                $scope.$apply();
              });
            } else {
              toast.title = `${item._id} is extended.`;
              toast.body = `The expiration of ${label} ${item._id} is extended by 6 months.`;
              loadAll();
            }
          },
          (error) => {
            toast.title = `Error during the extension of ${item._id}.`;
            toast.body = (error.data && error.data.error) || error.body;
            loadAll();
          }
        );
      };

      $scope.itemFilter = (item) => {
        if ($scope.typeFilter !== "all" && item._type !== $scope.typeFilter) return false;
        if ($scope.filters.status[item.status] == false) return false;
        if ($scope.search.trim().length == 0) return true;
        if (item._source && item._source.indexOf($scope.search) > -1) return true;
        if (item._id.indexOf($scope.search) > -1) return true;
        return false;
      };
    },
  ])
  .controller("dashboardController", [
    "$scope",
    "$location",
    function ($scope, $location) {
      $location.url("/dashboard");
    },
  ])
  .controller("prDashboardController", [
    "$scope",
    "$location",
    function ($scope, $location) {
      $location.url("/dashboard");
    },
  ])
  .controller("statusController", [
    "$scope",
    "$http",
    "$routeParams",
    function ($scope, $http, $routeParams) {
      $scope.repoId = $routeParams.repoId;
      $scope.repo = null;
      $scope.progress = 0;
      $scope.rateLimitResetAt = 0;
      $scope.rateLimitCountdown = "";

      var countdownTimer = null;
      function startRateLimitCountdown(resetAt) {
        $scope.rateLimitResetAt = resetAt;
        if (countdownTimer) clearInterval(countdownTimer);
        function tick() {
          var remaining = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000));
          if (remaining <= 0) {
            $scope.rateLimitCountdown = "";
            $scope.rateLimitResetAt = 0;
            clearInterval(countdownTimer);
            countdownTimer = null;
          } else {
            var min = Math.floor(remaining / 60);
            var sec = remaining % 60;
            $scope.rateLimitCountdown = min > 0
              ? min + "m " + sec + "s"
              : sec + "s";
          }
          $scope.$applyAsync();
        }
        tick();
        countdownTimer = setInterval(tick, 1000);
      }
      $scope.$on("$destroy", function () {
        if (countdownTimer) clearInterval(countdownTimer);
      });

      function parseStatusMessage(msg) {
        if (!msg) return msg;
        var m = msg.match(/^rate_limited:(\d+)$/);
        if (m) {
          startRateLimitCountdown(parseInt(m[1], 10));
          return null;
        }
        $scope.rateLimitResetAt = 0;
        return msg;
      }

      $scope.getStatus = () => {
        $http
          .get("/api/repo/" + $scope.repoId, {
            repoId: $scope.repoId,
            repoUrl: $scope.repoUrl,
          })
          .then(
            (res) => {
              $scope.repo = res.data;
              if (res.data.rateLimitResetAt) {
                startRateLimitCountdown(res.data.rateLimitResetAt);
              } else {
                $scope.repo.statusMessage = parseStatusMessage($scope.repo.statusMessage);
              }
              if ($scope.repo.status == "ready") {
                $scope.progress = 100;
              } else if ($scope.repo.status == "queue") {
                $scope.progress = 10;
              } else if ($scope.repo.status == "downloaded") {
                $scope.progress = 50;
              } else if ($scope.repo.status == "download") {
                $scope.progress = 25;
              } else if ($scope.repo.status == "preparing") {
                $scope.progress = 25;
              } else if ($scope.repo.status == "anonymizing") {
                $scope.progress = 75;
              }
              var shouldPoll = $scope.repo.status != "ready";
              if ($scope.repo.status == "error" && !$scope.rateLimitResetAt) {
                shouldPoll = false;
              }
              if (shouldPoll) {
                setTimeout($scope.getStatus, 2000);
              }
            },
            (err) => {
              $scope.error = err.data.error;
            }
          );
      };
      $scope.getStatus();
    },
  ])
  .controller("anonymizeController", [
    "$scope",
    "$http",
    "$sce",
    "$routeParams",
    "$location",
    "$translate",
    "$timeout",
    function ($scope, $http, $sce, $routeParams, $location, $translate, $timeout) {
      // Unified state
      $scope.sourceUrl = "";
      $scope.detectedType = null; // 'repo' | 'pr' | 'gist'
      $scope.repoId = "";
      $scope.pullRequestId = "";
      $scope.gistId = "";
      $scope.terms = "";
      $scope.defaultTerms = "";
      $scope.branches = [];
      $scope.source = { branch: "", commit: "" };
      $scope.options = {
        expirationMode: "remove",
        expirationDate: new Date(),
        update: false,
        image: true,
        pdf: true,
        notebook: true,
        link: true,
        body: true,
        title: true,
        origin: false,
        diff: true,
        content: true,
        comments: true,
        username: true,
        date: true,
      };
      // Default expiration: 6 months from today.
      function defaultExpirationDate() {
        const d = new Date();
        d.setMonth(d.getMonth() + 6);
        return d;
      }
      $scope.options.expirationDate = defaultExpirationDate();
      // Format a Date to a "yyyy-MM-dd" string using local date parts. Using
      // toISOString() here would convert to UTC and can shift the bound by a
      // day for users in negative-offset timezones, which the native date
      // picker (local calendar) would then render off by one.
      function toLocalDateString(d) {
        const y = d.getFullYear();
        const m = String(d.getMonth() + 1).padStart(2, "0");
        const day = String(d.getDate()).padStart(2, "0");
        return `${y}-${m}-${day}`;
      }
      // Bound the expiration date: no earlier than today, no later than 1 year
      // out.
      $scope.minExpirationDate = toLocalDateString(new Date());
      const maxDate = new Date();
      maxDate.setFullYear(maxDate.getFullYear() + 1);
      $scope.maxExpirationDate = toLocalDateString(maxDate);
      $scope.anonymize_readme = "";
      $scope.readme = "";
      $scope.html_readme = "";
      $scope.isUpdate = false;

      function getDefault(cb) {
        $http.get("/api/user/default").then((res) => {
          const data = res.data;
          if (data.terms) {
            $scope.defaultTerms = data.terms.join("\n");
          }
          $scope.options = Object.assign({}, $scope.options, data.options);
          // Honour a server-provided default date, otherwise fall back to the
          // 6-month default. (Previously this re-added the offset on top of the
          // already-defaulted date, doubling it.)
          $scope.options.expirationDate =
            data.options && data.options.expirationDate
              ? new Date(data.options.expirationDate)
              : defaultExpirationDate();
          if (cb) cb();
        });
      }

      // Helper to safely set validity on form fields
      function setValidity(field, key, value) {
        if ($scope.anonymize && $scope.anonymize[field]) {
          $scope.anonymize[field].$setValidity(key, value);
        }
      }

      function parseRepoFullName(url) {
        try {
          const parsed = parseGithubUrl(url);
          if (parsed && parsed.owner && parsed.repo) {
            return parsed.owner + "/" + parsed.repo;
          }
        } catch (_) { /* sourceUrl not yet parseable */ }
        return null;
      }

      function sourceRepositoryID() {
        if (!$scope.isUpdate || !$scope._originalRepositoryID) return undefined;
        const currentFullName = parseRepoFullName($scope.sourceUrl);
        return currentFullName === $scope._originalFullName
          ? $scope._originalRepositoryID
          : undefined;
      }

      getDefault(() => {
        // Edit mode: repo
        if ($routeParams.repoId && $routeParams.repoId != "") {
          $scope.isUpdate = true;
          $scope.detectedType = "repo";
          $scope.repoId = $routeParams.repoId;
          $http.get("/api/repo/" + $scope.repoId).then(
            async (res) => {
              $scope.sourceUrl = "https://github.com/" + res.data.source.fullName;
              $scope._originalFullName = res.data.source.fullName;
              $scope.terms = res.data.options.terms.filter((f) => f).join("\n");
              $scope.source = res.data.source;
              $scope.role = res.data.role || "owner";
              $scope.coauthors = res.data.coauthors || [];
              // Remember the saved branch so the source.branch watcher knows
              // not to bump source.commit to GitHub HEAD on edit-page load
              // (#360). Without this, just opening the Edit form silently
              // pulled in any new commits and saving — even to toggle a
              // checkbox — picked them up.
              $scope._originalBranch = res.data.source.branch;
              $scope.options = Object.assign({}, $scope.options, res.data.options);
              $scope.conference = res.data.conference;
              $scope.repositoryID = res.data.source.repositoryID;
              $scope._originalRepositoryID = res.data.source.repositoryID;
              if (res.data.options.expirationDate) {
                $scope.options.expirationDate = new Date(res.data.options.expirationDate);
              }
              await Promise.all([getRepoDetails(), getReadme()]);
              anonymizeReadme();
              $scope.$apply();
            },
            () => { $location.url("/404"); }
          );
          $scope.$watch("anonymize", () => {
            if ($scope.anonymize.repoId) $scope.anonymize.repoId.$$element[0].disabled = true;
          });
        }
        // Edit mode: PR
        if ($routeParams.pullRequestId && $routeParams.pullRequestId != "") {
          $scope.isUpdate = true;
          $scope.detectedType = "pr";
          $scope.pullRequestId = $routeParams.pullRequestId;
          $http.get("/api/pr/" + $scope.pullRequestId).then(
            async (res) => {
              $scope.sourceUrl = "https://github.com/" + res.data.source.repositoryFullName + "/pull/" + res.data.source.pullRequestId;
              $scope.terms = res.data.options.terms.filter((f) => f).join("\n");
              $scope.source = res.data.source;
              $scope.options = Object.assign({}, $scope.options, res.data.options);
              $scope.conference = res.data.conference;
              if (res.data.options.expirationDate) {
                $scope.options.expirationDate = new Date(res.data.options.expirationDate);
              }
              try {
                $scope.details = (await $http.get(`/api/pr/${res.data.source.repositoryFullName}/${res.data.source.pullRequestId}`)).data;
              } catch (error) {
                const code = error && error.data && error.data.error;
                if (code) {
                  $translate("ERRORS." + code).then((translation) => {
                    $scope.addToast({ title: "Error", date: new Date(), body: translation });
                    $scope.error = translation;
                  }, console.error);
                  displayErrorMessage(code);
                }
              }
              $scope.$apply();
            },
            () => { $location.url("/404"); }
          );
          $scope.$watch("anonymize", () => {
            if ($scope.anonymize.pullRequestId) $scope.anonymize.pullRequestId.$$element[0].disabled = true;
            if ($scope.anonymize.sourceUrl) $scope.anonymize.sourceUrl.$$element[0].disabled = true;
          });
        }
        // Edit mode: Gist
        if ($routeParams.gistId && $routeParams.gistId != "") {
          $scope.isUpdate = true;
          $scope.detectedType = "gist";
          $scope.gistId = $routeParams.gistId;
          $http.get("/api/gist/" + $scope.gistId).then(
            async (res) => {
              $scope.sourceUrl = "https://gist.github.com/" + res.data.source.gistId;
              $scope.terms = res.data.options.terms.filter((f) => f).join("\n");
              $scope.source = res.data.source;
              $scope.options = Object.assign({}, $scope.options, res.data.options);
              $scope.conference = res.data.conference;
              if (res.data.options.expirationDate) {
                $scope.options.expirationDate = new Date(res.data.options.expirationDate);
              }
              $scope.details = (await $http.get(`/api/gist/source/${res.data.source.gistId}`)).data;
              $scope.$apply();
            },
            () => { $location.url("/404"); }
          );
          $scope.$watch("anonymize", () => {
            if ($scope.anonymize.gistId) $scope.anonymize.gistId.$$element[0].disabled = true;
            if ($scope.anonymize.sourceUrl) $scope.anonymize.sourceUrl.$$element[0].disabled = true;
          });
        }
      });

      // URL change handler - auto-detect type
      $scope.urlSelected = async () => {
        $scope.terms = $scope.defaultTerms;
        if (!$scope.isUpdate) {
          $scope.repoId = "";
          $scope.pullRequestId = "";
          $scope.gistId = "";
        }
        $scope.details = null;
        $scope.branches = [];
        $scope.source = { type: "GitHubStream", branch: "", commit: "" };
        $scope.anonymize_readme = "";
        $scope.readme = "";
        $scope.html_readme = "";
        $scope.detectedType = null;

        let o;
        try {
          o = parseGithubUrl($scope.sourceUrl);
        } catch (error) {
          setValidity("sourceUrl", "github", false);
          return;
        }
        setValidity("sourceUrl", "github", true);
        try {
          if (o.gistId && !o.repo) {
            $scope.detectedType = "gist";
            $scope.source = { gistId: o.gistId };
            await getGistDetails();
          } else if (o.pullRequestId) {
            $scope.detectedType = "pr";
            $scope.source = { repositoryFullName: o.owner + "/" + o.repo, pullRequestId: o.pullRequestId };
            await getPrDetails();
          } else {
            $scope.detectedType = "repo";
            await Promise.all([getRepoDetails(), getReadme()]);
            anonymizeReadme();
          }
        } catch (error) {
          return;
        }
        $scope.$apply();
        $('[data-toggle="tooltip"]').tooltip();
      };
      $('[data-toggle="tooltip"]').tooltip();

      // ========== REPO LOGIC ==========
      $scope.$watch("options.update", (v) => {
        if ($scope.detectedType !== "repo") return;
        if ($scope.anonymize && $scope.anonymize.commit) {
          $scope.anonymize.commit.$$element[0].disabled = !!v;
        }
      });

      $scope.$watch("source.branch", async () => {
        if ($scope.detectedType !== "repo") return;
        const selected = $scope.branches.filter((f) => f.name == $scope.source.branch)[0];
        if (!selected) return;
        // In update mode, preserve the saved commit while the branch is
        // unchanged — see #360. Saving the form (e.g. to turn off
        // auto-update) used to bump the commit to GitHub HEAD because this
        // watcher overwrote it on edit-page load.
        const keepSavedCommit =
          $scope.isUpdate &&
          $scope._originalBranch === $scope.source.branch &&
          !!$scope.source.commit;
        if (!keepSavedCommit) {
          $scope.source.commit = selected.commit;
        }
        $scope.readme = selected.readme;
        await getReadme();
        anonymizeReadme();
        $scope.$apply();
      });

      $scope.getBranches = async (force) => {
        const o = parseGithubUrl($scope.sourceUrl);
        try {
          const branches = await $http.get(`/api/repo/${o.owner}/${o.repo}/branches`, {
            params: { force: force === true ? "1" : "0", repositoryID: sourceRepositoryID() },
          });
          $scope.branches = branches.data;
          $scope.sourceUnreachable = false;
          if (!$scope.source.branch) {
            $scope.source.branch = $scope.details.defaultBranch;
          }
          const selected = $scope.branches.filter((b) => b.name == $scope.source.branch);
          if (selected.length > 0) {
            // When the user explicitly clicks refresh (force=true), always
            // update the commit to the latest on the branch. Only preserve
            // the saved commit on the initial edit-page load (#360).
            const keepSavedCommit =
              !force &&
              $scope.isUpdate &&
              !$scope.options.update &&
              $scope._originalBranch === $scope.source.branch &&
              !!$scope.source.commit;
            if (!keepSavedCommit) {
              $scope.source.commit = selected[0].commit;
            }
            $scope.readme = selected[0].readme;
            await getReadme(force);
          }
        } catch (error) {
          $scope.branches = [];
          $scope.sourceUnreachable = error && (error.status === 404 || (error.data && error.data.error === "repo_not_found"));
          const code = (error && error.data && error.data.error) || (error && error.status === 404 ? "repo_not_found" : "unknown_error");
          $translate("ERRORS." + code).then((translation) => {
            $scope.toasts = $scope.toasts || [];
            $scope.addToast({ title: "Error", date: new Date(), body: translation });
            $scope.error = translation;
          }, console.error);
          if (typeof setValidity === "function") {
            setValidity("sourceUrl", "missing", false);
          }
        }
        $scope.$apply();
      };

      async function getRepoDetails() {
        const o = parseGithubUrl($scope.sourceUrl);
        try {
          resetValidity();
          // force=1 so newly enabled features (e.g. GitHub Pages — see
          // #364) are reflected without waiting for the cached metadata to
          // expire. The endpoint hits the GitHub API once.
          const res = await $http.get(`/api/repo/${o.owner}/${o.repo}/`, {
            params: { repositoryID: sourceRepositoryID(), force: "1" },
          });
          $scope.details = res.data;
          if ($scope.details && $scope.details.id) {
            $scope.repositoryID = $scope.details.id;
          }
          if (!$scope.repoId) {
            $scope.repoId = $scope.details.repo + "-" + generateRandomId(4);
          }
          await $scope.getBranches();
        } catch (error) {
          if (error.data) {
            $translate("ERRORS." + error.data.error).then((translation) => {
              $scope.addToast({ title: "Error", date: new Date(), body: translation });
              $scope.error = translation;
            }, console.error);
            displayErrorMessage(error.data.error);
          }
          setValidity("sourceUrl", "missing", false);
          throw error;
        }
      }

      async function getReadme(force) {
        if ($scope.readme && !force) return $scope.readme;
        const o = parseGithubUrl($scope.sourceUrl);
        try {
          const res = await $http.get(`/api/repo/${o.owner}/${o.repo}/readme`, {
            params: { force: force === true ? "1" : "0", branch: $scope.source.branch, repositoryID: sourceRepositoryID() },
          });
          $scope.readme = res.data;
        } catch (error) {
          $scope.readme = "";
        }
      }

      // Both anonymizeReadme() and anonymizePrContent() used to reimplement
      // ContentAnonimizer client-side, which drifted from the backend (term
      // boundary fixes, accent matching, custom replacements all only landed
      // in the server). Send the snippets to /api/anonymize-preview instead so
      // the preview matches what reviewers see byte-for-byte. Calls are
      // debounced and the in-flight request is dropped on the next change so
      // typing in the form stays responsive.

      function previewOptions() {
        const opts = {
          terms: $scope.terms ? $scope.terms.split("\n") : [],
          image: !!$scope.options.image,
          link: !!$scope.options.link,
          repoId: $scope.repoId,
        };
        if ($scope.source && $scope.source.branch) {
          opts.branchName = $scope.source.branch;
        }
        try {
          const o = parseGithubUrl($scope.sourceUrl);
          opts.repoName = `${o.owner}/${o.repo}`;
        } catch (_) { /* sourceUrl not yet parseable */ }
        return opts;
      }

      // Single-flight + debounced wrapper. Returns a promise that resolves
      // with the latest server result; intermediate calls are coalesced.
      function makePreviewBatcher(buildBody, applyResult) {
        let pendingTimer = null;
        let inflightToken = 0;
        return function schedule() {
          if (pendingTimer) $timeout.cancel(pendingTimer);
          pendingTimer = $timeout(() => {
            pendingTimer = null;
            const myToken = ++inflightToken;
            const body = buildBody();
            if (!body) return;
            $http.post("/api/anonymize-preview", body).then(
              (res) => {
                if (myToken !== inflightToken) return; // stale
                applyResult(res.data);
              },
              () => { /* ignore preview errors; no UI feedback needed */ }
            );
          }, 200);
        };
      }

      const scheduleReadmePreview = makePreviewBatcher(
        () => {
          if (!$scope.readme) return null;
          return { content: $scope.readme, options: previewOptions() };
        },
        (data) => {
          $scope.anonymize_readme = data.content || "";
          let baseUrl = "";
          try {
            const o = parseGithubUrl($scope.sourceUrl);
            // Fall back to the repo's default branch when source.branch
            // hasn't loaded yet — without this, relative <img src="./X">
            // resolved against a baseUrl like ".../raw//" (no branch
            // segment), so the browser fetched ".../raw/X" and 404'd
            // (#407).
            const branch =
              $scope.source.branch ||
              ($scope.details && $scope.details.defaultBranch) ||
              "main";
            baseUrl = `https://github.com/${o.owner}/${o.repo}/raw/${branch}/`;
          } catch (_) { /* fall through with empty base */ }
          const html = renderMD($scope.anonymize_readme, baseUrl);
          $scope.html_readme = $sce.trustAsHtml(html);
          $timeout(Prism.highlightAll, 150);
        }
      );

      function anonymizeReadme() {
        if (!$scope.anonymize || !$scope.anonymize.terms) return;
        // The "regex characters detected" hint is informational, not a blocker
        // — IP addresses, escaped chars, etc. are all legitimate terms (#430).
        $scope.termsRegexWarning =
          !!$scope.terms && !!$scope.terms.match(/[-[\]{}()*+?.,\\^$|#]/g);
        scheduleReadmePreview();
      }

      // ========== PR LOGIC ==========
      async function getPrDetails() {
        const o = parseGithubUrl($scope.sourceUrl);
        try {
          resetValidity();
          const res = await $http.get(`/api/pr/${o.owner}/${o.repo}/${o.pullRequestId}`);
          $scope.details = res.data;
          if (!$scope.pullRequestId) {
            $scope.pullRequestId = o.repo + "-PR" + o.pullRequestId + "-" + generateRandomId(4);
          }
        } catch (error) {
          if (error.data) {
            $translate("ERRORS." + error.data.error).then((translation) => {
              $scope.addToast({ title: "Error", date: new Date(), body: translation });
              $scope.error = translation;
            }, console.error);
            displayErrorMessage(error.data.error);
          }
          setValidity("sourceUrl", "missing", false);
          throw error;
        }
      }

      // Angular templates evaluate this synchronously, so we keep a
      // {original -> anonymized} cache populated by a debounced batch call to
      // /api/anonymize-preview whenever the PR details, terms, or options
      // change. anonymizePrContent() returns the cached value if known and
      // falls back to the original until the next cycle resolves.
      let _prAnonCache = new Map();
      let _prSeenContents = new Set();

      function collectPrContents() {
        const out = new Set();
        const d = $scope.details && $scope.details.pullRequest;
        if (!d) return out;
        if (typeof d.title === "string") out.add(d.title);
        if (typeof d.body === "string") out.add(d.body);
        if (typeof d.diff === "string") out.add(d.diff);
        const comments =
          ($scope.details && $scope.details.comments) || [];
        for (const c of comments) {
          if (typeof c.author === "string") out.add(c.author);
          if (typeof c.body === "string") out.add(c.body);
        }
        return out;
      }

      const refreshPrPreview = makePreviewBatcher(
        () => {
          const seen = collectPrContents();
          _prSeenContents = seen;
          const list = Array.from(seen);
          if (list.length === 0) return null;
          return { contents: list, options: previewOptions() };
        },
        (data) => {
          if (!data || !Array.isArray(data.contents)) return;
          const seen = Array.from(_prSeenContents);
          const next = new Map();
          for (let i = 0; i < seen.length && i < data.contents.length; i++) {
            next.set(seen[i], data.contents[i]);
          }
          _prAnonCache = next;
        }
      );

      $scope.anonymizePrContent = function (content) {
        if (!content) return content;
        if (_prAnonCache.has(content)) return _prAnonCache.get(content);
        if (!_prSeenContents.has(content)) {
          refreshPrPreview();
        }
        return content;
      };

      // ========== GIST LOGIC ==========
      async function getGistDetails() {
        const o = parseGithubUrl($scope.sourceUrl);
        try {
          resetValidity();
          const res = await $http.get(`/api/gist/source/${o.gistId}`);
          $scope.details = res.data;
          if (!$scope.gistId) {
            $scope.gistId = "gist-" + o.gistId.substring(0, 6) + "-" + generateRandomId(4);
          }
        } catch (error) {
          if (error.data) {
            $translate("ERRORS." + error.data.error).then((translation) => {
              $scope.addToast({ title: "Error", date: new Date(), body: translation });
              $scope.error = translation;
            }, console.error);
            displayErrorMessage(error.data.error);
          }
          setValidity("sourceUrl", "missing", false);
          throw error;
        }
      }

      let _gistAnonCache = new Map();
      let _gistSeenContents = new Set();
      let _gistCacheVersion = 0;

      function collectGistContents() {
        const out = new Set();
        const d = $scope.details && $scope.details.gist;
        if (!d) return out;
        if (typeof d.description === "string") out.add(d.description);
        if (typeof d.ownerLogin === "string") out.add(d.ownerLogin);
        const files = (d.files) || [];
        for (const f of files) {
          if (typeof f.filename === "string") out.add(f.filename);
          if (typeof f.content === "string") out.add(f.content);
        }
        const comments = d.comments || [];
        for (const c of comments) {
          if (typeof c.author === "string") out.add(c.author);
          if (typeof c.body === "string") out.add(c.body);
        }
        return out;
      }

      const refreshGistPreview = makePreviewBatcher(
        () => {
          const seen = collectGistContents();
          _gistSeenContents = seen;
          const list = Array.from(seen);
          if (list.length === 0) return null;
          return { contents: list, options: previewOptions() };
        },
        (data) => {
          if (!data || !Array.isArray(data.contents)) return;
          const seen = Array.from(_gistSeenContents);
          const next = new Map();
          for (let i = 0; i < seen.length && i < data.contents.length; i++) {
            next.set(seen[i], data.contents[i]);
          }
          _gistAnonCache = next;
          _gistCacheVersion++;
          rebuildPreviewGistFiles();
        }
      );

      $scope.anonymizeGistContent = function (content) {
        if (!content) return content;
        if (_gistAnonCache.has(content)) return _gistAnonCache.get(content);
        if (!_gistSeenContents.has(content)) {
          refreshGistPreview();
        }
        return content;
      };

      // Precomputed file objects for the preview pane so <gist-file>'s
      // two-way binding has a stable reference. Recomputes when the source
      // files change OR when the anonymization cache turns over.
      $scope.previewGistFiles = [];
      function rebuildPreviewGistFiles() {
        const files =
          ($scope.details && $scope.details.gist && $scope.details.gist.files) || [];
        $scope.previewGistFiles = files.map((f) => ({
          filename: $scope.anonymizeGistContent(f.filename),
          content: $scope.anonymizeGistContent(f.content),
          language: f.language,
        }));
      }
      // _prAnonCache turns over inside refreshGistPreview's applyResult; the
      // simplest signal we have is the digest cycle, so re-derive each digest.
      // Cheap when _gistAnonCache hits.
      $scope.$watch("details.gist.files", rebuildPreviewGistFiles, true);
      $scope.$watch("terms", rebuildPreviewGistFiles);

      // ========== SHARED LOGIC ==========
      function getConference() {
        if (!$scope.conference) return;
        $http.get("/api/conferences/" + $scope.conference).then(
          (res) => {
            $scope.conference_data = res.data;
            $scope.conference_data.startDate = new Date($scope.conference_data.startDate);
            $scope.conference_data.endDate = new Date($scope.conference_data.endDate);
            $scope.options.expirationDate = new Date($scope.conference_data.endDate);
            $scope.options.expirationMode = "remove";
            $scope.options.update = $scope.conference_data.options.update;
            $scope.options.image = $scope.conference_data.options.image;
            $scope.options.pdf = $scope.conference_data.options.pdf;
            $scope.options.notebook = $scope.conference_data.options.notebook;
            $scope.options.link = $scope.conference_data.options.link;
          },
          () => { $scope.conference_data = null; }
        );
      }

      function resetValidity() {
        setValidity("repoId", "used", true);
        setValidity("repoId", "format", true);
        setValidity("pullRequestId", "used", true);
        setValidity("pullRequestId", "format", true);
        setValidity("gistId", "used", true);
        setValidity("gistId", "format", true);
        setValidity("sourceUrl", "used", true);
        setValidity("sourceUrl", "missing", true);
        setValidity("sourceUrl", "access", true);
        setValidity("sourceUrl", "github", true);
        setValidity("commit", "exists", true);
        setValidity("conference", "activated", true);
        setValidity("terms", "format", true);
        $scope.termsRegexWarning = false;
      }

      // Guards against submitting a missing or out-of-range expiration date.
      // When the picked date fails min/max validation AngularJS sets the model
      // to undefined, so we check both the field validity and the model value.
      function expirationDateInvalid() {
        const field = $scope.anonymize && $scope.anonymize.expirationDate;
        if (!$scope.options.expirationDate || (field && field.$invalid)) {
          if (field && field.$setDirty) field.$setDirty();
          $scope.error = "Please choose a valid expiration date.";
          return true;
        }
        return false;
      }

      function displayErrorMessage(message) {
        const idField =
          $scope.detectedType === "pr"
            ? "pullRequestId"
            : $scope.detectedType === "gist"
            ? "gistId"
            : "repoId";
        switch (message) {
          case "repoId_already_used": setValidity(idField, "used", false); break;
          case "invalid_repoId": setValidity(idField, "format", false); break;
          case "options_not_provided": setValidity(idField, "format", false); break;
          case "repo_already_anonymized": setValidity("sourceUrl", "used", false); break;
          case "invalid_terms_format": setValidity("terms", "format", false); break;
          case "repo_not_found": setValidity("sourceUrl", "missing", false); break;
          case "repo_not_accessible": setValidity("sourceUrl", "access", false); break;
          case "commit_not_found": setValidity("commit", "exists", false); break;
          case "conf_not_activated": setValidity("conference", "activated", false); break;
        }
      }

      // ========== CO-AUTHORS ==========
      $scope.coauthors = $scope.coauthors || [];
      $scope.coauthorResults = [];
      $scope.coauthorError = "";

      $scope.searchCoauthors = () => {
        const q = ($scope.coauthorSearch || "").trim();
        $scope.coauthorError = "";
        if (q.length < 2) {
          $scope.coauthorResults = [];
          return;
        }
        $http.get("/api/user/search/github-users", { params: { q } }).then(
          (res) => {
            const existing = new Set(
              ($scope.coauthors || []).map((c) => (c.username || "").toLowerCase())
            );
            $scope.coauthorResults = (res.data || []).filter(
              (u) => !existing.has((u.username || "").toLowerCase())
            );
          },
          () => { $scope.coauthorResults = []; }
        );
      };

      $scope.addCoauthor = (u, event) => {
        if (event) event.preventDefault();
        if (!u || !u.username) return;
        $http
          .post("/api/repo/" + $scope.repoId + "/coauthors", {
            username: u.username,
          })
          .then(
            (res) => {
              $scope.coauthors = res.data || [];
              $scope.coauthorResults = [];
              $scope.coauthorSearch = "";
              $scope.coauthorError = "";
            },
            (err) => {
              const code = (err && err.data && err.data.error) || "unknown_error";
              $scope.coauthorError = code;
            }
          );
      };

      $scope.removeCoauthor = (c) => {
        if (!c || !c.username) return;
        if (!confirm("Remove co-author " + c.username + "?")) return;
        $http
          .delete(
            "/api/repo/" +
              $scope.repoId +
              "/coauthors/" +
              encodeURIComponent(c.username)
          )
          .then(
            (res) => { $scope.coauthors = res.data || []; },
            (err) => {
              const code = (err && err.data && err.data.error) || "unknown_error";
              $scope.coauthorError = code;
            }
          );
      };

      // Submit: repo
      $scope.anonymizeRepo = (event) => {
        if (expirationDateInvalid()) return;
        event.target.disabled = true;
        const o = parseGithubUrl($scope.sourceUrl);
        const payload = {
          repoId: $scope.repoId,
          terms: $scope.terms.trim().split("\n").filter((f) => f),
          fullName: `${o.owner}/${o.repo}`,
          repository: $scope.sourceUrl,
          options: $scope.options,
          source: $scope.source,
          conference: $scope.conference,
        };
        if ($scope.details) payload.options.pageSource = $scope.details.pageSource;
        resetValidity();
        const url = $scope.isUpdate ? "/api/repo/" + $scope.repoId : "/api/repo/";
        $http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
          () => { window.location.href = "/status/" + $scope.repoId; },
          (error) => {
            if (error.data) {
              $translate("ERRORS." + error.data.error).then((t) => { $scope.error = t; }, console.error);
              displayErrorMessage(error.data.error);
            }
          }
        ).finally(() => { event.target.disabled = false; $scope.$apply(); });
      };

      // Submit: Gist
      $scope.anonymizeGist = (event) => {
        if (expirationDateInvalid()) return;
        event.target.disabled = true;
        const o = parseGithubUrl($scope.sourceUrl);
        const payload = {
          gistId: $scope.gistId,
          terms: $scope.terms.trim().split("\n").filter((f) => f),
          source: { gistId: o.gistId },
          options: $scope.options,
          conference: $scope.conference,
        };
        resetValidity();
        const url = $scope.isUpdate ? "/api/gist/" + $scope.gistId : "/api/gist/";
        $http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
          () => { window.location.href = "/gist/" + $scope.gistId; },
          (error) => {
            if (error.data) {
              $translate("ERRORS." + error.data.error).then((t) => { $scope.error = t; }, console.error);
              displayErrorMessage(error.data.error);
            }
          }
        ).finally(() => { event.target.disabled = false; $scope.$apply(); });
      };

      // Submit: PR
      $scope.anonymizePullRequest = (event) => {
        if (expirationDateInvalid()) return;
        event.target.disabled = true;
        const o = parseGithubUrl($scope.sourceUrl);
        const payload = {
          pullRequestId: $scope.pullRequestId,
          terms: $scope.terms.trim().split("\n").filter((f) => f),
          source: { repositoryFullName: `${o.owner}/${o.repo}`, pullRequestId: o.pullRequestId },
          options: $scope.options,
          conference: $scope.conference,
        };
        resetValidity();
        const url = $scope.isUpdate ? "/api/pr/" + $scope.pullRequestId : "/api/pr/";
        $http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
          () => { window.location.href = "/pr/" + $scope.pullRequestId; },
          (error) => {
            if (error.data) {
              $translate("ERRORS." + error.data.error).then((t) => { $scope.error = t; }, console.error);
              displayErrorMessage(error.data.error);
            }
          }
        ).finally(() => { event.target.disabled = false; $scope.$apply(); });
      };

      $scope.$watch("conference", () => { getConference(); });
      $scope.$watch("terms", () => {
        if ($scope.detectedType === "repo") anonymizeReadme();
        if ($scope.detectedType === "pr") refreshPrPreview();
        if ($scope.detectedType === "gist") refreshGistPreview();
      });
      $scope.$watch("options.image", () => {
        if ($scope.detectedType === "repo") anonymizeReadme();
        if ($scope.detectedType === "pr") refreshPrPreview();
        if ($scope.detectedType === "gist") refreshGistPreview();
      });
      $scope.$watch("options.link", () => {
        if ($scope.detectedType === "repo") anonymizeReadme();
        if ($scope.detectedType === "pr") refreshPrPreview();
        if ($scope.detectedType === "gist") refreshGistPreview();
      });
      $scope.$watch("details", () => {
        if ($scope.detectedType === "pr") refreshPrPreview();
        if ($scope.detectedType === "gist") refreshGistPreview();
      }, true);
    },
  ])
  .controller("exploreController", [
    "$scope",
    "$http",
    "$location",
    "$routeParams",
    "$sce",
    "$q",
    function ($scope, $http, $location, $routeParams, $sce, $q) {
      $scope.files = [];
      $scope.isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
      $scope.fileSearchQuery = "";
      $scope.fileSearchResults = null;
      $scope.fileSearchLoading = false;

      document.addEventListener("keydown", function (e) {
        if ((e.metaKey || e.ctrlKey) && e.key === "k") {
          e.preventDefault();
          var input = document.querySelector(".tree-search-input");
          if (input) {
            input.focus();
            input.select();
          }
        }
      });
      var searchCanceller = null;
      $scope.onFileSearchChange = function () {
        // Cancel any in-flight search request
        if (searchCanceller) {
          searchCanceller.resolve();
          searchCanceller = null;
        }
        const query = $scope.fileSearchQuery;
        if (!query || query.length < 2) {
          $scope.fileSearchResults = null;
          $scope.fileSearchLoading = false;
          return;
        }
        $scope.fileSearchLoading = true;
        searchCanceller = $q.defer();
        $http.get(
          `/api/repo/${$scope.repoId}/files/search?q=${encodeURIComponent(query)}`,
          { timeout: searchCanceller.promise }
        ).then(function (res) {
          searchCanceller = null;
          $scope.fileSearchLoading = false;
          // Merge search results into $scope.files so the tree can render them.
          // Ancestor folders must appear before their children for toArray() to work.
          var existing = {};
          $scope.files.forEach(function(f) {
            existing[(f.path || "") + "/" + f.name] = true;
          });
          // First pass: collect ancestor folders (shallow to deep)
          var foldersToAdd = [];
          var folderSeen = {};
          for (var i = 0; i < res.data.length; i++) {
            var f = res.data[i];
            if (f.path) {
              var segments = f.path.split("/");
              var acc = "";
              for (var j = 0; j < segments.length; j++) {
                var parent = acc;
                acc = acc ? acc + "/" + segments[j] : segments[j];
                var folderKey = parent + "/" + segments[j];
                if (!existing[folderKey] && !folderSeen[folderKey]) {
                  folderSeen[folderKey] = true;
                  foldersToAdd.push({ name: segments[j], path: parent });
                }
              }
            }
          }
          // Sort folders by depth (shallow first)
          foldersToAdd.sort(function(a, b) {
            return (a.path || "").split("/").length - (b.path || "").split("/").length;
          });
          // Add folders first, then files
          if (foldersToAdd.length > 0) {
            $scope.files.push.apply($scope.files, foldersToAdd);
          }
          var filesToAdd = [];
          for (var k = 0; k < res.data.length; k++) {
            var rf = res.data[k];
            var key = (rf.path || "") + "/" + rf.name;
            if (!existing[key] && rf.size != null) {
              filesToAdd.push(rf);
              existing[key] = true;
            }
          }
          if (filesToAdd.length > 0) {
            $scope.files.push.apply($scope.files, filesToAdd);
          }
          $scope.fileSearchResults = res.data;
        }, function () {
          // Only clear loading if this wasn't a cancellation
          if (!searchCanceller) {
            $scope.fileSearchLoading = false;
            $scope.fileSearchResults = [];
          }
        });
      };
      const extensionModes = {
        yml: "yaml",
        txt: "text",
        py: "python",
        js: "javascript",
        ts: "typescript",
      };
      const textFiles = ["license", "txt"];
      const imageFiles = [
        "png",
        "jpg",
        "jpeg",
        "gif",
        "svg",
        "ico",
        "bmp",
        "tiff",
        "tif",
        "webp",
        "avif",
        "heif",
        "heic",
      ];
      const audioFiles = ["wav", "mp3", "ogg", "wma", "flac", "aac", "m4a"];
      const mediaFiles = [
        "mp4",
        "avi",
        "webm",
        "mov",
        "mpg",
        "mpeg",
        "mkv",
        "flv",
        "wmv",
        "3gp",
        "3g2",
        "m4v",
        "f4v",
        "f4p",
        "f4a",
        "f4b",
      ];

      $scope.$on("$routeUpdate", function (event, current) {
        if (($routeParams.path || "") == $scope.filePath) {
          return;
        }
        $scope.filePath = $routeParams.path || "";
        $scope.paths = $scope.filePath
          .split("/")
          .filter((f) => f && f.trim().length > 0);

        if ($scope.repoId != $routeParams.repoId) {
          return init();
        }

        updateContent();

        // #510 — if we navigated into a subdirectory whose file listing
        // hasn't been fetched, lazy-load the parent directories in the
        // background so getSelectedFile() can populate $scope.file with the
        // right sha for the next interaction. Done after updateContent so
        // the request fires immediately (getContent falls back to sha "0").
        for (let i = 0; i < $scope.paths.length - 1; i++) {
          const dirPath = i > 0 ? $scope.paths.slice(0, i).join("/") : "";
          const alreadyLoaded = $scope.files.some((f) => f.path === dirPath);
          if (!alreadyLoaded) {
            $scope.getFiles(dirPath);
          }
        }
      });

      function selectFile() {
        if ($scope.paths[0] != "") {
          return;
        }
        const readmePriority = [
          "readme.md",
          "readme.txt",
          "readme.org",
          "readme.1st",
          "readme",
        ];
        const readmeCandidates = {};
        for (const file of $scope.files) {
          if (file.name.toLowerCase().indexOf("readme") > -1) {
            readmeCandidates[file.name.toLowerCase()] = file.name;
          }
        }
        let best_match = null;
        for (const p of readmePriority) {
          if (readmeCandidates[p]) {
            best_match = p;
            break;
          }
        }
        if (!best_match && Object.keys(readmeCandidates).length > 0)
          best_match = Object.keys(readmeCandidates)[0];
        if (best_match) {
          let uri = $location.url();
          if (uri[uri.length - 1] != "/") {
            uri += "/";
          }

          // redirect to readme
          $location.url(
            uri + encodePathForUrl(readmeCandidates[best_match])
          );
        }
      }
      $scope.fileCounts = null;
      $scope.getFiles = function (path) {
        return $http.get(
          `/api/repo/${$scope.repoId}/files/?path=${encodeURIComponent(path)}&v=${$scope.options.lastUpdateDate}`
        ).then(function (res) {
          const normalized = path || "";
          $scope.files = $scope.files.filter((f) => f.path !== normalized);
          $scope.files.push(...res.data);
          return res.data;
        }, function (err) {
          $scope.type = "error";
          $scope.content = (err && err.data && err.data.error) || "unknown_error";
          $scope.files = [];
        });
      };
      function fetchFileCounts() {
        $http.get(
          `/api/repo/${$scope.repoId}/files/counts`
        ).then(function (res) {
          $scope.fileCounts = res.data;
        }, function () {
          $scope.fileCounts = {};
        });
      }

      function getSelectedFile() {
        return $scope.files.filter(
          (f) =>
            f.name == $scope.paths[$scope.paths.length - 1] &&
            f.path == $scope.paths.slice(0, $scope.paths.length - 1).join("/")
        )[0];
      }

      var rlCountdownTimer = null;
      $scope.$on("$destroy", function () { if (rlCountdownTimer) clearInterval(rlCountdownTimer); });

      function getOptions(callback) {
        $http.get(`/api/repo/${$scope.repoId}/options`).then(
          (res) => {
            $scope.options = res.data;
            if ($scope.options.url) {
              window.location = $scope.options.url;
              return;
            }
            if (callback) {
              callback(res.data);
            }
          },
          (err) => {
            var data = err.data || {};
            if (data.error === "rate_limited" && data.resetAt) {
              $scope.type = "rate_limited";
              $scope.rateLimitResetAt = data.resetAt;
              if (rlCountdownTimer) clearInterval(rlCountdownTimer);
              function rlTick() {
                var remaining = Math.max(0, Math.ceil(($scope.rateLimitResetAt - Date.now()) / 1000));
                if (remaining <= 0) {
                  $scope.rateLimitCountdown = "";
                  $scope.rateLimitResetAt = 0;
                  if (rlCountdownTimer) { clearInterval(rlCountdownTimer); rlCountdownTimer = null; }
                  getOptions(callback);
                } else {
                  var min = Math.floor(remaining / 60);
                  var sec = remaining % 60;
                  $scope.rateLimitCountdown = min > 0 ? min + "m " + sec + "s" : sec + "s";
                }
                $scope.$applyAsync();
              }
              rlTick();
              rlCountdownTimer = setInterval(rlTick, 1000);
            } else if (data.error === "repository_not_ready") {
              $scope.type = "loading";
              setTimeout(function () { getOptions(callback); }, 3000);
            } else {
              $scope.type = "error";
              $scope.content = data.error;
            }
          }
        );
      }

      // Defined as methods rather than inline `showSource = !showSource` in the
      // template: the toolbar and the file view sit in different child scopes,
      // so an inline assignment would shadow the value on one of them instead
      // of updating the controller's.
      $scope.toggleSource = function () {
        $scope.showSource = !$scope.showSource;
      };

      $scope.toggleAllowScripts = function () {
        $scope.allowScripts = !$scope.allowScripts;
      };

      function getMode(extension) {
        if (extensionModes[extension]) {
          return extensionModes[extension];
        }
        return extension;
      }

      function getType(extension) {
        if (extension == "pdf") {
          return "pdf";
        }
        // Rendered as a document in a sandboxed frame rather than as source —
        // see html-doc.js. "html" is reserved for markup we generated
        // ourselves (rendered markdown/org) and inject directly.
        if (extension == "html" || extension == "htm") {
          return "html-doc";
        }
        if (extension == "md") {
          return "md";
        }
        if (extension == "org") {
          return "org";
        }
        if (extension == "ipynb") {
          return "IPython";
        }
        if (textFiles.indexOf(extension) > -1) {
          return "text";
        }
        if (imageFiles.indexOf(extension) > -1) {
          return "image";
        }
        if (mediaFiles.indexOf(extension) > -1) {
          return "media";
        }
        if (audioFiles.indexOf(extension) > -1) {
          return "audio";
        }
        return "code";
      }

      function getContent(path, fileInfo) {
        if (!path) {
          $scope.type = "error";
          $scope.content = "no_file_selected";
          return;
        }
        const originalType = $scope.type;
        $scope.type = "loading";
        $scope.content = "loading";
        // fileInfo can be undefined when the user navigates (e.g. clicks a
        // markdown link into a subdir whose file list hasn't loaded yet) —
        // see #510. Fall back to "0" so the request still goes through; the
        // server returns a fresh ETag on first hit either way.
        const sha = (fileInfo && fileInfo.sha) || "0";
        $http
          .get(
            `/api/repo/${$scope.repoId}/file/${encodePathForUrl(path)}?v=` +
              sha,
            {
              transformResponse: (data) => {
                return data;
              },
            }
          )
          .then(
            (res) => {
              $scope.type = originalType;
              $scope.content = res.data;
              if ($scope.content == "") {
                $scope.content = null;
              }

              if ($scope.type == "md") {
                $scope.content = $sce.trustAsHtml(
                  renderMD(res.data, $location.url() + "/../")
                );
                $scope.type = "html";
              }
              if ($scope.type == "org") {
                const content = contentAbs2Relative(res.data);

                const orgParser = new Org.Parser();
                const orgDocument = orgParser.parse(content);
                var orgHTMLDocument = orgDocument.convert(Org.ConverterHTML, {
                  headerOffset: 1,
                  exportFromLineNumber: false,
                  suppressSubScriptHandling: true,
                  suppressAutoLink: false,
                });
                $scope.content = $sce.trustAsHtml(orgHTMLDocument.toString());
                $scope.type = "html";
              }
              if (
                $scope.type == "code" &&
                res.headers("content-type") == "application/octet-stream"
              ) {
                $scope.type = "binary";
                $scope.content = "binary";
              }
              setTimeout(() => {
                Prism.highlightAll();
              }, 50);
            },
            (err) => {
              $scope.type = "error";
              $scope.content = "unknown_error";
              try {
                err.data = JSON.parse(err.data);
                if (err.data.error) {
                  $scope.content = err.data.error;
                } else {
                  $scope.content = err.data;
                }
              } catch (ignore) {
                console.log(err);
                if (err.status == -1) {
                  $scope.content = "request_error";
                } else if (err.status == 502) {
                  // cloudflare error
                  $scope.content = "unreachable";
                }
              }
            }
          );
      }

      function updateContent() {
        $scope.content = "";
        $scope.file = getSelectedFile();
        let fileVersion = "0";
        if ($scope.file && $scope.file.sha) {
          fileVersion = $scope.file.sha;
        }
        $scope.url = `/api/repo/${$scope.repoId}/file/${encodePathForUrl(
          $scope.filePath
        )}?v=${fileVersion}`;
        // Directory the file lives in, used as the <base> for a rendered HTML
        // document so its relative images/stylesheets still resolve.
        const dirPath = $scope.filePath.substring(
          0,
          $scope.filePath.lastIndexOf("/") + 1
        );
        $scope.fileBaseUrl = `/api/repo/${$scope.repoId}/file/${
          dirPath ? encodePathForUrl(dirPath) : ""
        }`;
        $scope.showSource = false;
        // Scripts in a repository's HTML are opt-in, per file — see
        // html-doc.js. Reset on navigation so trust never carries over from
        // one file to the next.
        $scope.allowScripts = false;

        let extension = $scope.filePath.toLowerCase();
        const extensionIndex = extension.lastIndexOf(".");
        if (extensionIndex > -1) {
          extension = extension.substring(extensionIndex + 1);
        }

        $scope.aceOption = {
          readOnly: true,
          useWrapMode: true,
          showGutter: true,
          theme: "chrome",
          useSoftTab: true,
          showPrintMargin: true,
          tabSize: 2,
          highlightSelectedWord: true,
          fontSize: 15,
          keyBinding: "vscode",
          fullLineSelection: true,
          highlightActiveLine: false,
          highlightGutterLine: false,
          cursor: "hide",
          showInvisibles: false,
          showIndentGuides: true,
          showPrintMargin: false,
          highlightSelectedWord: false,
          enableBehaviours: true,
          fadeFoldWidgets: false,
          mode: getMode(extension),

          onLoad: function (_editor) {
            const Range = ace.require("ace/range").Range;
            let activeLineMarker = null;

            function highlightLines(from, to) {
              if (activeLineMarker !== null) {
                _editor.session.removeMarker(activeLineMarker);
                activeLineMarker = null;
              }
              if (from === null || from === undefined) return;
              activeLineMarker = _editor.session.addMarker(
                new Range(from, 0, to, 1),
                "highlighted-line",
                "fullLine"
              );
            }

            function applyHashFromUrl(scroll) {
              const m = window.location.hash.match(/^#L(\d+)(?:-L(\d+))?/);
              if (!m) {
                highlightLines(null);
                return;
              }
              const from = parseInt(m[1]) - 1;
              const to = m[2] ? parseInt(m[2]) - 1 : from;
              highlightLines(from, to);
              if (scroll) {
                setTimeout(() => {
                  _editor.scrollToLine(from, true, true, function () {});
                }, 100);
              }
            }

            applyHashFromUrl(true);

            // #392 — clicking a gutter line updates the URL to #L<n> and
            // shift-clicking extends to #L<from>-L<to> so the user can copy
            // a stable link to a specific line. Use replaceState to avoid
            // polluting history with every click.
            let anchorRow = null;
            _editor.on("guttermousedown", function (e) {
              const row = e.getDocumentPosition().row;
              const shift = e.domEvent && e.domEvent.shiftKey;
              let from = row;
              let to = row;
              if (shift && anchorRow !== null) {
                from = Math.min(anchorRow, row);
                to = Math.max(anchorRow, row);
              } else {
                anchorRow = row;
              }
              const hash =
                from === to
                  ? `#L${from + 1}`
                  : `#L${from + 1}-L${to + 1}`;
              const url =
                window.location.pathname + window.location.search + hash;
              window.history.replaceState(null, "", url);
              highlightLines(from, to);
              e.stop();
            });

            window.addEventListener("hashchange", () => applyHashFromUrl(false));

            _editor.setFontSize($scope.aceOption.fontSize);
            _editor.setReadOnly($scope.aceOption.readOnly);
            _editor.setKeyboardHandler($scope.aceOption.keyBinding);
            _editor.setSelectionStyle(
              $scope.aceOption.fullLineSelection ? "line" : "text"
            );
            _editor.setOption("displayIndentGuides", true);
            _editor.setHighlightActiveLine(
              $scope.aceOption.highlightActiveLine
            );
            if ($scope.aceOption.cursor == "hide") {
              _editor.renderer.$cursorLayer.element.style.display = "none";
            }
            _editor.setHighlightGutterLine(
              $scope.aceOption.highlightGutterLine
            );
            _editor.setShowInvisibles($scope.aceOption.showInvisibles);
            _editor.setDisplayIndentGuides($scope.aceOption.showIndentGuides);

            _editor.renderer.setShowPrintMargin(
              $scope.aceOption.showPrintMargin
            );
            _editor.setHighlightSelectedWord(
              $scope.aceOption.highlightSelectedWord
            );
            _editor.session.setUseSoftTabs($scope.aceOption.useSoftTab);
            _editor.session.setTabSize($scope.aceOption.tabSize);
            _editor.setBehavioursEnabled($scope.aceOption.enableBehaviours);
            _editor.setFadeFoldWidgets($scope.aceOption.fadeFoldWidgets);
          },
        };
        $scope.$on("dark-mode", (event, on) => {
          if (on) {
            $scope.aceOption.theme = "nord_dark";
          } else {
            $scope.aceOption.theme = "chrome";
          }
        });
        if ($scope.isDarkMode) {
          $scope.aceOption.theme = "nord_dark";
        }
        $scope.type = getType(extension);

        if ($scope.type == "pdf") {
          // The viewer streams the file itself from $scope.url, so fetching
          // the bytes again here only to hold them as a JS string wastes a
          // request and a lot of memory on a large report. Content stays
          // non-null so the Raw/Download actions remain available.
          $scope.content = "pdf";
          return;
        }

        getContent($scope.filePath, $scope.file);
      }

      function init() {
        $scope.repoId = $routeParams.repoId;
        $scope.type = "loading";
        $scope.filePath = $routeParams.path || "";
        $scope.paths = $scope.filePath.split("/");

        getOptions(function (options) {
          fetchFileCounts();
          var chain = $q.resolve();
          for (let i = 0; i < $scope.paths.length; i++) {
            const path = i > 0 ? $scope.paths.slice(0, i).join("/") : "";
            chain = chain.then(function () {
              return $scope.getFiles(path);
            }).then(function () {
              if ($scope.type === "error") {
                return $q.reject("error");
              }
            });
          }
          chain.then(function () {
            if ($scope.files.length == 1 && $scope.files[0].name == "") {
              $scope.files = [];
              $scope.type = "empty";
            } else {
              selectFile();
              updateContent();
            }
          });
        });
      }

      init();
    },
  ])
  // anonymizePullRequestController removed - unified into anonymizeController
  .controller("pullRequestController", [
    "$scope",
    "$http",
    "$location",
    "$routeParams",
    "$sce",
    function ($scope, $http, $location, $routeParams, $sce) {
      async function getOption(callback) {
        $http.get(`/api/pr/${$scope.pullRequestId}/options`).then(
          (res) => {
            $scope.options = res.data;
            if ($scope.options.url) {
              // the repository is expired with redirect option
              window.location = $scope.options.url;
              return;
            }
            if (callback) {
              callback(res.data);
            }
          },
          (err) => {
            $scope.type = "error";
            $scope.content = err.data.error;
          }
        );
      }
      async function getPullRequest(callback) {
        $http.get(`/api/pr/${$scope.pullRequestId}/content`).then(
          (res) => {
            $scope.details = res.data;
            if (callback) {
              callback(res.data);
            }
          },
          (err) => {
            $scope.type = "error";
            $scope.content = err.data.error;
          }
        );
      }

      function init() {
        $scope.pullRequestId = $routeParams.pullRequestId;
        $scope.type = "loading";

        getOption((_) => {
          getPullRequest();
        });
      }

      init();
    },
  ])
  .controller("gistController", [
    "$scope",
    "$http",
    "$location",
    "$routeParams",
    "$sce",
    function ($scope, $http, $location, $routeParams, $sce) {
      async function getOption(callback) {
        $http.get(`/api/gist/${$scope.gistId}/options`).then(
          (res) => {
            $scope.options = res.data;
            if ($scope.options.url) {
              window.location = $scope.options.url;
              return;
            }
            if (callback) callback(res.data);
          },
          (err) => {
            $scope.type = "error";
            $scope.content = err.data.error;
          }
        );
      }
      async function getGist(callback) {
        $http.get(`/api/gist/${$scope.gistId}/content`).then(
          (res) => {
            $scope.details = res.data;
            // Pick the default tab once the content is loaded. The ng-init in
            // the template runs before this async response arrives (details is
            // still null then), so without this a files-only gist would default
            // to the hidden "comments" tab and render blank.
            const hasFiles = res.data && res.data.files && res.data.files.length;
            $scope.tabState = { active: hasFiles ? "files" : "comments" };
            if (callback) callback(res.data);
          },
          (err) => {
            $scope.type = "error";
            $scope.content = err.data.error;
          }
        );
      }

      function init() {
        $scope.gistId = $routeParams.gistId;
        $scope.type = "loading";
        getOption(() => { getGist(); });
      }

      init();
    },
  ])
  .controller("conferencesController", [
    "$scope",
    "$http",
    "$location",
    function ($scope, $http, $location) {
      $scope.$watch("user.status", () => {
        if ($scope.user == null) {
          $location.url("/");
        }
      });
      if ($scope.user == null) {
        $location.url("/");
      }

      $scope.conferences = [];
      $scope.search = "";

      const conferencesPrefsKey = "conferences.filterPrefs";
      const conferencesPrefDefaults = {
        filters: { status: { ready: true, expired: false, removed: false } },
        orderBy: "name",
      };
      const savedConferencesPrefs = loadFilterPrefs(conferencesPrefsKey) || {};
      $scope.filters = {
        status: Object.assign(
          {},
          conferencesPrefDefaults.filters.status,
          (savedConferencesPrefs.filters && savedConferencesPrefs.filters.status) || {}
        ),
      };
      $scope.orderBy = savedConferencesPrefs.orderBy || conferencesPrefDefaults.orderBy;

      $scope.$watch("orderBy", () => {
        saveFilterPrefs(conferencesPrefsKey, {
          filters: $scope.filters,
          orderBy: $scope.orderBy,
        });
      });
      $scope.$watch(
        "filters",
        () => {
          saveFilterPrefs(conferencesPrefsKey, {
            filters: $scope.filters,
            orderBy: $scope.orderBy,
          });
        },
        true
      );

      $scope.removeConference = function (conf) {
        if (
          confirm(
            `Are you sure that you want to remove the conference ${conf.name}? All the repositories linked to this conference will expire.`
          )
        ) {
          const toast = {
            title: `Removing ${conf.name}...`,
            date: new Date(),
            body: `The conference ${conf.name} is going to be removed.`,
          };
          $scope.addToast(toast);
          $http.delete(`/api/conferences/${conf.conferenceID}`).then(() => {
            toast.title = `${conf.name} is removed.`;
            toast.body = `The conference ${conf.name} is removed.`;
            getConferences();
          });
        }
      };

      function getConferences() {
        $http.get("/api/conferences/").then(
          (res) => {
            $scope.conferences = res.data || [];
          },
          (err) => {
            console.error(err);
          }
        );
      }
      getConferences();

      $scope.conferenceFilter = (conference) => {
        if ($scope.filters.status[conference.status] == false) return false;

        if ($scope.search.trim().length == 0) return true;

        if (conference.name.indexOf($scope.search) > -1) return true;
        if (conference.conferenceID.indexOf($scope.search) > -1) return true;

        return false;
      };
    },
  ])
  .controller("newConferenceController", [
    "$scope",
    "$http",
    "$location",
    "$routeParams",
    function ($scope, $http, $location, $routeParams) {
      $scope.$watch("user.status", () => {
        if ($scope.user == null) {
          $location.url("/");
        }
      });
      if ($scope.user == null) {
        $location.url("/");
      }

      $scope.plans = [];
      $scope.editionMode = false;

      function getConference() {
        $http
          .get("/api/conferences/" + $routeParams.conferenceId)
          .then((res) => {
            $scope.options = res.data;
            $scope.options.startDate = new Date($scope.options.startDate);
            $scope.options.endDate = new Date($scope.options.endDate);
          });
      }
      if ($routeParams.conferenceId) {
        $scope.editionMode = true;
        getConference();
      }

      function getPlans() {
        $http.get("/api/conferences/plans").then((res) => {
          $scope.plans = res.data;

          $scope.plan = $scope.plans.filter(
            (f) => f.id == $scope.options.plan.planID
          )[0];
        });
      }
      getPlans();
      const start = new Date();
      start.setDate(1);
      start.setMonth(start.getMonth() + 1);
      const end = new Date();
      end.setMonth(start.getMonth() + 7, 0);
      $scope.options = {
        startDate: start,
        endDate: end,
        plan: {
          planID: "free_conference",
        },
        options: {
          link: true,
          image: true,
          pdf: true,
          notebook: true,
          update: true,
          page: true,
        },
      };
      $scope.plan = null;

      $scope.$watch("options.plan.planID", () => {
        $scope.plan = $scope.plans.filter(
          (f) => f.id == $scope.options.plan.planID
        )[0];
      });

      function resetValidity() {
        $scope.conference.name.$setValidity("required", true);
        $scope.conference.conferenceID.$setValidity("pattern", true);
        $scope.conference.conferenceID.$setValidity("required", true);
        $scope.conference.conferenceID.$setValidity("used", true);
        $scope.conference.startDate.$setValidity("required", true);
        $scope.conference.startDate.$setValidity("invalid", true);
        $scope.conference.endDate.$setValidity("required", true);
        $scope.conference.endDate.$setValidity("invalid", true);
        $scope.conference.$setValidity("error", true);
      }

      function displayErrorMessage(message) {
        switch (message) {
          case "conf_name_missing":
            $scope.conference.name.$setValidity("required", false);
            break;
          case "conf_id_missing":
            $scope.conference.conferenceID.$setValidity("required", false);
            break;
          case "conf_id_format":
            $scope.conference.conferenceID.$setValidity("pattern", false);
            break;
          case "conf_id_used":
            $scope.conference.conferenceID.$setValidity("used", false);
            break;
          case "conf_start_date_missing":
            $scope.conference.startDate.$setValidity("required", false);
            break;
          case "conf_end_date_missing":
            $scope.conference.endDate.$setValidity("required", false);
            break;
          case "conf_start_date_invalid":
            $scope.conference.startDate.$setValidity("invalid", false);
            break;
          case "conf_end_date_invalid":
            $scope.conference.endDate.$setValidity("invalid", false);
            break;
          default:
            $scope.conference.$setValidity("error", false);
            break;
        }
      }

      $scope.submit = function () {
        const toast = {
          title: `Creating ${$scope.options.name}...`,
          date: new Date(),
          body: `The conference ${$scope.options.conferenceID} is in creation.`,
        };
        if ($scope.editionMode) {
          toast.title = `Updating ${$scope.options.name}...`;
          toast.body = `The conference '${$scope.options.conferenceID}' is updating.`;
        }
        $scope.addToast(toast);
        resetValidity();
        $http
          .post(
            "/api/conferences/" +
              ($scope.editionMode ? $scope.options.conferenceID : ""),
            $scope.options
          )
          .then(
            () => {
              if (!$scope.editionMode) {
                toast.title = `${$scope.options.name} created`;
                toast.body = `The conference '${$scope.options.conferenceID}' is created.`;
              } else {
                toast.title = `${$scope.options.name} updated`;
                toast.body = `The conference '${$scope.options.conferenceID}' is updated.`;
              }
              $location.url("/conference/" + $scope.options.conferenceID);
            },
            (error) => {
              displayErrorMessage(error.data.error);
              $scope.removeToast(toast);
            }
          );
      };
    },
  ])
  .controller("conferenceController", [
    "$scope",
    "$http",
    "$location",
    "$routeParams",
    function ($scope, $http, $location, $routeParams) {
      $scope.$watch("user.status", () => {
        if ($scope.user == null) {
          $location.url("/");
        }
      });
      if ($scope.user == null) {
        $location.url("/");
      }
      $scope.conference = null;

      $scope.search = "";
      $scope.filters = {
        status: { ready: true, expired: false, removed: false },
      };
      $scope.orderBy = "-anonymizeDate";

      $scope.repoFiler = (repo) => {
        if ($scope.filters.status[repo.status] == false) return false;

        if ($scope.search.trim().length == 0) return true;

        if (repo.source.fullName.indexOf($scope.search) > -1) return true;
        if (repo.repoId.indexOf($scope.search) > -1) return true;

        return false;
      };

      function getConference() {
        $http
          .get("/api/conferences/" + $routeParams.conferenceId)
          .then((res) => {
            $scope.conference = res.data;
          });
      }
      getConference();
    },
  ]);

$(document).on("click", "#navbarSupportedContent .nav-link", function (e) {
  if ($(this).attr("data-toggle") === "dropdown") return;
  var $collapse = $("#navbarSupportedContent");
  if ($collapse.hasClass("show")) {
    $collapse.collapse("hide");
  }
});
