Files
anonymous_github/public/script/app.js
T
Thomas DurieuxandClaude Opus 5.5 6226acff80 fix(ui): show a vertical file tree in the reviewer preview
The "What reviewers will see" window now has a sidebar like the explorer's
instead of the one-row file strip: folders first, then files, with the
explorer's tree rows, icons and teal selection on the README. Folders expand
from the loaded file list. The sidebar is 24% of the window (180-240px),
tinted full height, and scrolls apart from the document. Phones hide it and
show the explorer's "Files" button in the file bar. Loading shows skeleton
rows; without a file list the sidebar shows the README only.

Removes the strip fitter and the dead rail/strip CSS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 03:54:56 +00:00

4909 lines
221 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { setupBilling, readBilling, isRepositoryAddon } 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 { statusGroup } from "./admin.js";
import { humanTime, dateEcho, shortDate } from "./formatters.js";
import { hangCodeLines } from "./components.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");
// Badges and icons become a compact inline chip; only images that declare a
// large size keep a block placeholder so the page layout stays the same.
const w = parseInt(img.getAttribute("width"), 10) || 0;
const h = parseInt(img.getAttribute("height"), 10) || 0;
ph.className = w >= 200 || h >= 120 ? "img-unavailable is-large" : "img-unavailable";
// Never show raw mask tokens: the label is fixed, and the alt text (with masks spelled
// out as "[redacted term N]") is only a tooltip.
ph.textContent = "Image unavailable";
ph.title = img.alt ? "Image unavailable: " + img.alt.replace(/\bXXXX-(\d+)\b/g, "[redacted term $1]") : "Image unavailable";
ph.setAttribute("role", "img");
ph.setAttribute("aria-label", ph.title);
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 = "";
// Keep the full term in the text (hidden): Prism re-highlights from
// textContent, and a second pass would otherwise turn "XXXX-1" into "1".
const src = mark.ownerDocument.createElement("span");
src.className = "redact-src";
src.textContent = m[0].slice(0, m[0].length - m[1].length);
mark.appendChild(src);
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 = new URLSearchParams(window.location.search).get("billing") === "success";
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");
};
// Header fitter: the media queries in style.css (F2) handle the usual
// widths; this catches what they can't predict (wider fonts, an Upgrade
// link, long usernames). It adds .nav-fit-1 (extras into the avatar menu),
// then .nav-fit-2 (tighter gaps) while the expanded bar overflows.
const headerListen = createListeners();
const headerTimers = createTimers();
let headerFitQueued = false;
const fitHeader = () => {
headerFitQueued = false;
const nav = document.querySelector("nav.navbar.navbar-expand-lg");
if (!nav) return;
nav.classList.remove("nav-fit-1", "nav-fit-2");
if (window.innerWidth < 992) return;
const collapse = nav.querySelector(".navbar-collapse");
const overflows = () => nav.scrollWidth > nav.clientWidth + 1 || (!!collapse && collapse.scrollWidth > collapse.clientWidth + 1);
if (overflows()) nav.classList.add("nav-fit-1");
if (overflows()) nav.classList.add("nav-fit-2");
};
const queueHeaderFit = () => {
if (headerFitQueued) return;
headerFitQueued = true;
nextTick(() => headerTimers.timeout(fitHeader, 0));
};
headerListen(window, "resize", queueHeaderFit);
state.on("routeChange", queueHeaderFit);
state.on("billing:updated", queueHeaderFit);
if (document.fonts?.ready) document.fonts.ready.then(queueHeaderFit).catch(() => {});
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;
// One syntax palette (style.css "R12.6") colours Prism and Ace in both
// themes; prism.css only supplies layout, so dark mode needs no theme file.
const lightPrismLink = "/css/prism.css";
$("link[href='/css/prism-okaidia.css']").remove();
if (on) {
$("body").addClass("dark-mode");
$(`link[href='${lightPrismLink}']`).remove();
} else {
$("body").removeClass("dark-mode");
if (!document.querySelector(`link[href='${lightPrismLink}']`)) {
const link = document.createElement("link");
link.href = lightPrismLink;
link.rel = "stylesheet";
document.head.append(link);
}
}
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;
queueHeaderFit();
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();
// Search: filter the static answers in place. Every word must appear in the question or its answer.
// Matching answers are shown open while searching; categories without a match are hidden.
state.faqQuery = "";
state.faqResults = 0;
const normalize = (text) => (text || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\s+/g, " ");
state.filterFaq = () => {
const words = normalize(state.faqQuery).trim().split(" ").filter(Boolean);
let count = 0;
document.querySelectorAll(".faq-section .panel-group").forEach((group) => {
let groupCount = 0;
group.querySelectorAll(":scope > .panel").forEach((panel) => {
const text = panel.dataset.faqText || (panel.dataset.faqText = normalize(panel.textContent));
const match = words.every((w) => text.includes(w));
panel.classList.toggle("faq-hit", words.length > 0 && match);
panel.hidden = words.length > 0 && !match;
if (match) groupCount++;
});
const hide = words.length > 0 && groupCount === 0;
group.hidden = hide;
const head = group.previousElementSibling;
if (head?.classList.contains("faq-cat")) head.hidden = hide;
count += groupCount;
});
state.faqResults = words.length ? count : 0;
};
state.clearFaqSearch = () => {
state.faqQuery = "";
state.filterFaq();
document.getElementById("faq-search-input")?.focus();
};
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;
// Yearly is the better deal, so it is shown first.
state.profilePlan = "author_annual";
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");
// The Paper pack covers the usual case, so it is listed first.
state.profileAddonPlans = () => (state.billing?.plans || []).filter(p => isRepositoryAddon(p.id))
.sort((a, b) => Number(b.id === "repo_pack") - Number(a.id === "repo_pack"));
state.profileAddonId = "repo_pack";
state.profilePackPlan = () => (state.billing?.plans || []).find(p => p.id === "repo_pack");
state.profileSlotPlan = () => (state.billing?.plans || []).find(p => p.id === "repo_slot");
state.profileRepositories = () => (state.billing?.repositories || []).filter(r => r.status !== "removed");
state.openProfileAddon = (planId) => {
if (planId) state.profileAddonId = planId;
state.showRepositoryPass = true;
state.billingRepository = "";
nextTick(() => {
const panel = document.getElementById("settings-addons-title");
panel?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
panel?.focus?.({ preventScroll: true });
});
};
const addonOrder = ["repo_slot", "author_pass", "repo_extend", "repo_never"];
state.profileSingleAddons = () => state.profileAddonPlans().filter(p => p.id !== "repo_pack")
.sort((a, b) => addonOrder.indexOf(a.id) - addonOrder.indexOf(b.id));
state.profileSingleId = "repo_slot";
state.profileSingleAddon = () => state.profileSingleAddons().find(p => p.id === state.profileSingleId) || state.profileSingleAddons()[0];
state.profileCheapestAddon = () => [...state.profileSingleAddons()].sort((a, b) => (a.amount ?? Infinity) - (b.amount ?? Infinity))[0];
state.profileAuthorPlansByValue = () => [...state.profileAuthorPlans()].sort((a, b) => Number(a.interval === "year") - Number(b.interval === "year"));
// One line for a pack's three parts.
state.profilePackSummary = p => p.parts.map(part => part.planId === "repo_slot" ? "Extra repository"
: `${state.profileGrantName(part)} until ${state.billingDate(part.until)}`).join(" · ");
state.profileAddonFeatures = id => ({
repo_pack: ["One more repository, outside your free ones", `Its own ${state.billingBytes(state.profileSlotPlan()?.storage || state.profilePassPlan()?.storage)} of storage`, "Automatic updates for 180 days", "Online for up to a year", "No subscription"],
repo_slot: ["One more repository, outside your free ones", `Its own ${state.billingBytes(state.profileSlotPlan()?.storage)} of storage`, "Valid while the repository exists", "Refresh it by hand"],
author_pass: ["Automatic updates for 180 days", `Its own ${state.billingBytes(state.profilePassPlan()?.storage)} of storage`, "No subscription"],
repo_extend: ["Online for up to one more year", "Buy again to add another year", "No subscription"],
repo_never: ["The link never expires", "No subscription", "Ends only if the payment is refunded"],
}[id] || []);
state.profileAddonBlurb = id => ({ repo_slot: "One more repository", author_pass: "Auto-updates for 180 days",
repo_extend: "Online one more year", repo_never: "The link never expires" }[id] || "");
state.profileAuthorPurchase = () => (state.billing?.purchases || []).find(p => p.recurring && ["active", "overdue"].includes(p.status));
state.profileRepositoryExpiry = repo => repo.status === "expired" ? "expired"
: repo.expirationMode === "never" ? "never expires" : repo.expirationDate ? "expires " + state.billingDate(repo.expirationDate) : "no date";
// Annual saving against twelve monthly payments, in whole percent.
state.profileAnnualSavings = () => {
const monthly = state.profileAuthorPlans().find(p => p.interval === "month")?.amount;
const annual = state.profileAuthorPlans().find(p => p.interval === "year")?.amount;
return monthly && annual && annual < monthly * 12 ? Math.round((1 - annual / (monthly * 12)) * 100) : 0;
};
state.profilePurchases = active => (state.billing?.purchases || []).filter(p => ["active", "overdue"].includes(p.status) === active);
state.profilePurchaseStatus = p => ({ active: "Active", overdue: "Payment overdue", ended: "Ended", refunded: "Refunded", disputed: "Disputed" }[p.status] || p.status);
// Plain wording for how long a purchase lasts; the "no end" sentinel is never shown.
state.profilePurchaseTerm = p => {
const ended = p.status && !["active", "overdue"].includes(p.status);
if (p.recurring) return ended ? (p.until ? "Ended " + state.billingDate(p.until) : "") : (p.cancelAtPeriodEnd ? "Ends " : "Renews ") + state.billingDate(p.until);
if (p.planId === "repo_pack") {
const dates = p.parts.map(part => part.until).filter(Boolean).map(d => new Date(d).getTime());
return ended || !dates.length ? "" : "Until " + state.billingDate(new Date(Math.max(...dates)));
}
if (!p.until) return ended ? "" : p.planId === "repo_slot" ? "While the repository exists" : "No expiration";
return (ended || new Date(p.until).getTime() < Date.now() ? "Ended " : "Until ") + state.billingDate(p.until);
};
state.profileAddon = () => state.profileAddonPlans().find(p => p.id === state.profileAddonId) || state.profileAddonPlans()[0];
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());
// The cheapest conference package, for the "from" price.
state.profileConferencePlan = () => (state.billing?.plans || []).filter(p => ["conference_workshop", "conference", "conference_large"].includes(p.id) && p.amount != null)
.sort((a, b) => a.amount - b.amount)[0];
state.profileGrantName = grant => ({ author_monthly: "Author monthly", author_annual: "Author annual", author_pass: "Live updates", repo_extend: "Extended expiration", repo_never: "Never expire", repo_slot: "Extra repository", repo_pack: "Paper pack", conference: "Conference package", conference_workshop: "Workshop package", conference_large: "Large conference package" }[grant.planId] || grant.planId);
setupBilling(state, http, () => quotaService.load().then(quota => { state.quota = quota; }, console.error), {
portalMessage: () => {
const grant = state.profileAuthorGrant();
if (!grant) return "Your billing details are up to date.";
return grant.cancelAtPeriodEnd ? `Renewal cancelled. Your access continues until ${state.billingDate(grant.until)}.` : `Your subscription renews on ${state.billingDate(grant.until)}.`;
},
});
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" },
];
// True once stats have loaded and every total is zero (fresh or beta deployments).
state.statsEmpty = () => !!state.stat && state.cards.every((c) => !c.total);
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.idleText = item => {
const t = humanTime(item?.anonymizeDate || item?.lastView) || "";
if (/ ago$/.test(t)) return "No activity for " + t.replace(/ ago$/, "");
return "No activity since " + t.replace(", " + new Date().getFullYear(), "");
};
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: "Preparing",
error: "Error",
expired: "Expiring / expired",
removed: "Removing / 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: {
// Authors open on their live work; removed links sit behind the
// "Removed N" view instead of burying the active projects.
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 };
// "Nov 3" this year, "Nov 3, 2025" otherwise (shared with conference detail).
item._expiry.label = shortDate(item.options.expirationDate) || undefined;
} 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 };
// A live link that expires within a week gets a relative, highlighted "in 21 hours" / "in 3 days".
const left = new Date(item.options.expirationDate).getTime() - Date.now();
if (left > 0 && left <= 7 * 24 * 3600 * 1000) {
const hours = Math.max(1, Math.round(left / 3600000));
const days = Math.round(left / 86400000);
item._expiry.soon = hours < 48
? "in " + hours + (hours === 1 ? " hour" : " hours")
: "in " + days + " days";
}
}
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.removedCount = items.filter(item => item._statusKey === "removed").length;
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;
if (!append) updateRemovedCount();
}, 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;
// ---- Status view (Active | Needs attention | Removed N) -------------
// "Active" is every status except removed and is the default; "Removed"
// shows only removed links. Any other mix is a custom Status filter.
const statusKeys = () => Object.keys(state.statusKeyLabels);
state.onlyRemovedShown = () => statusKeys().every(k => (k === "removed") === (state.filters.status[k] !== false));
state.isActiveDefault = () => statusKeys().every(k => (k === "removed") === (state.filters.status[k] === false));
state.statusView = () => {
if (state.projectView === "attention") return "attention";
if (state.onlyRemovedShown()) return "removed";
if (state.filters.status.removed === false) return "active";
return "custom";
};
state.setStatusView = view => {
if (view === "attention") return state.setProjectView("attention");
state.projectView = "all";
statusKeys().forEach(k => { state.filters.status[k] = view === "removed" ? k === "removed" : k !== "removed"; });
};
// Status chips for filters the view tabs do not already express.
state.statusChipKeys = () => {
const view = state.statusView();
if (view === "removed") return [];
return statusKeys().filter(k => state.filters.status[k] === false && !(k === "removed" && view !== "custom"));
};
// The summary API reports the account total and the filtered count, so the
// removed count follows from them whenever status is the only filter. Under a
// search or type filter the last known account-wide count is kept.
state.removedCount = null;
function updateRemovedCount() {
if (legacyDashboard) {
state.removedCount = state.items.filter(item => item._statusKey === "removed").length;
return;
}
const totals = state.dashboardTotals;
if (!totals || state.projectView !== "all" || state.search.trim() || state.typeFilter !== "all") return;
if (state.isActiveDefault()) state.removedCount = Math.max(0, totals.total - totals.filtered);
else if (state.onlyRemovedShown()) state.removedCount = totals.filtered;
}
state.showViewTabs = () => state.statusView() !== "active" || state.attentionCount() > 0 || state.removedCount > 0
|| (state.removedCount == null && !!state.dashboardTotals && state.dashboardTotals.total > state.dashboardTotals.filtered);
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.statusChipKeys().length > 0;
// A plain view (Active, Removed, Needs attention) counts its projects;
// a search, type or extra status filter makes it "N shown".
state.isPlainView = () => !state.showClearFilters();
state.clearFilters = () => {
const view = state.statusView();
state.typeFilter = "all";
state.search = "";
state.setStatusView(view === "removed" ? "removed" : "active");
};
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 being extended.`,
});
state.addToast(toast);
const endpoint = `${apiBaseOf(item._type)}/${item._id}/extend`;
http.post(endpoint).then(
(res) => {
const until = res?.data?.expirationDate ? ` until ${new Date(res.data.expirationDate).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}` : "";
if (item._type === "repo") {
waitRepoToBeReady(item._id, () => {
toast.title = `${item._id} is extended.`;
toast.body = `${label} ${item._id} is extended${until}.`;
}, undefined, item.status);
} else {
toast.title = `${item._id} is extended.`;
toast.body = `${label} ${item._id} is extended${until}.`;
loadAll();
}
},
(error) => {
toast.title = `Error during the extension of ${item._id}.`;
const code = error.data && error.data.error;
toast.body = code || error.body;
if (code && Object.prototype.hasOwnProperty.call(translations.ERRORS, code)) toast.body = translations.ERRORS[code];
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 });
}
};
// Focusing the link box selects the whole URL, like the read-only field it replaces.
state.selectShareUrl = event => {
const selection = window.getSelection?.();
if (!selection || !event?.target) return;
const range = document.createRange();
range.selectNodeContents(event.target);
selection.removeAllRanges();
selection.addRange(range);
};
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(isRepositoryAddon(state.hostingChoice()) ? state.hostingChoice() : "");
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 = "";
state.hostingSaving = false;
state.selectedHostingPlan = () => state.authorPlans().find(p => p.id === state.hostingPlan) || state.hostingSubscriptionPlan?.();
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));
// A paid, open conference hosts the submission instead of the author.
state.conferenceHosting = () => !!(state.conference && state.conference_data?.hosting);
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 && !state.repositoryGrants("repo_never").length;
// Reported usage may include removed reservations; admission reclaims them.
return !!(needsLifetime || (!state.conferenceHosting() && state.hostingQuotaBlocked));
};
// One-time add-ons the current user bought for this repository.
state.repositoryGrants = planId => (state.billing?.grants || []).filter(g => g.planId === planId && g.scope === `repo:${state.repoId}` &&
["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
state.repositoryAddonPlans = () => (state.billing?.plans || []).filter(p => isRepositoryAddon(p.id) &&
(["repo_extend", "repo_pack"].includes(p.id) || !state.repositoryGrants(p.id).length));
// One-time add-ons that resolve this submission: Never expire for a
// never-expiring link, otherwise the paper pack or an extra repository
// when the free allowance is full. The first is preselected.
state.hostingOneTimePlans = () => {
const offered = ids => ids.map(id => (state.billing?.plans || []).find(p => p.id === id && !state.repositoryGrants(id).length)).filter(Boolean);
if (state.options?.expirationMode === "never" && !state._existingNever && !state.repositoryGrants("repo_never").length) return offered(["repo_never"]);
return state.hostingQuotaBlocked && !state.conferenceHosting() ? offered(["repo_slot", "repo_pack"]) : [];
};
state.hostingOneTime = () => state.hostingOneTimePlans()[0];
// Automatic updates, as the server decides them (hasAutomaticUpdates):
// free hosting and an Extra repository refresh by hand; a conference
// package, Live updates or a Paper pack for this repository, or an author
// subscription include them. Unknown (billing not loaded, someone else's
// repository) counts as included, so no paid hint flashes.
const MANUAL_REFRESH_PLANS = ["free", "repo_slot"];
state.liveUpdatesIncluded = () => {
if (!state.billing?.enabled || state.detectedType !== "repo") return true;
if (state.isUpdate && state.role !== "owner") return true;
if (state.conferenceHosting() || state.hasAuthorHosting()) return true;
const plan = state.billing?.allowance?.plan;
if (plan && !MANUAL_REFRESH_PLANS.includes(plan)) return true;
return state.repositoryGrants("author_pass").length > 0 || state.repositoryGrants("repo_pack").length > 0;
};
state.liveUpdatesPrice = () => {
const plan = (state.billing?.plans || []).find(p => p.id === "author_pass");
return plan?.amount != null ? state.billingAmount(plan) : "";
};
state.showHostingOptions = () => {
state.mobilePane = "settings";
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "start", behavior: "smooth" }));
};
state.neverExpireTag = () => {
if (state.hasAuthorHosting()) return "Included in your subscription";
if (state.repositoryGrants("repo_never").length) return "Paid for this repository";
const plan = (state.billing?.plans || []).find(p => p.id === "repo_never");
return plan?.amount != null ? `${state.billingAmount(plan)} once, or with a subscription` : "Subscription or one-time add-on";
};
// Annual before monthly: it is the cheaper subscription.
// Subscriptions appear as one option with a monthly/yearly switch.
state.hostingCycle = "author_annual";
state.hostingSubscriptionPlan = () => state.authorPlans().find(p => p.id === state.hostingCycle) || state.authorPlans().find(p => p.interval === "year") || state.authorPlans()[0];
state.hostingAnnualSavings = () => {
const monthly = state.authorPlans().find(p => p.interval === "month")?.amount;
const annual = state.authorPlans().find(p => p.interval === "year")?.amount;
return monthly && annual && annual < monthly * 12 ? Math.round((1 - annual / (monthly * 12)) * 100) : 0;
};
state.hostingOptionPlans = () => state.hostingOneTimePlans().concat(state.authorPlans().length ? [state.hostingSubscriptionPlan()] : []);
state.hostingOptionName = plan => ({ repo_slot: "Add this repository", repo_pack: "Paper pack", repo_never: "Never expire",
author_annual: "Author subscription", author_monthly: "Author subscription" }[plan.id] || plan.name);
state.hostingOptionBlurb = plan => ({
repo_slot: "Hosted outside your free repositories. Refresh it by hand.",
repo_pack: "Adds automatic updates for 180 days and keeps the link online for a year.",
repo_never: "This link never expires. No subscription.",
}[plan.id] || `${plan.repositories} repositories, ${state.billingBytes(plan.storage)}, automatic updates and links that never expire.`);
state.hostingChoice = () => {
const oneTime = state.hostingOneTimePlans();
if (oneTime.some(p => p.id === state.hostingPlan) || state.authorPlans().some(p => p.id === state.hostingPlan)) return state.hostingPlan;
return oneTime[0]?.id || state.hostingSubscriptionPlan()?.id || "";
};
state.checkoutRepository = async (addon = "") => {
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;
}
// The subscription option shows one cycle; Checkout must open that same cycle.
const plan = state.authorPlans().find(p => p.id === state.hostingPlan) || state.hostingSubscriptionPlan();
if (!addon && !plan) throw new Error("No author plan available");
await state.checkout(addon || plan.id, addon ? 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. The latest date
// mirrors the server: a year without billing or with an author
// subscription, otherwise the free window, extended by this repository's
// Extended expiration add-ons or its conference's review window. A saved
// later date stays selectable.
state.minExpirationDate = toLocalDateString(new Date());
const updateExpirationLimit = () => {
const now = new Date();
let maximum = new Date(now);
maximum.setFullYear(maximum.getFullYear() + 1);
// Gists and pull requests keep their one-year limit; billing only limits repositories.
if (state.billing?.enabled && state.detectedType === "repo" && !state.hasAuthorHosting()) {
maximum = new Date(now);
maximum.setMonth(maximum.getMonth() + (state.billing.freeExpirationMonths || 5));
for (const grant of state.repositoryGrants("repo_extend")) if (new Date(grant.until) > maximum) maximum = new Date(grant.until);
if (state.conference_data?.endDate && new Date(state.conference_data.endDate) > maximum) maximum = new Date(state.conference_data.endDate);
if (state._savedExpirationDate && state._savedExpirationDate > maximum) maximum = new Date(state._savedExpirationDate);
// A default or carried-over date must fit the limit; only the saved
// date may stay later.
const date = state.options?.expirationDate;
const saved = state._savedExpirationDate && date instanceof Date && date.getTime() === state._savedExpirationDate.getTime();
if (date instanceof Date && date > maximum && !saved) state.options.expirationDate = new Date(maximum);
}
state.maxExpirationDate = toLocalDateString(maximum);
};
updateExpirationLimit();
state.watch(() => [state.billing, state.repoId, state.conference_data, state.isUpdate, state._savedExpirationDate, state.options?.expirationMode, state.detectedType], updateExpirationLimit, true, false);
state.anonymize_readme = "";
state.readme = "";
state.html_readme = "";
state.isUpdate = false;
// Landing: the user's GitHub repositories, so step 1 is a pick, not a blank field.
state.userRepos = [];
state.userReposStatus = "idle";
state.userRepoFilter = "";
state.loadUserRepos = (force = false) => {
if (state.userReposStatus === "loading") return;
state.userReposStatus = "loading";
http.get("/api/user/all_repositories", force ? { params: { force: "1" } } : {}).then(
(res) => {
if (sourceDisposed) return;
const rows = Array.isArray(res.data) ? res.data.filter(r => r && r.fullName) : [];
state.userRepos = rows.sort((x, y) => x.fullName.localeCompare(y.fullName, undefined, { sensitivity: "base" }));
state.userReposStatus = "ready";
},
() => { if (!sourceDisposed) state.userReposStatus = "error"; }
);
};
state.filteredUserRepos = () => {
const q = String(state.userRepoFilter || "").trim().toLowerCase();
const rows = state.userRepos || [];
return q ? rows.filter(r => r.fullName.toLowerCase().includes(q)) : rows;
};
state.selectUserRepo = (repo) => {
if (!repo?.fullName) return;
state.sourceUrl = "https://github.com/" + repo.fullName;
return state.urlSelected();
};
if (!params.repoId && !params.pullRequestId && !params.gistId) Promise.resolve().then(() => { if (!sourceDisposed) state.loadUserRepos(); });
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;
}
}
// Long PR and gist URLs scroll inside the field, so name the resolved
// source underneath: "owner/repo · PR #938", "owner · gist 311fc90…2351".
state.sourceSummary = () => {
const url = String(state.sourceUrl || "");
let m = /github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)/i.exec(url);
if (m) return m[1] + " · PR #" + m[2];
m = /gist\.github\.com\/([^/\s]+)\/([0-9a-f]{8,})/i.exec(url);
if (m) return m[1] + " · gist " + (m[2].length > 12 ? m[2].slice(0, 7) + "…" + m[2].slice(-4) : m[2]);
return "";
};
// "What reviewers will see": the anonymous URL being configured, shown in
// the preview window's address bar and kept in sync with the ID field.
state.previewId = () => state.detectedType === "pr" ? state.pullRequestId : state.detectedType === "gist" ? state.gistId : state.repoId;
state.previewPath = () => {
const id = state.previewId();
if (!id) return "";
return (state.detectedType === "pr" ? "/pr/" : state.detectedType === "gist" ? "/gist/" : "/r/") + encodeURIComponent(id) + "/";
};
state.previewUrl = () => {
const path = state.previewPath();
return path ? "anonymous.4open.science" + path.replace(/\/$/, "") : "";
};
// Occurrences of XXXX-n in what the preview currently shows.
state.previewRedactions = () => {
const count = (text) => typeof text === "string" ? (text.match(REDACTION_RE) || []).length : 0;
if (state.detectedType === "repo") return count(String(state.html_readme || "").replace(/<[^>]*>/g, " "));
const cache = state.detectedType === "pr" ? _prAnonCache : _gistAnonCache;
let n = 0;
cache.forEach((value) => { n += count(value); });
return n;
};
// The preview's miniature file tree, from the same tree the
// Unredacted-files picker loads. Like the explorer: folders first, then
// files, each alphabetical; folders start collapsed and open on click.
// Only the open folders' rows are built, so large repositories stay cheap.
state.previewTreeOpen = {};
const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" });
const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
state.previewTree = () => {
if (!state.preserve.commit || !preserveIndex) return [];
const readme = (preserveIndex.dirs.get("")?.files || []).find((f) => /^readme(\.|$)/i.test(f));
const rows = [];
const walk = (dir, depth) => {
const node = preserveIndex.dirs.get(dir);
if (!node) return;
for (const sub of [...node.dirs].sort((a, b) => byName(baseName(a), baseName(b)))) {
const open = !!state.previewTreeOpen[sub];
rows.push({ path: sub, name: baseName(sub), dir: true, open, depth });
if (open) walk(sub, depth + 1);
}
for (const file of [...node.files].sort((a, b) => byName(baseName(a), baseName(b)))) {
rows.push({ path: file, name: baseName(file), dir: false, readme: file === readme, depth });
}
};
walk("", 0);
return rows;
};
state.previewTreeCount = () => {
const root = state.preserve.commit && preserveIndex ? preserveIndex.dirs.get("") : null;
return root ? root.dirs.length + root.files.length : 0;
};
// Like the explorer, the selected README is scrolled into the sidebar's
// view when the list loads (only the sidebar scrolls, not the page).
function revealPreviewReadme() {
nextTick(() => {
const nav = document.querySelector(".reviewer-preview .rp-side");
const row = nav?.querySelector(".rp-files > li.active");
if (!nav || !row || !nav.clientHeight) return;
const bottom = row.getBoundingClientRect().bottom - nav.getBoundingClientRect().top + nav.scrollTop;
if (bottom > nav.scrollTop + nav.clientHeight) nav.scrollTop = bottom - nav.clientHeight + row.offsetHeight * 2;
});
}
state.togglePreviewFolder = (path) => {
state.previewTreeOpen[path] = !state.previewTreeOpen[path];
};
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._savedExpirationDate = res.data.options.expirationDate ? new Date(res.data.options.expirationDate) : null;
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;
state.previewTreeOpen = {};
revealPreviewReadme();
} 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;
}
}
// The preview window shows the repository's top-level files once the
// commit is known (one tree request, shared with the picker).
state.watch("source.commit", (commit) => {
if (state.detectedType === "repo" && commit && state.sourceUrl) loadPreserveTree().catch(() => {});
});
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();
// Wrapped README code in the preview continues under a hanging indent.
if (typeof hangCodeLines === "function") document.querySelectorAll(".reviewer-preview .anonymize-preview-body").forEach(hangCodeLines);
}, 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) {
// The hosting section explains a hosting limit and offers the fix.
const hostingLimit = ["quota_exceeded", "subscription_required_for_never_expiration"].includes(error.data.error);
if (!hostingLimit) translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
displayErrorMessage(error.data.error);
if (hostingLimit) {
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);
state.loadedFolders = { ...state.loadedFolders, [normalized]: true };
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;
const one = (n) => n.toFixed(1).replace(/\.0$/, "");
return kb < 1024 ? one(kb) + " KB" : kb < 1024 * 1024 ? one(kb / 1024) + " MB" : one(kb / 1024 / 1024) + " GB";
};
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();
});
};
// Notebooks: count the same markers in cell sources and text outputs (not in the JSON keys or image data).
let nbCount = { content: null, n: 0 };
const notebookRedactions = (content) => {
if (nbCount.content === content) return nbCount.n;
let nb = content;
if (typeof nb === "string") { try { nb = JSON.parse(nb); } catch { nb = null; } }
const texts = [];
const add = (v) => { if (Array.isArray(v)) texts.push(v.join("")); else if (typeof v === "string") texts.push(v); };
((nb && (nb.cells || (nb.worksheets || []).flatMap((w) => w.cells || []))) || []).forEach((cell) => {
add(cell?.source ?? cell?.input);
(cell?.outputs || []).forEach((o) => {
add(o?.text);
if (o?.data) { add(o.data["text/plain"]); add(o.data["text/markdown"]); add(o.data["text/html"]); }
add(o?.traceback);
});
});
const n = texts.reduce((sum, t) => sum + (t.match(REDACTION_RE) || []).length, 0);
nbCount = { content, n };
return n;
};
state.redactionCount = function () {
if (state.type === "IPython") return notebookRedactions(state.content);
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;
};
// CSV/TSV files up to 1 MB render as a table (sticky header, rules, tabular
// figures) with a Raw toggle back to the code view.
state.csvView = "table";
let csvCache = { content: null, rows: [] };
const parseDelimited = (text, sep) => {
const rows = [];
let row = [], cell = "", quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted) {
if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else quoted = false; }
else cell += ch;
} else if (ch === '"' && cell === "") quoted = true;
else if (ch === sep) { row.push(cell); cell = ""; }
else if (ch === "\n" || ch === "\r") {
if (ch === "\r" && text[i + 1] === "\n") i++;
row.push(cell); rows.push(row); row = []; cell = "";
} else cell += ch;
}
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
return rows.filter((r) => r.length > 1 || r[0] !== "");
};
state.isCsv = () => typeof state.content === "string" && state.type !== "html"
&& /\.(csv|tsv)$/i.test(state.filePath || "") && state.content.length <= 1024 * 1024;
state.csvRows = () => {
if (csvCache.content !== state.content) {
const sep = /\.tsv$/i.test(state.filePath || "") ? "\t" : ",";
csvCache = { content: state.content, rows: parseDelimited(state.content, sep) };
}
return csvCache.rows;
};
state.csvNumCol = (ci) => {
const rows = state.csvRows().slice(1).map((r) => r[ci]).filter((v) => v != null && String(v).trim() !== "");
return rows.length > 0 && rows.every((v) => state.csvNumeric(v));
};
state.csvNumeric = (value) => /^\s*[-+]?(\d[\d,]*)?(\.\d+)?(e[-+]?\d+)?%?\s*$/i.test(value) && /\d/.test(value);
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;
state.imageSize = "";
// 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", csv: "CSV", tsv: "TSV", 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;
}
if (state.type == "media" || state.type == "audio") {
// The player streams (and range-requests) the file itself; reading it
// as text first would download a whole video twice.
state.content = state.type;
return;
}
getContent(state.filePath, state.file);
}
// Files without a text body (media, notebooks, binaries, empty files and
// per-file errors) share the code view's framed card and header.
const FRAMED_TYPES = ["image", "media", "audio", "pdf", "IPython", "binary"];
const FILE_ERRORS = ["file_too_big", "file_not_found", "file_not_accessible", "file_not_supported", "folder_not_supported"];
state.fileFrame = () => {
if (!state.filePath) return false;
if (FRAMED_TYPES.includes(state.type)) return state.content != null;
if (state.type === "error") return FILE_ERRORS.includes(state.content);
return state.content == null && ["code", "text"].includes(state.type);
};
state.fileKind = () => {
const name = (state.paths || [])[(state.paths || []).length - 1] || "";
const dot = name.lastIndexOf(".");
const ext = dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
if (state.type === "image") return (ext === "JPG" ? "JPEG" : ext) + " image";
const MEDIA_NAMES = { WEBM: "WebM", OGG: "Ogg", OGV: "Ogg", MOV: "QuickTime", M4V: "M4V", MKV: "Matroska" };
if (state.type === "media") return (MEDIA_NAMES[ext] || ext) + " video";
if (state.type === "audio") return ext + " audio";
if (state.type === "pdf") return "PDF";
if (state.type === "IPython") return "Jupyter notebook";
if (state.type === "binary") return "Binary file";
return state.languageName || (ext ? ext + " file" : "File");
};
state.imageSize = "";
state.onImageLoad = (event) => {
const img = event.target;
if (img?.naturalWidth) state.imageSize = `${img.naturalWidth} × ${img.naturalHeight}`;
};
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.loadedFolders = {};
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("/");
}
// Named-month echo under the review-window dates ("Starts Oct 8, 2025 · in 3 weeks").
// The partial stays markup-only, so the echo nodes are placed from here.
const renderDateEchoes = () => {
for (const [id, prefix, key] of [["startDate", "Starts", "startDate"], ["endDate", "Ends", "endDate"]]) {
const input = document.getElementById(id);
if (!input) continue;
let echo = input.parentElement.querySelector(":scope > .date-echo");
if (!echo) {
echo = document.createElement("p");
echo.className = "date-echo";
echo.setAttribute("aria-live", "polite");
input.insertAdjacentElement("afterend", echo);
}
const text = dateEcho(state.options?.[key]);
echo.textContent = text ? prefix + " " + text : "";
echo.hidden = !text;
}
};
if (typeof state.watchGroup === "function") state.watchGroup(["options.startDate", "options.endDate"], () => nextTick(renderDateEchoes));
// A short example that fits the field at every width (the label already names it).
const shortNameExample = (tries = 20) => {
const input = document.querySelector("input#name.form-control");
const idInput = document.querySelector("input#conferenceID.form-control");
if (idInput) idInput.placeholder = "e.g. ICSE27";
if (input) input.placeholder = "e.g. ICSE 2027";
else if (tries > 0) setTimeout(() => shortNameExample(tries - 1), 50);
};
setTimeout(() => shortNameExample(), 0);
state.plans = [];
state.plansLoaded = false;
// With billing on, the review packages (one per venue size) are the only
// plans; their prices come from Stripe through the billing endpoint.
state.conferenceBilling = null;
readBilling(http).then(res => { state.conferenceBilling = res.data; }).catch(() => {});
state.conferenceTiers = () => state.plans.filter(p => p.id !== "free_conference").map(p => {
const priced = (state.conferenceBilling?.plans || []).find(b => b.id === p.id) || {};
return { ...p, storage: p.storagePerRepo, ...priced, id: p.id, name: p.name };
});
state.conferenceAudience = id => ({ conference_workshop: "Workshops and small venues", conference: "A typical conference", conference_large: "Large or multi-track venues" }[id] || "");
state.conferencePlanName = id => conferencePlanName(id);
state.conferenceAmount = (plan, cents = plan?.amount) => {
return new Intl.NumberFormat(undefined, { style: "currency", currency: plan?.currency || "eur",
minimumFractionDigits: cents % 100 ? 2 : 0, maximumFractionDigits: 2 }).format(cents / 100);
};
state.conferenceBytes = value => {
const bytes = Number(value) || 0;
const exponent = Math.min(4, Math.floor(Math.log2(Math.max(bytes, 1)) / 10));
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(bytes / 1024 ** exponent)} ${["B", "KB", "MB", "GB", "TB"][exponent]}`;
};
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.plansLoaded = true;
// New conferences default to the middle package when it is offered.
if (!state.editionMode && state.plans.length && !state.plans.some(p => p.id == state.options.plan.planID))
state.options.plan.planID = (state.plans.find(p => p.id === "conference") || state.plans[0]).id;
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; });
};
};
function conferencePlanName(id) {
return { free_conference: "Free (no longer offered to new conferences)", conference_workshop: "Workshop package", conference: "Conference package", conference_large: "Large conference package", premium_conference: "Premium (legacy)", unlimited_conference: "Unlimited (legacy)" }[id] || id || "Free";
}
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.conferencePlanName = id => conferencePlanName(id);
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, preparing: true, error: true, expired: true, removed: false },
};
state.orderBy = "-anonymizeDate";
// Same view tabs as the author dashboard: Current (everything except
// removed, the default) | Needs attention (errors) | Removed.
const conferenceStatusGroup = statusGroup;
const confKeys = ["ready", "preparing", "error", "expired", "removed"];
const confViewKeys = { current: k => k !== "removed", attention: k => k === "error", removed: k => k === "removed" };
state.confView = () => Object.keys(confViewKeys).find(v => confKeys.every(k => confViewKeys[v](k) === (state.filters.status[k] !== false))) || "custom";
state.setConfView = (view) => { confKeys.forEach(k => { state.filters.status[k] = (confViewKeys[view] || confViewKeys.current)(k); }); };
state.confCount = (view) => (state.conference?.repositories || []).filter((r) => confViewKeys[view](conferenceStatusGroup(r.status))).length;
state.copyingId = null;
state.copyRepoLink = async (repo) => {
if (!repo?.repoId || state.copyingId) return;
const link = new URL("/r/" + repo.repoId + "/", window.location.origin).href;
state.copyingId = repo.repoId;
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; }
};
// Expired rows carry the same "Extend 6 months" remedy as My work. The API lets the repository's authors
// and admins extend; anyone else gets an explanation instead of a silent failure.
state.extendingId = null;
state.extendRepo = (repo) => {
if (!repo?.repoId || state.extendingId) return;
state.extendingId = repo.repoId;
http.post("/api/repo/" + repo.repoId + "/extend").then(
(res) => {
const until = res?.data?.expirationDate ? ` until ${new Date(res.data.expirationDate).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}` : "";
state.addToast({ title: `${repo.repoId} is extended.`, body: `Reviewers can open it again${until}.` });
repo.status = "ready";
if (res?.data?.expirationDate) repo.options = { ...(repo.options || {}), expirationDate: res.data.expirationDate };
state.extendingId = null;
},
(error) => {
const code = error?.status || error?.data?.status;
state.addToast({ title: `Couldn't extend ${repo.repoId}.`, body: code === 401 || code === 403 ? "Only the repository's authors can extend this link. Ask them to extend it from My work." : "Try again in a moment." });
state.extendingId = null;
}
);
};
state.repoFiler = (repo) => {
if (state.filters.status[conferenceStatusGroup(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(() => {});
};
// Repository picker: the bottom fade means "more below", so it goes away once the list is scrolled to the end.
if (typeof document !== "undefined") {
document.addEventListener("scroll", event => {
const list = event.target;
if (!list || !list.classList || !list.classList.contains("repo-picker-list")) return;
list.classList.toggle("is-end", list.scrollTop + list.clientHeight >= list.scrollHeight - 2);
}, true);
}