Files
anonymous_github/public/script/app.js
T
Thomas DurieuxandClaude Opus 5.5 643f4b87e9 feat: let authors leave their bibliography unredacted (#941) (#943)
* feat: let owners leave chosen files unredacted (#941)

Redacting self-citations in a bibliography reveals which papers are the
authors' own. Owners can now pick files whose content is served as
written; file names are still redacted. Patterns follow .gitignore rules.

The form gets a file picker fed by GitHub's tree API, and the terms box
grows with its content and only warns about regex symbols that can stop a
term from matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: honour !patterns, keep picked braces literal, preview unredacted README

- Patterns now apply in order and the last match wins, as in .gitignore,
  so "*.bib" then "!private.bib" excludes private.bib instead of
  preserving every other file.
- Picked paths escape glob characters, braces included, with a
  backslash; "[{]" did not survive brace expansion.
- The README preview drops terms when the README is an unredacted file,
  and refreshes when the list changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep rule order when deduplicating unredacted-file patterns

Rules apply in order and the last match wins, so dedupe keeps the last
copy of a repeated pattern instead of the first. The picker also keeps a
plain path in its place when it follows another rule, rather than moving
it to the front as a pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: apply unredacted-file rules to folders, as in .gitignore

A rule that matches a folder now covers every file inside it, so "*.bib"
then "!private" keeps private/refs.bib redacted. A trailing "/" limits a
rule to folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: folder unchecks win over broad rules; accept short commit hashes

- Unchecking a folder that a broader rule such as "*.bib" still covers
  appends a "!/folder/" exclusion, so its files really go back to being
  redacted.
- The tree endpoint accepts the 6+ character hashes the commit field
  allows and resolves them to a full SHA before reading the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: picker counts folder rules exactly as the server does

Rules ending in "/" stay patterns instead of being read as picked files,
and the checkboxes now run the saved rule list (picks, then patterns)
through the server's matcher, so the dialog cannot show files as
redacted that the server leaves unredacted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: insert escaped terms literally in "Match as written"

String.replace expanded "$&", "$'" and "$1" in the term, so the fix
could corrupt the line. Splice by index instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:10:54 +01:00

4277 lines
180 KiB
JavaScript

import { setupBilling, readBilling } from "./billing.js";
import { reactive, nextTick, markRaw } from "vue";
import { loadEditor } from "./lazy-assets.js";
import { createTimers, createListeners } from "./state.js";
import { formDraft } from "./drafts.js";
import { escape as globEscape, unescape as globUnescape } from "minimatch";
import { compilePreserveMatcher, dedupePreservePatterns } from "../../src/core/preserve-files";
import { termHints, escapeTermLine } from "../../src/core/term-hints";
import translations from "../i18n/locale-en.json";
// A README image that fails to load becomes a quiet placeholder, not a broken-image glyph.
if (typeof window !== "undefined" && window.document?.addEventListener) {
window.document.addEventListener("error", (event) => {
const img = event.target;
if (!img || img.tagName !== "IMG" || img.dataset.failed || !img.closest?.(".markdown-body, .anonymize-preview-body")) return;
img.dataset.failed = "1";
const ph = window.document.createElement("span");
ph.className = "img-unavailable";
ph.textContent = img.alt ? "Image unavailable: " + img.alt : "Image unavailable";
img.replaceWith(ph);
}, true);
}
function viewerErrorCode(error) {
let data = error?.data;
if (typeof data === "string") {
try { data = JSON.parse(data); } catch (_) { data = null; }
}
if (typeof data?.error === "string" && Object.prototype.hasOwnProperty.call(translations.ERRORS, data.error)) return data.error;
if (error?.status === -1 || error?.status === 0) return "request_error";
if ([502, 503, 504].includes(error?.status)) return "unreachable";
return "unknown_error";
}
// The mask comes from /api/options; mainController sets it once loaded.
let redactionMask = "XXXX";
export const setRedactionMask = (mask) => { redactionMask = mask || "XXXX"; };
// Matches the replacements the anonymizer writes: MASK and MASK-<n>.
export const redactionPattern = (mask = redactionMask) => {
if (!mask) return null;
const escaped = mask.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(escaped + "(?:-\\d+)?", "g");
};
// Wraps redacted terms under root in <mark class="redaction">, touching text
// nodes only so attributes and URLs stay intact. Mermaid sources are left
// alone (Mermaid parses their text), and so are code blocks Prism will
// rewrite unless root is that highlighted block itself.
export const markRedactionsIn = (root, mask = redactionMask) => {
const pattern = redactionPattern(mask);
if (!pattern || !root || !root.textContent.includes(mask)) return;
const doc = root.ownerDocument;
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
const text = node.nodeValue;
const parent = node.parentElement;
if (!text.includes(mask) || !parent || parent.closest("script, style, mark.redaction, .mermaid")) continue;
const highlighted = parent.closest('code[class*="language-"]');
if (highlighted && !highlighted.contains(root)) continue;
const fragment = doc.createDocumentFragment();
let last = 0;
for (const match of text.matchAll(pattern)) {
fragment.append(text.slice(last, match.index));
const mark = doc.createElement("mark");
mark.className = "redaction";
mark.title = "Redacted by Anonymous GitHub";
mark.textContent = match[0];
fragment.append(mark);
last = match.index + match[0].length;
}
fragment.append(text.slice(last));
node.replaceWith(fragment);
}
};
// Redactions are the product's signature: show every XXXX-n mask as a numbered slot.
const REDACTION_RE = /\bXXXX-\d+\b/g;
export const slotRedactions = function (html) {
if (!html || html.indexOf("XXXX-") === -1 || typeof window.DOMParser !== "function") return html;
const doc = new window.DOMParser().parseFromString("<div>" + html + "</div>", "text/html");
const walker = doc.createTreeWalker(doc.body, 4);
const nodes = [];
while (walker.nextNode()) {
REDACTION_RE.lastIndex = 0;
// Prism re-highlights code blocks from their text, so those are slotted in its hook.
if (walker.currentNode.parentElement?.closest("pre code")) continue;
if (REDACTION_RE.test(walker.currentNode.nodeValue)) nodes.push(walker.currentNode);
}
for (const node of nodes) {
REDACTION_RE.lastIndex = 0;
const frag = doc.createDocumentFragment();
let last = 0, m;
const text = node.nodeValue;
while ((m = REDACTION_RE.exec(text))) {
frag.appendChild(doc.createTextNode(text.slice(last, m.index)));
const mark = doc.createElement("mark");
mark.className = "redact-chip";
mark.setAttribute("title", "Redacted term " + m[0].slice(5) + " — the same term everywhere in this snapshot");
// Display: an empty slot holding only the term's number (the file keeps XXXX-N).
const num = m[0].slice(5);
mark.textContent = "";
const idx = doc.createElement("i");
idx.textContent = num;
mark.appendChild(idx);
mark.dataset.term = m[0];
mark.setAttribute("aria-label", "Redacted term " + num);
// Keep trailing punctuation on the chip's line (no orphaned "." or ",").
const trail = /^[.,;:!?)\]]+/.exec(text.slice(m.index + m[0].length));
if (trail) {
const glue = doc.createElement("span");
glue.className = "redact-glue";
glue.appendChild(mark);
glue.appendChild(doc.createTextNode(trail[0]));
frag.appendChild(glue);
last = m.index + m[0].length + trail[0].length;
} else {
frag.appendChild(mark);
last = m.index + m[0].length;
}
}
frag.appendChild(doc.createTextNode(text.slice(last)));
node.parentNode.replaceChild(frag, node);
}
return doc.body.firstChild.innerHTML;
};
// Prism rewrites fenced code blocks from their text, so slot redactions once it is done.
export const installPrismRedactionSlots = () => {
if (window.Prism?.hooks && !window.Prism.redactionHook) {
window.Prism.hooks.add("complete", (env) => {
markRedactionsIn(env.element);
// Show the same numbered slot as the rest of the viewer.
env.element.querySelectorAll?.("mark.redaction").forEach((mark) => {
const m = /^XXXX-(\d+)$/.exec(mark.textContent);
if (!m) return;
mark.className = "redact-chip";
mark.setAttribute("title", "Redacted term " + m[1] + " — the same term everywhere in this snapshot");
mark.setAttribute("aria-label", "Redacted term " + m[1]);
mark.dataset.term = m[0];
// Same width as the XXXX-n it replaces, so code columns stay aligned.
mark.style.setProperty("--slot-ch", m[0].length + "ch");
mark.textContent = "";
const idx = mark.ownerDocument.createElement("i");
idx.textContent = m[1];
mark.appendChild(idx);
});
});
window.Prism.redactionHook = true;
}
};
export const highlightRedactions = (html, mask = redactionMask) => {
if (!html || !mask || !html.includes(mask)) return html;
const doc = new DOMParser().parseFromString("<body>" + html + "</body>", "text/html");
markRedactionsIn(doc.body, mask);
return doc.body.innerHTML;
};
async function copyText(text) {
if (window.navigator.clipboard?.writeText) {
await window.navigator.clipboard.writeText(text);
return;
}
const field = document.createElement("textarea");
field.value = text;
field.setAttribute("readonly", "");
field.style.position = "fixed";
field.style.opacity = "0";
const focused = document.activeElement;
document.body.appendChild(field);
try {
field.select();
if (!document.execCommand?.("copy")) throw Error("Copy unavailable");
} finally { field.remove(); focused?.focus(); }
}
function previewTabKeydown(event) {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
const tabs = [...event.currentTarget.closest('[role="tablist"]').querySelectorAll('[role="tab"]')];
const index = tabs.indexOf(event.currentTarget);
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1
: (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
event.preventDefault();
tabs[next]?.focus();
tabs[next]?.click();
}
// Page setup functions run inside Vue effect scopes.
export const mainController = function (state, http, location, timeout) {
state.previewTabKeydown = previewTabKeydown;
state.headerCanUpgrade = false;
state.headerShowSupport = false;
state.headerBillingPending = ["success", "portal"].includes(new URLSearchParams(window.location.search).get("billing"));
const updateHeaderBilling = billing => {
state.headerShowSupport = !(billing?.grants || []).some(g => ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
const subscribed = (billing?.grants || []).some(g => ["author_monthly", "author_annual"].includes(g.planId) && ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
state.headerCanUpgrade = !!billing?.enabled && billing.checkoutEnabled !== false && !subscribed && (billing.plans || []).some(p => ["author_monthly", "author_annual"].includes(p.id));
};
state.on("billing:updated", (_, billing) => updateHeaderBilling(billing));
state.on("billing:pending", (_, pending) => { state.headerBillingPending = pending; });
state.openHeaderUpgrade = () => {
if (location.path() === "/profile") state.emit("settings:upgrade");
else location.url("/profile?upgrade=1");
};
state.title = "Main";
state.user = { status: "connection" };
state.site_options;
state.toasts = [];
state.removeToast = function (toast) {
const index = state.toasts.indexOf(toast);
if (index === -1) return;
state.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.
state.addToast = function (toast) {
state.toasts.push(toast);
timeout(function () {
state.removeToast(toast);
}, 8000);
return toast;
};
// Anonymized content that signed-out reviewers browse.
const reviewerRoutes = /^\/(r|repository|pr|gist)\//;
state.path = location.url();
state.paths = location.path().substring(1).split("/");
state.isReviewerRoute = reviewerRoutes.test(location.path());
state.darkMode = function (on) {
localStorage.setItem("darkMode", on);
state.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();
}
state.emit("dark-mode", on);
};
state.darkMode(localStorage.getItem("darkMode") == "true");
state.emailPromptOpen = false;
state.emailPromptAddress = "";
state.emailPromptBusy = false;
state.emailPromptError = "";
let emailPromptFocus;
state.respondToEmailPrompt = async (action) => {
if (state.emailPromptBusy) return;
state.emailPromptBusy = true;
state.emailPromptError = "";
try {
const res = await http.post("/github/connections/email", { action, email: state.emailPromptAddress },
{ headers: { "X-CSRF-Token": state.user.emailPromptCSRF } });
if (action === "save") state.user.notificationEmail = res.data.notificationEmail;
state.emailPromptOpen = false;
state.user.showEmailPrompt = false;
emailPromptFocus?.focus();
} catch (error) { state.emailPromptError = error.data?.error || "Unable to save your preference. Please try again."; }
finally { state.emailPromptBusy = false; }
};
function getUser() {
http.get("/api/user").then(
(res) => {
if (res) state.user = res.data;
if (state.user?.username) readBilling(http).then(res => updateHeaderBilling(res.data)).catch(() => {});
else state.headerShowSupport = true;
if (state.user?.emailNotificationsEnabled && state.user.showEmailPrompt && !state.user.notificationEmail) {
emailPromptFocus = document.activeElement;
state.emailPromptOpen = true;
timeout(() => {
const dialog = document.getElementById("email-prompt");
if (dialog?.showModal) dialog.showModal();
else dialog?.setAttribute("open", "");
document.getElementById("email-prompt-address")?.focus();
}, 0);
}
},
() => {
state.user = null;
state.headerShowSupport = true;
}
);
}
getUser();
function getOptions() {
http.get("/api/options").then(
(res) => {
if (res) state.site_options = res.data;
setRedactionMask(res?.data?.ANONYMIZATION_MASK);
state.emit("site-options");
},
() => {
state.site_options = null;
}
);
}
getOptions();
function getMessage() {
http.get("/api/message").then(
(res) => {
if (res) state.generalMessage = res.data;
},
() => {
state.generalMessage = null;
}
);
}
getMessage();
function changedUrl(_, current) {
if (current) {
state.title = current.title;
}
state.path = location.url();
state.paths = location.path().substring(1).split("/");
state.isReviewerRoute = reviewerRoutes.test(location.path());
}
state.on("routeChange", changedUrl);
state.on("routeUpdate", changedUrl);
};
export const faqController = function (state, http) {
const listen = createListeners();
const timers = createTimers();
const revealAnswer = () => {
const id = window.location.hash.slice(1);
const answer = document.getElementById(id);
if (!answer?.classList.contains("panel-collapse")) return;
$(answer).collapse("show");
const heading = document.getElementById(answer.getAttribute("aria-labelledby"));
heading?.setAttribute("tabindex", "-1");
heading?.focus({ preventScroll: true });
heading?.scrollIntoView?.({ block: "start" });
};
// Router fragments do not emit hashchange, and answers mount after navigation.
state.on("routeUpdate", () => timers.timeout(revealAnswer, 0));
listen(window, "hashchange", revealAnswer);
timers.timeout(revealAnswer, 0);
};
export const profileController = function (state, http, translate, timeout, quotaService) {
state.settingsTab = /(?:billing|upgrade)=/.test(window.location.search) ? "billing" : "defaults";
state.confirmDeleteAccount = false;
state.deleteUsername = "";
state.profilePastSubscription = () => !state.profileAuthorGrant() && (state.billing?.grants || []).some(g => ["author_monthly", "author_annual"].includes(g.planId));
state.showAuthorPlans = new URLSearchParams(window.location.search).get("upgrade") === "1";
state.showRepositoryPass = false;
state.profilePlan = "author_monthly";
state.openProfileUpgrade = () => {
state.settingsTab = "billing";
state.showAuthorPlans = true;
nextTick(() => document.getElementById("settings-plan-comparison")?.focus());
};
state.on("settings:upgrade", () => state.openProfileUpgrade());
state.profileAuthorPlans = () => (state.billing?.plans || []).filter(p => ["author_monthly", "author_annual"].includes(p.id));
state.profileSelectedPlan = () => state.profileAuthorPlans().find(p => p.id === state.profilePlan) || state.profileAuthorPlans()[0];
state.profilePassPlan = () => (state.billing?.plans || []).find(p => p.id === "author_pass");
state.profileAuthorGrant = () => (state.billing?.grants || []).find(g => ["author_monthly", "author_annual"].includes(g.planId) && ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
state.profileGrantName = grant => ({ author_monthly: "Author monthly", author_annual: "Author annual", author_pass: "Repository pass", conference: "Conference hosting" }[grant.planId] || grant.planId);
setupBilling(state, http, () => quotaService.load().then(quota => { state.quota = quota; }, console.error));
state.terms = "";
state.options = {
expirationMode: "remove",
update: false,
image: true,
pdf: true,
notebook: true,
loc: true,
link: true,
};
state.saving = false;
state.message = null;
state.error = null;
state.accountEmail = "";
state.emailLoading = true;
state.emailCSRFReady = false;
state.emailSaving = false;
state.emailSaved = false;
state.emailError = "";
let emailCSRF = "";
state.loadAccountEmail = async () => {
state.emailLoading = true;
state.emailError = "";
try {
const res = await http.get("/github/connections/email");
state.accountEmail = res.data.notificationEmail || "";
emailCSRF = res.data.csrf;
state.emailCSRFReady = !!emailCSRF;
} catch {
state.emailError = "Unable to load your email address. Please try again.";
} finally { state.emailLoading = false; }
};
state.saveAccountEmail = async () => {
if (state.emailSaving || state.emailLoading || !emailCSRF) return;
state.emailSaving = true;
state.emailSaved = false;
state.emailError = "";
try {
const res = await http.post("/github/connections/email", { email: state.accountEmail },
{ headers: { "X-CSRF-Token": emailCSRF } });
state.accountEmail = res.data.notificationEmail;
if (state.user) state.user.notificationEmail = res.data.notificationEmail;
state.emailSaved = true;
} catch {
state.emailError = "Unable to save your email address. Check the address and try again.";
} finally { state.emailSaving = false; }
};
state.loadAccountEmail();
quotaService.load().then((quota) => {
state.quota = quota;
}, console.error);
const draft = formDraft(state, ["terms", "options"], "profile");
state.discardDraft = () => draft.discard();
function getDefault() {
http.get("/api/user/default").then((res) => {
const data = res.data || {};
if (data.terms) {
state.terms = data.terms.join("\n");
}
state.options = Object.assign({}, state.options, data.options);
draft.restore();
});
}
getDefault();
let savedTimer = null;
state.saveDefault = ($event) => {
if ($event && $event.preventDefault) $event.preventDefault();
if (state.saving) return;
const submitted = draft.checkpoint();
const params = {
terms: state.terms
.split("\n")
.map((t) => t.trim())
.filter((t) => t.length > 0),
options: state.options,
};
state.saving = true;
state.error = null;
http.post("/api/user/default", params).then(
() => {
draft.accept(submitted);
state.saving = false;
state.message = state.unsavedChanges ? null : "Saved";
if (savedTimer) timeout.cancel(savedTimer);
savedTimer = timeout(() => {
state.message = null;
}, 2500);
},
(error) => {
state.saving = false;
const code = error && error.data && error.data.error;
const fallback = "Unable to save your defaults. Please try again.";
// Unknown codes translate to the generic unknown_error message;
// this page has a more specific one.
Promise.all([translate("ERRORS." + code), translate("ERRORS.unknown_error")]).then(([translation, unknown]) => {
state.error = code && translation !== unknown ? translation : fallback;
}, () => {
state.error = fallback;
});
}
);
};
state.deleteAccount = () => {
if (state.deletingAccount || !state.confirmDeleteAccount || state.deleteUsername !== state.user?.username) return;
state.deletingAccount = true;
http.delete("/api/user").then(
() => {
window.location.href = "/";
},
() => {
state.deletingAccount = false;
state.deleteError =
"Unable to delete the account. Please try again.";
}
);
};
};
export const claimController = function (state, http, location) {
state.repoId = null;
state.repoUrl = null;
state.claim = () => {
http
.post("/api/repo/claim", {
repoId: state.repoId,
repoUrl: state.repoUrl,
})
.then(
(res) => {
location.url("/dashboard");
},
(err) => {
state.error = err.data;
state.claimForm.repoUrl.setValidity("not_found", false);
state.claimForm.repoId.setValidity("not_found", false);
}
);
};
};
export const homeController = function (state, http, location, window, timeout) {
if (state.user && !state.user.status) {
location.url("/dashboard");
}
state.watch("user.status", () => {
if (state.user && !state.user.status) {
location.url("/dashboard");
}
});
// "What you get": one screenshot, three tabs. The selected key drives
// both the expanded description and the visible panel.
state.features = [
{
key: "anonymize",
num: "01",
eyebrow: "Anonymize",
title: "Double-anonymous,",
accent: "your rules.",
text:
"Choose what reviewers may see: links, images, PDFs, notebooks, GitHub Pages. Add your own terms, with regex if you need it, and pick the expiration date.",
cta: "Start an anonymization",
href: "/anonymize",
url: "anonymous.4open.science/anonymize",
img: "/imgs/anonymize.png",
alt: "The anonymize form: source repository, terms to redact, options and a live README preview",
},
{
key: "review",
num: "02",
eyebrow: "Review",
title: "Reviewers browse",
accent: "the real thing.",
text:
"Highlighted source code, rendered PDFs, images, and notebooks, in a familiar file explorer. GitHub Pages is also supported.",
cta: "Open the example",
href: "https://anonymous.4open.science/r/840c8c57-3c32-451e-bf12-0e20be300389/",
target: "_self",
url: "anonymous.4open.science/r/840c8c57-…",
img: "/imgs/explorer.png",
alt: "The repository explorer with a file tree and a rendered README",
},
{
key: "manage",
num: "03",
eyebrow: "Manage",
title: "One dashboard,",
accent: "until the decision.",
text:
"Monitor views, edit configuration, remove or update your repository. Program chairs can group submissions under a conference with one shared expiry.",
cta: "Open the dashboard",
href: "/dashboard",
needsUser: true,
url: "anonymous.4open.science/dashboard",
img: "/imgs/dashboard.png",
alt: "The dashboard listing anonymized repositories with status, views and expiry",
},
];
state.feature = state.features[0].key;
state.selectFeature = function (key) {
state.feature = key;
};
// Signed-out visitors cannot open the dashboard; send them to sign in.
state.featureHref = function (f) {
return f.needsUser && !state.user ? "/signin" : f.href;
};
state.featureTarget = function (f) {
return f.needsUser && !state.user ? "_self" : f.target || undefined;
};
state.featureKeydown = function ($event, index) {
const step = {
ArrowDown: 1,
ArrowRight: 1,
ArrowUp: -1,
ArrowLeft: -1,
Home: "first",
End: "last",
}[$event.key];
if (step === undefined) return;
$event.preventDefault();
const n = state.features.length;
const next =
step === "first" ? 0 : step === "last" ? n - 1 : (index + step + n) % n;
state.feature = state.features[next].key;
timeout(() => {
const el = window.document.getElementById("feature-tab-" + state.feature);
if (el) el.focus();
});
};
// Live redaction demo on the landing page: same masking rule as the product (XXXX-n per distinct term).
state.demoText = "# DeepLearnUtils\nDeveloped by Jane Smith at MIT CSAIL.\nSee Smith et al., \"Attention That Works,\" 2026.\nContact: jane.smith@mit.edu";
state.demoTerms = "Jane Smith, Smith, MIT CSAIL, jane.smith@mit.edu";
state.demoParts = function () {
const terms = (state.demoTerms || "").split(/[,\n]/).map((t) => t.trim()).filter(Boolean).sort((a, b) => b.length - a.length);
const escape = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (!terms.length) return [{ text: state.demoText }];
const re = new RegExp("(" + terms.map(escape).join("|") + ")", "gi");
const index = {};
let n = 0;
return (state.demoText || "").split(re).filter((p) => p !== "").map((p) => {
const key = terms.find((t) => t.toLowerCase() === p.toLowerCase());
if (!key) return { text: p };
if (!index[key.toLowerCase()]) index[key.toLowerCase()] = ++n;
return { text: String(index[key.toLowerCase()]), redacted: true, original: p };
});
};
state.demoCount = () => state.demoParts().filter((p) => p.redacted).length;
state.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) => {
state.stat = res.data;
state.cards[0].total = res.data.nbRepositories;
state.cards[1].total = res.data.nbUsers;
state.cards[2].total = res.data.nbPageViews;
state.cards[3].total = res.data.nbPullRequests;
}).catch(() => {
// Leave the placeholders in place rather than showing zeros.
});
}
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;
}
state.history = {
repositories: buildSeriesView([]),
users: buildSeriesView([]),
pageViews: buildSeriesView([]),
pullRequests: buildSeriesView([]),
};
http.get("/api/stat/history?days=60").then((res) => {
const rows = res.data || [];
state.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)),
};
}).catch(() => {});
};
export const unifiedDashboardController = function (state, http, location, promises, window, quotaService) {
const timers = createTimers();
state.on("routeLeave", function () {
$('[data-toggle="tooltip"]').tooltip("dispose");
});
state.watch("user.status", () => {
if (state.user == null) {
location.url("/");
}
});
if (state.user == null) {
location.url("/");
}
timers.timeout(() => {
$('[data-toggle="tooltip"]').tooltip();
}, 250);
state.quotaExpanded = false;
state.closeQuota = () => {
state.quotaExpanded = false;
nextTick(() => document.querySelector(".dashboard-quota > summary")?.focus());
};
createListeners()(document, "click", event => {
if (state.quotaExpanded && !event.target.closest?.(".dashboard-quota")) state.quotaExpanded = false;
});
state.filtersExpanded = false;
state.projectView = "all";
state.selectedProject = null;
state.projectName = "";
state.nameSaving = false;
state.nameError = "";
state.nameMessage = "";
state.needsAttention = item => item.status === "error" || item._broken
|| (statusKey(item.status) === "progress" && item._stale);
state.attentionCount = () => state.dashboardTotals?.attention ?? state.items.filter(state.needsAttention).length;
state.hasProjects = () => (state.dashboardTotals?.total ?? state.items.length) > 0;
state.setProjectView = view => {
state.projectView = view;
if (view === "attention") {
state.filters.status.error = true;
state.filters.status.progress = true;
}
};
state.projectDetailsId = item => "project-details-" + encodeURIComponent(item._type + ":" + (item._id || item._source || "unknown"));
state.showProjectDetails = async item => {
state.selectedProject = item;
state.projectName = item.projectName || "";
state.nameError = "";
state.nameMessage = "";
await nextTick();
const panel = document.getElementById(state.projectDetailsId(item));
panel?.focus();
panel?.scrollIntoView?.({ block: "nearest" });
};
state.closeProjectDetails = item => {
state.selectedProject = null;
nextTick(() => document.querySelector(`[data-project-actions="${encodeURIComponent(item._type + ":" + item._id)}"]`)?.focus());
};
state.saveProjectName = async item => {
if (state.nameSaving || !item._id) return;
const name = state.projectName.trim();
if (name.length > 100) { state.nameError = "Use 100 characters or fewer."; return; }
state.nameSaving = true;
state.nameError = "";
state.nameMessage = "";
try {
const res = await http.post("/api/user/project-name", { type: item._type, id: item._id, name });
item.projectName = res.data.name;
item._label = item.projectName || item._fallbackLabel;
if (state.selectedProject === item) state.nameMessage = name ? "Project name saved." : "Default project name restored.";
if (!legacyDashboard) {
state.dashboardCursor = null;
await loadAll();
if (state.selectedProject === item) {
state.selectedProject = state.items.find(current => current._type === item._type && current._id === item._id) || null;
}
}
} catch (_) {
if (state.selectedProject === item) state.nameError = "The project name could not be saved. Try again.";
} finally { state.nameSaving = false; }
};
state.copyingId = null;
state.copyLink = async (item) => {
if (!item._viewUrl || state.copyingId) return;
const link = new URL(item._viewUrl, window.location.origin).href;
state.copyingId = item._type + ":" + item._id;
try {
await copyText(link);
state.addToast({ title: "Link copied", body: "The anonymous link is ready to share." });
} catch {
state.addToast({ title: "Copy this anonymous link", body: link });
} finally { state.copyingId = null; }
};
state.items = [];
state.search = "";
state.loading = true;
// Status buckets used by the Status filter. Raw statuses are mapped
// onto these keys so in-progress and error items can be filtered too.
state.statusKeyLabels = {
ready: "Ready",
progress: "In progress",
error: "Error",
expired: "Expired",
removed: "Removed",
};
const inProgress = ["queue", "download", "downloaded", "preparing", "anonymizing"];
function statusKey(status) {
if (status === "ready" || status === "error") return status;
if (status === "expired" || status === "expiring") return "expired";
if (status === "removed" || status === "removing") return "removed";
if (inProgress.indexOf(status) > -1) return "progress";
return "progress";
}
// An in-progress item whose last activity is older than this is
// probably stuck; the row says so instead of showing "Downloading".
const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
const dashboardPrefsKey = "dashboard.filterPrefs";
const dashboardPrefDefaults = {
typeFilter: "all",
filters: {
status: { ready: true, progress: true, error: true, expired: true, removed: false },
},
orderBy: "-anonymizeDate",
};
const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {};
state.typeFilter = savedDashboardPrefs.typeFilter || dashboardPrefDefaults.typeFilter;
state.filters = {
status: Object.assign(
{},
dashboardPrefDefaults.filters.status,
(savedDashboardPrefs.filters && savedDashboardPrefs.filters.status) || {}
),
};
state.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy;
// ---- Sorting -------------------------------------------------------
// `orderBy` is kept as the Angular orderBy expression ("-field" for
// descending) so saved preferences stay compatible.
const sortFields = {
_name: { label: "Anonymous ID", defaultDesc: false },
_label: { label: "Project", defaultDesc: false },
anonymizeDate: { label: "Anonymize date", defaultDesc: true },
status: { label: "Status", defaultDesc: false },
lastView: { label: "Last view", defaultDesc: true },
pageView: { label: "Views", defaultDesc: true },
"options.expirationDate": { label: "Expiration", defaultDesc: false },
};
state.sortFields = sortFields;
state.sortField = () => state.orderBy.replace(/^-/, "");
state.sortDesc = () => state.orderBy.charAt(0) === "-";
state.sortLabel = () => {
const f = sortFields[state.sortField()];
return f ? f.label : "Custom";
};
state.isSortedBy = (field) => state.sortField() === field;
state.setSort = (field, desc) => {
if (typeof desc !== "boolean") {
desc = state.isSortedBy(field)
? !state.sortDesc()
: !!(sortFields[field] && sortFields[field].defaultDesc);
}
state.orderBy = (desc ? "-" : "") + field;
};
state.toggleSortDirection = () => {
state.setSort(state.sortField(), !state.sortDesc());
};
state.watchGroup(
["typeFilter", "orderBy"],
() => {
saveFilterPrefs(dashboardPrefsKey, {
typeFilter: state.typeFilter,
filters: state.filters,
orderBy: state.orderBy,
});
}
);
state.watch(
"filters",
() => {
saveFilterPrefs(dashboardPrefsKey, {
typeFilter: state.typeFilter,
filters: state.filters,
orderBy: state.orderBy,
});
},
true
);
// ---- Quota (shared with the settings page via quotaService) --------
quotaService.load().then((quota) => {
state.quota = quota;
}, console.error);
// ---- Items ---------------------------------------------------------
// Fields shared by repositories, pull requests and gists. Records that
// lost their identifier (legacy data) are flagged as broken instead of
// rendering an empty link to /pr/undefined/.
function decorateItem(item, id, name, source, editUrl, viewUrl) {
if (!item.pageView) item.pageView = 0;
if (!item.lastView) item.lastView = "";
item.options = item.options || {};
item.options.terms = (item.options.terms || []).filter((f) => f);
item._id = id || "";
item._source = source;
item._broken = !id;
item._name = id || source || "(unnamed)";
item._fallbackLabel = item._type === "gist" ? "Gist " + (id || source || "without an ID") : source || name || item._name;
item._label = item.projectName || item._fallbackLabel;
item._editUrl = id ? editUrl : null;
item._viewUrl = id ? viewUrl : null;
if (item._broken) {
item.status = "error";
item.statusMessage = "incomplete_record";
}
item._statusKey = statusKey(item.status);
const last = item.anonymizeDate || item.lastView;
item._stale =
item._statusKey === "progress" &&
!!last &&
Date.now() - new Date(last).getTime() > STALE_AFTER_MS;
// What the Expires column should show.
if (item.status === "expired" || item.status === "expiring") {
item._expiry = { kind: "expired", date: item.options.expirationDate };
} else if (item.status !== "ready") {
item._expiry = { kind: "none" };
} else if (item.options.expirationMode === "never" || !item.options.expirationDate) {
item._expiry = { kind: "never" };
} else {
item._expiry = { kind: "date", date: item.options.expirationDate };
}
return item;
}
function safeGet(url) {
return http.get(url).then(
(res) => res.data || [],
(err) => {
console.error(err);
return [];
}
);
}
// All three lists load in parallel and are merged once, so the table
// does not re-sort three times while it fills in.
function loadLegacy() {
state.loading = true;
return promises
.all([
safeGet("/api/user/anonymized_repositories"),
safeGet("/api/user/anonymized_pull_requests"),
safeGet("/api/user/anonymized_gists"),
])
.then((results) => {
const repos = results[0];
const prs = results[1];
const gists = results[2];
const items = [];
repos.forEach((repo) => {
repo._type = "repo";
const src = repo.source || {};
items.push(
decorateItem(
repo,
repo.repoId,
repo.repoId,
src.fullName,
"/anonymize/" + repo.repoId,
"/r/" + repo.repoId + "/"
)
);
});
prs.forEach((pr) => {
pr._type = "pr";
const src = pr.source || {};
items.push(
decorateItem(
pr,
pr.pullRequestId,
pr.pullRequestId,
src.repositoryFullName ? src.repositoryFullName + "#" + src.pullRequestId : undefined,
"/pull-request-anonymize/" + pr.pullRequestId,
"/pr/" + pr.pullRequestId + "/"
)
);
});
gists.forEach((g) => {
g._type = "gist";
const src = g.source || {};
items.push(
decorateItem(
g,
g.gistId,
g.gistId,
src.gistId,
"/gist-anonymize/" + g.gistId,
"/gist/" + g.gistId + "/"
)
);
});
state.items = items;
state.loading = false;
});
}
let dashboardGeneration = 0;
let legacyDashboard = false;
state.dashboardCursor = null;
state.dashboardError = "";
function decorateSummary(item) {
const src = item.source || {};
if (item._type === "repo") return decorateItem(item, item.repoId, item.repoId, src.fullName, "/anonymize/" + item.repoId, "/r/" + item.repoId + "/");
if (item._type === "pr") return decorateItem(item, item.pullRequestId, item.pullRequestId,
src.repositoryFullName ? src.repositoryFullName + "#" + src.pullRequestId : undefined,
"/pull-request-anonymize/" + item.pullRequestId, "/pr/" + item.pullRequestId + "/");
return decorateItem(item, item.gistId, item.gistId, src.gistId, "/gist-anonymize/" + item.gistId, "/gist/" + item.gistId + "/");
}
function loadAll(append = false) {
if (legacyDashboard) return loadLegacy();
const generation = ++dashboardGeneration;
state.loading = !append;
state.loadingMore = append;
state.dashboardError = "";
return http.get("/api/user/dashboard", { params: {
q: state.search, type: state.typeFilter, sort: state.orderBy,
statuses: Object.keys(state.filters.status).filter(key => state.filters.status[key] !== false).join(",") || "none",
attention: String(state.projectView === "attention"),
cursor: append ? state.dashboardCursor : undefined,
} }).then(response => {
if (generation !== dashboardGeneration) return;
// Compatibility with servers that have not gained the summary API.
if (!Array.isArray(response.data?.items)) { legacyDashboard = true; return loadLegacy(); }
state.items = (append ? state.items : []).concat(response.data.items.map(decorateSummary));
state.dashboardCursor = response.data.cursor;
state.dashboardTotals = response.data;
}, error => {
if (generation !== dashboardGeneration) return;
if (error.status === 404) { legacyDashboard = true; return loadLegacy(); }
state.dashboardError = "Projects could not be loaded. Please try again.";
}).finally(() => {
if (generation === dashboardGeneration) { state.loading = false; state.loadingMore = false; }
});
}
state.loadMoreProjects = () => loadAll(true);
let reloadTimer;
state.watchGroup(["search", "typeFilter", "orderBy", "projectView"], () => {
if (legacyDashboard) return;
if (reloadTimer) timers.timeout.cancel(reloadTimer);
reloadTimer = timers.timeout(() => loadAll(), 150);
}, false);
state.watch("filters", () => {
if (!legacyDashboard) loadAll();
}, true, false);
state.on("dispose", () => { dashboardGeneration++; });
loadAll();
// Whole row opens the anonymized view; clicks on links, buttons and the
// actions menu keep their own behaviour.
state.openItem = (item, $event) => {
if (!item._viewUrl) return;
const target = $event && $event.target;
if (target && target.closest && target.closest("a, button, .dropdown, input")) return;
window.location.href = item._viewUrl;
};
state.hiddenStatusCount = () =>
Object.keys(state.filters.status).filter((k) => state.filters.status[k] === false).length;
state.hasHiddenStatus = () => state.hiddenStatusCount() > 0;
state.hasActiveFilters = () =>
state.projectView !== "all" ||
state.typeFilter !== "all" ||
state.search.trim().length > 0 ||
Object.keys(state.filters.status).some((k) => state.filters.status[k] === false);
state.showClearFilters = () => state.search.trim().length > 0
|| state.typeFilter !== "all" || state.hiddenStatusCount() > 1;
state.clearFilters = () => {
state.projectView = "all";
state.typeFilter = "all";
state.search = "";
Object.keys(state.filters.status).forEach((k) => {
state.filters.status[k] = true;
});
};
function waitRepoToBeReady(repoId, callback, onError, previousStatus) {
http.get("/api/repo/" + repoId).then(async (res) => {
let statusChanged = previousStatus !== undefined && previousStatus !== res.data.status;
for (const item of state.items) {
if (item._type === "repo" && item.repoId == repoId) {
statusChanged = statusChanged || item.status !== res.data.status;
const neededAttention = state.needsAttention(item);
item.status = res.data.status;
item.statusMessage = res.data.statusMessage;
item._statusKey = statusKey(item.status);
if (item._statusKey !== "progress") item._stale = false;
const needsAttention = state.needsAttention(item);
if (state.dashboardTotals && neededAttention !== needsAttention) {
state.dashboardTotals.attention = Math.max(0, state.dashboardTotals.attention + (needsAttention ? 1 : -1));
}
break;
}
}
if (statusChanged && !legacyDashboard) {
state.dashboardCursor = null;
const selected = state.selectedProject;
await loadAll();
if (selected && state.selectedProject === selected) {
state.selectedProject = state.items.find(item => item._type === selected._type && item._id === selected._id) || null;
}
}
if (
res.data.status == "ready" ||
res.data.status == "error" ||
res.data.status == "removed" ||
res.data.status == "expired"
) {
callback(res.data);
return;
}
timers.timeout(() => waitRepoToBeReady(repoId, callback, onError, res.data.status), 2500);
}, onError);
}
const labelOf = (t) =>
t === "repo" ? "repository" : t === "gist" ? "gist" : "pull request";
const apiBaseOf = (t) =>
t === "repo" ? "/api/repo" : t === "gist" ? "/api/gist" : "/api/pr";
state.removeItem = (item) => {
const label = labelOf(item._type);
if (confirm(`Are you sure that you want to remove the ${label} ${item._id}?`)) {
const toast = reactive({
title: `Removing ${item._id}...`,
date: new Date(),
body: `The ${label} ${item._id} is going to be removed.`,
});
state.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.`;
}, undefined, item.status);
} 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();
}
);
}
};
state.refreshItem = (item) => {
const label = labelOf(item._type);
const toast = reactive({
title: `Refreshing ${item._id}...`,
date: new Date(),
body: `The ${label} ${item._id} is going to be refreshed.`,
});
state.addToast(toast);
const onError = (error) => {
toast.title = `Error during the refresh of ${item._id}.`;
toast.body = error.data?.error || error.body || "The refresh could not be completed. Please try again.";
loadAll();
};
const endpoint = `${apiBaseOf(item._type)}/${item._id}/refresh`;
http.post(endpoint).then(
() => {
if (item._type === "repo") {
waitRepoToBeReady(item._id, (repo) => {
if (repo.status !== "ready") {
onError({ body: repo.statusMessage || `The repository is ${repo.status}.` });
return;
}
toast.title = `${item._id} is refreshed.`;
toast.body = `The ${label} ${item._id} is refreshed.`;
}, onError, item.status);
} else {
toast.title = `${item._id} is refreshed.`;
toast.body = `The ${label} ${item._id} is refreshed.`;
loadAll();
}
},
onError
);
};
state.extendItem = (item) => {
const label = labelOf(item._type);
const toast = reactive({
title: `Extending ${item._id}...`,
date: new Date(),
body: `The expiration of ${label} ${item._id} is going to be extended by 6 months.`,
});
state.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.`;
}, undefined, item.status);
} 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();
}
);
};
state.itemFilter = (item) => {
if (state.projectView === "attention" && !state.needsAttention(item)) return false;
if (state.typeFilter !== "all" && item._type !== state.typeFilter) return false;
if (state.filters.status[item._statusKey] === false) return false;
const needle = state.search.trim().toLowerCase();
if (needle.length == 0) return true;
if (item._label && String(item._label).toLowerCase().indexOf(needle) > -1) return true;
if (item._source && String(item._source).toLowerCase().indexOf(needle) > -1) return true;
if (item._id && String(item._id).toLowerCase().indexOf(needle) > -1) return true;
if (item.conference && String(item.conference).toLowerCase().indexOf(needle) > -1) return true;
return false;
};
};
export const dashboardController = function (state, location) {
location.url("/dashboard");
};
export const prDashboardController = function (state, location) {
location.url("/dashboard");
};
export const statusController = function (state, http, params) {
const timers = createTimers();
state.repoId = params.repoId;
state.reviewUrl = window.location.origin + "/r/" + encodeURIComponent(params.repoId) + "/";
state.repo = null;
state.progress = 0;
state.rateLimitResetAt = 0;
state.rateLimitCountdown = "";
state.shareUrl = (window.location?.origin || "") + "/r/" + encodeURIComponent(state.repoId) + "/";
state.copied = false;
let copiedTimer = null;
state.copyShareUrl = async () => {
try {
await copyText(state.shareUrl);
state.copied = true;
if (copiedTimer) timers.timeout.cancel(copiedTimer);
copiedTimer = timers.timeout(() => { state.copied = false; }, 2000);
} catch {
state.addToast?.({ title: "Copy this anonymous link", body: state.shareUrl });
}
};
var countdownTimer = null;
let pollTimer = null;
let destroyed = false;
function startRateLimitCountdown(resetAt) {
state.rateLimitResetAt = resetAt;
if (countdownTimer) timers.interval.cancel(countdownTimer);
function tick() {
var remaining = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000));
if (remaining <= 0) {
state.rateLimitCountdown = "";
state.rateLimitResetAt = 0;
timers.interval.cancel(countdownTimer);
countdownTimer = null;
} else {
var min = Math.floor(remaining / 60);
var sec = remaining % 60;
state.rateLimitCountdown = min > 0
? min + "m " + sec + "s"
: sec + "s";
}
}
tick();
countdownTimer = timers.interval(tick, 1000);
}
state.on("dispose", function () {
destroyed = true;
if (countdownTimer) timers.interval.cancel(countdownTimer);
if (pollTimer) timers.timeout.cancel(pollTimer);
});
function parseStatusMessage(msg) {
if (!msg) return msg;
var m = msg.match(/^rate_limited:(\d+)$/);
if (m) {
startRateLimitCountdown(parseInt(m[1], 10));
return null;
}
state.rateLimitResetAt = 0;
return msg;
}
state.getStatus = () => {
if (destroyed) return;
http
.get("/api/repo/" + state.repoId, {
repoId: state.repoId,
repoUrl: state.repoUrl,
})
.then(
(res) => {
if (destroyed) return;
state.repo = res.data;
if (res.data.rateLimitResetAt) {
startRateLimitCountdown(res.data.rateLimitResetAt);
} else {
state.repo.statusMessage = parseStatusMessage(state.repo.statusMessage);
}
if (state.repo.status == "ready") {
state.progress = 100;
} else if (state.repo.status == "queue") {
state.progress = 10;
} else if (state.repo.status == "downloaded") {
state.progress = 50;
} else if (state.repo.status == "download") {
state.progress = 25;
} else if (state.repo.status == "preparing") {
state.progress = 25;
} else if (state.repo.status == "anonymizing") {
state.progress = 75;
}
var shouldPoll = !["ready", "removed", "expired"].includes(state.repo.status);
if (state.repo.status == "error" && !state.rateLimitResetAt) {
shouldPoll = false;
}
if (shouldPoll) {
pollTimer = timers.timeout(state.getStatus, 2000);
}
},
(err) => {
state.error = err.data.error;
}
);
};
state.getStatus();
};
export const anonymizeController = function (state, http, html, params, location, translate, timeout) {
// The preview shows redactions as the same numbered slots reviewers see.
installPrismRedactionSlots();
state.renderRedacted = slotRedactions;
// Unified state
state.sourceUrl = "";
state.workspaceOpen = false;
state.sourceUrlError = "";
state.previewStatus = "idle";
state.sourceLoading = false;
state.mobilePane = "settings";
const workspaceMedia = window.matchMedia?.("(max-width: 767px)");
state.mobileWorkspace = !!workspaceMedia?.matches;
if (workspaceMedia?.addEventListener) createListeners()(workspaceMedia, "change", event => { state.mobileWorkspace = event.matches; });
state.selectMobilePane = (pane) => { state.mobilePane = pane; };
let sourceGeneration = 0;
let sourceDisposed = false;
const sourceRequests = new Set();
const isCurrentSource = (request, branch = false) => !sourceDisposed
&& request.generation === sourceGeneration
&& request.url === state.sourceUrl.trim()
&& request.connection === state.githubConnection
&& (!branch || request.branch === state.source.branch);
const beginSourceRequest = () => {
const request = { generation: sourceGeneration, url: state.sourceUrl.trim(), connection: state.githubConnection, branch: state.source.branch };
sourceRequests.add(request);
state.sourceLoading = true;
return request;
};
const finishSourceRequest = (request) => {
sourceRequests.delete(request);
if (!sourceDisposed) state.sourceLoading = [...sourceRequests].some(request => isCurrentSource(request));
};
state.on("dispose", () => { sourceDisposed = true; sourceGeneration++; });
let selectedSourceKey = null;
const sourceKey = (parsed) => parsed.gistId && !parsed.repo
? "gist:" + parsed.gistId.toLowerCase()
: (parsed.owner + "/" + parsed.repo).toLowerCase() + (parsed.pullRequestId ? "/pull/" + parsed.pullRequestId : "");
const draft = formDraft(state, ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"], "anonymize:" + (location.path?.() || ""));
let restoredCheckoutConference = null;
let checkoutDraftId = null;
let checkoutDraftSnapshot = null;
state.hostingReview = false;
state.previewExpanded = false;
state.taskValidationError = "";
state.editRepository = () => {
state.mobilePane = "settings";
state.previewExpanded = false;
nextTick(() => document.querySelector(".anonymize-fields .is-invalid")?.focus());
};
state.submitRepository = (event) => {
state.taskValidationError = "";
if (state.sourceLoading || state.billingBusy || state.hostingSaving || state.billingPending) return;
if (expirationDateInvalid() || state.anonymize?.invalid || state.sourceUrlError) {
state.taskValidationError = "Check the highlighted fields before continuing.";
state.editRepository();
return;
}
if (state.hostingNeedsPayment()) return state.checkoutRepository();
return state.anonymizeRepo(event);
};
state.hostingPayment = false;
state.hostingAlternatives = false;
state.hostingPrice = plan => {
if (plan.amount == null) return "Price shown at checkout";
const amount = new Intl.NumberFormat(undefined, { style: "currency", currency: plan.currency, minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(plan.amount / 100);
return amount + (plan.interval === "year" ? " billed annually" : " billed monthly");
};
state.hostingPlan = "author_monthly";
state.hostingSaving = false;
state.selectedHostingPlan = () => state.authorPlans().find(p => p.id === state.hostingPlan) || state.authorPlans()[0];
const loadHostingQuota = () => http.get("/api/user/quota").then(res => { state.hostingQuota = res.data; });
setupBilling(state, http, async () => {
await loadHostingQuota();
state.hostingQuotaBlocked = false;
}, { confirmedMessage: "Hosting status refreshed. Your draft is ready to continue." });
loadHostingQuota().catch(() => {});
state.authorPlans = () => (state.billing?.plans || []).filter(p => ["author_monthly", "author_annual"].includes(p.id));
state.hasAuthorHosting = () => ["author_monthly", "author_annual"].includes(state.billing?.allowance?.plan);
state.hostingNeedsPayment = () => {
if (!state.billing?.enabled || state.detectedType !== "repo" || state.hasAuthorHosting() ||
(state.isUpdate && state.role !== "owner")) return false;
const needsLifetime = state.options?.expirationMode === "never" && !state._existingNever;
// Reported usage may include removed reservations; admission reclaims them.
return !!(needsLifetime || (!state.conference && state.hostingQuotaBlocked));
};
state.checkoutRepository = async (pass = false) => {
if (state.hostingSaving || state.billingBusy || !state.billing?.csrf || state.sourceLoading) return;
state.hostingSaving = true;
state.billingError = "";
try {
const values = draft.checkpoint();
const serialized = JSON.stringify(values);
if (!checkoutDraftId || serialized !== checkoutDraftSnapshot) {
const saved = await http.post("/api/billing/draft", { path: location.path(), values }, { headers: { "x-csrf-token": state.billing.csrf } });
checkoutDraftId = saved.data.id;
checkoutDraftSnapshot = serialized;
}
const plan = state.authorPlans().find(p => p.id === state.hostingPlan) || state.authorPlans()[0];
if (!pass && !plan) throw new Error("No author plan available");
await state.checkout(pass ? "author_pass" : plan.id, pass ? state.repoId : "", { draftId: checkoutDraftId });
} catch { state.billingError = "Your draft could not be saved for checkout. Your form is unchanged. Please try again."; }
finally { state.hostingSaving = false; }
};
state.useDatedHosting = () => { state.options.expirationMode = "remove"; };
state.discardDraft = async () => {
draft.discard();
state.workspaceOpen = !!state.sourceUrl;
state.error = null;
if (state.sourceUrl) await state.urlSelected(true);
else {
sourceGeneration++;
state.sourceLoading = false;
state.detectedType = null;
state.details = null;
state.sourceUrlError = "";
scheduleReadmePreview.cancel();
refreshPrPreview.cancel();
refreshGistPreview.cancel();
state.previewStatus = "idle";
}
};
state.githubConnection = undefined;
state.githubConnections = null;
state.gistOAuthRequired = false;
state.gistAppRequired = false;
const saveGitHubDraft = () => {
const draft = {};
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) draft[key] = state[key];
sessionStorage.setItem("github-access-draft", JSON.stringify({ path: location.path(), savedAt: Date.now(), draft }));
};
state.connectGistApp = () => {
saveGitHubDraft();
window.location.href = "/github/app/login?returnTo=" + encodeURIComponent(location.path());
};
state.connectGistOAuth = () => {
saveGitHubDraft();
window.location.href = "/github/login?returnTo=" + encodeURIComponent(location.path());
};
state.grantGitHubAccess = () => {
saveGitHubDraft();
const returnTo = location.path();
const repository = parseRepoFullName(state.sourceUrl) || "";
const route = state.githubConnections?.appConnected ? "/github/app/install" : "/github/app/login";
window.location.href = route + "?returnTo=" + encodeURIComponent(returnTo) + "&repository=" + encodeURIComponent(repository) + "&install=1";
};
state.chooseGitHubConnection = async (value) => {
state.githubConnection = value;
state.readme = "";
if (state.sourceUrl) await refreshGitHubAccess();
};
state.detectedType = null; // 'repo' | 'pr' | 'gist'
state.repoId = "";
state.pullRequestId = "";
state.gistId = "";
state.terms = "";
state.defaultTerms = "";
state.branches = [];
state.source = { branch: "", commit: "" };
state.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;
}
state.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.
state.minExpirationDate = toLocalDateString(new Date());
const maxDate = new Date();
maxDate.setFullYear(maxDate.getFullYear() + 1);
state.maxExpirationDate = toLocalDateString(maxDate);
state.anonymize_readme = "";
state.readme = "";
state.html_readme = "";
state.isUpdate = false;
state.previousConfigurations = [];
state.previousConfigurationId = "";
let previousRepositories;
const normalizedRepositoryName = name => String(name || "").replace(/\/$/, "").replace(/\.git$/i, "").toLowerCase();
async function findPreviousConfigurations() {
if (state.isUpdate || params.repoId || params.pullRequestId || params.gistId) return;
const request = { generation: sourceGeneration, url: state.sourceUrl.trim(), connection: state.githubConnection };
try {
// This endpoint only returns repositories the signed-in user can manage.
previousRepositories ||= http.get("/api/user/anonymized_repositories").then(res => res.data);
const repositories = await previousRepositories;
if (!isCurrentSource(request)) return;
const name = normalizedRepositoryName(parseRepoFullName(request.url));
state.previousConfigurations = repositories.filter(repo => repo.repoId && repo.options
&& normalizedRepositoryName(repo.source?.fullName) === name)
.sort((a, b) => (Date.parse(b.anonymizeDate) || 0) - (Date.parse(a.anonymizeDate) || 0));
state.previousConfigurationId = state.previousConfigurations[0]?.repoId || "";
} catch (_) {
// A suggestion failure must not prevent creating a repository. Retry on the next URL selection.
previousRepositories = undefined;
}
}
state.existingConfigurationUrl = () => {
const previous = state.previousConfigurations.find(repo => repo.repoId === state.previousConfigurationId);
return previous ? "/anonymize/" + encodeURIComponent(previous.repoId) : null;
};
function getDefault(cb) {
http.get("/api/user/default").then((res) => {
const data = res.data;
if (data.terms) {
state.defaultTerms = data.terms.join("\n");
}
state.options = Object.assign({}, state.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.)
state.options.expirationDate =
data.options && data.options.expirationDate
? new Date(data.options.expirationDate)
: defaultExpirationDate();
if (!params.repoId && !params.pullRequestId && !params.gistId && !state.anonymize?.terms?.dirty) state.terms = state.defaultTerms;
if (cb) cb();
});
}
// Helper to safely set validity on form fields
function setValidity(field, key, value) {
if (state.anonymize && state.anonymize[field]) {
state.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;
}
state.accessRepairAllowed = false;
state.repairingGitHubAccess = false;
state.accessRepairMessage = "";
state.repairGitHubAccess = async () => {
if (!state.accessRepairAllowed || state.repairingGitHubAccess || !state.githubConnections?.appEnabled || !params.repoId || state.role !== "owner" ||
state.githubConnection !== "github-app" || parseRepoFullName(state.sourceUrl) !== state._originalFullName) return;
state.repairingGitHubAccess = true;
state.accessRepairMessage = "";
const request = beginSourceRequest();
try {
const connections = await http.get("/github/connections");
state.githubConnections = connections.data;
if (!connections.data.appEnabled) return;
const repaired = await http.post("/github/connections/migrate", { type: "repository", id: params.repoId, connection: "github-app" },
{ headers: { "X-CSRF-Token": connections.data.csrf } });
if (!isCurrentSource(request)) return;
state.error = null;
if (repaired.data.fullName) {
state._originalFullName = repaired.data.fullName;
state.source.fullName = repaired.data.fullName;
state.sourceUrl = "https://github.com/" + repaired.data.fullName;
}
state.accessRepairMessage = "GitHub access repaired. Your anonymous URL and published commit are unchanged.";
await state.urlSelected(true);
} catch (_) {
if (isCurrentSource(request)) state.accessRepairMessage = "Unable to repair GitHub access. Check the App's repository permissions in Manage access, then retry. Your published snapshot has not been replaced.";
} finally {
state.repairingGitHubAccess = false;
finishSourceRequest(request);
}
};
state.reconnectingSource = false;
state.reconnectRepositoryId = undefined;
state.reconnectSource = async () => {
if (!confirm("Reconnect to the repository currently at this GitHub URL? Review its branch and commit before saving. Saving will replace the published snapshot.")) return;
state.reconnectingSource = true;
state.reconnectRepositoryId = undefined;
state.source.commit = "";
state.source.branch = "";
state.readme = "";
state.branches = [];
try { await getRepoDetails(); }
catch (_) { /* getRepoDetails displays the access error. */ }
};
function sourceRepositoryID() {
if (state.reconnectingSource) return undefined;
if (!state.isUpdate || !state._originalRepositoryID) return undefined;
const currentFullName = parseRepoFullName(state.sourceUrl);
return currentFullName === state._originalFullName
? state._originalRepositoryID
: undefined;
}
async function refreshGitHubAccess() {
state._preservingDraft = true;
try { await state.urlSelected(true); }
finally { state._preservingDraft = false; }
}
async function restoreGitHubDraft() {
const billingDraft = new URLSearchParams(window.location.search).get("billingDraft");
if (billingDraft) {
draft.restore();
try {
const saved = (await http.get("/api/billing/draft/" + encodeURIComponent(billingDraft))).data;
if (saved.path !== location.path()) throw new Error("Wrong task");
restoredCheckoutConference = saved.values.conference || null;
for (const key of ["sourceUrl", "terms", "repoId", "source", "options", "conference", "githubConnection"]) {
if (Object.prototype.hasOwnProperty.call(saved.values, key)) state[key] = saved.values[key];
}
if (state.options?.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate);
checkoutDraftId = saved.id;
checkoutDraftSnapshot = JSON.stringify(draft.checkpoint());
state.draftRestored = true;
state.hostingReview = true;
if (state.sourceUrl) await refreshGitHubAccess();
// Consume the return token so revisiting the form cannot overwrite newer edits.
location.replace?.(location.path());
return true;
} catch { state.billingError = "The checkout draft could not be restored. Retry by reloading this page; any local draft is still available."; }
}
let saved;
try { saved = JSON.parse(sessionStorage.getItem("github-access-draft") || "null"); }
catch (_) { return false; }
if (!saved || saved.path !== location.path() || Date.now() - saved.savedAt >= 30 * 60000) {
if (!draft.restore()) return false;
if (state.sourceUrl) await refreshGitHubAccess();
return true;
}
draft.restore();
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) {
if (Object.prototype.hasOwnProperty.call(saved.draft, key)) state[key] = saved.draft[key];
}
if (state.options.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate);
await refreshGitHubAccess();
sessionStorage.removeItem("github-access-draft");
return true;
}
getDefault(() => {
if (!params.repoId && !params.pullRequestId && !params.gistId) timeout(restoreGitHubDraft, 0);
// Edit mode: repo
if (params.repoId && params.repoId != "") {
state.isUpdate = true;
state.detectedType = "repo";
state.repoId = params.repoId;
http.get("/api/repo/" + state.repoId).then(
async (res) => {
state.accessRepairAllowed = !["removed", "archived", "preparing", "queue", "download", "removing", "expiring"].includes(res.data.status);
state.githubConnection = res.data.connection || "oauth";
state.sourceUrl = "https://github.com/" + res.data.source.fullName;
state._originalFullName = res.data.source.fullName;
state.terms = res.data.options.terms.filter((f) => f).join("\n");
state.source = res.data.source;
state.role = res.data.role || "owner";
state._existingNever = res.data.options.expirationMode === "never";
state.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.
state._originalBranch = res.data.source.branch;
state.options = Object.assign({}, state.options, res.data.options);
state.conference = res.data.conference;
state._originalConference = res.data.conference;
state.repositoryID = res.data.source.repositoryID;
state._originalRepositoryID = res.data.source.repositoryID;
if (res.data.options.expirationDate) {
state.options.expirationDate = new Date(res.data.options.expirationDate);
}
selectedSourceKey = sourceKey(parseGithubUrl(state.sourceUrl));
if (await restoreGitHubDraft()) return;
await Promise.all([getRepoDetails(), getReadme()]);
anonymizeReadme();
},
() => { location.url("/404"); }
);
}
// Edit mode: PR
if (params.pullRequestId && params.pullRequestId != "") {
state.isUpdate = true;
state.detectedType = "pr";
state.pullRequestId = params.pullRequestId;
http.get("/api/pr/" + state.pullRequestId).then(
async (res) => {
state.githubConnection = res.data.connection || "oauth";
state.sourceUrl = "https://github.com/" + res.data.source.repositoryFullName + "/pull/" + res.data.source.pullRequestId;
state.terms = res.data.options.terms.filter((f) => f).join("\n");
state.source = res.data.source;
state.options = Object.assign({}, state.options, res.data.options);
state.conference = res.data.conference;
state._originalConference = res.data.conference;
if (res.data.options.expirationDate) {
state.options.expirationDate = new Date(res.data.options.expirationDate);
}
selectedSourceKey = sourceKey(parseGithubUrl(state.sourceUrl));
if (await restoreGitHubDraft()) return;
try { await getPrDetails(); }
catch (_) { /* The source handler displays the error. */ }
},
() => { location.url("/404"); }
);
}
// Edit mode: Gist
if (params.gistId && params.gistId != "") {
state.isUpdate = true;
state.detectedType = "gist";
state.gistId = params.gistId;
http.get("/api/gist/" + state.gistId).then(
async (res) => {
state.sourceUrl = "https://gist.github.com/" + res.data.source.gistId;
state.terms = res.data.options.terms.filter((f) => f).join("\n");
state.source = res.data.source;
state.options = Object.assign({}, state.options, res.data.options);
state.conference = res.data.conference;
state._originalConference = res.data.conference;
if (res.data.options.expirationDate) {
state.options.expirationDate = new Date(res.data.options.expirationDate);
}
selectedSourceKey = sourceKey(parseGithubUrl(state.sourceUrl));
if (await restoreGitHubDraft()) return;
await getGistDetails();
},
() => { location.url("/404"); }
);
}
});
http.get("/github/connections").then(res => {
state.githubConnections = res.data;
// Preserve saved resources and an explicit or restored draft choice.
if (!params.repoId && !params.pullRequestId && !params.gistId && state.githubConnection === undefined
&& res.data.appEnabled && res.data.appConnected) {
state.githubConnection = "github-app";
if (state.sourceUrl && state.workspaceOpen) refreshGitHubAccess();
}
}).catch(() => {});
// Keep the same input focused until a complete GitHub URL can be loaded.
state.urlSelected = async (preserveDraft = false) => {
const generation = ++sourceGeneration;
state.previousConfigurations = [];
state.previousConfigurationId = "";
let o;
try { o = parseGithubUrl(state.sourceUrl.trim()); }
catch (_) {
state.sourceLoading = false;
scheduleReadmePreview.cancel();
refreshPrPreview.cancel();
refreshGistPreview.cancel();
state.previewStatus = "idle";
state.sourceUrlError = state.sourceUrl ? "Please provide a valid GitHub URL." : "";
setValidity("sourceUrl", "github", false);
return;
}
const key = sourceKey(o);
const preserveSelection = preserveDraft || selectedSourceKey === key;
selectedSourceKey = key;
state.sourceUrlError = "";
setValidity("sourceUrl", "github", true);
const transferFocus = document.activeElement?.id === "sourceUrl-landing";
state.workspaceOpen = true;
// Show the pasted URL from its start, not scrolled to the caret.
setTimeout(() => { const el = window.document.getElementById("sourceUrl"); if (!el) return; try { el.setSelectionRange(0, 0); } catch { /* not a text input */ } el.scrollLeft = 0; }, 50);
if (transferFocus) timeout(() => document.getElementById("sourceUrl")?.focus(), 0);
state.reconnectingSource = false;
state.reconnectRepositoryId = undefined;
if (!preserveSelection && !state.isUpdate) {
state.repoId = "";
state.pullRequestId = "";
state.gistId = "";
}
state.details = null;
state.branches = [];
if (!preserveSelection) state.source = { type: "GitHubStream", branch: "", commit: "" };
scheduleReadmePreview.cancel();
refreshPrPreview.cancel();
refreshGistPreview.cancel();
_prAnonCache.clear();
_gistAnonCache.clear();
state.previewStatus = "idle";
state.anonymize_readme = "";
state.readme = "";
state.html_readme = "";
state.detectedType = null;
state.gistOAuthRequired = false;
state.gistAppRequired = false;
state._preservingDraft = preserveSelection;
try {
if (o.gistId && !o.repo) {
state.detectedType = "gist";
state.source = { gistId: o.gistId };
await getGistDetails();
} else if (o.pullRequestId) {
state.detectedType = "pr";
state.source = { repositoryFullName: o.owner + "/" + o.repo, pullRequestId: o.pullRequestId };
await getPrDetails();
} else {
state.detectedType = "repo";
void findPreviousConfigurations();
await Promise.all([getRepoDetails(), getReadme()]);
anonymizeReadme();
}
} catch (_) { /* Source errors are displayed by the request handlers. */ }
finally { if (generation === sourceGeneration) state._preservingDraft = false; }
$('[data-toggle="tooltip"]').tooltip();
};
$('[data-toggle="tooltip"]').tooltip();
// ========== REPO LOGIC ==========
state.watch("source.branch", async () => {
if (state.detectedType !== "repo") return;
const selected = state.branches.filter((f) => f.name == state.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 =
!state.reconnectingSource &&
state.isUpdate &&
state._originalBranch === state.source.branch &&
!!state.source.commit;
if (!keepSavedCommit && !(state._preservingDraft && state.source.commit)) {
state.source.commit = selected.commit;
}
state.readme = selected.readme;
await getReadme();
anonymizeReadme();
});
state.getBranches = async (force) => {
const o = parseGithubUrl(state.sourceUrl);
const request = beginSourceRequest();
try {
const branches = await http.get(`/api/repo/${o.owner}/${o.repo}/branches`, {
params: { reconnect: state.reconnectingSource ? "1" : undefined, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, force: force === true || state.reconnectingSource ? "1" : "0", repositoryID: sourceRepositoryID() },
});
if (!isCurrentSource(request)) return;
state.branches = branches.data;
state.sourceUnreachable = false;
if (!state.source.branch) {
state.source.branch = state.details.defaultBranch;
}
const selected = state.branches.filter((b) => b.name == state.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 =
!state.reconnectingSource &&
!force &&
state.isUpdate &&
!state.options.update &&
state._originalBranch === state.source.branch &&
!!state.source.commit;
if (!keepSavedCommit && !(state._preservingDraft && state.source.commit)) {
state.source.commit = selected[0].commit;
}
state.readme = selected[0].readme;
await getReadme(force);
}
} catch (error) {
if (!isCurrentSource(request)) return;
state.branches = [];
state.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) => {
state.toasts = state.toasts || [];
state.addToast({ title: "Error", date: new Date(), body: translation });
state.error = translation;
}, console.error);
if (typeof setValidity === "function") {
setValidity("sourceUrl", "missing", false);
}
} finally { finishSourceRequest(request); }
};
async function getRepoDetails() {
const o = parseGithubUrl(state.sourceUrl);
const request = beginSourceRequest();
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: { reconnect: state.reconnectingSource ? "1" : undefined, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, repositoryID: sourceRepositoryID(), force: "1" },
});
if (!isCurrentSource(request)) return;
state.details = res.data;
if (state.reconnectingSource) state.reconnectRepositoryId = state.details.externalId;
if (state.details && state.details.id) {
state.repositoryID = state.details.id;
}
if (!state.repoId) {
state.repoId = state.details.repo + "-" + generateRandomId(4);
}
await state.getBranches(state.reconnectingSource);
} catch (error) {
if (!isCurrentSource(request)) return;
if (error.data) {
translate("ERRORS." + error.data.error).then((translation) => {
state.addToast({ title: "Error", date: new Date(), body: translation });
state.error = translation;
}, console.error);
displayErrorMessage(error.data.error);
}
setValidity("sourceUrl", "missing", false);
throw error;
} finally { finishSourceRequest(request); }
}
async function getReadme(force) {
if (state.readme && !force) return state.readme;
const o = parseGithubUrl(state.sourceUrl);
const request = beginSourceRequest();
try {
const res = await http.get(`/api/repo/${o.owner}/${o.repo}/readme`, {
params: { reconnect: state.reconnectingSource ? "1" : undefined, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, force: force === true ? "1" : "0", branch: state.source.branch, repositoryID: sourceRepositoryID() },
});
if (isCurrentSource(request, true)) state.readme = res.data;
} catch (error) {
if (isCurrentSource(request, true)) state.readme = "";
} finally { finishSourceRequest(request); }
}
// 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.
// ---- "Leave files unredacted" picker (#941) ----
// Picked files are saved first, as root-anchored paths with glob characters
// (braces included) escaped by a backslash; folders and anything typed by
// hand follow as patterns. Patterns apply in order, so a later "!pattern"
// can exclude a picked file, exactly as on the server.
const globOpts = { magicalBraces: true };
// A picked file: a root-anchored path with no glob characters. Folder rules
// ("/paper/") stay patterns.
const isPickedPattern = (p) => p.startsWith("/") && !p.endsWith("/") && globEscape(globUnescape(p.slice(1), globOpts), globOpts) === p.slice(1);
const pickedRules = () => Object.keys(state.preserve.picked).filter((f) => state.preserve.picked[f]).sort()
.map((f) => "/" + globEscape(f, globOpts));
const folderPattern = (dir) => "/" + globEscape(dir, globOpts) + "/**";
const folderExclusion = (dir) => "!/" + globEscape(dir, globOpts) + "/";
const patternLines = () => String(state.preserve.patterns || "").split("\n").map((l) => l.trim()).filter(Boolean);
let preserveIndex = null;
// The checkboxes use the exact rule list that will be saved (picks first,
// then patterns) through the server's matcher, so they cannot disagree.
let pickVersion = 0;
let matcherKey = null;
let matcherFn = () => false;
function preserveMatcher() {
const key = pickVersion + "\u0000" + state.preserve.patterns;
if (key !== matcherKey) {
matcherKey = key;
try { matcherFn = compilePreserveMatcher(dedupePreservePatterns([...pickedRules(), ...patternLines()])); } catch { matcherFn = () => false; }
}
return matcherFn;
}
function buildPreserveIndex(files) {
const dirs = new Map([["", { dirs: [], files: [] }]]);
for (const file of files) {
const parts = file.split("/");
let parent = "";
for (let i = 0; i < parts.length - 1; i++) {
const dir = parts.slice(0, i + 1).join("/");
if (!dirs.has(dir)) { dirs.set(dir, { dirs: [], files: [] }); dirs.get(parent).dirs.push(dir); }
parent = dir;
}
dirs.get(parent).files.push(file);
}
for (const entry of dirs.values()) entry.dirs.sort();
return { files, dirs, descendants: new Map() };
}
function descendants(dir) {
if (!preserveIndex.descendants.has(dir)) {
preserveIndex.descendants.set(dir, preserveIndex.files.filter((f) => f.startsWith(dir + "/")));
}
return preserveIndex.descendants.get(dir);
}
const fileChecked = (file) => preserveMatcher()(file);
function dirState(dir) {
const files = descendants(dir);
let checked = 0;
for (const f of files) if (fileChecked(f)) checked++;
return checked === 0 ? "none" : checked === files.length ? "all" : "some";
}
state.preserve = { open: false, loading: false, error: "", truncated: false, commit: "", search: "", expanded: {}, picked: {}, patterns: "", patternsOpen: false };
state.preserveSummary = () => {
const list = (state.options?.preserveFiles || []).map((p) => String(p).trim()).filter(Boolean)
.map((p) => isPickedPattern(p) ? globUnescape(p.slice(1), globOpts) : p);
if (!list.length) return "None. Every file is redacted.";
return list.slice(0, 2).join(", ") + (list.length > 2 ? ` and ${list.length - 2} more` : "");
};
async function loadPreserveTree() {
const commit = state.source?.commit;
if (preserveIndex && state.preserve.commit === commit) return;
state.preserve.loading = true;
state.preserve.error = "";
preserveIndex = null;
try {
const o = parseGithubUrl(state.sourceUrl);
const res = await http.get(`/api/repo/${o.owner}/${o.repo}/tree`, {
params: { commit, connection: state.githubConnection, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, repositoryID: sourceRepositoryID() },
});
if (state.source?.commit !== commit) return;
preserveIndex = markRaw(buildPreserveIndex(res.data.files || []));
state.preserve.commit = commit;
state.preserve.truncated = !!res.data.truncated;
} catch (error) {
state.preserve.error = "The file list could not be loaded from GitHub. You can still use patterns.";
state.preserve.patternsOpen = true;
} finally {
state.preserve.loading = false;
}
}
state.openPreserveFiles = () => {
// Picks are saved first, so only the leading run of plain paths can be
// shown as picks; a plain path after another rule stays a pattern to
// keep its place in the order.
const picked = {};
const patterns = [];
for (const raw of state.options?.preserveFiles || []) {
const p = String(raw).trim();
if (!p) continue;
if (!patterns.length && isPickedPattern(p)) picked[globUnescape(p.slice(1), globOpts)] = true;
else patterns.push(p);
}
const expanded = {};
for (const file of Object.keys(picked)) {
const parts = file.split("/");
for (let i = 1; i < parts.length; i++) expanded[parts.slice(0, i).join("/")] = true;
}
Object.assign(state.preserve, { open: true, search: "", picked, expanded, patterns: patterns.join("\n"), patternsOpen: patterns.length > 0 });
pickVersion++;
timeout(() => {
const dialog = document.getElementById("preserve-files-dialog");
if (dialog?.showModal && !dialog.open) dialog.showModal();
document.getElementById("preserve-files-search")?.focus();
}, 0);
loadPreserveTree();
};
state.closePreserveFiles = () => {
document.getElementById("preserve-files-dialog")?.close?.();
state.preserve.open = false;
};
state.savePreserveFiles = () => {
state.options.preserveFiles = dedupePreservePatterns([...pickedRules(), ...patternLines()]);
state.closePreserveFiles();
};
state.preserveRows = () => {
if (!preserveIndex) return [];
const matcher = preserveMatcher();
const fileRow = (file, depth, label) => {
const picked = !!state.preserve.picked[file];
const checked = matcher(file);
const note = !picked && checked ? "matched by a pattern" : picked && !checked ? "excluded by a pattern" : "";
return { kind: "file", path: file, label, depth, state: checked ? "all" : "none", locked: !picked && checked, note };
};
const search = String(state.preserve.search || "").trim().toLowerCase();
if (search) {
return preserveIndex.files.filter((f) => f.toLowerCase().includes(search)).slice(0, 300).map((f) => fileRow(f, 0, f));
}
const rows = [];
const walk = (dir, depth) => {
const entry = preserveIndex.dirs.get(dir);
for (const sub of entry.dirs) {
const open = !!state.preserve.expanded[sub];
const ownPattern = patternLines().includes(folderPattern(sub));
rows.push({ kind: "dir", path: sub, label: sub.slice(dir ? dir.length + 1 : 0) + "/", depth, open, state: dirState(sub), locked: false, note: ownPattern ? "whole folder" : "" });
if (open) walk(sub, depth + 1);
}
for (const file of entry.files) rows.push(fileRow(file, depth, file.slice(dir ? dir.length + 1 : 0)));
};
walk("", 0);
return rows;
};
state.preserveCount = () => preserveIndex ? preserveIndex.files.filter(fileChecked).length : 0;
state.togglePreserveDir = (dir) => { state.preserve.expanded[dir] = !state.preserve.expanded[dir]; };
state.togglePreserveFile = (file) => { state.preserve.picked[file] = !state.preserve.picked[file]; pickVersion++; };
// Unchecking a folder must win over broader rules such as "*.bib", so a
// folder exclusion is appended when files inside would stay checked.
state.togglePreserveFolder = (dir) => {
const own = folderPattern(dir);
const exclusion = folderExclusion(dir);
const lines = patternLines().filter((l) => l !== own && l !== exclusion);
const wasChecked = dirState(dir) === "all" || patternLines().includes(own);
for (const f of descendants(dir)) state.preserve.picked[f] = false;
pickVersion++;
state.preserve.patterns = (wasChecked ? lines : [...lines, own]).join("\n");
if (wasChecked && descendants(dir).some(fileChecked)) {
state.preserve.patterns = [...lines, exclusion].join("\n");
}
};
// GitHub's README lookup order. The exact path is not known here, so the
// preview treats the README as unredacted if any candidate is selected:
// when unsure, it shows the author more exposure, not less.
const README_CANDIDATES = [".github", "", "docs"].flatMap((dir) =>
["README.md", "README", "README.markdown", "README.rst", "README.txt", "readme.md"].map((name) => (dir ? dir + "/" : "") + name));
state.readmePreserved = () => {
if (state.detectedType !== "repo") return false;
const patterns = (state.options?.preserveFiles || []).map((p) => String(p).trim()).filter(Boolean);
if (!patterns.length) return false;
try {
const matcher = compilePreserveMatcher(patterns);
return README_CANDIDATES.some((path) => matcher(path));
} catch { return false; }
};
function previewOptions() {
const opts = {
terms: state.terms && !state.readmePreserved() ? state.terms.split("\n") : [],
image: !!state.options.image,
link: !!state.options.link,
repoId: state.repoId,
};
if (state.source && state.source.branch) {
opts.branchName = state.source.branch;
}
try {
const o = parseGithubUrl(state.sourceUrl);
opts.repoName = `${o.owner}/${o.repo}`;
} catch (_) { /* sourceUrl not yet parseable */ }
return opts;
}
// Invalidate as soon as an option changes, before the debounce expires.
function makePreviewBatcher(kind, buildBody, applyResult) {
let pendingTimer = null;
let generation = 0;
const schedule = () => {
if (state.detectedType !== kind) return;
if (pendingTimer) timeout.cancel(pendingTimer);
const current = ++generation;
state.previewStatus = "loading";
pendingTimer = timeout(() => {
pendingTimer = null;
const body = buildBody();
if (!body) { state.previewStatus = "idle"; return; }
http.post("/api/anonymize-preview", body).then(res => {
if (current !== generation) return;
applyResult(res.data, body);
state.previewStatus = "ready";
}).catch(() => {
if (current === generation) state.previewStatus = "error";
});
}, 200);
};
schedule.cancel = () => {
generation++;
if (pendingTimer) timeout.cancel(pendingTimer);
pendingTimer = null;
};
state.on("dispose", schedule.cancel);
return schedule;
}
state.retryPreview = () => {
if (state.detectedType === "repo") scheduleReadmePreview();
if (state.detectedType === "pr") refreshPrPreview();
if (state.detectedType === "gist") refreshGistPreview();
};
const scheduleReadmePreview = makePreviewBatcher(
"repo",
() => {
if (!state.readme) return null;
return { content: state.readme, options: previewOptions() };
},
(data) => {
if (typeof data?.content !== "string") throw Error("Invalid preview response");
state.anonymize_readme = data.content;
let baseUrl = "";
try {
const o = parseGithubUrl(state.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 =
state.source.branch ||
(state.details && state.details.defaultBranch) ||
"main";
baseUrl = `https://github.com/${o.owner}/${o.repo}/raw/${branch}/`;
} catch (_) { /* fall through with empty base */ }
const html = renderMD(state.anonymize_readme, baseUrl);
state.html_readme = html;
timeout(Prism.highlightAll, 150);
}
);
function anonymizeReadme() {
if (!state.anonymize || !state.anonymize.terms) return;
// The "regex characters detected" hint is informational, not a blocker
// — IP addresses, escaped chars, etc. are all legitimate terms (#430).
scheduleReadmePreview();
}
// ========== PR LOGIC ==========
async function getPrDetails() {
const o = parseGithubUrl(state.sourceUrl);
const request = beginSourceRequest();
try {
resetValidity();
const res = await http.get(`/api/pr/${o.owner}/${o.repo}/${o.pullRequestId}`, { params: { connection: state.githubConnection } });
if (!isCurrentSource(request)) return;
state.details = res.data;
if (!state.pullRequestId) {
state.pullRequestId = o.repo + "-PR" + o.pullRequestId + "-" + generateRandomId(4);
}
} catch (error) {
if (!isCurrentSource(request)) return;
if (error.data) {
translate("ERRORS." + error.data.error).then((translation) => {
state.addToast({ title: "Error", date: new Date(), body: translation });
state.error = translation;
}, console.error);
displayErrorMessage(error.data.error);
}
setValidity("sourceUrl", "missing", false);
throw error;
} finally { finishSourceRequest(request); }
}
// Original text is never substituted for a pending or failed preview.
const _prAnonCache = reactive(new Map());
function collectPrContents() {
const out = new Set();
const d = state.details && state.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 =
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 refreshPrPreview = makePreviewBatcher(
"pr",
() => {
const seen = collectPrContents();
const list = Array.from(seen);
if (list.length === 0) return null;
return { contents: list, options: previewOptions() };
},
(data, body) => {
if (!Array.isArray(data?.contents) || data.contents.length !== body.contents.length || data.contents.some(value => typeof value !== "string")) throw Error("Invalid preview response");
const seen = body.contents;
const next = new Map();
for (let i = 0; i < seen.length && i < data.contents.length; i++) {
next.set(seen[i], data.contents[i]);
}
_prAnonCache.clear();
next.forEach((value, key) => _prAnonCache.set(key, value));
}
);
state.anonymizePrContent = function (content) {
if (!content) return content;
if (_prAnonCache.has(content)) return _prAnonCache.get(content);
return "";
};
// ========== GIST LOGIC ==========
async function getGistDetails() {
const o = parseGithubUrl(state.sourceUrl);
const request = beginSourceRequest();
try {
resetValidity();
state.gistOAuthRequired = false;
state.gistAppRequired = false;
const res = await http.get(`/api/gist/source/${o.gistId}`);
if (!isCurrentSource(request)) return;
state.details = res.data;
if (!state.gistId) {
state.gistId = "gist-" + o.gistId.substring(0, 6) + "-" + generateRandomId(4);
}
} catch (error) {
if (!isCurrentSource(request)) return;
if (error.data?.error === "github_app_reconnect_required") {
state.gistAppRequired = true;
setValidity("sourceUrl", "missing", false);
return;
}
if (error.data?.error === "github_oauth_required") {
state.gistOAuthRequired = true;
setValidity("sourceUrl", "missing", false);
return;
}
if (error.data) {
translate("ERRORS." + error.data.error).then((translation) => {
state.addToast({ title: "Error", date: new Date(), body: translation });
state.error = translation;
}, console.error);
displayErrorMessage(error.data.error);
}
setValidity("sourceUrl", "missing", false);
throw error;
} finally { finishSourceRequest(request); }
}
const _gistAnonCache = reactive(new Map());
function collectGistContents() {
const out = new Set();
const d = state.details && state.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(
"gist",
() => {
const seen = collectGistContents();
const list = Array.from(seen);
if (list.length === 0) return null;
return { contents: list, options: previewOptions() };
},
(data, body) => {
if (!Array.isArray(data?.contents) || data.contents.length !== body.contents.length || data.contents.some(value => typeof value !== "string")) throw Error("Invalid preview response");
const seen = body.contents;
const next = new Map();
for (let i = 0; i < seen.length && i < data.contents.length; i++) {
next.set(seen[i], data.contents[i]);
}
_gistAnonCache.clear();
next.forEach((value, key) => _gistAnonCache.set(key, value));
rebuildPreviewGistFiles();
}
);
state.anonymizeGistContent = function (content) {
if (!content) return content;
if (_gistAnonCache.has(content)) return _gistAnonCache.get(content);
return "";
};
// Precomputed file objects for the preview pane so <gist-file>'s
// props have a stable reference. Recomputes when the source
// files change OR when the anonymization cache turns over.
state.previewGistFiles = [];
function rebuildPreviewGistFiles() {
const files =
(state.details && state.details.gist && state.details.gist.files) || [];
state.previewGistFiles = files.map((f) => ({
filename: state.anonymizeGistContent(f.filename),
content: state.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.
state.watch("details.gist.files", rebuildPreviewGistFiles, true);
state.watch("terms", rebuildPreviewGistFiles);
// ========== SHARED LOGIC ==========
function getConference() {
const conference = state.conference;
state.conference_data = null;
if (!conference) return;
const preserveSavedOptions =
conference === restoredCheckoutConference ||
(state.isUpdate && conference === state._originalConference);
if (conference !== restoredCheckoutConference) restoredCheckoutConference = null;
http.get("/api/conferences/" + conference).then(
(res) => {
if (state.conference !== conference) return;
state.conference_data = res.data;
state.conference_data.startDate = new Date(state.conference_data.startDate);
state.conference_data.endDate = new Date(state.conference_data.endDate);
// Conference defaults must not overwrite an existing submission's
// saved settings when its edit form loads (#791).
if (preserveSavedOptions) return;
state.options.expirationDate = new Date(state.conference_data.endDate);
state.options.expirationMode = "remove";
state.options.update = state.conference_data.options.update;
state.options.image = state.conference_data.options.image;
state.options.pdf = state.conference_data.options.pdf;
state.options.notebook = state.conference_data.options.notebook;
state.options.link = state.conference_data.options.link;
},
() => {
if (state.conference === conference) state.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);
state.termHints = [];
}
// 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() {
if (state.detectedType === "repo" && state.options.expirationMode === "never") return false;
const field = state.anonymize && state.anonymize.expirationDate;
if (!state.options.expirationDate || (field && field.invalid)) {
if (field && field.setDirty) field.setDirty();
state.error = "Please choose a valid expiration date.";
return true;
}
return false;
}
function displayErrorMessage(message) {
const idField =
state.detectedType === "pr"
? "pullRequestId"
: state.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 ==========
state.coauthors = state.coauthors || [];
state.coauthorResults = [];
state.coauthorError = "";
state.searchCoauthors = () => {
const q = (state.coauthorSearch || "").trim();
state.coauthorError = "";
if (q.length < 2) {
state.coauthorResults = [];
return;
}
http.get("/api/user/search/github-users", { params: { q } }).then(
(res) => {
const existing = new Set(
(state.coauthors || []).map((c) => (c.username || "").toLowerCase())
);
state.coauthorResults = (res.data || []).filter(
(u) => !existing.has((u.username || "").toLowerCase())
);
},
() => { state.coauthorResults = []; }
);
};
state.addCoauthor = (u, event) => {
if (event) event.preventDefault();
if (!u || !u.username) return;
http
.post("/api/repo/" + state.repoId + "/coauthors", {
username: u.username,
})
.then(
(res) => {
state.coauthors = res.data || [];
state.coauthorResults = [];
state.coauthorSearch = "";
state.coauthorError = "";
},
(err) => {
const code = (err && err.data && err.data.error) || "unknown_error";
state.coauthorError = code;
}
);
};
state.removeCoauthor = (c) => {
if (!c || !c.username) return;
if (!confirm("Remove co-author " + c.username + "?")) return;
http
.delete(
"/api/repo/" +
state.repoId +
"/coauthors/" +
encodeURIComponent(c.username)
)
.then(
(res) => { state.coauthors = res.data || []; },
(err) => {
const code = (err && err.data && err.data.error) || "unknown_error";
state.coauthorError = code;
}
);
};
// Submit: repo
state.anonymizeRepo = (event) => {
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
if (state.reconnectingSource && !state.reconnectRepositoryId) return;
if (expirationDateInvalid()) { state.editRepository(); return; }
if (state.anonymize?.invalid || state.sourceUrlError) {
state.editRepository();
state.error = "Please correct the highlighted fields before saving.";
return;
}
// Capacity is enforced on the server, which can release removed reservations.
if (state.hostingNeedsPayment() && state.options.expirationMode === "never") {
state.hostingReview = true;
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "center" }));
return;
}
const button = event.currentTarget || event.target.closest?.("button") || event.target;
button.disabled = true;
const o = parseGithubUrl(state.sourceUrl);
const payload = {
repoId: state.repoId,
terms: state.terms.trim().split("\n").filter((f) => f),
connection: state.githubConnection,
fullName: `${o.owner}/${o.repo}`,
repository: state.sourceUrl,
reconnectRepositoryId: state.reconnectRepositoryId,
options: state.options,
source: state.source,
conference: state.conference,
};
if (state.details) payload.options.pageSource = state.details.pageSource;
resetValidity();
const url = state.isUpdate ? "/api/repo/" + state.repoId : "/api/repo/";
http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
() => { draft.clear(); window.location.href = "/status/" + state.repoId; },
(error) => {
if (error.data) {
translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
displayErrorMessage(error.data.error);
if (["quota_exceeded", "subscription_required_for_never_expiration"].includes(error.data.error)) {
if (error.data.error === "subscription_required_for_never_expiration") state._existingNever = false;
state.hostingQuotaBlocked = true;
state.hostingReview = true;
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "center" }));
}
}
}
).finally(() => { button.disabled = false; });
};
// Submit: Gist
state.anonymizeGist = (event) => {
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
if (expirationDateInvalid()) return;
if (state.anonymize?.invalid || state.sourceUrlError) {
state.error = "Please correct the highlighted fields before saving.";
return;
}
const button = event.currentTarget || event.target.closest?.("button") || event.target;
button.disabled = true;
const o = parseGithubUrl(state.sourceUrl);
const payload = {
gistId: state.gistId,
terms: state.terms.trim().split("\n").filter((f) => f),
source: { gistId: o.gistId },
options: state.options,
conference: state.conference,
};
resetValidity();
const url = state.isUpdate ? "/api/gist/" + state.gistId : "/api/gist/";
http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
() => { draft.clear(); window.location.href = "/gist/" + state.gistId; },
(error) => {
if (error.data) {
translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
displayErrorMessage(error.data.error);
}
}
).finally(() => { button.disabled = false; });
};
// Submit: PR
state.anonymizePullRequest = (event) => {
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
if (expirationDateInvalid()) return;
if (state.anonymize?.invalid || state.sourceUrlError) {
state.error = "Please correct the highlighted fields before saving.";
return;
}
const button = event.currentTarget || event.target.closest?.("button") || event.target;
button.disabled = true;
const o = parseGithubUrl(state.sourceUrl);
const payload = {
pullRequestId: state.pullRequestId,
connection: state.githubConnection,
terms: state.terms.trim().split("\n").filter((f) => f),
source: { repositoryFullName: `${o.owner}/${o.repo}`, pullRequestId: o.pullRequestId },
options: state.options,
conference: state.conference,
};
resetValidity();
const url = state.isUpdate ? "/api/pr/" + state.pullRequestId : "/api/pr/";
http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
() => { draft.clear(); window.location.href = "/pr/" + state.pullRequestId; },
(error) => {
if (error.data) {
translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
displayErrorMessage(error.data.error);
}
}
).finally(() => { button.disabled = false; });
};
state.watch("conference", () => { getConference(); });
state.termHints = [];
state.matchTermAsWritten = (line) => {
state.terms = escapeTermLine(state.terms, line);
};
state.watch("options.preserveFiles", () => {
if (state.detectedType === "repo") scheduleReadmePreview();
});
state.watch("terms", () => {
state.termHints = termHints(state.terms);
if (state.detectedType === "repo") anonymizeReadme();
if (state.detectedType === "pr") refreshPrPreview();
if (state.detectedType === "gist") refreshGistPreview();
});
state.watch("options.image", () => {
if (state.detectedType === "repo") anonymizeReadme();
if (state.detectedType === "pr") refreshPrPreview();
if (state.detectedType === "gist") refreshGistPreview();
});
state.watch("options.link", () => {
if (state.detectedType === "repo") anonymizeReadme();
if (state.detectedType === "pr") refreshPrPreview();
if (state.detectedType === "gist") refreshGistPreview();
});
state.watch("details", () => {
if (state.detectedType === "pr") refreshPrPreview();
if (state.detectedType === "gist") refreshGistPreview();
}, true);
};
export const exploreController = function (state, http, location, params, html, promises) {
const timers = createTimers();
installPrismRedactionSlots();
const listen = createListeners();
state.on("dark-mode", (event, on) => {
if (!state.aceOption) return;
if (on) {
state.aceOption.theme = "nord_dark";
} else {
state.aceOption.theme = "chrome";
}
});
let contentGeneration = 0;
let contentCanceller = null;
let loadingNoticeTimer = null;
state.fileLoadingSlow = false;
state.fileReceivedBytes = 0;
function cancelContentLoad() {
if (contentCanceller) { contentCanceller.resolve(); contentCanceller = null; }
if (loadingNoticeTimer) { timers.timeout.cancel(loadingNoticeTimer); loadingNoticeTimer = null; }
}
let destroyed = false;
state.on("dispose", () => {
destroyed = true;
contentGeneration++;
cancelContentLoad();
if (searchCanceller) searchCanceller.resolve();
});
state.files = [];
state.isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
state.fileSearchQuery = "";
state.fileSearchResults = null;
state.fileSearchLoading = false;
listen(document, "click", (e) => {
if (state.symbolsOpen && !e.target.closest?.(".symbols")) state.symbolsOpen = false;
if (state.moreOpen && !e.target.closest?.(".tb-more")) state.moreOpen = false;
if (state.tocOpen && !e.target.closest?.(".tb-toc-m")) state.tocOpen = false;
// The ⋯ menu item that opens it is removed on click, so ignore detached targets.
if (state.gotoOpen && e.target.isConnected && !e.target.closest?.(".goto-pop, .btn-goto, .tb-more")) state.gotoOpen = false;
if (state.accountOpen && !e.target.closest?.(".acct")) state.accountOpen = false;
if (state.helpOpen && !e.target.closest?.(".vb-help")) state.helpOpen = false;
});
listen(document, "keydown", (e) => {
if (e.key === "Escape") { state.symbolsOpen = false; state.moreOpen = false; state.tocOpen = false; state.gotoOpen = false; state.accountOpen = false; state.helpOpen = false; }
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key && e.key.toLowerCase() === "f" && state.type === "html" && window.document.querySelector(".reading-layout")) {
e.preventDefault();
state.toggleMdFind(true);
}
});
listen(document, "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;
state.onFileSearchChange = function () {
// Cancel any in-flight search request
if (searchCanceller) {
searchCanceller.resolve();
searchCanceller = null;
}
const query = state.fileSearchQuery;
if (!query || query.length < 2) {
state.fileSearchResults = null;
state.fileSearchLoading = false;
return;
}
state.fileSearchLoading = true;
const requestCanceller = promises.defer();
searchCanceller = requestCanceller;
http.get(
`/api/repo/${state.repoId}/files/search?q=${encodeURIComponent(query)}`,
{ timeout: requestCanceller.promise }
).then(function (res) {
if (destroyed || searchCanceller !== requestCanceller) return;
searchCanceller = null;
state.fileSearchLoading = false;
// Merge search results into state.files so the tree can render them.
// Ancestor folders must appear before their children for toArray() to work.
var existing = {};
state.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) {
state.files.push.apply(state.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) {
state.files.push.apply(state.files, filesToAdd);
}
state.fileSearchResults = res.data;
}, function () {
if (!destroyed && searchCanceller === requestCanceller) {
searchCanceller = null;
state.fileSearchLoading = false;
state.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",
];
state.on("routeUpdate", function (event, current) {
if (state.repoId != params.repoId) return init();
if ((params.path || "") == state.filePath) {
return;
}
// On phones the file tree is a drawer: close it once a file is opened.
if (window.matchMedia?.("(max-width: 767px)").matches) state.sidebarCollapsed = true;
state.filePath = params.path || "";
state.paths = state.filePath
.split("/")
.filter((f) => f && f.trim().length > 0);
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 state.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 < state.paths.length - 1; i++) {
const dirPath = i > 0 ? state.paths.slice(0, i).join("/") : "";
const alreadyLoaded = state.files.some((f) => f.path === dirPath);
if (!alreadyLoaded) {
state.getFiles(dirPath);
}
}
});
function selectFile() {
if (state.paths[0] != "") {
return;
}
const readmePriority = [
"readme.md",
"readme.txt",
"readme.org",
"readme.1st",
"readme",
];
const readmeCandidates = {};
for (const file of state.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])
);
}
}
state.fileCounts = null;
let listingGeneration = 0;
let countRequests = new Set();
let countsUnavailable = false;
state.getFiles = function (path, background = false) {
const repoId = state.repoId;
const generation = listingGeneration;
return http.get(
`/api/repo/${state.repoId}/files/?path=${encodeURIComponent(path)}&v=${state.options.lastUpdateDate}`
).then(function (res) {
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return [];
const normalized = path || "";
state.files = state.files.filter((f) => f.path !== normalized);
state.files.push(...res.data);
fetchFileCounts(normalized, generation);
return res.data;
}, function (err) {
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return [];
if (!background) {
state.type = "error";
state.content = viewerErrorCode(err);
state.files = [];
}
});
};
function fetchFileCounts(path, generation) {
if (countsUnavailable || countRequests.has(path)) return;
countRequests.add(path);
const repoId = state.repoId;
http.get(
`/api/repo/${state.repoId}/files/counts?path=${encodeURIComponent(path)}`
).then(function (res) {
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return;
state.fileCounts = { ...state.fileCounts, ...res.data };
}, function () {
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return;
// Counts are optional. Do not retry a guarded or rate-limited query
// every time a visitor opens another directory.
countsUnavailable = true;
});
}
function getSelectedFile() {
return state.files.filter(
(f) =>
f.name == state.paths[state.paths.length - 1] &&
f.path == state.paths.slice(0, state.paths.length - 1).join("/")
)[0];
}
var rlCountdownTimer = null;
state.on("dispose", function () { if (rlCountdownTimer) timers.interval.cancel(rlCountdownTimer); });
function getOptions(callback) {
if (destroyed) return;
const repoId = state.repoId;
http.get(`/api/repo/${state.repoId}/options`).then(
(res) => {
if (destroyed || repoId !== state.repoId) return;
state.options = res.data;
if (state.options.url) {
window.location = state.options.url;
return;
}
if (callback) {
callback(res.data);
}
},
(err) => {
if (destroyed || repoId !== state.repoId) return;
var data = err.data || {};
if (data.error === "rate_limited" && data.resetAt) {
state.type = "rate_limited";
state.rateLimitResetAt = data.resetAt;
if (rlCountdownTimer) timers.interval.cancel(rlCountdownTimer);
function rlTick() {
var remaining = Math.max(0, Math.ceil((state.rateLimitResetAt - Date.now()) / 1000));
if (remaining <= 0) {
state.rateLimitCountdown = "";
state.rateLimitResetAt = 0;
if (rlCountdownTimer) { timers.interval.cancel(rlCountdownTimer); rlCountdownTimer = null; }
getOptions(callback);
} else {
var min = Math.floor(remaining / 60);
var sec = remaining % 60;
state.rateLimitCountdown = min > 0 ? min + "m " + sec + "s" : sec + "s";
}
}
rlTick();
rlCountdownTimer = timers.interval(rlTick, 1000);
} else if (data.error === "repository_not_ready") {
state.type = "loading";
timers.timeout(function () { getOptions(callback); }, 3000);
} else {
state.type = "error";
state.content = viewerErrorCode(err);
}
}
);
}
// 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.
state.toggleSource = function () {
state.showSource = !state.showSource;
};
state.toggleAllowScripts = function () {
state.allowScripts = !state.allowScripts;
};
// Wrap on wide screens; on phones code scrolls sideways so tokens stay intact.
const storedWrap = (() => { try { return window.localStorage?.getItem("wrapLines"); } catch { return null; } })();
state.wrapLines = (storedWrap ?? (window.matchMedia?.("(max-width: 767px)").matches ? "false" : "true")) !== "false";
state.toggleWrap = function () {
state.wrapLines = !state.wrapLines;
try { window.localStorage?.setItem("wrapLines", String(state.wrapLines)); } catch { /* storage unavailable */ }
state.codeEditor?.session.setUseWrapMode(state.wrapLines);
};
// Find opens as an overlay at the top right of the code, like an editor.
// Find on rendered Markdown pages: highlight matches inside the reading column.
function mdFindRoot() { return window.document.querySelector(".reading-layout .markdown-body"); }
function clearMdFind() {
const root = mdFindRoot();
if (!root) return;
root.querySelectorAll("mark.md-find").forEach((m) => { m.replaceWith(window.document.createTextNode(m.textContent)); });
root.normalize();
}
state.runMdFind = function () {
clearMdFind();
state.mdFindTotal = 0;
state.mdFindIndex = 0;
const q = (state.mdFindQuery || "").trim();
const root = mdFindRoot();
if (!q || !root) return;
const cs = !!state.mdFindCase;
const needle = cs ? q : q.toLowerCase();
const walker = window.document.createTreeWalker(root, 4);
const nodes = [];
while (walker.nextNode()) if ((cs ? walker.currentNode.nodeValue : walker.currentNode.nodeValue.toLowerCase()).includes(needle)) nodes.push(walker.currentNode);
let total = 0;
for (const node of nodes) {
const text = node.nodeValue, lower = cs ? text : text.toLowerCase();
const frag = window.document.createDocumentFragment();
let last = 0, i;
while ((i = lower.indexOf(needle, last)) !== -1) {
frag.appendChild(window.document.createTextNode(text.slice(last, i)));
const m = window.document.createElement("mark");
m.className = "md-find";
m.textContent = text.slice(i, i + q.length);
frag.appendChild(m);
total++;
last = i + q.length;
}
frag.appendChild(window.document.createTextNode(text.slice(last)));
node.parentNode.replaceChild(frag, node);
}
state.mdFindTotal = total;
if (total) state.stepMdFind(0);
};
state.stepMdFind = function (delta) {
const root = mdFindRoot();
if (!root || !state.mdFindTotal) return;
const marks = root.querySelectorAll("mark.md-find");
state.mdFindIndex = (state.mdFindIndex + delta + marks.length) % marks.length;
marks.forEach((m, i) => m.classList.toggle("is-current", i === state.mdFindIndex));
marks[state.mdFindIndex]?.scrollIntoView({ block: "center", behavior: "smooth" });
};
state.toggleMdFind = function (open) {
state.mdFindOpen = open === undefined ? !state.mdFindOpen : open;
if (!state.mdFindOpen) { clearMdFind(); state.mdFindTotal = 0; return; }
nextTick(() => window.document.querySelector(".md-find-input")?.focus());
};
state.findInFile = function () {
const editor = state.codeEditor;
if (!editor) return;
editor.focus();
editor.execCommand("find");
// Docked under the toolbar: push the code down while the find bar is open.
const dock = () => {
const box = editor.searchBox?.element;
if (!box) return;
editor.renderer.setScrollMargin(52, 12, 0, 0);
if (box._dockObserver) return;
box._dockObserver = new MutationObserver(() => {
editor.renderer.setScrollMargin(box.style.display !== "none" ? 52 : 12, 12, 0, 0);
});
box._dockObserver.observe(box, { attributes: true, attributeFilter: ["style"] });
};
dock();
setTimeout(dock, 150);
};
// Lightweight outline: top-level classes, functions and exported constants.
const symbolPatterns = [
[/^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, "class"],
[/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/, "function"],
[/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/, "function"],
[/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/, "function"],
[/^\s*class\s+([A-Za-z_]\w*)/, "class"],
[/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/, "function"],
[/^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)/, "function"],
[/^\s*(?:public|private|protected|static|\s)+[\w<>\[\],\s]+\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{?\s*$/, "function"],
];
let symbolCache = { content: null, list: [] };
state.symbolsOpen = false;
state.symbols = function () {
if (typeof state.content !== "string" || state.content.length > 2_000_000) return [];
if (symbolCache.content === state.content) return symbolCache.list;
const list = [];
state.content.split("\n").forEach((text, index) => {
for (const [re, kind] of symbolPatterns) {
const m = re.exec(text);
if (m && !/^(if|for|while|switch|return|catch)$/.test(m[1])) { list.push({ name: m[1], kind, line: index + 1 }); break; }
}
});
symbolCache = { content: state.content, list: list.slice(0, 300) };
return symbolCache.list;
};
state.goToSymbol = function (line) {
state.symbolsOpen = false;
const editor = state.codeEditor;
if (!editor) return;
editor.scrollToLine(line - 1, true, true, () => {});
editor.gotoLine(line, 0, true);
window.history.replaceState(null, "", window.location.pathname + window.location.search + "#L" + line);
window.dispatchEvent(new HashChangeEvent("hashchange"));
};
// Go to line: a small popover in the file header (Ace's own prompt ignores the site theme).
state.gotoLine = function () {
if (!state.codeEditor) return;
state.moreOpen = false;
state.gotoError = "";
state.gotoValue = String(state.codeEditor.getCursorPosition().row + 1);
state.gotoOpen = true;
setTimeout(() => { const el = window.document.getElementById("goto-input"); if (el) { el.focus(); el.select(); } }, 0);
};
state.closeGoto = function () {
state.gotoOpen = false;
state.gotoError = "";
state.codeEditor?.focus();
};
state.submitGoto = function () {
const editor = state.codeEditor;
if (!editor) return;
const m = /^\s*:?\s*(\d+)\s*(?:[:,]\s*(\d+))?\s*$/.exec(state.gotoValue || "");
const lines = editor.session.getLength();
if (!m || +m[1] < 1 || +m[1] > lines) { state.gotoError = "Enter a line from 1 to " + lines; return; }
editor.gotoLine(+m[1], m[2] ? Math.max(0, +m[2] - 1) : 0, true);
state.gotoOpen = false;
state.gotoError = "";
editor.focus();
};
// Root listing above the README: folders first, then files.
state.rootEntries = function () {
return [...(state.files || [])].sort((a, b) => {
const af = a.size == null, bf = b.size == null;
if (af !== bf) return af ? -1 : 1;
return a.name.localeCompare(b.name);
});
};
state.formatSize = function (bytes) {
if (bytes == null) return "";
if (bytes < 1024) return bytes + " B";
const kb = bytes / 1024;
return kb < 1024 ? kb.toFixed(1) + " KB" : (kb / 1024).toFixed(1) + " MB";
};
state.fileSize = function (path) {
if (!path) return null;
const clean = path.replace(/^\/+/, "");
const entry = (state.files || []).find((f) => (f.path ? f.path + "/" : "") + f.name === clean);
return entry && entry.size != null ? entry.size : null;
};
state.revealFolder = function (name) {
state.sidebarHidden = false;
state.sidebarCollapsed = false;
nextTick(() => {
const link = window.document.querySelector('.files a[data-path="/' + (window.CSS?.escape ? window.CSS.escape(name) : name) + '"]');
if (!link) return;
if (!link.closest("li")?.classList.contains("open")) link.click();
link.scrollIntoView({ block: "nearest" });
link.focus();
});
};
state.redactionCount = function () {
if (typeof state.content !== "string") return 0;
const text = state.type === "html" ? state.content.replace(/<[^>]*>/g, " ") : state.content;
return (text.match(REDACTION_RE) || []).length;
};
state.renderRedacted = slotRedactions;
// Reading mode: slugged heading ids + an "On this page" outline (like Mintlify/Stripe docs).
const slug = (t) => t.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").slice(0, 60) || "section";
state.renderReadme = function (html) {
const out = state.renderRedacted(html);
if (!out || typeof window.DOMParser !== "function") return out;
const doc = new window.DOMParser().parseFromString("<div>" + out + "</div>", "text/html");
const seen = {};
doc.querySelectorAll("h1, h2, h3").forEach((h) => {
let id = h.id || slug(h.textContent);
if (seen[id]) id += "-" + (++seen[id]); else seen[id] = 1;
h.id = id;
});
return doc.body.firstChild.innerHTML;
};
let tocCache = { content: null, list: [] };
state.readmeToc = function () {
if (state.type !== "html" || typeof state.content !== "string") return [];
if (tocCache.content === state.content) return tocCache.list;
const doc = new window.DOMParser().parseFromString("<div>" + state.renderReadme(state.sanitize ? state.sanitize(state.content) : state.content) + "</div>", "text/html");
const list = [...doc.querySelectorAll("h2, h3")].map((h) => ({ id: h.id, text: h.textContent.trim(), level: h.tagName === "H3" ? 3 : 2 })).slice(0, 40);
tocCache = { content: state.content, list };
return list;
};
state.activeHeading = null;
listen(window, "scroll", () => {
const toc = state.readmeToc ? state.readmeToc() : [];
if (!toc.length) return;
// Active = the first heading visible below the sticky chrome; if none is on screen,
// the last heading already scrolled past (the reader is inside that section).
const chrome = 150;
let current = "", firstVisible = "";
for (const h of toc) {
const el = window.document.getElementById(h.id);
if (!el) continue;
const top = el.getBoundingClientRect().top;
if (top < chrome) current = h.id;
else if (!firstVisible && top < window.innerHeight - 80) firstVisible = h.id;
}
if (firstVisible) current = firstVisible;
if (state.activeHeading !== current) state.activeHeading = current;
}, { capture: true, passive: true });
state.scrollToHeading = function (id) {
if (!id) {
const sc = window.document.querySelector(".explorer-content, .file-card-body, .main-pane");
(sc || window).scrollTo({ top: 0, behavior: "smooth" });
state.activeHeading = "";
return;
}
const el = window.document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
state.activeHeading = id;
};
state.redactParts = function (text) {
const out = []; let last = 0; let m; const re = /\bXXXX-(\d+)\b/g;
text = String(text || "");
while ((m = re.exec(text))) { if (m.index > last) out.push({ t: text.slice(last, m.index) }); out.push({ n: m[1] }); last = m.index + m[0].length; }
if (last < text.length) out.push({ t: text.slice(last) });
return out;
};
state.pathCopied = false;
state.copyPath = async function () {
if (!window.navigator.clipboard?.writeText) return;
await window.navigator.clipboard.writeText(state.filePath || "");
state.pathCopied = true;
setTimeout(() => { state.pathCopied = false; }, 1600);
};
state.contentCopied = false;
state.copyContent = async function () {
if (typeof state.content !== "string" || !window.navigator.clipboard?.writeText) return;
// Rendered Markdown: copy the source, not the HTML.
let text = state.content;
if (state.type === "html" && state.url) {
try { text = await (await window.fetch(state.url, { credentials: "same-origin" })).text(); } catch (_) { return; }
}
await window.navigator.clipboard.writeText(text);
state.contentCopied = true;
setTimeout(() => { state.contentCopied = false; }, 1600);
};
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) {
const generation = contentGeneration;
if (!path) {
state.type = "error";
state.content = "no_file_selected";
return;
}
const originalType = state.type;
// Load the editor alongside content, not after the response finishes.
// Its mounting path still reports errors and can retry a failed load.
if (originalType === "code" || originalType === "text") loadEditor().catch(() => {});
state.type = "loading";
state.content = "loading";
state.fileLoadingSlow = false;
state.fileReceivedBytes = 0;
const canceller = promises.defer();
contentCanceller = canceller;
loadingNoticeTimer = timers.timeout(() => {
if (!destroyed && generation === contentGeneration && state.type === "loading") state.fileLoadingSlow = true;
}, 1500);
// 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/${state.repoId}/file/${encodePathForUrl(path)}?v=` +
sha,
{
timeout: canceller.promise,
onDownloadProgress: bytes => {
if (!destroyed && generation === contentGeneration) state.fileReceivedBytes = bytes;
},
transformResponse: (data) => {
return data;
},
}
)
.then(
(res) => {
if (destroyed || generation !== contentGeneration) return;
cancelContentLoad();
state.type = originalType;
state.content = res.data;
if (state.content == "") {
state.content = null;
}
if (state.type == "md") {
state.content = renderMD(res.data, location.url() + "/../");
state.type = "html";
}
if (state.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,
});
state.content = DOMPurify.sanitize(orgHTMLDocument.toString());
state.type = "html";
}
if (
state.type == "code" &&
res.headers("content-type") == "application/octet-stream"
) {
state.type = "binary";
state.content = "binary";
}
timers.timeout(() => {
Prism.highlightAll();
}, 50);
},
(err) => {
if (destroyed || generation !== contentGeneration) return;
cancelContentLoad();
state.type = "error";
state.content = viewerErrorCode(err);
}
);
}
function updateContent() {
contentGeneration++;
cancelContentLoad();
state.content = "";
state.file = getSelectedFile();
let fileVersion = "0";
if (state.file && state.file.sha) {
fileVersion = state.file.sha;
}
state.url = `/api/repo/${state.repoId}/file/${encodePathForUrl(
state.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 = state.filePath.substring(
0,
state.filePath.lastIndexOf("/") + 1
);
state.fileBaseUrl = `/api/repo/${state.repoId}/file/${
dirPath ? encodePathForUrl(dirPath) : ""
}`;
state.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.
state.allowScripts = false;
let extension = state.filePath.toLowerCase();
const extensionIndex = extension.lastIndexOf(".");
if (extensionIndex > -1) {
extension = extension.substring(extensionIndex + 1);
}
const languageNames = { javascript: "JavaScript", typescript: "TypeScript", tsx: "TSX", jsx: "JSX", python: "Python", java: "Java", c_cpp: "C/C++", csharp: "C#", golang: "Go", rust: "Rust", ruby: "Ruby", php: "PHP", html: "HTML", css: "CSS", scss: "SCSS", json: "JSON", yaml: "YAML", markdown: "Markdown", sh: "Shell", dockerfile: "Dockerfile", xml: "XML", sql: "SQL", kotlin: "Kotlin", swift: "Swift", scala: "Scala", r: "R", julia: "Julia", latex: "LaTeX", toml: "TOML", text: "" };
const detectedMode = getMode(extension);
state.languageName = languageNames[detectedMode] ?? (detectedMode ? detectedMode.charAt(0).toUpperCase() + detectedMode.slice(1) : "");
state.aceOption = {
readOnly: true,
useWrapMode: true,
showGutter: true,
theme: "chrome",
useSoftTab: true,
tabSize: 2,
fontSize: window.matchMedia?.("(max-width: 767px)").matches ? 12 : 13,
keyBinding: "vscode",
fullLineSelection: true,
highlightActiveLine: false,
highlightGutterLine: false,
cursor: "hide",
showInvisibles: false,
showIndentGuides: false,
showPrintMargin: false,
highlightSelectedWord: false,
enableBehaviours: true,
fadeFoldWidgets: false,
mode: getMode(extension),
onLoad: function (_editor) {
state.codeEditor = markRaw(_editor);
{
// Render each XXXX-n as its own "redacted" token so the text layer
// draws it exactly like the Markdown chip (teal text on a teal tint).
const session = _editor.session;
const origGetTokens = session.getTokens.bind(session);
const re = /\bXXXX-\d+\b/g;
session.getTokens = function (row) {
const tokens = origGetTokens(row);
if (!tokens.some((t) => t.value.indexOf("XXXX-") !== -1)) return tokens;
const out = [];
for (const t of tokens) {
if (t.value.indexOf("XXXX-") === -1) { out.push(t); continue; }
let last = 0;
re.lastIndex = 0;
let m;
while ((m = re.exec(t.value))) {
if (m.index > last) out.push({ type: t.type, value: t.value.slice(last, m.index) });
out.push({ type: "redacted.n" + m[0].slice(5), value: m[0] });
last = m.index + m[0].length;
}
if (last < t.value.length) out.push({ type: t.type, value: t.value.slice(last) });
}
return out;
};
// Each redacted token shows its term number inside the slot.
if (!window.document.getElementById("redact-index-rules")) {
const st = window.document.createElement("style");
st.id = "redact-index-rules";
st.textContent = Array.from({ length: 99 }, (_, i) => `.ace_redacted.ace_n${i + 1}::after{content:"${i + 1}"}`).join("");
window.document.head.appendChild(st);
}
_editor.renderer.updateFull(true);
}
_editor.session.setUseWrapMode(state.wrapLines !== false);
// The right-edge fade is a cue only while there is more code to the right.
const card = _editor.container.closest?.(".file-card");
const syncEdge = () => {
if (!card) return;
const r = _editor.renderer;
const max = (r.layerConfig?.width || 0) - (r.$size?.scrollerWidth || 0);
card.classList.toggle("at-right-edge", max <= 2 || r.getScrollLeft() >= max - 2);
};
_editor.session.on("changeScrollLeft", syncEdge);
_editor.renderer.on("afterRender", syncEdge);
// On phones code scrolls sideways; keep the scrollbar visible as a cue.
if (window.matchMedia?.("(max-width: 767px)").matches) _editor.setOption("hScrollBarAlwaysVisible", true);
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) {
timers.timeout(() => {
_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();
});
const removeHashListener = listen(window, "hashchange", () => applyHashFromUrl(false));
_editor.setFontSize(state.aceOption.fontSize);
_editor.setOptions({
fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
});
_editor.container.style.lineHeight = "20px";
_editor.renderer.setPadding(16);
_editor.renderer.setScrollMargin(12, 12, 0, 0);
_editor.renderer.updateFontSize();
_editor.setReadOnly(state.aceOption.readOnly);
_editor.setKeyboardHandler(state.aceOption.keyBinding);
_editor.setSelectionStyle(
state.aceOption.fullLineSelection ? "line" : "text"
);
_editor.setOption("displayIndentGuides", false);
_editor.setHighlightActiveLine(
state.aceOption.highlightActiveLine
);
if (state.aceOption.cursor == "hide") {
_editor.renderer.$cursorLayer.element.style.display = "none";
}
_editor.setHighlightGutterLine(
state.aceOption.highlightGutterLine
);
_editor.setShowInvisibles(state.aceOption.showInvisibles);
_editor.setDisplayIndentGuides(state.aceOption.showIndentGuides);
_editor.renderer.setShowPrintMargin(
state.aceOption.showPrintMargin
);
_editor.setHighlightSelectedWord(
state.aceOption.highlightSelectedWord
);
_editor.session.setUseSoftTabs(state.aceOption.useSoftTab);
_editor.session.setTabSize(state.aceOption.tabSize);
_editor.setBehavioursEnabled(state.aceOption.enableBehaviours);
_editor.setFadeFoldWidgets(state.aceOption.fadeFoldWidgets);
// Ctrl/Cmd+F searches inside the file (Ace searchbox), like GitHub's blob view.
const removeFindListener = listen(window, "keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "g" && document.body.contains(_editor.container)) {
event.preventDefault();
state.gotoLine();
return;
}
if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "z" && document.body.contains(_editor.container)) {
event.preventDefault();
state.toggleWrap();
return;
}
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "f" && document.body.contains(_editor.container)) {
event.preventDefault();
state.findInFile();
}
});
return () => { removeHashListener(); removeFindListener(); };
},
};
if (state.isDarkMode) {
state.aceOption.theme = "nord_dark";
}
state.type = getType(extension);
if (state.type == "pdf") {
// The viewer streams the file itself from state.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.
state.content = "pdf";
return;
}
getContent(state.filePath, state.file);
}
state.isSourceAccessError = () => ["repo_not_found", "token_expired", "github_app_access_required",
"github_app_reconnect_required", "repo_saml_enforcement", "repo_access_limited"].includes(state.content);
state.retryContent = () => init();
function init() {
contentGeneration++;
cancelContentLoad();
state.files = [];
state.content = null;
state.fileCounts = null;
listingGeneration++;
countRequests = new Set();
countsUnavailable = false;
state.fileSearchQuery = "";
state.onFileSearchChange();
state.repoId = params.repoId;
state.type = "loading";
state.filePath = params.path || "";
state.paths = state.filePath.split("/");
const repoId = state.repoId;
getOptions(function (options) {
if (state.filePath) {
// File URLs are independently authorized by the content endpoint.
// The tree is navigation metadata, not a prerequisite for reading.
updateContent();
const directories = [...new Set(state.paths.map((_, i) => state.paths.slice(0, i).join("/")))];
promises.all(directories.map(path => state.getFiles(path, true)));
return;
}
var chain = promises.resolve();
for (let i = 0; i < state.paths.length; i++) {
const path = i > 0 ? state.paths.slice(0, i).join("/") : "";
chain = chain.then(function () {
return state.getFiles(path);
}).then(function () {
if (state.type === "error") {
return promises.reject("error");
}
});
}
chain.then(function () {
if (destroyed || repoId !== state.repoId) return;
if (state.files.length == 1 && state.files[0].name == "") {
state.files = [];
state.type = "empty";
} else {
selectFile();
updateContent();
}
});
});
}
init();
};
export const pullRequestController = function (state, http, location, params, html) {
async function getOption(callback) {
http.get(`/api/pr/${state.pullRequestId}/options`).then(
(res) => {
state.options = res.data;
if (state.options.url) {
// the repository is expired with redirect option
window.location = state.options.url;
return;
}
if (callback) {
callback(res.data);
}
},
(err) => {
state.type = "error";
state.content = err.data.error;
}
);
}
async function getPullRequest(callback) {
http.get(`/api/pr/${state.pullRequestId}/content`).then(
(res) => {
state.details = res.data;
state.tabState = { active: res.data.diff ? "diff" : "comments" };
if (callback) {
callback(res.data);
}
},
(err) => {
state.type = "error";
state.content = err.data.error;
}
);
}
function init() {
state.pullRequestId = params.pullRequestId;
state.type = "loading";
getOption((_) => {
getPullRequest();
});
}
init();
};
export const gistController = function (state, http, location, params, html) {
async function getOption(callback) {
http.get(`/api/gist/${state.gistId}/options`).then(
(res) => {
state.options = res.data;
if (state.options.url) {
window.location = state.options.url;
return;
}
if (callback) callback(res.data);
},
(err) => {
state.type = "error";
state.content = err.data.error;
}
);
}
async function getGist(callback) {
http.get(`/api/gist/${state.gistId}/content`).then(
(res) => {
state.details = res.data;
// Choose the visible tab after the asynchronous content arrives.
const hasFiles = res.data && res.data.files && res.data.files.length;
state.tabState = { active: hasFiles ? "files" : "comments" };
if (callback) callback(res.data);
},
(err) => {
state.type = "error";
state.content = err.data.error;
}
);
}
function init() {
state.gistId = params.gistId;
state.type = "loading";
getOption(() => { getGist(); });
}
init();
};
export const conferencesController = function (state, http, location) {
state.watch("user.status", () => {
if (state.user == null) {
location.url("/");
}
});
if (state.user == null) {
location.url("/");
}
state.conferences = [];
state.loading = true;
state.error = "";
state.search = "";
const conferencesPrefsKey = "conferences.filterPrefs";
const conferencesPrefDefaults = {
filters: { status: { ready: true, expired: false, removed: false } },
orderBy: "name",
};
const savedConferencesPrefs = loadFilterPrefs(conferencesPrefsKey) || {};
state.filters = {
status: Object.assign(
{},
conferencesPrefDefaults.filters.status,
(savedConferencesPrefs.filters && savedConferencesPrefs.filters.status) || {}
),
};
state.orderBy = savedConferencesPrefs.orderBy || conferencesPrefDefaults.orderBy;
state.watch("orderBy", () => {
saveFilterPrefs(conferencesPrefsKey, {
filters: state.filters,
orderBy: state.orderBy,
});
});
state.watch(
"filters",
() => {
saveFilterPrefs(conferencesPrefsKey, {
filters: state.filters,
orderBy: state.orderBy,
});
},
true
);
state.removeConference = function (conf) {
if (conf.removing) return;
if (
confirm(
`Are you sure that you want to remove the conference ${conf.name}? All the repositories linked to this conference will expire.`
)
) {
conf.removing = true;
const toast = reactive({
title: `Removing ${conf.name}...`,
date: new Date(),
body: `The conference ${conf.name} is going to be removed.`,
});
state.addToast(toast);
http.delete(`/api/conferences/${conf.conferenceID}`).then(() => {
toast.title = `${conf.name} is removed.`;
toast.body = `The conference ${conf.name} is removed.`;
state.loadConferences();
}).catch(() => {
toast.title = "Unable to remove conference";
toast.body = "Please try again. The conference has not been removed.";
}).finally(() => { conf.removing = false; });
}
};
state.loadConferences = () => {
state.loading = true;
state.error = "";
return http.get("/api/conferences/").then(
(res) => {
state.conferences = res.data || [];
},
(err) => {
state.error = "Unable to load your conferences. Please try again.";
}
).finally(() => { state.loading = false; });
};
state.loadConferences();
state.conferenceFilter = (conference) => {
if (state.filters.status[conference.status] == false) return false;
const query = state.search.trim().toLowerCase();
if (!query) return true;
if ((conference.name || "").toLowerCase().includes(query)) return true;
if ((conference.conferenceID || "").toLowerCase().includes(query)) return true;
return false;
};
};
export const newConferenceController = function (state, http, location, params) {
state.watch("user.status", () => {
if (state.user == null) {
location.url("/");
}
});
if (state.user == null) {
location.url("/");
}
state.plans = [];
state.editionMode = false;
state.saving = false;
state.error = "";
function getConference() {
return http
.get("/api/conferences/" + params.conferenceId)
.then((res) => {
state.options = { ...state.options, ...res.data, billing: res.data.billing || {} };
state.options.startDate = new Date(state.options.startDate);
state.options.endDate = new Date(state.options.endDate);
});
}
if (params.conferenceId) {
state.editionMode = true;
getConference();
}
function getPlans() {
http.get("/api/conferences/plans").then((res) => {
state.plans = res.data;
state.plan = state.plans.filter(
(f) => f.id == state.options.plan.planID
)[0];
});
}
getPlans();
const start = new Date();
start.setDate(1);
start.setMonth(start.getMonth() + 1);
const end = new Date(start);
end.setTime(start.getTime() + 179 * 86400000);
state.options = {
billing: {},
startDate: start,
endDate: end,
plan: {
planID: "free_conference",
},
options: {
link: true,
image: true,
pdf: true,
notebook: true,
update: true,
page: true,
},
};
state.plan = null;
state.watch("options.plan.planID", () => {
state.plan = state.plans.filter(
(f) => f.id == state.options.plan.planID
)[0];
});
function resetValidity() {
state.conference.name.setValidity("required", true);
state.conference.conferenceID.setValidity("pattern", true);
state.conference.conferenceID.setValidity("required", true);
state.conference.conferenceID.setValidity("used", true);
state.conference.startDate.setValidity("required", true);
state.conference.startDate.setValidity("invalid", true);
state.conference.endDate.setValidity("required", true);
state.conference.endDate.setValidity("invalid", true);
state.conference.setValidity("error", true);
}
function displayErrorMessage(message) {
switch (message) {
case "conf_name_missing":
state.conference.name.setValidity("required", false);
break;
case "conf_id_missing":
state.conference.conferenceID.setValidity("required", false);
break;
case "conf_id_format":
state.conference.conferenceID.setValidity("pattern", false);
break;
case "conf_id_used":
state.conference.conferenceID.setValidity("used", false);
break;
case "conf_start_date_missing":
state.conference.startDate.setValidity("required", false);
break;
case "conf_end_date_missing":
state.conference.endDate.setValidity("required", false);
break;
case "conf_start_date_invalid":
state.conference.startDate.setValidity("invalid", false);
break;
case "conf_end_date_invalid":
state.conference.endDate.setValidity("invalid", false);
break;
default:
state.conference.setValidity("error", false);
break;
}
}
state.submit = function () {
if (state.saving) return;
state.saving = true;
state.error = "";
const submitted = JSON.parse(JSON.stringify(state.options));
const toast = reactive({
title: `Creating ${submitted.name}...`,
date: new Date(),
body: `The conference ${submitted.conferenceID} is in creation.`,
});
if (state.editionMode) {
toast.title = `Updating ${submitted.name}...`;
toast.body = `The conference '${submitted.conferenceID}' is updating.`;
}
state.addToast(toast);
resetValidity();
http
.post(
"/api/conferences/" +
(state.editionMode ? submitted.conferenceID : ""),
submitted
)
.then(
() => {
if (!state.editionMode) {
toast.title = `${submitted.name} created`;
toast.body = `The conference '${submitted.conferenceID}' is created.`;
} else {
toast.title = `${submitted.name} updated`;
toast.body = `The conference '${submitted.conferenceID}' is updated.`;
}
location.url("/conference/" + submitted.conferenceID);
},
(error) => {
displayErrorMessage(error.data?.error);
state.error = "Unable to save the conference. Please correct any highlighted fields and try again.";
state.removeToast(toast);
}
).finally(() => { state.saving = false; });
};
};
export const conferenceController = function (state, http, location, params) {
setupBilling(state, http, () => getConference(), { confirmedMessage: "Payment status checked. Your conference is ready for submissions once payment is confirmed." });
state.watch("user.status", () => {
if (state.user == null) {
location.url("/");
}
});
if (state.user == null) {
location.url("/");
}
state.conference = null;
state.search = "";
state.filters = {
status: { ready: true, expired: false, removed: false },
};
state.orderBy = "-anonymizeDate";
state.repoFiler = (repo) => {
if (state.filters.status[repo.status] == false) return false;
const query = state.search.trim().toLowerCase();
if (!query) return true;
if ((repo.source?.fullName || "").toLowerCase().includes(query)) return true;
if ((repo.repoId || "").toLowerCase().includes(query)) return true;
return false;
};
function getConference() {
http
.get("/api/conferences/" + params.conferenceId)
.then((res) => {
state.conference = res.data;
});
}
getConference();
};
export const connectionsController = function (state, http) {
state.notificationEmail = "";
state.emailSaved = false;
state.saveNotificationEmail = async () => {
state.busy = true;
state.emailSaved = false;
state.connectionError = "";
try {
const res = await http.post("/github/connections/email", { email: state.notificationEmail },
{ headers: { "X-CSRF-Token": state.connections.csrf } });
state.notificationEmail = res.data.notificationEmail;
if (state.user) state.user.notificationEmail = res.data.notificationEmail;
state.emailSaved = true;
} catch (error) { state.connectionError = error.data?.error || "Unable to save email address."; }
finally { state.busy = false; }
};
state.connections = null;
state.connectionError = "";
state.busy = false;
state.loadConnections = () => http.get("/github/connections").then(res => {
state.connections = res.data;
state.notificationEmail = res.data.notificationEmail || "";
}).catch(error => { state.connectionError = error.data?.error || "Unable to load connections."; });
state.changeConnection = async (resource, connection, preview) => {
state.busy = true;
state.connectionError = "";
try {
const result = await http.post("/github/connections/migrate", { type: resource.type, id: resource.id, connection, preview },
{ headers: { "X-CSRF-Token": state.connections.csrf } });
if (preview) resource.eligible = result.data.eligible;
else await state.loadConnections();
} catch (error) { state.connectionError = error.data?.error || "Unable to change connection."; }
finally { state.busy = false; }
};
state.disconnectOAuth = async () => {
state.busy = true;
try {
await http.post("/github/connections/disconnect-oauth", {}, { headers: { "X-CSRF-Token": state.connections.csrf } });
await state.loadConnections();
} catch (error) { state.connectionError = error.data?.error || "Unable to disconnect OAuth."; }
finally { state.busy = false; }
};
state.loadConnections();
};
export const signinController = function (state, http) {
state.accountRecovery = false;
http.get("/github/account-recovery").then(res => { state.accountRecovery = res.data.required; }).catch(() => {});
};