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 { escape as globEscape, unescape as globUnescape } from "minimatch"; import { compilePreserveMatcher, dedupePreservePatterns } from "../../src/core/preserve-files"; import { termHints, escapeTermLine } from "../../src/core/term-hints"; import translations from "../i18n/locale-en.json"; // A README image that fails to load becomes a quiet placeholder, not a broken-image glyph. if (typeof window !== "undefined" && window.document?.addEventListener) { window.document.addEventListener("error", (event) => { const img = event.target; if (!img || img.tagName !== "IMG" || img.dataset.failed || !img.closest?.(".markdown-body, .anonymize-preview-body")) return; img.dataset.failed = "1"; const ph = window.document.createElement("span"); ph.className = "img-unavailable"; ph.textContent = img.alt ? "Image unavailable: " + img.alt : "Image unavailable"; img.replaceWith(ph); }, true); } function viewerErrorCode(error) { let data = error?.data; if (typeof data === "string") { try { data = JSON.parse(data); } catch (_) { data = null; } } if (typeof data?.error === "string" && Object.prototype.hasOwnProperty.call(translations.ERRORS, data.error)) return data.error; if (error?.status === -1 || error?.status === 0) return "request_error"; if ([502, 503, 504].includes(error?.status)) return "unreachable"; return "unknown_error"; } // The mask comes from /api/options; mainController sets it once loaded. let redactionMask = "XXXX"; export const setRedactionMask = (mask) => { redactionMask = mask || "XXXX"; }; // Matches the replacements the anonymizer writes: MASK and MASK-. 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 , 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("
" + html + "
", "text/html"); const walker = doc.createTreeWalker(doc.body, 4); const nodes = []; while (walker.nextNode()) { REDACTION_RE.lastIndex = 0; // Prism re-highlights code blocks from their text, so those are slotted in its hook. if (walker.currentNode.parentElement?.closest("pre code")) continue; if (REDACTION_RE.test(walker.currentNode.nodeValue)) nodes.push(walker.currentNode); } for (const node of nodes) { REDACTION_RE.lastIndex = 0; const frag = doc.createDocumentFragment(); let last = 0, m; const text = node.nodeValue; while ((m = REDACTION_RE.exec(text))) { frag.appendChild(doc.createTextNode(text.slice(last, m.index))); const mark = doc.createElement("mark"); mark.className = "redact-chip"; mark.setAttribute("title", "Redacted term " + m[0].slice(5) + " — the same term everywhere in this snapshot"); // Display: an empty slot holding only the term's number (the file keeps XXXX-N). const num = m[0].slice(5); mark.textContent = ""; const idx = doc.createElement("i"); idx.textContent = num; mark.appendChild(idx); mark.dataset.term = m[0]; mark.setAttribute("aria-label", "Redacted term " + num); // Keep trailing punctuation on the chip's line (no orphaned "." or ","). const trail = /^[.,;:!?)\]]+/.exec(text.slice(m.index + m[0].length)); if (trail) { const glue = doc.createElement("span"); glue.className = "redact-glue"; glue.appendChild(mark); glue.appendChild(doc.createTextNode(trail[0])); frag.appendChild(glue); last = m.index + m[0].length + trail[0].length; } else { frag.appendChild(mark); last = m.index + m[0].length; } } frag.appendChild(doc.createTextNode(text.slice(last))); node.parentNode.replaceChild(frag, node); } return doc.body.firstChild.innerHTML; }; // Prism rewrites fenced code blocks from their text, so slot redactions once it is done. export const installPrismRedactionSlots = () => { if (window.Prism?.hooks && !window.Prism.redactionHook) { window.Prism.hooks.add("complete", (env) => { markRedactionsIn(env.element); // Show the same numbered slot as the rest of the viewer. env.element.querySelectorAll?.("mark.redaction").forEach((mark) => { const m = /^XXXX-(\d+)$/.exec(mark.textContent); if (!m) return; mark.className = "redact-chip"; mark.setAttribute("title", "Redacted term " + m[1] + " — the same term everywhere in this snapshot"); mark.setAttribute("aria-label", "Redacted term " + m[1]); mark.dataset.term = m[0]; // Same width as the XXXX-n it replaces, so code columns stay aligned. mark.style.setProperty("--slot-ch", m[0].length + "ch"); mark.textContent = ""; const idx = mark.ownerDocument.createElement("i"); idx.textContent = m[1]; mark.appendChild(idx); }); }); window.Prism.redactionHook = true; } }; export const highlightRedactions = (html, mask = redactionMask) => { if (!html || !mask || !html.includes(mask)) return html; const doc = new DOMParser().parseFromString("" + html + "", "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"); }; state.title = "Main"; state.user = { status: "connection" }; state.site_options; state.toasts = []; state.removeToast = function (toast) { const index = state.toasts.indexOf(toast); if (index === -1) return; state.toasts.splice(index, 1); }; // Auto-dismiss toasts after a fixed delay so they don't pile up across // navigations (e.g. the "README not found" toast re-fired every time the // edit screen was reopened — see #246). Long-running operations that // mutate the toast (remove/refresh) will simply disappear once the // delay elapses; users can re-check status from the dashboard. state.addToast = function (toast) { state.toasts.push(toast); timeout(function () { state.removeToast(toast); }, 8000); return toast; }; // Anonymized content that signed-out reviewers browse. const reviewerRoutes = /^\/(r|repository|pr|gist)\//; state.path = location.url(); state.paths = location.path().substring(1).split("/"); state.isReviewerRoute = reviewerRoutes.test(location.path()); state.darkMode = function (on) { localStorage.setItem("darkMode", on); state.isDarkMode = on; const darkPrismLink = "/css/prism-okaidia.css"; const lightPrismLink = "/css/prism.css"; if (on) { $("body").addClass("dark-mode"); let link = document.createElement("link"); link.href = darkPrismLink; link.rel = "stylesheet"; document.head.append(link); $(`link[href='${lightPrismLink}']`).remove(); } else { $("body").removeClass("dark-mode"); let link = document.createElement("link"); link.href = lightPrismLink; link.rel = "stylesheet"; document.head.append(link); $(`link[href='${darkPrismLink}']`).remove(); } state.emit("dark-mode", on); }; state.darkMode(localStorage.getItem("darkMode") == "true"); state.emailPromptOpen = false; state.emailPromptAddress = ""; state.emailPromptBusy = false; state.emailPromptError = ""; let emailPromptFocus; state.respondToEmailPrompt = async (action) => { if (state.emailPromptBusy) return; state.emailPromptBusy = true; state.emailPromptError = ""; try { const res = await http.post("/github/connections/email", { action, email: state.emailPromptAddress }, { headers: { "X-CSRF-Token": state.user.emailPromptCSRF } }); if (action === "save") state.user.notificationEmail = res.data.notificationEmail; state.emailPromptOpen = false; state.user.showEmailPrompt = false; emailPromptFocus?.focus(); } catch (error) { state.emailPromptError = error.data?.error || "Unable to save your preference. Please try again."; } finally { state.emailPromptBusy = false; } }; function getUser() { http.get("/api/user").then( (res) => { if (res) state.user = res.data; if (state.user?.username) readBilling(http).then(res => updateHeaderBilling(res.data)).catch(() => {}); else state.headerShowSupport = true; if (state.user?.emailNotificationsEnabled && state.user.showEmailPrompt && !state.user.notificationEmail) { emailPromptFocus = document.activeElement; state.emailPromptOpen = true; timeout(() => { const dialog = document.getElementById("email-prompt"); if (dialog?.showModal) dialog.showModal(); else dialog?.setAttribute("open", ""); document.getElementById("email-prompt-address")?.focus(); }, 0); } }, () => { state.user = null; state.headerShowSupport = true; } ); } getUser(); function getOptions() { http.get("/api/options").then( (res) => { if (res) state.site_options = res.data; setRedactionMask(res?.data?.ANONYMIZATION_MASK); state.emit("site-options"); }, () => { state.site_options = null; } ); } getOptions(); function getMessage() { http.get("/api/message").then( (res) => { if (res) state.generalMessage = res.data; }, () => { state.generalMessage = null; } ); } getMessage(); function changedUrl(_, current) { if (current) { state.title = current.title; } state.path = location.url(); state.paths = location.path().substring(1).split("/"); state.isReviewerRoute = reviewerRoutes.test(location.path()); } state.on("routeChange", changedUrl); state.on("routeUpdate", changedUrl); }; export const faqController = function (state, http) { const listen = createListeners(); const timers = createTimers(); const revealAnswer = () => { const id = window.location.hash.slice(1); const answer = document.getElementById(id); if (!answer?.classList.contains("panel-collapse")) return; $(answer).collapse("show"); const heading = document.getElementById(answer.getAttribute("aria-labelledby")); heading?.setAttribute("tabindex", "-1"); heading?.focus({ preventScroll: true }); heading?.scrollIntoView?.({ block: "start" }); }; // Router fragments do not emit hashchange, and answers mount after navigation. state.on("routeUpdate", () => timers.timeout(revealAnswer, 0)); listen(window, "hashchange", revealAnswer); timers.timeout(revealAnswer, 0); }; export const profileController = function (state, http, translate, timeout, quotaService) { state.settingsTab = /(?:billing|upgrade)=/.test(window.location.search) ? "billing" : "defaults"; state.confirmDeleteAccount = false; state.deleteUsername = ""; state.profilePastSubscription = () => !state.profileAuthorGrant() && (state.billing?.grants || []).some(g => ["author_monthly", "author_annual"].includes(g.planId)); state.showAuthorPlans = new URLSearchParams(window.location.search).get("upgrade") === "1"; state.showRepositoryPass = false; // 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" }, ]; function getStat() { http.get("/api/stat/").then((res) => { state.stat = res.data; state.cards[0].total = res.data.nbRepositories; state.cards[1].total = res.data.nbUsers; state.cards[2].total = res.data.nbPageViews; state.cards[3].total = res.data.nbPullRequests; }).catch(() => { // Leave the placeholders in place rather than showing zeros. }); } getStat(); function buildSeriesView(series) { const view = { series: series, bars: [], viewW: 100, deltaToday: 0, pctChange: 0, pctAbs: 0, isUp: true, }; if (!series || series.length < 2) return view; // Bars represent the *daily increment* (today - yesterday), not the // cumulative total. The big number above the chart shows the total. const deltas = new Array(series.length - 1); for (let i = 1; i < series.length; i++) { deltas[i - 1] = series[i] - series[i - 1]; } const n = deltas.length; const max = Math.max.apply(null, deltas); const min = Math.min.apply(null, deltas); // Anchor scale to zero so visually small days look small even when all // deltas are positive; only fall back to min when there are negatives. const base = Math.min(0, min); const range = max - base || 1; view.viewW = n * 2; view.bars = new Array(n); for (let i = 0; i < n; i++) { const norm = (deltas[i] - base) / range; const h = Math.max(1.5, norm * 34); view.bars[i] = { x: (i * 2 + 0.25).toFixed(2), y: (36 - h).toFixed(2), w: "1.5", h: h.toFixed(2), }; } view.deltaToday = deltas[n - 1]; if (n >= 2) { const prior = deltas[n - 2]; if (prior) { view.pctChange = ((view.deltaToday - prior) / prior) * 100; } } view.pctAbs = Math.round(Math.abs(view.pctChange)); view.isUp = view.pctChange >= 0; return view; } state.history = { repositories: buildSeriesView([]), users: buildSeriesView([]), pageViews: buildSeriesView([]), pullRequests: buildSeriesView([]), }; http.get("/api/stat/history?days=60").then((res) => { const rows = res.data || []; state.history = { repositories: buildSeriesView(rows.map((r) => r.nbRepositories || 0)), users: buildSeriesView(rows.map((r) => r.nbUsers || 0)), pageViews: buildSeriesView(rows.map((r) => r.nbPageViews || 0)), pullRequests: buildSeriesView(rows.map((r) => r.nbPullRequests || 0)), }; }).catch(() => {}); }; export const unifiedDashboardController = function (state, http, location, promises, window, quotaService) { const timers = createTimers(); state.on("routeLeave", function () { $('[data-toggle="tooltip"]').tooltip("dispose"); }); state.watch("user.status", () => { if (state.user == null) { location.url("/"); } }); if (state.user == null) { location.url("/"); } timers.timeout(() => { $('[data-toggle="tooltip"]').tooltip(); }, 250); state.quotaExpanded = false; state.closeQuota = () => { state.quotaExpanded = false; nextTick(() => document.querySelector(".dashboard-quota > summary")?.focus()); }; createListeners()(document, "click", event => { if (state.quotaExpanded && !event.target.closest?.(".dashboard-quota")) state.quotaExpanded = false; }); state.filtersExpanded = false; state.projectView = "all"; state.selectedProject = null; state.projectName = ""; state.nameSaving = false; state.nameError = ""; state.nameMessage = ""; state.needsAttention = item => item.status === "error" || item._broken || (statusKey(item.status) === "progress" && item._stale); state.attentionCount = () => state.dashboardTotals?.attention ?? state.items.filter(state.needsAttention).length; state.hasProjects = () => (state.dashboardTotals?.total ?? state.items.length) > 0; state.setProjectView = view => { state.projectView = view; if (view === "attention") { state.filters.status.error = true; state.filters.status.progress = true; } }; state.projectDetailsId = item => "project-details-" + encodeURIComponent(item._type + ":" + (item._id || item._source || "unknown")); state.showProjectDetails = async item => { state.selectedProject = item; state.projectName = item.projectName || ""; state.nameError = ""; state.nameMessage = ""; await nextTick(); const panel = document.getElementById(state.projectDetailsId(item)); panel?.focus(); panel?.scrollIntoView?.({ block: "nearest" }); }; state.closeProjectDetails = item => { state.selectedProject = null; nextTick(() => document.querySelector(`[data-project-actions="${encodeURIComponent(item._type + ":" + item._id)}"]`)?.focus()); }; state.saveProjectName = async item => { if (state.nameSaving || !item._id) return; const name = state.projectName.trim(); if (name.length > 100) { state.nameError = "Use 100 characters or fewer."; return; } state.nameSaving = true; state.nameError = ""; state.nameMessage = ""; try { const res = await http.post("/api/user/project-name", { type: item._type, id: item._id, name }); item.projectName = res.data.name; item._label = item.projectName || item._fallbackLabel; if (state.selectedProject === item) state.nameMessage = name ? "Project name saved." : "Default project name restored."; if (!legacyDashboard) { state.dashboardCursor = null; await loadAll(); if (state.selectedProject === item) { state.selectedProject = state.items.find(current => current._type === item._type && current._id === item._id) || null; } } } catch (_) { if (state.selectedProject === item) state.nameError = "The project name could not be saved. Try again."; } finally { state.nameSaving = false; } }; state.copyingId = null; state.copyLink = async (item) => { if (!item._viewUrl || state.copyingId) return; const link = new URL(item._viewUrl, window.location.origin).href; state.copyingId = item._type + ":" + item._id; try { await copyText(link); state.addToast({ title: "Link copied", body: "The anonymous link is ready to share." }); } catch { state.addToast({ title: "Copy this anonymous link", body: link }); } finally { state.copyingId = null; } }; state.items = []; state.search = ""; state.loading = true; // Status buckets used by the Status filter. Raw statuses are mapped // onto these keys so in-progress and error items can be filtered too. state.statusKeyLabels = { ready: "Ready", progress: "In progress", error: "Error", expired: "Expired", removed: "Removed", }; const inProgress = ["queue", "download", "downloaded", "preparing", "anonymizing"]; function statusKey(status) { if (status === "ready" || status === "error") return status; if (status === "expired" || status === "expiring") return "expired"; if (status === "removed" || status === "removing") return "removed"; if (inProgress.indexOf(status) > -1) return "progress"; return "progress"; } // An in-progress item whose last activity is older than this is // probably stuck; the row says so instead of showing "Downloading". const STALE_AFTER_MS = 2 * 60 * 60 * 1000; const dashboardPrefsKey = "dashboard.filterPrefs"; const dashboardPrefDefaults = { typeFilter: "all", filters: { status: { ready: true, progress: true, error: true, expired: true, removed: false }, }, orderBy: "-anonymizeDate", }; const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {}; state.typeFilter = savedDashboardPrefs.typeFilter || dashboardPrefDefaults.typeFilter; state.filters = { status: Object.assign( {}, dashboardPrefDefaults.filters.status, (savedDashboardPrefs.filters && savedDashboardPrefs.filters.status) || {} ), }; state.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy; // ---- Sorting ------------------------------------------------------- // `orderBy` is kept as the Angular orderBy expression ("-field" for // descending) so saved preferences stay compatible. const sortFields = { _name: { label: "Anonymous ID", defaultDesc: false }, _label: { label: "Project", defaultDesc: false }, anonymizeDate: { label: "Anonymize date", defaultDesc: true }, status: { label: "Status", defaultDesc: false }, lastView: { label: "Last view", defaultDesc: true }, pageView: { label: "Views", defaultDesc: true }, "options.expirationDate": { label: "Expiration", defaultDesc: false }, }; state.sortFields = sortFields; state.sortField = () => state.orderBy.replace(/^-/, ""); state.sortDesc = () => state.orderBy.charAt(0) === "-"; state.sortLabel = () => { const f = sortFields[state.sortField()]; return f ? f.label : "Custom"; }; state.isSortedBy = (field) => state.sortField() === field; state.setSort = (field, desc) => { if (typeof desc !== "boolean") { desc = state.isSortedBy(field) ? !state.sortDesc() : !!(sortFields[field] && sortFields[field].defaultDesc); } state.orderBy = (desc ? "-" : "") + field; }; state.toggleSortDirection = () => { state.setSort(state.sortField(), !state.sortDesc()); }; state.watchGroup( ["typeFilter", "orderBy"], () => { saveFilterPrefs(dashboardPrefsKey, { typeFilter: state.typeFilter, filters: state.filters, orderBy: state.orderBy, }); } ); state.watch( "filters", () => { saveFilterPrefs(dashboardPrefsKey, { typeFilter: state.typeFilter, filters: state.filters, orderBy: state.orderBy, }); }, true ); // ---- Quota (shared with the settings page via quotaService) -------- quotaService.load().then((quota) => { state.quota = quota; }, console.error); // ---- Items --------------------------------------------------------- // Fields shared by repositories, pull requests and gists. Records that // lost their identifier (legacy data) are flagged as broken instead of // rendering an empty link to /pr/undefined/. function decorateItem(item, id, name, source, editUrl, viewUrl) { if (!item.pageView) item.pageView = 0; if (!item.lastView) item.lastView = ""; item.options = item.options || {}; item.options.terms = (item.options.terms || []).filter((f) => f); item._id = id || ""; item._source = source; item._broken = !id; item._name = id || source || "(unnamed)"; item._fallbackLabel = item._type === "gist" ? "Gist " + (id || source || "without an ID") : source || name || item._name; item._label = item.projectName || item._fallbackLabel; item._editUrl = id ? editUrl : null; item._viewUrl = id ? viewUrl : null; if (item._broken) { item.status = "error"; item.statusMessage = "incomplete_record"; } item._statusKey = statusKey(item.status); const last = item.anonymizeDate || item.lastView; item._stale = item._statusKey === "progress" && !!last && Date.now() - new Date(last).getTime() > STALE_AFTER_MS; // What the Expires column should show. if (item.status === "expired" || item.status === "expiring") { item._expiry = { kind: "expired", date: item.options.expirationDate }; } else if (item.status !== "ready") { item._expiry = { kind: "none" }; } else if (item.options.expirationMode === "never" || !item.options.expirationDate) { item._expiry = { kind: "never" }; } else { item._expiry = { kind: "date", date: item.options.expirationDate }; } return item; } function safeGet(url) { return http.get(url).then( (res) => res.data || [], (err) => { console.error(err); return []; } ); } // All three lists load in parallel and are merged once, so the table // does not re-sort three times while it fills in. function loadLegacy() { state.loading = true; return promises .all([ safeGet("/api/user/anonymized_repositories"), safeGet("/api/user/anonymized_pull_requests"), safeGet("/api/user/anonymized_gists"), ]) .then((results) => { const repos = results[0]; const prs = results[1]; const gists = results[2]; const items = []; repos.forEach((repo) => { repo._type = "repo"; const src = repo.source || {}; items.push( decorateItem( repo, repo.repoId, repo.repoId, src.fullName, "/anonymize/" + repo.repoId, "/r/" + repo.repoId + "/" ) ); }); prs.forEach((pr) => { pr._type = "pr"; const src = pr.source || {}; items.push( decorateItem( pr, pr.pullRequestId, pr.pullRequestId, src.repositoryFullName ? src.repositoryFullName + "#" + src.pullRequestId : undefined, "/pull-request-anonymize/" + pr.pullRequestId, "/pr/" + pr.pullRequestId + "/" ) ); }); gists.forEach((g) => { g._type = "gist"; const src = g.source || {}; items.push( decorateItem( g, g.gistId, g.gistId, src.gistId, "/gist-anonymize/" + g.gistId, "/gist/" + g.gistId + "/" ) ); }); state.items = items; state.loading = false; }); } let dashboardGeneration = 0; let legacyDashboard = false; state.dashboardCursor = null; state.dashboardError = ""; function decorateSummary(item) { const src = item.source || {}; if (item._type === "repo") return decorateItem(item, item.repoId, item.repoId, src.fullName, "/anonymize/" + item.repoId, "/r/" + item.repoId + "/"); if (item._type === "pr") return decorateItem(item, item.pullRequestId, item.pullRequestId, src.repositoryFullName ? src.repositoryFullName + "#" + src.pullRequestId : undefined, "/pull-request-anonymize/" + item.pullRequestId, "/pr/" + item.pullRequestId + "/"); return decorateItem(item, item.gistId, item.gistId, src.gistId, "/gist-anonymize/" + item.gistId, "/gist/" + item.gistId + "/"); } function loadAll(append = false) { if (legacyDashboard) return loadLegacy(); const generation = ++dashboardGeneration; state.loading = !append; state.loadingMore = append; state.dashboardError = ""; return http.get("/api/user/dashboard", { params: { q: state.search, type: state.typeFilter, sort: state.orderBy, statuses: Object.keys(state.filters.status).filter(key => state.filters.status[key] !== false).join(",") || "none", attention: String(state.projectView === "attention"), cursor: append ? state.dashboardCursor : undefined, } }).then(response => { if (generation !== dashboardGeneration) return; // Compatibility with servers that have not gained the summary API. if (!Array.isArray(response.data?.items)) { legacyDashboard = true; return loadLegacy(); } state.items = (append ? state.items : []).concat(response.data.items.map(decorateSummary)); state.dashboardCursor = response.data.cursor; state.dashboardTotals = response.data; }, error => { if (generation !== dashboardGeneration) return; if (error.status === 404) { legacyDashboard = true; return loadLegacy(); } state.dashboardError = "Projects could not be loaded. Please try again."; }).finally(() => { if (generation === dashboardGeneration) { state.loading = false; state.loadingMore = false; } }); } state.loadMoreProjects = () => loadAll(true); let reloadTimer; state.watchGroup(["search", "typeFilter", "orderBy", "projectView"], () => { if (legacyDashboard) return; if (reloadTimer) timers.timeout.cancel(reloadTimer); reloadTimer = timers.timeout(() => loadAll(), 150); }, false); state.watch("filters", () => { if (!legacyDashboard) loadAll(); }, true, false); state.on("dispose", () => { dashboardGeneration++; }); loadAll(); // Whole row opens the anonymized view; clicks on links, buttons and the // actions menu keep their own behaviour. state.openItem = (item, $event) => { if (!item._viewUrl) return; const target = $event && $event.target; if (target && target.closest && target.closest("a, button, .dropdown, input")) return; window.location.href = item._viewUrl; }; state.hiddenStatusCount = () => Object.keys(state.filters.status).filter((k) => state.filters.status[k] === false).length; state.hasHiddenStatus = () => state.hiddenStatusCount() > 0; state.hasActiveFilters = () => state.projectView !== "all" || state.typeFilter !== "all" || state.search.trim().length > 0 || Object.keys(state.filters.status).some((k) => state.filters.status[k] === false); state.showClearFilters = () => state.search.trim().length > 0 || state.typeFilter !== "all" || state.hiddenStatusCount() > 1; state.clearFilters = () => { state.projectView = "all"; state.typeFilter = "all"; state.search = ""; Object.keys(state.filters.status).forEach((k) => { state.filters.status[k] = true; }); }; function waitRepoToBeReady(repoId, callback, onError, previousStatus) { http.get("/api/repo/" + repoId).then(async (res) => { let statusChanged = previousStatus !== undefined && previousStatus !== res.data.status; for (const item of state.items) { if (item._type === "repo" && item.repoId == repoId) { statusChanged = statusChanged || item.status !== res.data.status; const neededAttention = state.needsAttention(item); item.status = res.data.status; item.statusMessage = res.data.statusMessage; item._statusKey = statusKey(item.status); if (item._statusKey !== "progress") item._stale = false; const needsAttention = state.needsAttention(item); if (state.dashboardTotals && neededAttention !== needsAttention) { state.dashboardTotals.attention = Math.max(0, state.dashboardTotals.attention + (needsAttention ? 1 : -1)); } break; } } if (statusChanged && !legacyDashboard) { state.dashboardCursor = null; const selected = state.selectedProject; await loadAll(); if (selected && state.selectedProject === selected) { state.selectedProject = state.items.find(item => item._type === selected._type && item._id === selected._id) || null; } } if ( res.data.status == "ready" || res.data.status == "error" || res.data.status == "removed" || res.data.status == "expired" ) { callback(res.data); return; } timers.timeout(() => waitRepoToBeReady(repoId, callback, onError, res.data.status), 2500); }, onError); } const labelOf = (t) => t === "repo" ? "repository" : t === "gist" ? "gist" : "pull request"; const apiBaseOf = (t) => t === "repo" ? "/api/repo" : t === "gist" ? "/api/gist" : "/api/pr"; state.removeItem = (item) => { const label = labelOf(item._type); if (confirm(`Are you sure that you want to remove the ${label} ${item._id}?`)) { const toast = reactive({ title: `Removing ${item._id}...`, date: new Date(), body: `The ${label} ${item._id} is going to be removed.`, }); state.addToast(toast); const endpoint = `${apiBaseOf(item._type)}/${item._id}`; http.delete(endpoint).then( () => { if (item._type === "repo") { waitRepoToBeReady(item._id, () => { toast.title = `${item._id} is removed.`; toast.body = `The ${label} ${item._id} is removed.`; }, undefined, item.status); } else { toast.title = `${item._id} is removed.`; toast.body = `The ${label} ${item._id} is removed.`; loadAll(); } }, (error) => { toast.title = `Error during the removal of ${item._id}.`; toast.body = error.body; loadAll(); } ); } }; state.refreshItem = (item) => { const label = labelOf(item._type); const toast = reactive({ title: `Refreshing ${item._id}...`, date: new Date(), body: `The ${label} ${item._id} is going to be refreshed.`, }); state.addToast(toast); const onError = (error) => { toast.title = `Error during the refresh of ${item._id}.`; toast.body = error.data?.error || error.body || "The refresh could not be completed. Please try again."; loadAll(); }; const endpoint = `${apiBaseOf(item._type)}/${item._id}/refresh`; http.post(endpoint).then( () => { if (item._type === "repo") { waitRepoToBeReady(item._id, (repo) => { if (repo.status !== "ready") { onError({ body: repo.statusMessage || `The repository is ${repo.status}.` }); return; } toast.title = `${item._id} is refreshed.`; toast.body = `The ${label} ${item._id} is refreshed.`; }, onError, item.status); } else { toast.title = `${item._id} is refreshed.`; toast.body = `The ${label} ${item._id} is refreshed.`; loadAll(); } }, onError ); }; state.extendItem = (item) => { const label = labelOf(item._type); const toast = reactive({ title: `Extending ${item._id}...`, date: new Date(), body: `The expiration of ${label} ${item._id} is 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()}` : ""; 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 }); } }; 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]; 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; state.previousConfigurations = []; state.previousConfigurationId = ""; let previousRepositories; const normalizedRepositoryName = name => String(name || "").replace(/\/$/, "").replace(/\.git$/i, "").toLowerCase(); async function findPreviousConfigurations() { if (state.isUpdate || params.repoId || params.pullRequestId || params.gistId) return; const request = { generation: sourceGeneration, url: state.sourceUrl.trim(), connection: state.githubConnection }; try { // This endpoint only returns repositories the signed-in user can manage. previousRepositories ||= http.get("/api/user/anonymized_repositories").then(res => res.data); const repositories = await previousRepositories; if (!isCurrentSource(request)) return; const name = normalizedRepositoryName(parseRepoFullName(request.url)); state.previousConfigurations = repositories.filter(repo => repo.repoId && repo.options && normalizedRepositoryName(repo.source?.fullName) === name) .sort((a, b) => (Date.parse(b.anonymizeDate) || 0) - (Date.parse(a.anonymizeDate) || 0)); state.previousConfigurationId = state.previousConfigurations[0]?.repoId || ""; } catch (_) { // A suggestion failure must not prevent creating a repository. Retry on the next URL selection. previousRepositories = undefined; } } state.existingConfigurationUrl = () => { const previous = state.previousConfigurations.find(repo => repo.repoId === state.previousConfigurationId); return previous ? "/anonymize/" + encodeURIComponent(previous.repoId) : null; }; function getDefault(cb) { http.get("/api/user/default").then((res) => { const data = res.data; if (data.terms) { state.defaultTerms = data.terms.join("\n"); } state.options = Object.assign({}, state.options, data.options); // Honour a server-provided default date, otherwise fall back to the // 6-month default. (Previously this re-added the offset on top of the // already-defaulted date, doubling it.) state.options.expirationDate = data.options && data.options.expirationDate ? new Date(data.options.expirationDate) : defaultExpirationDate(); if (!params.repoId && !params.pullRequestId && !params.gistId && !state.anonymize?.terms?.dirty) state.terms = state.defaultTerms; if (cb) cb(); }); } // Helper to safely set validity on form fields function setValidity(field, key, value) { if (state.anonymize && state.anonymize[field]) { state.anonymize[field].setValidity(key, value); } } function parseRepoFullName(url) { try { const parsed = parseGithubUrl(url); if (parsed && parsed.owner && parsed.repo) { return parsed.owner + "/" + parsed.repo; } } catch (_) { /* sourceUrl not yet parseable */ } return null; } state.accessRepairAllowed = false; state.repairingGitHubAccess = false; state.accessRepairMessage = ""; state.repairGitHubAccess = async () => { if (!state.accessRepairAllowed || state.repairingGitHubAccess || !state.githubConnections?.appEnabled || !params.repoId || state.role !== "owner" || state.githubConnection !== "github-app" || parseRepoFullName(state.sourceUrl) !== state._originalFullName) return; state.repairingGitHubAccess = true; state.accessRepairMessage = ""; const request = beginSourceRequest(); try { const connections = await http.get("/github/connections"); state.githubConnections = connections.data; if (!connections.data.appEnabled) return; const repaired = await http.post("/github/connections/migrate", { type: "repository", id: params.repoId, connection: "github-app" }, { headers: { "X-CSRF-Token": connections.data.csrf } }); if (!isCurrentSource(request)) return; state.error = null; if (repaired.data.fullName) { state._originalFullName = repaired.data.fullName; state.source.fullName = repaired.data.fullName; state.sourceUrl = "https://github.com/" + repaired.data.fullName; } state.accessRepairMessage = "GitHub access repaired. Your anonymous URL and published commit are unchanged."; await state.urlSelected(true); } catch (_) { if (isCurrentSource(request)) state.accessRepairMessage = "Unable to repair GitHub access. Check the App's repository permissions in Manage access, then retry. Your published snapshot has not been replaced."; } finally { state.repairingGitHubAccess = false; finishSourceRequest(request); } }; state.reconnectingSource = false; state.reconnectRepositoryId = undefined; state.reconnectSource = async () => { if (!confirm("Reconnect to the repository currently at this GitHub URL? Review its branch and commit before saving. Saving will replace the published snapshot.")) return; state.reconnectingSource = true; state.reconnectRepositoryId = undefined; state.source.commit = ""; state.source.branch = ""; state.readme = ""; state.branches = []; try { await getRepoDetails(); } catch (_) { /* getRepoDetails displays the access error. */ } }; function sourceRepositoryID() { if (state.reconnectingSource) return undefined; if (!state.isUpdate || !state._originalRepositoryID) return undefined; const currentFullName = parseRepoFullName(state.sourceUrl); return currentFullName === state._originalFullName ? state._originalRepositoryID : undefined; } async function refreshGitHubAccess() { state._preservingDraft = true; try { await state.urlSelected(true); } finally { state._preservingDraft = false; } } async function restoreGitHubDraft() { const billingDraft = new URLSearchParams(window.location.search).get("billingDraft"); if (billingDraft) { draft.restore(); try { const saved = (await http.get("/api/billing/draft/" + encodeURIComponent(billingDraft))).data; if (saved.path !== location.path()) throw new Error("Wrong task"); restoredCheckoutConference = saved.values.conference || null; for (const key of ["sourceUrl", "terms", "repoId", "source", "options", "conference", "githubConnection"]) { if (Object.prototype.hasOwnProperty.call(saved.values, key)) state[key] = saved.values[key]; } if (state.options?.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate); checkoutDraftId = saved.id; checkoutDraftSnapshot = JSON.stringify(draft.checkpoint()); state.draftRestored = true; state.hostingReview = true; if (state.sourceUrl) await refreshGitHubAccess(); // Consume the return token so revisiting the form cannot overwrite newer edits. location.replace?.(location.path()); return true; } catch { state.billingError = "The checkout draft could not be restored. Retry by reloading this page; any local draft is still available."; } } let saved; try { saved = JSON.parse(sessionStorage.getItem("github-access-draft") || "null"); } catch (_) { return false; } if (!saved || saved.path !== location.path() || Date.now() - saved.savedAt >= 30 * 60000) { if (!draft.restore()) return false; if (state.sourceUrl) await refreshGitHubAccess(); return true; } draft.restore(); for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) { if (Object.prototype.hasOwnProperty.call(saved.draft, key)) state[key] = saved.draft[key]; } if (state.options.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate); await refreshGitHubAccess(); sessionStorage.removeItem("github-access-draft"); return true; } getDefault(() => { if (!params.repoId && !params.pullRequestId && !params.gistId) timeout(restoreGitHubDraft, 0); // Edit mode: repo if (params.repoId && params.repoId != "") { state.isUpdate = true; state.detectedType = "repo"; state.repoId = params.repoId; http.get("/api/repo/" + state.repoId).then( async (res) => { state.accessRepairAllowed = !["removed", "archived", "preparing", "queue", "download", "removing", "expiring"].includes(res.data.status); state.githubConnection = res.data.connection || "oauth"; state.sourceUrl = "https://github.com/" + res.data.source.fullName; state._originalFullName = res.data.source.fullName; state.terms = res.data.options.terms.filter((f) => f).join("\n"); state.source = res.data.source; state.role = res.data.role || "owner"; state._existingNever = res.data.options.expirationMode === "never"; state._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; } catch (error) { state.preserve.error = "The file list could not be loaded from GitHub. You can still use patterns."; state.preserve.patternsOpen = true; } finally { state.preserve.loading = false; } } state.openPreserveFiles = () => { // Picks are saved first, so only the leading run of plain paths can be // shown as picks; a plain path after another rule stays a pattern to // keep its place in the order. const picked = {}; const patterns = []; for (const raw of state.options?.preserveFiles || []) { const p = String(raw).trim(); if (!p) continue; if (!patterns.length && isPickedPattern(p)) picked[globUnescape(p.slice(1), globOpts)] = true; else patterns.push(p); } const expanded = {}; for (const file of Object.keys(picked)) { const parts = file.split("/"); for (let i = 1; i < parts.length; i++) expanded[parts.slice(0, i).join("/")] = true; } Object.assign(state.preserve, { open: true, search: "", picked, expanded, patterns: patterns.join("\n"), patternsOpen: patterns.length > 0 }); pickVersion++; timeout(() => { const dialog = document.getElementById("preserve-files-dialog"); if (dialog?.showModal && !dialog.open) dialog.showModal(); document.getElementById("preserve-files-search")?.focus(); }, 0); loadPreserveTree(); }; state.closePreserveFiles = () => { document.getElementById("preserve-files-dialog")?.close?.(); state.preserve.open = false; }; state.savePreserveFiles = () => { state.options.preserveFiles = dedupePreservePatterns([...pickedRules(), ...patternLines()]); state.closePreserveFiles(); }; state.preserveRows = () => { if (!preserveIndex) return []; const matcher = preserveMatcher(); const fileRow = (file, depth, label) => { const picked = !!state.preserve.picked[file]; const checked = matcher(file); const note = !picked && checked ? "matched by a pattern" : picked && !checked ? "excluded by a pattern" : ""; return { kind: "file", path: file, label, depth, state: checked ? "all" : "none", locked: !picked && checked, note }; }; const search = String(state.preserve.search || "").trim().toLowerCase(); if (search) { return preserveIndex.files.filter((f) => f.toLowerCase().includes(search)).slice(0, 300).map((f) => fileRow(f, 0, f)); } const rows = []; const walk = (dir, depth) => { const entry = preserveIndex.dirs.get(dir); for (const sub of entry.dirs) { const open = !!state.preserve.expanded[sub]; const ownPattern = patternLines().includes(folderPattern(sub)); rows.push({ kind: "dir", path: sub, label: sub.slice(dir ? dir.length + 1 : 0) + "/", depth, open, state: dirState(sub), locked: false, note: ownPattern ? "whole folder" : "" }); if (open) walk(sub, depth + 1); } for (const file of entry.files) rows.push(fileRow(file, depth, file.slice(dir ? dir.length + 1 : 0))); }; walk("", 0); return rows; }; state.preserveCount = () => preserveIndex ? preserveIndex.files.filter(fileChecked).length : 0; state.togglePreserveDir = (dir) => { state.preserve.expanded[dir] = !state.preserve.expanded[dir]; }; state.togglePreserveFile = (file) => { state.preserve.picked[file] = !state.preserve.picked[file]; pickVersion++; }; // Unchecking a folder must win over broader rules such as "*.bib", so a // folder exclusion is appended when files inside would stay checked. state.togglePreserveFolder = (dir) => { const own = folderPattern(dir); const exclusion = folderExclusion(dir); const lines = patternLines().filter((l) => l !== own && l !== exclusion); const wasChecked = dirState(dir) === "all" || patternLines().includes(own); for (const f of descendants(dir)) state.preserve.picked[f] = false; pickVersion++; state.preserve.patterns = (wasChecked ? lines : [...lines, own]).join("\n"); if (wasChecked && descendants(dir).some(fileChecked)) { state.preserve.patterns = [...lines, exclusion].join("\n"); } }; // GitHub's README lookup order. The exact path is not known here, so the // preview treats the README as unredacted if any candidate is selected: // when unsure, it shows the author more exposure, not less. const README_CANDIDATES = [".github", "", "docs"].flatMap((dir) => ["README.md", "README", "README.markdown", "README.rst", "README.txt", "readme.md"].map((name) => (dir ? dir + "/" : "") + name)); state.readmePreserved = () => { if (state.detectedType !== "repo") return false; const patterns = (state.options?.preserveFiles || []).map((p) => String(p).trim()).filter(Boolean); if (!patterns.length) return false; try { const matcher = compilePreserveMatcher(patterns); return README_CANDIDATES.some((path) => matcher(path)); } catch { return false; } }; function previewOptions() { const opts = { terms: state.terms && !state.readmePreserved() ? state.terms.split("\n") : [], image: !!state.options.image, link: !!state.options.link, repoId: state.repoId, }; if (state.source && state.source.branch) { opts.branchName = state.source.branch; } try { const o = parseGithubUrl(state.sourceUrl); opts.repoName = `${o.owner}/${o.repo}`; } catch (_) { /* sourceUrl not yet parseable */ } return opts; } // Invalidate as soon as an option changes, before the debounce expires. function makePreviewBatcher(kind, buildBody, applyResult) { let pendingTimer = null; let generation = 0; const schedule = () => { if (state.detectedType !== kind) return; if (pendingTimer) timeout.cancel(pendingTimer); const current = ++generation; state.previewStatus = "loading"; pendingTimer = timeout(() => { pendingTimer = null; const body = buildBody(); if (!body) { state.previewStatus = "idle"; return; } http.post("/api/anonymize-preview", body).then(res => { if (current !== generation) return; applyResult(res.data, body); state.previewStatus = "ready"; }).catch(() => { if (current === generation) state.previewStatus = "error"; }); }, 200); }; schedule.cancel = () => { generation++; if (pendingTimer) timeout.cancel(pendingTimer); pendingTimer = null; }; state.on("dispose", schedule.cancel); return schedule; } state.retryPreview = () => { if (state.detectedType === "repo") scheduleReadmePreview(); if (state.detectedType === "pr") refreshPrPreview(); if (state.detectedType === "gist") refreshGistPreview(); }; const scheduleReadmePreview = makePreviewBatcher( "repo", () => { if (!state.readme) return null; return { content: state.readme, options: previewOptions() }; }, (data) => { if (typeof data?.content !== "string") throw Error("Invalid preview response"); state.anonymize_readme = data.content; let baseUrl = ""; try { const o = parseGithubUrl(state.sourceUrl); // Fall back to the repo's default branch when source.branch // hasn't loaded yet — without this, relative // resolved against a baseUrl like ".../raw//" (no branch // segment), so the browser fetched ".../raw/X" and 404'd // (#407). const branch = state.source.branch || (state.details && state.details.defaultBranch) || "main"; baseUrl = `https://github.com/${o.owner}/${o.repo}/raw/${branch}/`; } catch (_) { /* fall through with empty base */ } const html = renderMD(state.anonymize_readme, baseUrl); state.html_readme = html; timeout(Prism.highlightAll, 150); } ); function anonymizeReadme() { if (!state.anonymize || !state.anonymize.terms) return; // The "regex characters detected" hint is informational, not a blocker // — IP addresses, escaped chars, etc. are all legitimate terms (#430). scheduleReadmePreview(); } // ========== PR LOGIC ========== async function getPrDetails() { const o = parseGithubUrl(state.sourceUrl); const request = beginSourceRequest(); try { resetValidity(); const res = await http.get(`/api/pr/${o.owner}/${o.repo}/${o.pullRequestId}`, { params: { connection: state.githubConnection } }); if (!isCurrentSource(request)) return; state.details = res.data; if (!state.pullRequestId) { state.pullRequestId = o.repo + "-PR" + o.pullRequestId + "-" + generateRandomId(4); } } catch (error) { if (!isCurrentSource(request)) return; if (error.data) { translate("ERRORS." + error.data.error).then((translation) => { state.addToast({ title: "Error", date: new Date(), body: translation }); state.error = translation; }, console.error); displayErrorMessage(error.data.error); } setValidity("sourceUrl", "missing", false); throw error; } finally { finishSourceRequest(request); } } // Original text is never substituted for a pending or failed preview. const _prAnonCache = reactive(new Map()); function collectPrContents() { const out = new Set(); const d = state.details && state.details.pullRequest; if (!d) return out; if (typeof d.title === "string") out.add(d.title); if (typeof d.body === "string") out.add(d.body); if (typeof d.diff === "string") out.add(d.diff); const comments = d.comments || []; for (const c of comments) { if (typeof c.author === "string") out.add(c.author); if (typeof c.body === "string") out.add(c.body); } return out; } const refreshPrPreview = makePreviewBatcher( "pr", () => { const seen = collectPrContents(); const list = Array.from(seen); if (list.length === 0) return null; return { contents: list, options: previewOptions() }; }, (data, body) => { if (!Array.isArray(data?.contents) || data.contents.length !== body.contents.length || data.contents.some(value => typeof value !== "string")) throw Error("Invalid preview response"); const seen = body.contents; const next = new Map(); for (let i = 0; i < seen.length && i < data.contents.length; i++) { next.set(seen[i], data.contents[i]); } _prAnonCache.clear(); next.forEach((value, key) => _prAnonCache.set(key, value)); } ); state.anonymizePrContent = function (content) { if (!content) return content; if (_prAnonCache.has(content)) return _prAnonCache.get(content); return ""; }; // ========== GIST LOGIC ========== async function getGistDetails() { const o = parseGithubUrl(state.sourceUrl); const request = beginSourceRequest(); try { resetValidity(); state.gistOAuthRequired = false; state.gistAppRequired = false; const res = await http.get(`/api/gist/source/${o.gistId}`); if (!isCurrentSource(request)) return; state.details = res.data; if (!state.gistId) { state.gistId = "gist-" + o.gistId.substring(0, 6) + "-" + generateRandomId(4); } } catch (error) { if (!isCurrentSource(request)) return; if (error.data?.error === "github_app_reconnect_required") { state.gistAppRequired = true; setValidity("sourceUrl", "missing", false); return; } if (error.data?.error === "github_oauth_required") { state.gistOAuthRequired = true; setValidity("sourceUrl", "missing", false); return; } if (error.data) { translate("ERRORS." + error.data.error).then((translation) => { state.addToast({ title: "Error", date: new Date(), body: translation }); state.error = translation; }, console.error); displayErrorMessage(error.data.error); } setValidity("sourceUrl", "missing", false); throw error; } finally { finishSourceRequest(request); } } const _gistAnonCache = reactive(new Map()); function collectGistContents() { const out = new Set(); const d = state.details && state.details.gist; if (!d) return out; if (typeof d.description === "string") out.add(d.description); if (typeof d.ownerLogin === "string") out.add(d.ownerLogin); const files = (d.files) || []; for (const f of files) { if (typeof f.filename === "string") out.add(f.filename); if (typeof f.content === "string") out.add(f.content); } const comments = d.comments || []; for (const c of comments) { if (typeof c.author === "string") out.add(c.author); if (typeof c.body === "string") out.add(c.body); } return out; } const refreshGistPreview = makePreviewBatcher( "gist", () => { const seen = collectGistContents(); const list = Array.from(seen); if (list.length === 0) return null; return { contents: list, options: previewOptions() }; }, (data, body) => { if (!Array.isArray(data?.contents) || data.contents.length !== body.contents.length || data.contents.some(value => typeof value !== "string")) throw Error("Invalid preview response"); const seen = body.contents; const next = new Map(); for (let i = 0; i < seen.length && i < data.contents.length; i++) { next.set(seen[i], data.contents[i]); } _gistAnonCache.clear(); next.forEach((value, key) => _gistAnonCache.set(key, value)); rebuildPreviewGistFiles(); } ); state.anonymizeGistContent = function (content) { if (!content) return content; if (_gistAnonCache.has(content)) return _gistAnonCache.get(content); return ""; }; // Precomputed file objects for the preview pane so '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); fetchFileCounts(normalized, generation); return res.data; }, function (err) { if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return []; if (!background) { state.type = "error"; state.content = viewerErrorCode(err); state.files = []; } }); }; function fetchFileCounts(path, generation) { if (countsUnavailable || countRequests.has(path)) return; countRequests.add(path); const repoId = state.repoId; http.get( `/api/repo/${state.repoId}/files/counts?path=${encodeURIComponent(path)}` ).then(function (res) { if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return; state.fileCounts = { ...state.fileCounts, ...res.data }; }, function () { if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return; // Counts are optional. Do not retry a guarded or rate-limited query // every time a visitor opens another directory. countsUnavailable = true; }); } function getSelectedFile() { return state.files.filter( (f) => f.name == state.paths[state.paths.length - 1] && f.path == state.paths.slice(0, state.paths.length - 1).join("/") )[0]; } var rlCountdownTimer = null; state.on("dispose", function () { if (rlCountdownTimer) timers.interval.cancel(rlCountdownTimer); }); function getOptions(callback) { if (destroyed) return; const repoId = state.repoId; http.get(`/api/repo/${state.repoId}/options`).then( (res) => { if (destroyed || repoId !== state.repoId) return; state.options = res.data; if (state.options.url) { window.location = state.options.url; return; } if (callback) { callback(res.data); } }, (err) => { if (destroyed || repoId !== state.repoId) return; var data = err.data || {}; if (data.error === "rate_limited" && data.resetAt) { state.type = "rate_limited"; state.rateLimitResetAt = data.resetAt; if (rlCountdownTimer) timers.interval.cancel(rlCountdownTimer); function rlTick() { var remaining = Math.max(0, Math.ceil((state.rateLimitResetAt - Date.now()) / 1000)); if (remaining <= 0) { state.rateLimitCountdown = ""; state.rateLimitResetAt = 0; if (rlCountdownTimer) { timers.interval.cancel(rlCountdownTimer); rlCountdownTimer = null; } getOptions(callback); } else { var min = Math.floor(remaining / 60); var sec = remaining % 60; state.rateLimitCountdown = min > 0 ? min + "m " + sec + "s" : sec + "s"; } } rlTick(); rlCountdownTimer = timers.interval(rlTick, 1000); } else if (data.error === "repository_not_ready") { state.type = "loading"; timers.timeout(function () { getOptions(callback); }, 3000); } else { state.type = "error"; state.content = viewerErrorCode(err); } } ); } // Defined as methods rather than inline `showSource = !showSource` in the // template: the toolbar and the file view sit in different child scopes, // so an inline assignment would shadow the value on one of them instead // of updating the controller's. state.toggleSource = function () { state.showSource = !state.showSource; }; state.toggleAllowScripts = function () { state.allowScripts = !state.allowScripts; }; // Wrap on wide screens; on phones code scrolls sideways so tokens stay intact. const storedWrap = (() => { try { return window.localStorage?.getItem("wrapLines"); } catch { return null; } })(); state.wrapLines = (storedWrap ?? (window.matchMedia?.("(max-width: 767px)").matches ? "false" : "true")) !== "false"; state.toggleWrap = function () { state.wrapLines = !state.wrapLines; try { window.localStorage?.setItem("wrapLines", String(state.wrapLines)); } catch { /* storage unavailable */ } state.codeEditor?.session.setUseWrapMode(state.wrapLines); }; // Find opens as an overlay at the top right of the code, like an editor. // Find on rendered Markdown pages: highlight matches inside the reading column. function mdFindRoot() { return window.document.querySelector(".reading-layout .markdown-body"); } function clearMdFind() { const root = mdFindRoot(); if (!root) return; root.querySelectorAll("mark.md-find").forEach((m) => { m.replaceWith(window.document.createTextNode(m.textContent)); }); root.normalize(); } state.runMdFind = function () { clearMdFind(); state.mdFindTotal = 0; state.mdFindIndex = 0; const q = (state.mdFindQuery || "").trim(); const root = mdFindRoot(); if (!q || !root) return; const cs = !!state.mdFindCase; const needle = cs ? q : q.toLowerCase(); const walker = window.document.createTreeWalker(root, 4); const nodes = []; while (walker.nextNode()) if ((cs ? walker.currentNode.nodeValue : walker.currentNode.nodeValue.toLowerCase()).includes(needle)) nodes.push(walker.currentNode); let total = 0; for (const node of nodes) { const text = node.nodeValue, lower = cs ? text : text.toLowerCase(); const frag = window.document.createDocumentFragment(); let last = 0, i; while ((i = lower.indexOf(needle, last)) !== -1) { frag.appendChild(window.document.createTextNode(text.slice(last, i))); const m = window.document.createElement("mark"); m.className = "md-find"; m.textContent = text.slice(i, i + q.length); frag.appendChild(m); total++; last = i + q.length; } frag.appendChild(window.document.createTextNode(text.slice(last))); node.parentNode.replaceChild(frag, node); } state.mdFindTotal = total; if (total) state.stepMdFind(0); }; state.stepMdFind = function (delta) { const root = mdFindRoot(); if (!root || !state.mdFindTotal) return; const marks = root.querySelectorAll("mark.md-find"); state.mdFindIndex = (state.mdFindIndex + delta + marks.length) % marks.length; marks.forEach((m, i) => m.classList.toggle("is-current", i === state.mdFindIndex)); marks[state.mdFindIndex]?.scrollIntoView({ block: "center", behavior: "smooth" }); }; state.toggleMdFind = function (open) { state.mdFindOpen = open === undefined ? !state.mdFindOpen : open; if (!state.mdFindOpen) { clearMdFind(); state.mdFindTotal = 0; return; } nextTick(() => window.document.querySelector(".md-find-input")?.focus()); }; state.findInFile = function () { const editor = state.codeEditor; if (!editor) return; editor.focus(); editor.execCommand("find"); // Docked under the toolbar: push the code down while the find bar is open. const dock = () => { const box = editor.searchBox?.element; if (!box) return; editor.renderer.setScrollMargin(52, 12, 0, 0); if (box._dockObserver) return; box._dockObserver = new MutationObserver(() => { editor.renderer.setScrollMargin(box.style.display !== "none" ? 52 : 12, 12, 0, 0); }); box._dockObserver.observe(box, { attributes: true, attributeFilter: ["style"] }); }; dock(); setTimeout(dock, 150); }; // Lightweight outline: top-level classes, functions and exported constants. const symbolPatterns = [ [/^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, "class"], [/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/, "function"], [/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/, "function"], [/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/, "function"], [/^\s*class\s+([A-Za-z_]\w*)/, "class"], [/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/, "function"], [/^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)/, "function"], [/^\s*(?:public|private|protected|static|\s)+[\w<>\[\],\s]+\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{?\s*$/, "function"], ]; let symbolCache = { content: null, list: [] }; state.symbolsOpen = false; state.symbols = function () { if (typeof state.content !== "string" || state.content.length > 2_000_000) return []; if (symbolCache.content === state.content) return symbolCache.list; const list = []; state.content.split("\n").forEach((text, index) => { for (const [re, kind] of symbolPatterns) { const m = re.exec(text); if (m && !/^(if|for|while|switch|return|catch)$/.test(m[1])) { list.push({ name: m[1], kind, line: index + 1 }); break; } } }); symbolCache = { content: state.content, list: list.slice(0, 300) }; return symbolCache.list; }; state.goToSymbol = function (line) { state.symbolsOpen = false; const editor = state.codeEditor; if (!editor) return; editor.scrollToLine(line - 1, true, true, () => {}); editor.gotoLine(line, 0, true); window.history.replaceState(null, "", window.location.pathname + window.location.search + "#L" + line); window.dispatchEvent(new HashChangeEvent("hashchange")); }; // Go to line: a small popover in the file header (Ace's own prompt ignores the site theme). state.gotoLine = function () { if (!state.codeEditor) return; state.moreOpen = false; state.gotoError = ""; state.gotoValue = String(state.codeEditor.getCursorPosition().row + 1); state.gotoOpen = true; setTimeout(() => { const el = window.document.getElementById("goto-input"); if (el) { el.focus(); el.select(); } }, 0); }; state.closeGoto = function () { state.gotoOpen = false; state.gotoError = ""; state.codeEditor?.focus(); }; state.submitGoto = function () { const editor = state.codeEditor; if (!editor) return; const m = /^\s*:?\s*(\d+)\s*(?:[:,]\s*(\d+))?\s*$/.exec(state.gotoValue || ""); const lines = editor.session.getLength(); if (!m || +m[1] < 1 || +m[1] > lines) { state.gotoError = "Enter a line from 1 to " + lines; return; } editor.gotoLine(+m[1], m[2] ? Math.max(0, +m[2] - 1) : 0, true); state.gotoOpen = false; state.gotoError = ""; editor.focus(); }; // Root listing above the README: folders first, then files. state.rootEntries = function () { return [...(state.files || [])].sort((a, b) => { const af = a.size == null, bf = b.size == null; if (af !== bf) return af ? -1 : 1; return a.name.localeCompare(b.name); }); }; state.formatSize = function (bytes) { if (bytes == null) return ""; if (bytes < 1024) return bytes + " B"; const kb = bytes / 1024; return kb < 1024 ? kb.toFixed(1) + " KB" : (kb / 1024).toFixed(1) + " MB"; }; state.fileSize = function (path) { if (!path) return null; const clean = path.replace(/^\/+/, ""); const entry = (state.files || []).find((f) => (f.path ? f.path + "/" : "") + f.name === clean); return entry && entry.size != null ? entry.size : null; }; state.revealFolder = function (name) { state.sidebarHidden = false; state.sidebarCollapsed = false; nextTick(() => { const link = window.document.querySelector('.files a[data-path="/' + (window.CSS?.escape ? window.CSS.escape(name) : name) + '"]'); if (!link) return; if (!link.closest("li")?.classList.contains("open")) link.click(); link.scrollIntoView({ block: "nearest" }); link.focus(); }); }; state.redactionCount = function () { if (typeof state.content !== "string") return 0; const text = state.type === "html" ? state.content.replace(/<[^>]*>/g, " ") : state.content; return (text.match(REDACTION_RE) || []).length; }; state.renderRedacted = slotRedactions; // Reading mode: slugged heading ids + an "On this page" outline (like Mintlify/Stripe docs). const slug = (t) => t.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").slice(0, 60) || "section"; state.renderReadme = function (html) { const out = state.renderRedacted(html); if (!out || typeof window.DOMParser !== "function") return out; const doc = new window.DOMParser().parseFromString("
" + out + "
", "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("
" + state.renderReadme(state.sanitize ? state.sanitize(state.content) : state.content) + "
", "text/html"); const list = [...doc.querySelectorAll("h2, h3")].map((h) => ({ id: h.id, text: h.textContent.trim(), level: h.tagName === "H3" ? 3 : 2 })).slice(0, 40); tocCache = { content: state.content, list }; return list; }; state.activeHeading = null; listen(window, "scroll", () => { const toc = state.readmeToc ? state.readmeToc() : []; if (!toc.length) return; // Active = the first heading visible below the sticky chrome; if none is on screen, // the last heading already scrolled past (the reader is inside that section). const chrome = 150; let current = "", firstVisible = ""; for (const h of toc) { const el = window.document.getElementById(h.id); if (!el) continue; const top = el.getBoundingClientRect().top; if (top < chrome) current = h.id; else if (!firstVisible && top < window.innerHeight - 80) firstVisible = h.id; } if (firstVisible) current = firstVisible; if (state.activeHeading !== current) state.activeHeading = current; }, { capture: true, passive: true }); state.scrollToHeading = function (id) { if (!id) { const sc = window.document.querySelector(".explorer-content, .file-card-body, .main-pane"); (sc || window).scrollTo({ top: 0, behavior: "smooth" }); state.activeHeading = ""; return; } const el = window.document.getElementById(id); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); state.activeHeading = id; }; state.redactParts = function (text) { const out = []; let last = 0; let m; const re = /\bXXXX-(\d+)\b/g; text = String(text || ""); while ((m = re.exec(text))) { if (m.index > last) out.push({ t: text.slice(last, m.index) }); out.push({ n: m[1] }); last = m.index + m[0].length; } if (last < text.length) out.push({ t: text.slice(last) }); return out; }; state.pathCopied = false; state.copyPath = async function () { if (!window.navigator.clipboard?.writeText) return; await window.navigator.clipboard.writeText(state.filePath || ""); state.pathCopied = true; setTimeout(() => { state.pathCopied = false; }, 1600); }; state.contentCopied = false; state.copyContent = async function () { if (typeof state.content !== "string" || !window.navigator.clipboard?.writeText) return; // Rendered Markdown: copy the source, not the HTML. let text = state.content; if (state.type === "html" && state.url) { try { text = await (await window.fetch(state.url, { credentials: "same-origin" })).text(); } catch (_) { return; } } await window.navigator.clipboard.writeText(text); state.contentCopied = true; setTimeout(() => { state.contentCopied = false; }, 1600); }; function getMode(extension) { if (extensionModes[extension]) { return extensionModes[extension]; } return extension; } function getType(extension) { if (extension == "pdf") { return "pdf"; } // Rendered as a document in a sandboxed frame rather than as source — // see html-doc.js. "html" is reserved for markup we generated // ourselves (rendered markdown/org) and inject directly. if (extension == "html" || extension == "htm") { return "html-doc"; } if (extension == "md") { return "md"; } if (extension == "org") { return "org"; } if (extension == "ipynb") { return "IPython"; } if (textFiles.indexOf(extension) > -1) { return "text"; } if (imageFiles.indexOf(extension) > -1) { return "image"; } if (mediaFiles.indexOf(extension) > -1) { return "media"; } if (audioFiles.indexOf(extension) > -1) { return "audio"; } return "code"; } function getContent(path, fileInfo) { const generation = contentGeneration; if (!path) { state.type = "error"; state.content = "no_file_selected"; return; } const originalType = state.type; // Load the editor alongside content, not after the response finishes. // Its mounting path still reports errors and can retry a failed load. if (originalType === "code" || originalType === "text") loadEditor().catch(() => {}); state.type = "loading"; state.content = "loading"; state.fileLoadingSlow = false; state.fileReceivedBytes = 0; const canceller = promises.defer(); contentCanceller = canceller; loadingNoticeTimer = timers.timeout(() => { if (!destroyed && generation === contentGeneration && state.type === "loading") state.fileLoadingSlow = true; }, 1500); // fileInfo can be undefined when the user navigates (e.g. clicks a // markdown link into a subdir whose file list hasn't loaded yet) — // see #510. Fall back to "0" so the request still goes through; the // server returns a fresh ETag on first hit either way. const sha = (fileInfo && fileInfo.sha) || "0"; http .get( `/api/repo/${state.repoId}/file/${encodePathForUrl(path)}?v=` + sha, { timeout: canceller.promise, onDownloadProgress: bytes => { if (!destroyed && generation === contentGeneration) state.fileReceivedBytes = bytes; }, transformResponse: (data) => { return data; }, } ) .then( (res) => { if (destroyed || generation !== contentGeneration) return; cancelContentLoad(); state.type = originalType; state.content = res.data; if (state.content == "") { state.content = null; } if (state.type == "md") { state.content = renderMD(res.data, location.url() + "/../"); state.type = "html"; } if (state.type == "org") { const content = contentAbs2Relative(res.data); const orgParser = new Org.Parser(); const orgDocument = orgParser.parse(content); var orgHTMLDocument = orgDocument.convert(Org.ConverterHTML, { headerOffset: 1, exportFromLineNumber: false, suppressSubScriptHandling: true, suppressAutoLink: false, }); state.content = DOMPurify.sanitize(orgHTMLDocument.toString()); state.type = "html"; } if ( state.type == "code" && res.headers("content-type") == "application/octet-stream" ) { state.type = "binary"; state.content = "binary"; } timers.timeout(() => { Prism.highlightAll(); }, 50); }, (err) => { if (destroyed || generation !== contentGeneration) return; cancelContentLoad(); state.type = "error"; state.content = viewerErrorCode(err); } ); } function updateContent() { contentGeneration++; cancelContentLoad(); state.content = ""; state.file = getSelectedFile(); let fileVersion = "0"; if (state.file && state.file.sha) { fileVersion = state.file.sha; } state.url = `/api/repo/${state.repoId}/file/${encodePathForUrl( state.filePath )}?v=${fileVersion}`; // Directory the file lives in, used as the for a rendered HTML // document so its relative images/stylesheets still resolve. const dirPath = state.filePath.substring( 0, state.filePath.lastIndexOf("/") + 1 ); state.fileBaseUrl = `/api/repo/${state.repoId}/file/${ dirPath ? encodePathForUrl(dirPath) : "" }`; state.showSource = false; // Scripts in a repository's HTML are opt-in, per file — see // html-doc.js. Reset on navigation so trust never carries over from // one file to the next. state.allowScripts = false; let extension = state.filePath.toLowerCase(); const extensionIndex = extension.lastIndexOf("."); if (extensionIndex > -1) { extension = extension.substring(extensionIndex + 1); } const languageNames = { javascript: "JavaScript", typescript: "TypeScript", tsx: "TSX", jsx: "JSX", python: "Python", java: "Java", c_cpp: "C/C++", csharp: "C#", golang: "Go", rust: "Rust", ruby: "Ruby", php: "PHP", html: "HTML", css: "CSS", scss: "SCSS", json: "JSON", yaml: "YAML", markdown: "Markdown", sh: "Shell", dockerfile: "Dockerfile", xml: "XML", sql: "SQL", kotlin: "Kotlin", swift: "Swift", scala: "Scala", r: "R", julia: "Julia", latex: "LaTeX", toml: "TOML", text: "" }; const detectedMode = getMode(extension); state.languageName = languageNames[detectedMode] ?? (detectedMode ? detectedMode.charAt(0).toUpperCase() + detectedMode.slice(1) : ""); state.aceOption = { readOnly: true, useWrapMode: true, showGutter: true, theme: "chrome", useSoftTab: true, tabSize: 2, fontSize: window.matchMedia?.("(max-width: 767px)").matches ? 12 : 13, keyBinding: "vscode", fullLineSelection: true, highlightActiveLine: false, highlightGutterLine: false, cursor: "hide", showInvisibles: false, showIndentGuides: false, showPrintMargin: false, highlightSelectedWord: false, enableBehaviours: true, fadeFoldWidgets: false, mode: getMode(extension), onLoad: function (_editor) { state.codeEditor = markRaw(_editor); { // Render each XXXX-n as its own "redacted" token so the text layer // draws it exactly like the Markdown chip (teal text on a teal tint). const session = _editor.session; const origGetTokens = session.getTokens.bind(session); const re = /\bXXXX-\d+\b/g; session.getTokens = function (row) { const tokens = origGetTokens(row); if (!tokens.some((t) => t.value.indexOf("XXXX-") !== -1)) return tokens; const out = []; for (const t of tokens) { if (t.value.indexOf("XXXX-") === -1) { out.push(t); continue; } let last = 0; re.lastIndex = 0; let m; while ((m = re.exec(t.value))) { if (m.index > last) out.push({ type: t.type, value: t.value.slice(last, m.index) }); out.push({ type: "redacted.n" + m[0].slice(5), value: m[0] }); last = m.index + m[0].length; } if (last < t.value.length) out.push({ type: t.type, value: t.value.slice(last) }); } return out; }; // Each redacted token shows its term number inside the slot. if (!window.document.getElementById("redact-index-rules")) { const st = window.document.createElement("style"); st.id = "redact-index-rules"; st.textContent = Array.from({ length: 99 }, (_, i) => `.ace_redacted.ace_n${i + 1}::after{content:"${i + 1}"}`).join(""); window.document.head.appendChild(st); } _editor.renderer.updateFull(true); } _editor.session.setUseWrapMode(state.wrapLines !== false); // The right-edge fade is a cue only while there is more code to the right. const card = _editor.container.closest?.(".file-card"); const syncEdge = () => { if (!card) return; const r = _editor.renderer; const max = (r.layerConfig?.width || 0) - (r.$size?.scrollerWidth || 0); card.classList.toggle("at-right-edge", max <= 2 || r.getScrollLeft() >= max - 2); }; _editor.session.on("changeScrollLeft", syncEdge); _editor.renderer.on("afterRender", syncEdge); // On phones code scrolls sideways; keep the scrollbar visible as a cue. if (window.matchMedia?.("(max-width: 767px)").matches) _editor.setOption("hScrollBarAlwaysVisible", true); const Range = ace.require("ace/range").Range; let activeLineMarker = null; function highlightLines(from, to) { if (activeLineMarker !== null) { _editor.session.removeMarker(activeLineMarker); activeLineMarker = null; } if (from === null || from === undefined) return; activeLineMarker = _editor.session.addMarker( new Range(from, 0, to, 1), "highlighted-line", "fullLine" ); } function applyHashFromUrl(scroll) { const m = window.location.hash.match(/^#L(\d+)(?:-L(\d+))?/); if (!m) { highlightLines(null); return; } const from = parseInt(m[1]) - 1; const to = m[2] ? parseInt(m[2]) - 1 : from; highlightLines(from, to); if (scroll) { timers.timeout(() => { _editor.scrollToLine(from, true, true, function () {}); }, 100); } } applyHashFromUrl(true); // #392 — clicking a gutter line updates the URL to #L and // shift-clicking extends to #L-L so the user can copy // a stable link to a specific line. Use replaceState to avoid // polluting history with every click. let anchorRow = null; _editor.on("guttermousedown", function (e) { const row = e.getDocumentPosition().row; const shift = e.domEvent && e.domEvent.shiftKey; let from = row; let to = row; if (shift && anchorRow !== null) { from = Math.min(anchorRow, row); to = Math.max(anchorRow, row); } else { anchorRow = row; } const hash = from === to ? `#L${from + 1}` : `#L${from + 1}-L${to + 1}`; const url = window.location.pathname + window.location.search + hash; window.history.replaceState(null, "", url); highlightLines(from, to); e.stop(); }); const removeHashListener = listen(window, "hashchange", () => applyHashFromUrl(false)); _editor.setFontSize(state.aceOption.fontSize); _editor.setOptions({ fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace", }); _editor.container.style.lineHeight = "20px"; _editor.renderer.setPadding(16); _editor.renderer.setScrollMargin(12, 12, 0, 0); _editor.renderer.updateFontSize(); _editor.setReadOnly(state.aceOption.readOnly); _editor.setKeyboardHandler(state.aceOption.keyBinding); _editor.setSelectionStyle( state.aceOption.fullLineSelection ? "line" : "text" ); _editor.setOption("displayIndentGuides", false); _editor.setHighlightActiveLine( state.aceOption.highlightActiveLine ); if (state.aceOption.cursor == "hide") { _editor.renderer.$cursorLayer.element.style.display = "none"; } _editor.setHighlightGutterLine( state.aceOption.highlightGutterLine ); _editor.setShowInvisibles(state.aceOption.showInvisibles); _editor.setDisplayIndentGuides(state.aceOption.showIndentGuides); _editor.renderer.setShowPrintMargin( state.aceOption.showPrintMargin ); _editor.setHighlightSelectedWord( state.aceOption.highlightSelectedWord ); _editor.session.setUseSoftTabs(state.aceOption.useSoftTab); _editor.session.setTabSize(state.aceOption.tabSize); _editor.setBehavioursEnabled(state.aceOption.enableBehaviours); _editor.setFadeFoldWidgets(state.aceOption.fadeFoldWidgets); // Ctrl/Cmd+F searches inside the file (Ace searchbox), like GitHub's blob view. const removeFindListener = listen(window, "keydown", (event) => { if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "g" && document.body.contains(_editor.container)) { event.preventDefault(); state.gotoLine(); return; } if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "z" && document.body.contains(_editor.container)) { event.preventDefault(); state.toggleWrap(); return; } if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "f" && document.body.contains(_editor.container)) { event.preventDefault(); state.findInFile(); } }); return () => { removeHashListener(); removeFindListener(); }; }, }; if (state.isDarkMode) { state.aceOption.theme = "nord_dark"; } state.type = getType(extension); if (state.type == "pdf") { // The viewer streams the file itself from state.url, so fetching // the bytes again here only to hold them as a JS string wastes a // request and a lot of memory on a large report. Content stays // non-null so the Raw/Download actions remain available. state.content = "pdf"; return; } getContent(state.filePath, state.file); } state.isSourceAccessError = () => ["repo_not_found", "token_expired", "github_app_access_required", "github_app_reconnect_required", "repo_saml_enforcement", "repo_access_limited"].includes(state.content); state.retryContent = () => init(); function init() { contentGeneration++; cancelContentLoad(); state.files = []; state.content = null; state.fileCounts = null; listingGeneration++; countRequests = new Set(); countsUnavailable = false; state.fileSearchQuery = ""; state.onFileSearchChange(); state.repoId = params.repoId; state.type = "loading"; state.filePath = params.path || ""; state.paths = state.filePath.split("/"); const repoId = state.repoId; getOptions(function (options) { if (state.filePath) { // File URLs are independently authorized by the content endpoint. // The tree is navigation metadata, not a prerequisite for reading. updateContent(); const directories = [...new Set(state.paths.map((_, i) => state.paths.slice(0, i).join("/")))]; promises.all(directories.map(path => state.getFiles(path, true))); return; } var chain = promises.resolve(); for (let i = 0; i < state.paths.length; i++) { const path = i > 0 ? state.paths.slice(0, i).join("/") : ""; chain = chain.then(function () { return state.getFiles(path); }).then(function () { if (state.type === "error") { return promises.reject("error"); } }); } chain.then(function () { if (destroyed || repoId !== state.repoId) return; if (state.files.length == 1 && state.files[0].name == "") { state.files = []; state.type = "empty"; } else { selectFile(); updateContent(); } }); }); } init(); }; export const pullRequestController = function (state, http, location, params, html) { async function getOption(callback) { http.get(`/api/pr/${state.pullRequestId}/options`).then( (res) => { state.options = res.data; if (state.options.url) { // the repository is expired with redirect option window.location = state.options.url; return; } if (callback) { callback(res.data); } }, (err) => { state.type = "error"; state.content = err.data.error; } ); } async function getPullRequest(callback) { http.get(`/api/pr/${state.pullRequestId}/content`).then( (res) => { state.details = res.data; state.tabState = { active: res.data.diff ? "diff" : "comments" }; if (callback) { callback(res.data); } }, (err) => { state.type = "error"; state.content = err.data.error; } ); } function init() { state.pullRequestId = params.pullRequestId; state.type = "loading"; getOption((_) => { getPullRequest(); }); } init(); }; export const gistController = function (state, http, location, params, html) { async function getOption(callback) { http.get(`/api/gist/${state.gistId}/options`).then( (res) => { state.options = res.data; if (state.options.url) { window.location = state.options.url; return; } if (callback) callback(res.data); }, (err) => { state.type = "error"; state.content = err.data.error; } ); } async function getGist(callback) { http.get(`/api/gist/${state.gistId}/content`).then( (res) => { state.details = res.data; // Choose the visible tab after the asynchronous content arrives. const hasFiles = res.data && res.data.files && res.data.files.length; state.tabState = { active: hasFiles ? "files" : "comments" }; if (callback) callback(res.data); }, (err) => { state.type = "error"; state.content = err.data.error; } ); } function init() { state.gistId = params.gistId; state.type = "loading"; getOption(() => { getGist(); }); } init(); }; export const conferencesController = function (state, http, location) { state.watch("user.status", () => { if (state.user == null) { location.url("/"); } }); if (state.user == null) { location.url("/"); } state.conferences = []; state.loading = true; state.error = ""; state.search = ""; const conferencesPrefsKey = "conferences.filterPrefs"; const conferencesPrefDefaults = { filters: { status: { ready: true, expired: false, removed: false } }, orderBy: "name", }; const savedConferencesPrefs = loadFilterPrefs(conferencesPrefsKey) || {}; state.filters = { status: Object.assign( {}, conferencesPrefDefaults.filters.status, (savedConferencesPrefs.filters && savedConferencesPrefs.filters.status) || {} ), }; state.orderBy = savedConferencesPrefs.orderBy || conferencesPrefDefaults.orderBy; state.watch("orderBy", () => { saveFilterPrefs(conferencesPrefsKey, { filters: state.filters, orderBy: state.orderBy, }); }); state.watch( "filters", () => { saveFilterPrefs(conferencesPrefsKey, { filters: state.filters, orderBy: state.orderBy, }); }, true ); state.removeConference = function (conf) { if (conf.removing) return; if ( confirm( `Are you sure that you want to remove the conference ${conf.name}? All the repositories linked to this conference will expire.` ) ) { conf.removing = true; const toast = reactive({ title: `Removing ${conf.name}...`, date: new Date(), body: `The conference ${conf.name} is going to be removed.`, }); state.addToast(toast); http.delete(`/api/conferences/${conf.conferenceID}`).then(() => { toast.title = `${conf.name} is removed.`; toast.body = `The conference ${conf.name} is removed.`; state.loadConferences(); }).catch(() => { toast.title = "Unable to remove conference"; toast.body = "Please try again. The conference has not been removed."; }).finally(() => { conf.removing = false; }); } }; state.loadConferences = () => { state.loading = true; state.error = ""; return http.get("/api/conferences/").then( (res) => { state.conferences = res.data || []; }, (err) => { state.error = "Unable to load your conferences. Please try again."; } ).finally(() => { state.loading = false; }); }; state.loadConferences(); state.conferenceFilter = (conference) => { if (state.filters.status[conference.status] == false) return false; const query = state.search.trim().toLowerCase(); if (!query) return true; if ((conference.name || "").toLowerCase().includes(query)) return true; if ((conference.conferenceID || "").toLowerCase().includes(query)) return true; return false; }; }; export const newConferenceController = function (state, http, location, params) { state.watch("user.status", () => { if (state.user == null) { location.url("/"); } }); if (state.user == null) { location.url("/"); } state.plans = []; state.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", "KiB", "MiB", "GiB", "TiB"][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" }[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, expired: false, removed: false }, }; state.orderBy = "-anonymizeDate"; state.repoFiler = (repo) => { if (state.filters.status[repo.status] == false) return false; const query = state.search.trim().toLowerCase(); if (!query) return true; if ((repo.source?.fullName || "").toLowerCase().includes(query)) return true; if ((repo.repoId || "").toLowerCase().includes(query)) return true; return false; }; function getConference() { http .get("/api/conferences/" + params.conferenceId) .then((res) => { state.conference = res.data; }); } getConference(); }; export const connectionsController = function (state, http) { state.notificationEmail = ""; state.emailSaved = false; state.saveNotificationEmail = async () => { state.busy = true; state.emailSaved = false; state.connectionError = ""; try { const res = await http.post("/github/connections/email", { email: state.notificationEmail }, { headers: { "X-CSRF-Token": state.connections.csrf } }); state.notificationEmail = res.data.notificationEmail; if (state.user) state.user.notificationEmail = res.data.notificationEmail; state.emailSaved = true; } catch (error) { state.connectionError = error.data?.error || "Unable to save email address."; } finally { state.busy = false; } }; state.connections = null; state.connectionError = ""; state.busy = false; state.loadConnections = () => http.get("/github/connections").then(res => { state.connections = res.data; state.notificationEmail = res.data.notificationEmail || ""; }).catch(error => { state.connectionError = error.data?.error || "Unable to load connections."; }); state.changeConnection = async (resource, connection, preview) => { state.busy = true; state.connectionError = ""; try { const result = await http.post("/github/connections/migrate", { type: resource.type, id: resource.id, connection, preview }, { headers: { "X-CSRF-Token": state.connections.csrf } }); if (preview) resource.eligible = result.data.eligible; else await state.loadConnections(); } catch (error) { state.connectionError = error.data?.error || "Unable to change connection."; } finally { state.busy = false; } }; state.disconnectOAuth = async () => { state.busy = true; try { await http.post("/github/connections/disconnect-oauth", {}, { headers: { "X-CSRF-Token": state.connections.csrf } }); await state.loadConnections(); } catch (error) { state.connectionError = error.data?.error || "Unable to disconnect OAuth."; } finally { state.busy = false; } }; state.loadConnections(); }; export const signinController = function (state, http) { state.accountRecovery = false; http.get("/github/account-recovery").then(res => { state.accountRecovery = res.data.required; }).catch(() => {}); };