mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
The "What reviewers will see" window now has a sidebar like the explorer's instead of the one-row file strip: folders first, then files, with the explorer's tree rows, icons and teal selection on the README. Folders expand from the loaded file list. The sidebar is 24% of the window (180-240px), tinted full height, and scrolls apart from the document. Phones hide it and show the explorer's "Files" button in the file bar. Loading shows skeleton rows; without a file list the sidebar shows the README only. Removes the strip fitter and the dead rail/strip CSS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4909 lines
221 KiB
JavaScript
4909 lines
221 KiB
JavaScript
import { setupBilling, readBilling, isRepositoryAddon } from "./billing.js";
|
||
import { reactive, nextTick, markRaw } from "vue";
|
||
import { loadEditor } from "./lazy-assets.js";
|
||
import { createTimers, createListeners } from "./state.js";
|
||
import { formDraft } from "./drafts.js";
|
||
import { statusGroup } from "./admin.js";
|
||
import { humanTime, dateEcho, shortDate } from "./formatters.js";
|
||
import { hangCodeLines } from "./components.js";
|
||
import { escape as globEscape, unescape as globUnescape } from "minimatch";
|
||
import { compilePreserveMatcher, dedupePreservePatterns } from "../../src/core/preserve-files";
|
||
import { termHints, escapeTermLine } from "../../src/core/term-hints";
|
||
import translations from "../i18n/locale-en.json";
|
||
|
||
// A README image that fails to load becomes a quiet placeholder, not a broken-image glyph.
|
||
if (typeof window !== "undefined" && window.document?.addEventListener) {
|
||
window.document.addEventListener("error", (event) => {
|
||
const img = event.target;
|
||
if (!img || img.tagName !== "IMG" || img.dataset.failed || !img.closest?.(".markdown-body, .anonymize-preview-body")) return;
|
||
img.dataset.failed = "1";
|
||
const ph = window.document.createElement("span");
|
||
// Badges and icons become a compact inline chip; only images that declare a
|
||
// large size keep a block placeholder so the page layout stays the same.
|
||
const w = parseInt(img.getAttribute("width"), 10) || 0;
|
||
const h = parseInt(img.getAttribute("height"), 10) || 0;
|
||
ph.className = w >= 200 || h >= 120 ? "img-unavailable is-large" : "img-unavailable";
|
||
// Never show raw mask tokens: the label is fixed, and the alt text (with masks spelled
|
||
// out as "[redacted term N]") is only a tooltip.
|
||
ph.textContent = "Image unavailable";
|
||
ph.title = img.alt ? "Image unavailable: " + img.alt.replace(/\bXXXX-(\d+)\b/g, "[redacted term $1]") : "Image unavailable";
|
||
ph.setAttribute("role", "img");
|
||
ph.setAttribute("aria-label", ph.title);
|
||
img.replaceWith(ph);
|
||
}, true);
|
||
}
|
||
|
||
function viewerErrorCode(error) {
|
||
let data = error?.data;
|
||
if (typeof data === "string") {
|
||
try { data = JSON.parse(data); } catch (_) { data = null; }
|
||
}
|
||
if (typeof data?.error === "string" && Object.prototype.hasOwnProperty.call(translations.ERRORS, data.error)) return data.error;
|
||
if (error?.status === -1 || error?.status === 0) return "request_error";
|
||
if ([502, 503, 504].includes(error?.status)) return "unreachable";
|
||
return "unknown_error";
|
||
}
|
||
|
||
// The mask comes from /api/options; mainController sets it once loaded.
|
||
let redactionMask = "XXXX";
|
||
export const setRedactionMask = (mask) => { redactionMask = mask || "XXXX"; };
|
||
|
||
// Matches the replacements the anonymizer writes: MASK and MASK-<n>.
|
||
export const redactionPattern = (mask = redactionMask) => {
|
||
if (!mask) return null;
|
||
const escaped = mask.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
return new RegExp(escaped + "(?:-\\d+)?", "g");
|
||
};
|
||
|
||
// Wraps redacted terms under root in <mark class="redaction">, touching text
|
||
// nodes only so attributes and URLs stay intact. Mermaid sources are left
|
||
// alone (Mermaid parses their text), and so are code blocks Prism will
|
||
// rewrite unless root is that highlighted block itself.
|
||
export const markRedactionsIn = (root, mask = redactionMask) => {
|
||
const pattern = redactionPattern(mask);
|
||
if (!pattern || !root || !root.textContent.includes(mask)) return;
|
||
const doc = root.ownerDocument;
|
||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||
const nodes = [];
|
||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||
for (const node of nodes) {
|
||
const text = node.nodeValue;
|
||
const parent = node.parentElement;
|
||
if (!text.includes(mask) || !parent || parent.closest("script, style, mark.redaction, .mermaid")) continue;
|
||
const highlighted = parent.closest('code[class*="language-"]');
|
||
if (highlighted && !highlighted.contains(root)) continue;
|
||
const fragment = doc.createDocumentFragment();
|
||
let last = 0;
|
||
for (const match of text.matchAll(pattern)) {
|
||
fragment.append(text.slice(last, match.index));
|
||
const mark = doc.createElement("mark");
|
||
mark.className = "redaction";
|
||
mark.title = "Redacted by Anonymous GitHub";
|
||
mark.textContent = match[0];
|
||
fragment.append(mark);
|
||
last = match.index + match[0].length;
|
||
}
|
||
fragment.append(text.slice(last));
|
||
node.replaceWith(fragment);
|
||
}
|
||
};
|
||
|
||
// Redactions are the product's signature: show every XXXX-n mask as a numbered slot.
|
||
const REDACTION_RE = /\bXXXX-\d+\b/g;
|
||
export const slotRedactions = function (html) {
|
||
if (!html || html.indexOf("XXXX-") === -1 || typeof window.DOMParser !== "function") return html;
|
||
const doc = new window.DOMParser().parseFromString("<div>" + html + "</div>", "text/html");
|
||
const walker = doc.createTreeWalker(doc.body, 4);
|
||
const nodes = [];
|
||
while (walker.nextNode()) {
|
||
REDACTION_RE.lastIndex = 0;
|
||
// Prism re-highlights code blocks from their text, so those are slotted in its hook.
|
||
if (walker.currentNode.parentElement?.closest("pre code")) continue;
|
||
if (REDACTION_RE.test(walker.currentNode.nodeValue)) nodes.push(walker.currentNode);
|
||
}
|
||
for (const node of nodes) {
|
||
REDACTION_RE.lastIndex = 0;
|
||
const frag = doc.createDocumentFragment();
|
||
let last = 0, m;
|
||
const text = node.nodeValue;
|
||
while ((m = REDACTION_RE.exec(text))) {
|
||
frag.appendChild(doc.createTextNode(text.slice(last, m.index)));
|
||
const mark = doc.createElement("mark");
|
||
mark.className = "redact-chip";
|
||
mark.setAttribute("title", "Redacted term " + m[0].slice(5) + " — the same term everywhere in this snapshot");
|
||
// Display: an empty slot holding only the term's number (the file keeps XXXX-N).
|
||
const num = m[0].slice(5);
|
||
mark.textContent = "";
|
||
const idx = doc.createElement("i");
|
||
idx.textContent = num;
|
||
mark.appendChild(idx);
|
||
mark.dataset.term = m[0];
|
||
mark.setAttribute("aria-label", "Redacted term " + num);
|
||
// Keep trailing punctuation on the chip's line (no orphaned "." or ",").
|
||
const trail = /^[.,;:!?)\]]+/.exec(text.slice(m.index + m[0].length));
|
||
if (trail) {
|
||
const glue = doc.createElement("span");
|
||
glue.className = "redact-glue";
|
||
glue.appendChild(mark);
|
||
glue.appendChild(doc.createTextNode(trail[0]));
|
||
frag.appendChild(glue);
|
||
last = m.index + m[0].length + trail[0].length;
|
||
} else {
|
||
frag.appendChild(mark);
|
||
last = m.index + m[0].length;
|
||
}
|
||
}
|
||
frag.appendChild(doc.createTextNode(text.slice(last)));
|
||
node.parentNode.replaceChild(frag, node);
|
||
}
|
||
return doc.body.firstChild.innerHTML;
|
||
};
|
||
|
||
// Prism rewrites fenced code blocks from their text, so slot redactions once it is done.
|
||
export const installPrismRedactionSlots = () => {
|
||
if (window.Prism?.hooks && !window.Prism.redactionHook) {
|
||
window.Prism.hooks.add("complete", (env) => {
|
||
markRedactionsIn(env.element);
|
||
// Show the same numbered slot as the rest of the viewer.
|
||
env.element.querySelectorAll?.("mark.redaction").forEach((mark) => {
|
||
const m = /^XXXX-(\d+)$/.exec(mark.textContent);
|
||
if (!m) return;
|
||
mark.className = "redact-chip";
|
||
mark.setAttribute("title", "Redacted term " + m[1] + " — the same term everywhere in this snapshot");
|
||
mark.setAttribute("aria-label", "Redacted term " + m[1]);
|
||
mark.dataset.term = m[0];
|
||
// Same width as the XXXX-n it replaces, so code columns stay aligned.
|
||
mark.style.setProperty("--slot-ch", m[0].length + "ch");
|
||
mark.textContent = "";
|
||
// Keep the full term in the text (hidden): Prism re-highlights from
|
||
// textContent, and a second pass would otherwise turn "XXXX-1" into "1".
|
||
const src = mark.ownerDocument.createElement("span");
|
||
src.className = "redact-src";
|
||
src.textContent = m[0].slice(0, m[0].length - m[1].length);
|
||
mark.appendChild(src);
|
||
const idx = mark.ownerDocument.createElement("i");
|
||
idx.textContent = m[1];
|
||
mark.appendChild(idx);
|
||
});
|
||
});
|
||
window.Prism.redactionHook = true;
|
||
}
|
||
};
|
||
|
||
export const highlightRedactions = (html, mask = redactionMask) => {
|
||
if (!html || !mask || !html.includes(mask)) return html;
|
||
const doc = new DOMParser().parseFromString("<body>" + html + "</body>", "text/html");
|
||
markRedactionsIn(doc.body, mask);
|
||
return doc.body.innerHTML;
|
||
};
|
||
|
||
async function copyText(text) {
|
||
if (window.navigator.clipboard?.writeText) {
|
||
await window.navigator.clipboard.writeText(text);
|
||
return;
|
||
}
|
||
const field = document.createElement("textarea");
|
||
field.value = text;
|
||
field.setAttribute("readonly", "");
|
||
field.style.position = "fixed";
|
||
field.style.opacity = "0";
|
||
const focused = document.activeElement;
|
||
document.body.appendChild(field);
|
||
try {
|
||
field.select();
|
||
if (!document.execCommand?.("copy")) throw Error("Copy unavailable");
|
||
} finally { field.remove(); focused?.focus(); }
|
||
}
|
||
|
||
function previewTabKeydown(event) {
|
||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||
const tabs = [...event.currentTarget.closest('[role="tablist"]').querySelectorAll('[role="tab"]')];
|
||
const index = tabs.indexOf(event.currentTarget);
|
||
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1
|
||
: (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
||
event.preventDefault();
|
||
tabs[next]?.focus();
|
||
tabs[next]?.click();
|
||
}
|
||
|
||
// Page setup functions run inside Vue effect scopes.
|
||
export const mainController = function (state, http, location, timeout) {
|
||
state.previewTabKeydown = previewTabKeydown;
|
||
state.headerCanUpgrade = false;
|
||
state.headerShowSupport = false;
|
||
state.headerBillingPending = new URLSearchParams(window.location.search).get("billing") === "success";
|
||
const updateHeaderBilling = billing => {
|
||
state.headerShowSupport = !(billing?.grants || []).some(g => ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
|
||
const subscribed = (billing?.grants || []).some(g => ["author_monthly", "author_annual"].includes(g.planId) && ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
|
||
state.headerCanUpgrade = !!billing?.enabled && billing.checkoutEnabled !== false && !subscribed && (billing.plans || []).some(p => ["author_monthly", "author_annual"].includes(p.id));
|
||
};
|
||
state.on("billing:updated", (_, billing) => updateHeaderBilling(billing));
|
||
state.on("billing:pending", (_, pending) => { state.headerBillingPending = pending; });
|
||
state.openHeaderUpgrade = () => {
|
||
if (location.path() === "/profile") state.emit("settings:upgrade");
|
||
else location.url("/profile?upgrade=1");
|
||
};
|
||
|
||
// Header fitter: the media queries in style.css (F2) handle the usual
|
||
// widths; this catches what they can't predict (wider fonts, an Upgrade
|
||
// link, long usernames). It adds .nav-fit-1 (extras into the avatar menu),
|
||
// then .nav-fit-2 (tighter gaps) while the expanded bar overflows.
|
||
const headerListen = createListeners();
|
||
const headerTimers = createTimers();
|
||
let headerFitQueued = false;
|
||
const fitHeader = () => {
|
||
headerFitQueued = false;
|
||
const nav = document.querySelector("nav.navbar.navbar-expand-lg");
|
||
if (!nav) return;
|
||
nav.classList.remove("nav-fit-1", "nav-fit-2");
|
||
if (window.innerWidth < 992) return;
|
||
const collapse = nav.querySelector(".navbar-collapse");
|
||
const overflows = () => nav.scrollWidth > nav.clientWidth + 1 || (!!collapse && collapse.scrollWidth > collapse.clientWidth + 1);
|
||
if (overflows()) nav.classList.add("nav-fit-1");
|
||
if (overflows()) nav.classList.add("nav-fit-2");
|
||
};
|
||
const queueHeaderFit = () => {
|
||
if (headerFitQueued) return;
|
||
headerFitQueued = true;
|
||
nextTick(() => headerTimers.timeout(fitHeader, 0));
|
||
};
|
||
headerListen(window, "resize", queueHeaderFit);
|
||
state.on("routeChange", queueHeaderFit);
|
||
state.on("billing:updated", queueHeaderFit);
|
||
if (document.fonts?.ready) document.fonts.ready.then(queueHeaderFit).catch(() => {});
|
||
|
||
state.title = "Main";
|
||
state.user = { status: "connection" };
|
||
state.site_options;
|
||
|
||
state.toasts = [];
|
||
|
||
state.removeToast = function (toast) {
|
||
const index = state.toasts.indexOf(toast);
|
||
if (index === -1) return;
|
||
state.toasts.splice(index, 1);
|
||
};
|
||
|
||
// Auto-dismiss toasts after a fixed delay so they don't pile up across
|
||
// navigations (e.g. the "README not found" toast re-fired every time the
|
||
// edit screen was reopened — see #246). Long-running operations that
|
||
// mutate the toast (remove/refresh) will simply disappear once the
|
||
// delay elapses; users can re-check status from the dashboard.
|
||
state.addToast = function (toast) {
|
||
state.toasts.push(toast);
|
||
timeout(function () {
|
||
state.removeToast(toast);
|
||
}, 8000);
|
||
return toast;
|
||
};
|
||
|
||
// Anonymized content that signed-out reviewers browse.
|
||
const reviewerRoutes = /^\/(r|repository|pr|gist)\//;
|
||
state.path = location.url();
|
||
state.paths = location.path().substring(1).split("/");
|
||
state.isReviewerRoute = reviewerRoutes.test(location.path());
|
||
|
||
state.darkMode = function (on) {
|
||
localStorage.setItem("darkMode", on);
|
||
state.isDarkMode = on;
|
||
// One syntax palette (style.css "R12.6") colours Prism and Ace in both
|
||
// themes; prism.css only supplies layout, so dark mode needs no theme file.
|
||
const lightPrismLink = "/css/prism.css";
|
||
$("link[href='/css/prism-okaidia.css']").remove();
|
||
if (on) {
|
||
$("body").addClass("dark-mode");
|
||
$(`link[href='${lightPrismLink}']`).remove();
|
||
} else {
|
||
$("body").removeClass("dark-mode");
|
||
if (!document.querySelector(`link[href='${lightPrismLink}']`)) {
|
||
const link = document.createElement("link");
|
||
link.href = lightPrismLink;
|
||
link.rel = "stylesheet";
|
||
document.head.append(link);
|
||
}
|
||
}
|
||
state.emit("dark-mode", on);
|
||
};
|
||
|
||
state.darkMode(localStorage.getItem("darkMode") == "true");
|
||
|
||
state.emailPromptOpen = false;
|
||
state.emailPromptAddress = "";
|
||
state.emailPromptBusy = false;
|
||
state.emailPromptError = "";
|
||
let emailPromptFocus;
|
||
state.respondToEmailPrompt = async (action) => {
|
||
if (state.emailPromptBusy) return;
|
||
state.emailPromptBusy = true;
|
||
state.emailPromptError = "";
|
||
try {
|
||
const res = await http.post("/github/connections/email", { action, email: state.emailPromptAddress },
|
||
{ headers: { "X-CSRF-Token": state.user.emailPromptCSRF } });
|
||
if (action === "save") state.user.notificationEmail = res.data.notificationEmail;
|
||
state.emailPromptOpen = false;
|
||
state.user.showEmailPrompt = false;
|
||
emailPromptFocus?.focus();
|
||
} catch (error) { state.emailPromptError = error.data?.error || "Unable to save your preference. Please try again."; }
|
||
finally { state.emailPromptBusy = false; }
|
||
};
|
||
|
||
function getUser() {
|
||
http.get("/api/user").then(
|
||
(res) => {
|
||
if (res) state.user = res.data;
|
||
queueHeaderFit();
|
||
if (state.user?.username) readBilling(http).then(res => updateHeaderBilling(res.data)).catch(() => {});
|
||
else state.headerShowSupport = true;
|
||
if (state.user?.emailNotificationsEnabled && state.user.showEmailPrompt && !state.user.notificationEmail) {
|
||
emailPromptFocus = document.activeElement;
|
||
state.emailPromptOpen = true;
|
||
timeout(() => {
|
||
const dialog = document.getElementById("email-prompt");
|
||
if (dialog?.showModal) dialog.showModal();
|
||
else dialog?.setAttribute("open", "");
|
||
document.getElementById("email-prompt-address")?.focus();
|
||
}, 0);
|
||
}
|
||
},
|
||
() => {
|
||
state.user = null;
|
||
state.headerShowSupport = true;
|
||
}
|
||
);
|
||
}
|
||
getUser();
|
||
|
||
function getOptions() {
|
||
http.get("/api/options").then(
|
||
(res) => {
|
||
if (res) state.site_options = res.data;
|
||
setRedactionMask(res?.data?.ANONYMIZATION_MASK);
|
||
state.emit("site-options");
|
||
},
|
||
() => {
|
||
state.site_options = null;
|
||
}
|
||
);
|
||
}
|
||
getOptions();
|
||
|
||
function getMessage() {
|
||
http.get("/api/message").then(
|
||
(res) => {
|
||
if (res) state.generalMessage = res.data;
|
||
},
|
||
() => {
|
||
state.generalMessage = null;
|
||
}
|
||
);
|
||
}
|
||
getMessage();
|
||
|
||
function changedUrl(_, current) {
|
||
if (current) {
|
||
state.title = current.title;
|
||
}
|
||
state.path = location.url();
|
||
state.paths = location.path().substring(1).split("/");
|
||
state.isReviewerRoute = reviewerRoutes.test(location.path());
|
||
}
|
||
|
||
state.on("routeChange", changedUrl);
|
||
state.on("routeUpdate", changedUrl);
|
||
};
|
||
|
||
export const faqController = function (state, http) {
|
||
const listen = createListeners();
|
||
const timers = createTimers();
|
||
// Search: filter the static answers in place. Every word must appear in the question or its answer.
|
||
// Matching answers are shown open while searching; categories without a match are hidden.
|
||
state.faqQuery = "";
|
||
state.faqResults = 0;
|
||
const normalize = (text) => (text || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\s+/g, " ");
|
||
state.filterFaq = () => {
|
||
const words = normalize(state.faqQuery).trim().split(" ").filter(Boolean);
|
||
let count = 0;
|
||
document.querySelectorAll(".faq-section .panel-group").forEach((group) => {
|
||
let groupCount = 0;
|
||
group.querySelectorAll(":scope > .panel").forEach((panel) => {
|
||
const text = panel.dataset.faqText || (panel.dataset.faqText = normalize(panel.textContent));
|
||
const match = words.every((w) => text.includes(w));
|
||
panel.classList.toggle("faq-hit", words.length > 0 && match);
|
||
panel.hidden = words.length > 0 && !match;
|
||
if (match) groupCount++;
|
||
});
|
||
const hide = words.length > 0 && groupCount === 0;
|
||
group.hidden = hide;
|
||
const head = group.previousElementSibling;
|
||
if (head?.classList.contains("faq-cat")) head.hidden = hide;
|
||
count += groupCount;
|
||
});
|
||
state.faqResults = words.length ? count : 0;
|
||
};
|
||
state.clearFaqSearch = () => {
|
||
state.faqQuery = "";
|
||
state.filterFaq();
|
||
document.getElementById("faq-search-input")?.focus();
|
||
};
|
||
const revealAnswer = () => {
|
||
const id = window.location.hash.slice(1);
|
||
const answer = document.getElementById(id);
|
||
if (!answer?.classList.contains("panel-collapse")) return;
|
||
$(answer).collapse("show");
|
||
const heading = document.getElementById(answer.getAttribute("aria-labelledby"));
|
||
heading?.setAttribute("tabindex", "-1");
|
||
heading?.focus({ preventScroll: true });
|
||
heading?.scrollIntoView?.({ block: "start" });
|
||
};
|
||
// Router fragments do not emit hashchange, and answers mount after navigation.
|
||
state.on("routeUpdate", () => timers.timeout(revealAnswer, 0));
|
||
listen(window, "hashchange", revealAnswer);
|
||
timers.timeout(revealAnswer, 0);
|
||
};
|
||
|
||
export const profileController = function (state, http, translate, timeout, quotaService) {
|
||
state.settingsTab = /(?:billing|upgrade)=/.test(window.location.search) ? "billing" : "defaults";
|
||
state.confirmDeleteAccount = false;
|
||
state.deleteUsername = "";
|
||
state.profilePastSubscription = () => !state.profileAuthorGrant() && (state.billing?.grants || []).some(g => ["author_monthly", "author_annual"].includes(g.planId));
|
||
state.showAuthorPlans = new URLSearchParams(window.location.search).get("upgrade") === "1";
|
||
state.showRepositoryPass = false;
|
||
// Yearly is the better deal, so it is shown first.
|
||
state.profilePlan = "author_annual";
|
||
state.openProfileUpgrade = () => {
|
||
state.settingsTab = "billing";
|
||
state.showAuthorPlans = true;
|
||
nextTick(() => document.getElementById("settings-plan-comparison")?.focus());
|
||
};
|
||
state.on("settings:upgrade", () => state.openProfileUpgrade());
|
||
state.profileAuthorPlans = () => (state.billing?.plans || []).filter(p => ["author_monthly", "author_annual"].includes(p.id));
|
||
state.profileSelectedPlan = () => state.profileAuthorPlans().find(p => p.id === state.profilePlan) || state.profileAuthorPlans()[0];
|
||
state.profilePassPlan = () => (state.billing?.plans || []).find(p => p.id === "author_pass");
|
||
// The Paper pack covers the usual case, so it is listed first.
|
||
state.profileAddonPlans = () => (state.billing?.plans || []).filter(p => isRepositoryAddon(p.id))
|
||
.sort((a, b) => Number(b.id === "repo_pack") - Number(a.id === "repo_pack"));
|
||
state.profileAddonId = "repo_pack";
|
||
state.profilePackPlan = () => (state.billing?.plans || []).find(p => p.id === "repo_pack");
|
||
state.profileSlotPlan = () => (state.billing?.plans || []).find(p => p.id === "repo_slot");
|
||
state.profileRepositories = () => (state.billing?.repositories || []).filter(r => r.status !== "removed");
|
||
state.openProfileAddon = (planId) => {
|
||
if (planId) state.profileAddonId = planId;
|
||
state.showRepositoryPass = true;
|
||
state.billingRepository = "";
|
||
nextTick(() => {
|
||
const panel = document.getElementById("settings-addons-title");
|
||
panel?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
|
||
panel?.focus?.({ preventScroll: true });
|
||
});
|
||
};
|
||
const addonOrder = ["repo_slot", "author_pass", "repo_extend", "repo_never"];
|
||
state.profileSingleAddons = () => state.profileAddonPlans().filter(p => p.id !== "repo_pack")
|
||
.sort((a, b) => addonOrder.indexOf(a.id) - addonOrder.indexOf(b.id));
|
||
state.profileSingleId = "repo_slot";
|
||
state.profileSingleAddon = () => state.profileSingleAddons().find(p => p.id === state.profileSingleId) || state.profileSingleAddons()[0];
|
||
state.profileCheapestAddon = () => [...state.profileSingleAddons()].sort((a, b) => (a.amount ?? Infinity) - (b.amount ?? Infinity))[0];
|
||
state.profileAuthorPlansByValue = () => [...state.profileAuthorPlans()].sort((a, b) => Number(a.interval === "year") - Number(b.interval === "year"));
|
||
// One line for a pack's three parts.
|
||
state.profilePackSummary = p => p.parts.map(part => part.planId === "repo_slot" ? "Extra repository"
|
||
: `${state.profileGrantName(part)} until ${state.billingDate(part.until)}`).join(" · ");
|
||
state.profileAddonFeatures = id => ({
|
||
repo_pack: ["One more repository, outside your free ones", `Its own ${state.billingBytes(state.profileSlotPlan()?.storage || state.profilePassPlan()?.storage)} of storage`, "Automatic updates for 180 days", "Online for up to a year", "No subscription"],
|
||
repo_slot: ["One more repository, outside your free ones", `Its own ${state.billingBytes(state.profileSlotPlan()?.storage)} of storage`, "Valid while the repository exists", "Refresh it by hand"],
|
||
author_pass: ["Automatic updates for 180 days", `Its own ${state.billingBytes(state.profilePassPlan()?.storage)} of storage`, "No subscription"],
|
||
repo_extend: ["Online for up to one more year", "Buy again to add another year", "No subscription"],
|
||
repo_never: ["The link never expires", "No subscription", "Ends only if the payment is refunded"],
|
||
}[id] || []);
|
||
state.profileAddonBlurb = id => ({ repo_slot: "One more repository", author_pass: "Auto-updates for 180 days",
|
||
repo_extend: "Online one more year", repo_never: "The link never expires" }[id] || "");
|
||
state.profileAuthorPurchase = () => (state.billing?.purchases || []).find(p => p.recurring && ["active", "overdue"].includes(p.status));
|
||
state.profileRepositoryExpiry = repo => repo.status === "expired" ? "expired"
|
||
: repo.expirationMode === "never" ? "never expires" : repo.expirationDate ? "expires " + state.billingDate(repo.expirationDate) : "no date";
|
||
// Annual saving against twelve monthly payments, in whole percent.
|
||
state.profileAnnualSavings = () => {
|
||
const monthly = state.profileAuthorPlans().find(p => p.interval === "month")?.amount;
|
||
const annual = state.profileAuthorPlans().find(p => p.interval === "year")?.amount;
|
||
return monthly && annual && annual < monthly * 12 ? Math.round((1 - annual / (monthly * 12)) * 100) : 0;
|
||
};
|
||
state.profilePurchases = active => (state.billing?.purchases || []).filter(p => ["active", "overdue"].includes(p.status) === active);
|
||
state.profilePurchaseStatus = p => ({ active: "Active", overdue: "Payment overdue", ended: "Ended", refunded: "Refunded", disputed: "Disputed" }[p.status] || p.status);
|
||
// Plain wording for how long a purchase lasts; the "no end" sentinel is never shown.
|
||
state.profilePurchaseTerm = p => {
|
||
const ended = p.status && !["active", "overdue"].includes(p.status);
|
||
if (p.recurring) return ended ? (p.until ? "Ended " + state.billingDate(p.until) : "") : (p.cancelAtPeriodEnd ? "Ends " : "Renews ") + state.billingDate(p.until);
|
||
if (p.planId === "repo_pack") {
|
||
const dates = p.parts.map(part => part.until).filter(Boolean).map(d => new Date(d).getTime());
|
||
return ended || !dates.length ? "" : "Until " + state.billingDate(new Date(Math.max(...dates)));
|
||
}
|
||
if (!p.until) return ended ? "" : p.planId === "repo_slot" ? "While the repository exists" : "No expiration";
|
||
return (ended || new Date(p.until).getTime() < Date.now() ? "Ended " : "Until ") + state.billingDate(p.until);
|
||
};
|
||
state.profileAddon = () => state.profileAddonPlans().find(p => p.id === state.profileAddonId) || state.profileAddonPlans()[0];
|
||
state.profileAuthorGrant = () => (state.billing?.grants || []).find(g => ["author_monthly", "author_annual"].includes(g.planId) && ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
|
||
// The cheapest conference package, for the "from" price.
|
||
state.profileConferencePlan = () => (state.billing?.plans || []).filter(p => ["conference_workshop", "conference", "conference_large"].includes(p.id) && p.amount != null)
|
||
.sort((a, b) => a.amount - b.amount)[0];
|
||
state.profileGrantName = grant => ({ author_monthly: "Author monthly", author_annual: "Author annual", author_pass: "Live updates", repo_extend: "Extended expiration", repo_never: "Never expire", repo_slot: "Extra repository", repo_pack: "Paper pack", conference: "Conference package", conference_workshop: "Workshop package", conference_large: "Large conference package" }[grant.planId] || grant.planId);
|
||
|
||
setupBilling(state, http, () => quotaService.load().then(quota => { state.quota = quota; }, console.error), {
|
||
portalMessage: () => {
|
||
const grant = state.profileAuthorGrant();
|
||
if (!grant) return "Your billing details are up to date.";
|
||
return grant.cancelAtPeriodEnd ? `Renewal cancelled. Your access continues until ${state.billingDate(grant.until)}.` : `Your subscription renews on ${state.billingDate(grant.until)}.`;
|
||
},
|
||
});
|
||
state.terms = "";
|
||
state.options = {
|
||
expirationMode: "remove",
|
||
update: false,
|
||
image: true,
|
||
pdf: true,
|
||
notebook: true,
|
||
loc: true,
|
||
link: true,
|
||
};
|
||
state.saving = false;
|
||
state.message = null;
|
||
state.error = null;
|
||
|
||
state.accountEmail = "";
|
||
state.emailLoading = true;
|
||
state.emailCSRFReady = false;
|
||
state.emailSaving = false;
|
||
state.emailSaved = false;
|
||
state.emailError = "";
|
||
let emailCSRF = "";
|
||
state.loadAccountEmail = async () => {
|
||
state.emailLoading = true;
|
||
state.emailError = "";
|
||
try {
|
||
const res = await http.get("/github/connections/email");
|
||
state.accountEmail = res.data.notificationEmail || "";
|
||
emailCSRF = res.data.csrf;
|
||
state.emailCSRFReady = !!emailCSRF;
|
||
} catch {
|
||
state.emailError = "Unable to load your email address. Please try again.";
|
||
} finally { state.emailLoading = false; }
|
||
};
|
||
state.saveAccountEmail = async () => {
|
||
if (state.emailSaving || state.emailLoading || !emailCSRF) return;
|
||
state.emailSaving = true;
|
||
state.emailSaved = false;
|
||
state.emailError = "";
|
||
try {
|
||
const res = await http.post("/github/connections/email", { email: state.accountEmail },
|
||
{ headers: { "X-CSRF-Token": emailCSRF } });
|
||
state.accountEmail = res.data.notificationEmail;
|
||
if (state.user) state.user.notificationEmail = res.data.notificationEmail;
|
||
state.emailSaved = true;
|
||
} catch {
|
||
state.emailError = "Unable to save your email address. Check the address and try again.";
|
||
} finally { state.emailSaving = false; }
|
||
};
|
||
state.loadAccountEmail();
|
||
|
||
quotaService.load().then((quota) => {
|
||
state.quota = quota;
|
||
}, console.error);
|
||
|
||
const draft = formDraft(state, ["terms", "options"], "profile");
|
||
state.discardDraft = () => draft.discard();
|
||
function getDefault() {
|
||
http.get("/api/user/default").then((res) => {
|
||
const data = res.data || {};
|
||
if (data.terms) {
|
||
state.terms = data.terms.join("\n");
|
||
}
|
||
state.options = Object.assign({}, state.options, data.options);
|
||
draft.restore();
|
||
});
|
||
}
|
||
getDefault();
|
||
|
||
let savedTimer = null;
|
||
state.saveDefault = ($event) => {
|
||
if ($event && $event.preventDefault) $event.preventDefault();
|
||
if (state.saving) return;
|
||
const submitted = draft.checkpoint();
|
||
const params = {
|
||
terms: state.terms
|
||
.split("\n")
|
||
.map((t) => t.trim())
|
||
.filter((t) => t.length > 0),
|
||
options: state.options,
|
||
};
|
||
state.saving = true;
|
||
state.error = null;
|
||
http.post("/api/user/default", params).then(
|
||
() => {
|
||
draft.accept(submitted);
|
||
state.saving = false;
|
||
state.message = state.unsavedChanges ? null : "Saved";
|
||
if (savedTimer) timeout.cancel(savedTimer);
|
||
savedTimer = timeout(() => {
|
||
state.message = null;
|
||
}, 2500);
|
||
},
|
||
(error) => {
|
||
state.saving = false;
|
||
const code = error && error.data && error.data.error;
|
||
const fallback = "Unable to save your defaults. Please try again.";
|
||
// Unknown codes translate to the generic unknown_error message;
|
||
// this page has a more specific one.
|
||
Promise.all([translate("ERRORS." + code), translate("ERRORS.unknown_error")]).then(([translation, unknown]) => {
|
||
state.error = code && translation !== unknown ? translation : fallback;
|
||
}, () => {
|
||
state.error = fallback;
|
||
});
|
||
}
|
||
);
|
||
};
|
||
|
||
state.deleteAccount = () => {
|
||
if (state.deletingAccount || !state.confirmDeleteAccount || state.deleteUsername !== state.user?.username) return;
|
||
state.deletingAccount = true;
|
||
http.delete("/api/user").then(
|
||
() => {
|
||
window.location.href = "/";
|
||
},
|
||
() => {
|
||
state.deletingAccount = false;
|
||
state.deleteError =
|
||
"Unable to delete the account. Please try again.";
|
||
}
|
||
);
|
||
};
|
||
};
|
||
|
||
export const claimController = function (state, http, location) {
|
||
state.repoId = null;
|
||
state.repoUrl = null;
|
||
state.claim = () => {
|
||
http
|
||
.post("/api/repo/claim", {
|
||
repoId: state.repoId,
|
||
repoUrl: state.repoUrl,
|
||
})
|
||
.then(
|
||
(res) => {
|
||
location.url("/dashboard");
|
||
},
|
||
(err) => {
|
||
state.error = err.data;
|
||
state.claimForm.repoUrl.setValidity("not_found", false);
|
||
state.claimForm.repoId.setValidity("not_found", false);
|
||
}
|
||
);
|
||
};
|
||
};
|
||
|
||
export const homeController = function (state, http, location, window, timeout) {
|
||
if (state.user && !state.user.status) {
|
||
location.url("/dashboard");
|
||
}
|
||
state.watch("user.status", () => {
|
||
if (state.user && !state.user.status) {
|
||
location.url("/dashboard");
|
||
}
|
||
});
|
||
|
||
// "What you get": one screenshot, three tabs. The selected key drives
|
||
// both the expanded description and the visible panel.
|
||
state.features = [
|
||
{
|
||
key: "anonymize",
|
||
num: "01",
|
||
eyebrow: "Anonymize",
|
||
title: "Double-anonymous,",
|
||
accent: "your rules.",
|
||
text:
|
||
"Choose what reviewers may see: links, images, PDFs, notebooks, GitHub Pages. Add your own terms, with regex if you need it, and pick the expiration date.",
|
||
cta: "Start an anonymization",
|
||
href: "/anonymize",
|
||
url: "anonymous.4open.science/anonymize",
|
||
img: "/imgs/anonymize.png",
|
||
alt: "The anonymize form: source repository, terms to redact, options and a live README preview",
|
||
},
|
||
{
|
||
key: "review",
|
||
num: "02",
|
||
eyebrow: "Review",
|
||
title: "Reviewers browse",
|
||
accent: "the real thing.",
|
||
text:
|
||
"Highlighted source code, rendered PDFs, images, and notebooks, in a familiar file explorer. GitHub Pages is also supported.",
|
||
cta: "Open the example",
|
||
href: "https://anonymous.4open.science/r/840c8c57-3c32-451e-bf12-0e20be300389/",
|
||
target: "_self",
|
||
url: "anonymous.4open.science/r/840c8c57-…",
|
||
img: "/imgs/explorer.png",
|
||
alt: "The repository explorer with a file tree and a rendered README",
|
||
},
|
||
{
|
||
key: "manage",
|
||
num: "03",
|
||
eyebrow: "Manage",
|
||
title: "One dashboard,",
|
||
accent: "until the decision.",
|
||
text:
|
||
"Monitor views, edit configuration, remove or update your repository. Program chairs can group submissions under a conference with one shared expiry.",
|
||
cta: "Open the dashboard",
|
||
href: "/dashboard",
|
||
needsUser: true,
|
||
url: "anonymous.4open.science/dashboard",
|
||
img: "/imgs/dashboard.png",
|
||
alt: "The dashboard listing anonymized repositories with status, views and expiry",
|
||
},
|
||
];
|
||
state.feature = state.features[0].key;
|
||
state.selectFeature = function (key) {
|
||
state.feature = key;
|
||
};
|
||
// Signed-out visitors cannot open the dashboard; send them to sign in.
|
||
state.featureHref = function (f) {
|
||
return f.needsUser && !state.user ? "/signin" : f.href;
|
||
};
|
||
state.featureTarget = function (f) {
|
||
return f.needsUser && !state.user ? "_self" : f.target || undefined;
|
||
};
|
||
state.featureKeydown = function ($event, index) {
|
||
const step = {
|
||
ArrowDown: 1,
|
||
ArrowRight: 1,
|
||
ArrowUp: -1,
|
||
ArrowLeft: -1,
|
||
Home: "first",
|
||
End: "last",
|
||
}[$event.key];
|
||
if (step === undefined) return;
|
||
$event.preventDefault();
|
||
const n = state.features.length;
|
||
const next =
|
||
step === "first" ? 0 : step === "last" ? n - 1 : (index + step + n) % n;
|
||
state.feature = state.features[next].key;
|
||
timeout(() => {
|
||
const el = window.document.getElementById("feature-tab-" + state.feature);
|
||
if (el) el.focus();
|
||
});
|
||
};
|
||
|
||
// Live redaction demo on the landing page: same masking rule as the product (XXXX-n per distinct term).
|
||
state.demoText = "# DeepLearnUtils\nDeveloped by Jane Smith at MIT CSAIL.\nSee Smith et al., \"Attention That Works,\" 2026.\nContact: jane.smith@mit.edu";
|
||
state.demoTerms = "Jane Smith, Smith, MIT CSAIL, jane.smith@mit.edu";
|
||
state.demoParts = function () {
|
||
const terms = (state.demoTerms || "").split(/[,\n]/).map((t) => t.trim()).filter(Boolean).sort((a, b) => b.length - a.length);
|
||
const escape = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
if (!terms.length) return [{ text: state.demoText }];
|
||
const re = new RegExp("(" + terms.map(escape).join("|") + ")", "gi");
|
||
const index = {};
|
||
let n = 0;
|
||
return (state.demoText || "").split(re).filter((p) => p !== "").map((p) => {
|
||
const key = terms.find((t) => t.toLowerCase() === p.toLowerCase());
|
||
if (!key) return { text: p };
|
||
if (!index[key.toLowerCase()]) index[key.toLowerCase()] = ++n;
|
||
return { text: String(index[key.toLowerCase()]), redacted: true, original: p };
|
||
});
|
||
};
|
||
state.demoCount = () => state.demoParts().filter((p) => p.redacted).length;
|
||
|
||
state.cards = [
|
||
{ key: "repositories", total: 0, label: "repositories anonymized" },
|
||
{ key: "users", total: 0, label: "researchers" },
|
||
{ key: "pageViews", total: 0, label: "page views" },
|
||
{ key: "pullRequests", total: 0, label: "pull requests" },
|
||
];
|
||
// True once stats have loaded and every total is zero (fresh or beta deployments).
|
||
state.statsEmpty = () => !!state.stat && state.cards.every((c) => !c.total);
|
||
function getStat() {
|
||
http.get("/api/stat/").then((res) => {
|
||
state.stat = res.data;
|
||
state.cards[0].total = res.data.nbRepositories;
|
||
state.cards[1].total = res.data.nbUsers;
|
||
state.cards[2].total = res.data.nbPageViews;
|
||
state.cards[3].total = res.data.nbPullRequests;
|
||
}).catch(() => {
|
||
// Leave the placeholders in place rather than showing zeros.
|
||
});
|
||
}
|
||
getStat();
|
||
|
||
function buildSeriesView(series) {
|
||
const view = {
|
||
series: series,
|
||
bars: [],
|
||
viewW: 100,
|
||
deltaToday: 0,
|
||
pctChange: 0,
|
||
pctAbs: 0,
|
||
isUp: true,
|
||
};
|
||
if (!series || series.length < 2) return view;
|
||
// Bars represent the *daily increment* (today - yesterday), not the
|
||
// cumulative total. The big number above the chart shows the total.
|
||
const deltas = new Array(series.length - 1);
|
||
for (let i = 1; i < series.length; i++) {
|
||
deltas[i - 1] = series[i] - series[i - 1];
|
||
}
|
||
const n = deltas.length;
|
||
const max = Math.max.apply(null, deltas);
|
||
const min = Math.min.apply(null, deltas);
|
||
// Anchor scale to zero so visually small days look small even when all
|
||
// deltas are positive; only fall back to min when there are negatives.
|
||
const base = Math.min(0, min);
|
||
const range = max - base || 1;
|
||
view.viewW = n * 2;
|
||
view.bars = new Array(n);
|
||
for (let i = 0; i < n; i++) {
|
||
const norm = (deltas[i] - base) / range;
|
||
const h = Math.max(1.5, norm * 34);
|
||
view.bars[i] = {
|
||
x: (i * 2 + 0.25).toFixed(2),
|
||
y: (36 - h).toFixed(2),
|
||
w: "1.5",
|
||
h: h.toFixed(2),
|
||
};
|
||
}
|
||
view.deltaToday = deltas[n - 1];
|
||
if (n >= 2) {
|
||
const prior = deltas[n - 2];
|
||
if (prior) {
|
||
view.pctChange = ((view.deltaToday - prior) / prior) * 100;
|
||
}
|
||
}
|
||
view.pctAbs = Math.round(Math.abs(view.pctChange));
|
||
view.isUp = view.pctChange >= 0;
|
||
return view;
|
||
}
|
||
|
||
state.history = {
|
||
repositories: buildSeriesView([]),
|
||
users: buildSeriesView([]),
|
||
pageViews: buildSeriesView([]),
|
||
pullRequests: buildSeriesView([]),
|
||
};
|
||
http.get("/api/stat/history?days=60").then((res) => {
|
||
const rows = res.data || [];
|
||
state.history = {
|
||
repositories: buildSeriesView(rows.map((r) => r.nbRepositories || 0)),
|
||
users: buildSeriesView(rows.map((r) => r.nbUsers || 0)),
|
||
pageViews: buildSeriesView(rows.map((r) => r.nbPageViews || 0)),
|
||
pullRequests: buildSeriesView(rows.map((r) => r.nbPullRequests || 0)),
|
||
};
|
||
}).catch(() => {});
|
||
};
|
||
|
||
export const unifiedDashboardController = function (state, http, location, promises, window, quotaService) {
|
||
const timers = createTimers();
|
||
state.on("routeLeave", function () {
|
||
$('[data-toggle="tooltip"]').tooltip("dispose");
|
||
});
|
||
state.watch("user.status", () => {
|
||
if (state.user == null) {
|
||
location.url("/");
|
||
}
|
||
});
|
||
if (state.user == null) {
|
||
location.url("/");
|
||
}
|
||
|
||
timers.timeout(() => {
|
||
$('[data-toggle="tooltip"]').tooltip();
|
||
}, 250);
|
||
|
||
state.quotaExpanded = false;
|
||
state.closeQuota = () => {
|
||
state.quotaExpanded = false;
|
||
nextTick(() => document.querySelector(".dashboard-quota > summary")?.focus());
|
||
};
|
||
createListeners()(document, "click", event => {
|
||
if (state.quotaExpanded && !event.target.closest?.(".dashboard-quota")) state.quotaExpanded = false;
|
||
});
|
||
state.filtersExpanded = false;
|
||
state.projectView = "all";
|
||
state.selectedProject = null;
|
||
state.projectName = "";
|
||
state.nameSaving = false;
|
||
state.nameError = "";
|
||
state.nameMessage = "";
|
||
state.needsAttention = item => item.status === "error" || item._broken
|
||
|| (statusKey(item.status) === "progress" && item._stale);
|
||
state.attentionCount = () => state.dashboardTotals?.attention ?? state.items.filter(state.needsAttention).length;
|
||
state.hasProjects = () => (state.dashboardTotals?.total ?? state.items.length) > 0;
|
||
state.setProjectView = view => {
|
||
state.projectView = view;
|
||
if (view === "attention") {
|
||
state.filters.status.error = true;
|
||
state.filters.status.progress = true;
|
||
}
|
||
};
|
||
state.idleText = item => {
|
||
const t = humanTime(item?.anonymizeDate || item?.lastView) || "";
|
||
if (/ ago$/.test(t)) return "No activity for " + t.replace(/ ago$/, "");
|
||
return "No activity since " + t.replace(", " + new Date().getFullYear(), "");
|
||
};
|
||
state.projectDetailsId = item => "project-details-" + encodeURIComponent(item._type + ":" + (item._id || item._source || "unknown"));
|
||
state.showProjectDetails = async item => {
|
||
state.selectedProject = item;
|
||
state.projectName = item.projectName || "";
|
||
state.nameError = "";
|
||
state.nameMessage = "";
|
||
await nextTick();
|
||
const panel = document.getElementById(state.projectDetailsId(item));
|
||
panel?.focus();
|
||
panel?.scrollIntoView?.({ block: "nearest" });
|
||
};
|
||
state.closeProjectDetails = item => {
|
||
state.selectedProject = null;
|
||
nextTick(() => document.querySelector(`[data-project-actions="${encodeURIComponent(item._type + ":" + item._id)}"]`)?.focus());
|
||
};
|
||
state.saveProjectName = async item => {
|
||
if (state.nameSaving || !item._id) return;
|
||
const name = state.projectName.trim();
|
||
if (name.length > 100) { state.nameError = "Use 100 characters or fewer."; return; }
|
||
state.nameSaving = true;
|
||
state.nameError = "";
|
||
state.nameMessage = "";
|
||
try {
|
||
const res = await http.post("/api/user/project-name", { type: item._type, id: item._id, name });
|
||
item.projectName = res.data.name;
|
||
item._label = item.projectName || item._fallbackLabel;
|
||
if (state.selectedProject === item) state.nameMessage = name ? "Project name saved." : "Default project name restored.";
|
||
if (!legacyDashboard) {
|
||
state.dashboardCursor = null;
|
||
await loadAll();
|
||
if (state.selectedProject === item) {
|
||
state.selectedProject = state.items.find(current => current._type === item._type && current._id === item._id) || null;
|
||
}
|
||
}
|
||
} catch (_) {
|
||
if (state.selectedProject === item) state.nameError = "The project name could not be saved. Try again.";
|
||
} finally { state.nameSaving = false; }
|
||
};
|
||
state.copyingId = null;
|
||
state.copyLink = async (item) => {
|
||
if (!item._viewUrl || state.copyingId) return;
|
||
const link = new URL(item._viewUrl, window.location.origin).href;
|
||
state.copyingId = item._type + ":" + item._id;
|
||
try {
|
||
await copyText(link);
|
||
state.addToast({ title: "Link copied", body: "The anonymous link is ready to share." });
|
||
} catch {
|
||
state.addToast({ title: "Copy this anonymous link", body: link });
|
||
} finally { state.copyingId = null; }
|
||
};
|
||
state.items = [];
|
||
state.search = "";
|
||
state.loading = true;
|
||
|
||
// Status buckets used by the Status filter. Raw statuses are mapped
|
||
// onto these keys so in-progress and error items can be filtered too.
|
||
state.statusKeyLabels = {
|
||
ready: "Ready",
|
||
progress: "Preparing",
|
||
error: "Error",
|
||
expired: "Expiring / expired",
|
||
removed: "Removing / removed",
|
||
};
|
||
const inProgress = ["queue", "download", "downloaded", "preparing", "anonymizing"];
|
||
function statusKey(status) {
|
||
if (status === "ready" || status === "error") return status;
|
||
if (status === "expired" || status === "expiring") return "expired";
|
||
if (status === "removed" || status === "removing") return "removed";
|
||
if (inProgress.indexOf(status) > -1) return "progress";
|
||
return "progress";
|
||
}
|
||
// An in-progress item whose last activity is older than this is
|
||
// probably stuck; the row says so instead of showing "Downloading".
|
||
const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
|
||
|
||
const dashboardPrefsKey = "dashboard.filterPrefs";
|
||
const dashboardPrefDefaults = {
|
||
typeFilter: "all",
|
||
filters: {
|
||
// Authors open on their live work; removed links sit behind the
|
||
// "Removed N" view instead of burying the active projects.
|
||
status: { ready: true, progress: true, error: true, expired: true, removed: false },
|
||
},
|
||
orderBy: "-anonymizeDate",
|
||
};
|
||
const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {};
|
||
state.typeFilter = savedDashboardPrefs.typeFilter || dashboardPrefDefaults.typeFilter;
|
||
state.filters = {
|
||
status: Object.assign(
|
||
{},
|
||
dashboardPrefDefaults.filters.status,
|
||
(savedDashboardPrefs.filters && savedDashboardPrefs.filters.status) || {}
|
||
),
|
||
};
|
||
state.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy;
|
||
|
||
// ---- Sorting -------------------------------------------------------
|
||
// `orderBy` is kept as the Angular orderBy expression ("-field" for
|
||
// descending) so saved preferences stay compatible.
|
||
const sortFields = {
|
||
_name: { label: "Anonymous ID", defaultDesc: false },
|
||
_label: { label: "Project", defaultDesc: false },
|
||
anonymizeDate: { label: "Anonymize date", defaultDesc: true },
|
||
status: { label: "Status", defaultDesc: false },
|
||
lastView: { label: "Last view", defaultDesc: true },
|
||
pageView: { label: "Views", defaultDesc: true },
|
||
"options.expirationDate": { label: "Expiration", defaultDesc: false },
|
||
};
|
||
state.sortFields = sortFields;
|
||
state.sortField = () => state.orderBy.replace(/^-/, "");
|
||
state.sortDesc = () => state.orderBy.charAt(0) === "-";
|
||
state.sortLabel = () => {
|
||
const f = sortFields[state.sortField()];
|
||
return f ? f.label : "Custom";
|
||
};
|
||
state.isSortedBy = (field) => state.sortField() === field;
|
||
state.setSort = (field, desc) => {
|
||
if (typeof desc !== "boolean") {
|
||
desc = state.isSortedBy(field)
|
||
? !state.sortDesc()
|
||
: !!(sortFields[field] && sortFields[field].defaultDesc);
|
||
}
|
||
state.orderBy = (desc ? "-" : "") + field;
|
||
};
|
||
state.toggleSortDirection = () => {
|
||
state.setSort(state.sortField(), !state.sortDesc());
|
||
};
|
||
|
||
state.watchGroup(
|
||
["typeFilter", "orderBy"],
|
||
() => {
|
||
saveFilterPrefs(dashboardPrefsKey, {
|
||
typeFilter: state.typeFilter,
|
||
filters: state.filters,
|
||
orderBy: state.orderBy,
|
||
});
|
||
}
|
||
);
|
||
state.watch(
|
||
"filters",
|
||
() => {
|
||
saveFilterPrefs(dashboardPrefsKey, {
|
||
typeFilter: state.typeFilter,
|
||
filters: state.filters,
|
||
orderBy: state.orderBy,
|
||
});
|
||
},
|
||
true
|
||
);
|
||
|
||
// ---- Quota (shared with the settings page via quotaService) --------
|
||
quotaService.load().then((quota) => {
|
||
state.quota = quota;
|
||
}, console.error);
|
||
|
||
// ---- Items ---------------------------------------------------------
|
||
// Fields shared by repositories, pull requests and gists. Records that
|
||
// lost their identifier (legacy data) are flagged as broken instead of
|
||
// rendering an empty link to /pr/undefined/.
|
||
function decorateItem(item, id, name, source, editUrl, viewUrl) {
|
||
if (!item.pageView) item.pageView = 0;
|
||
if (!item.lastView) item.lastView = "";
|
||
item.options = item.options || {};
|
||
item.options.terms = (item.options.terms || []).filter((f) => f);
|
||
item._id = id || "";
|
||
item._source = source;
|
||
item._broken = !id;
|
||
item._name = id || source || "(unnamed)";
|
||
item._fallbackLabel = item._type === "gist" ? "Gist " + (id || source || "without an ID") : source || name || item._name;
|
||
item._label = item.projectName || item._fallbackLabel;
|
||
item._editUrl = id ? editUrl : null;
|
||
item._viewUrl = id ? viewUrl : null;
|
||
if (item._broken) {
|
||
item.status = "error";
|
||
item.statusMessage = "incomplete_record";
|
||
}
|
||
item._statusKey = statusKey(item.status);
|
||
const last = item.anonymizeDate || item.lastView;
|
||
item._stale =
|
||
item._statusKey === "progress" &&
|
||
!!last &&
|
||
Date.now() - new Date(last).getTime() > STALE_AFTER_MS;
|
||
// What the Expires column should show.
|
||
if (item.status === "expired" || item.status === "expiring") {
|
||
item._expiry = { kind: "expired", date: item.options.expirationDate };
|
||
// "Nov 3" this year, "Nov 3, 2025" otherwise (shared with conference detail).
|
||
item._expiry.label = shortDate(item.options.expirationDate) || undefined;
|
||
} else if (item.status !== "ready") {
|
||
item._expiry = { kind: "none" };
|
||
} else if (item.options.expirationMode === "never" || !item.options.expirationDate) {
|
||
item._expiry = { kind: "never" };
|
||
} else {
|
||
item._expiry = { kind: "date", date: item.options.expirationDate };
|
||
// A live link that expires within a week gets a relative, highlighted "in 21 hours" / "in 3 days".
|
||
const left = new Date(item.options.expirationDate).getTime() - Date.now();
|
||
if (left > 0 && left <= 7 * 24 * 3600 * 1000) {
|
||
const hours = Math.max(1, Math.round(left / 3600000));
|
||
const days = Math.round(left / 86400000);
|
||
item._expiry.soon = hours < 48
|
||
? "in " + hours + (hours === 1 ? " hour" : " hours")
|
||
: "in " + days + " days";
|
||
}
|
||
}
|
||
return item;
|
||
}
|
||
|
||
function safeGet(url) {
|
||
return http.get(url).then(
|
||
(res) => res.data || [],
|
||
(err) => {
|
||
console.error(err);
|
||
return [];
|
||
}
|
||
);
|
||
}
|
||
|
||
// All three lists load in parallel and are merged once, so the table
|
||
// does not re-sort three times while it fills in.
|
||
function loadLegacy() {
|
||
state.loading = true;
|
||
return promises
|
||
.all([
|
||
safeGet("/api/user/anonymized_repositories"),
|
||
safeGet("/api/user/anonymized_pull_requests"),
|
||
safeGet("/api/user/anonymized_gists"),
|
||
])
|
||
.then((results) => {
|
||
const repos = results[0];
|
||
const prs = results[1];
|
||
const gists = results[2];
|
||
const items = [];
|
||
repos.forEach((repo) => {
|
||
repo._type = "repo";
|
||
const src = repo.source || {};
|
||
items.push(
|
||
decorateItem(
|
||
repo,
|
||
repo.repoId,
|
||
repo.repoId,
|
||
src.fullName,
|
||
"/anonymize/" + repo.repoId,
|
||
"/r/" + repo.repoId + "/"
|
||
)
|
||
);
|
||
});
|
||
prs.forEach((pr) => {
|
||
pr._type = "pr";
|
||
const src = pr.source || {};
|
||
items.push(
|
||
decorateItem(
|
||
pr,
|
||
pr.pullRequestId,
|
||
pr.pullRequestId,
|
||
src.repositoryFullName ? src.repositoryFullName + "#" + src.pullRequestId : undefined,
|
||
"/pull-request-anonymize/" + pr.pullRequestId,
|
||
"/pr/" + pr.pullRequestId + "/"
|
||
)
|
||
);
|
||
});
|
||
gists.forEach((g) => {
|
||
g._type = "gist";
|
||
const src = g.source || {};
|
||
items.push(
|
||
decorateItem(
|
||
g,
|
||
g.gistId,
|
||
g.gistId,
|
||
src.gistId,
|
||
"/gist-anonymize/" + g.gistId,
|
||
"/gist/" + g.gistId + "/"
|
||
)
|
||
);
|
||
});
|
||
state.items = items;
|
||
state.removedCount = items.filter(item => item._statusKey === "removed").length;
|
||
state.loading = false;
|
||
});
|
||
}
|
||
let dashboardGeneration = 0;
|
||
let legacyDashboard = false;
|
||
state.dashboardCursor = null;
|
||
state.dashboardError = "";
|
||
function decorateSummary(item) {
|
||
const src = item.source || {};
|
||
if (item._type === "repo") return decorateItem(item, item.repoId, item.repoId, src.fullName, "/anonymize/" + item.repoId, "/r/" + item.repoId + "/");
|
||
if (item._type === "pr") return decorateItem(item, item.pullRequestId, item.pullRequestId,
|
||
src.repositoryFullName ? src.repositoryFullName + "#" + src.pullRequestId : undefined,
|
||
"/pull-request-anonymize/" + item.pullRequestId, "/pr/" + item.pullRequestId + "/");
|
||
return decorateItem(item, item.gistId, item.gistId, src.gistId, "/gist-anonymize/" + item.gistId, "/gist/" + item.gistId + "/");
|
||
}
|
||
function loadAll(append = false) {
|
||
if (legacyDashboard) return loadLegacy();
|
||
const generation = ++dashboardGeneration;
|
||
state.loading = !append;
|
||
state.loadingMore = append;
|
||
state.dashboardError = "";
|
||
return http.get("/api/user/dashboard", { params: {
|
||
q: state.search, type: state.typeFilter, sort: state.orderBy,
|
||
statuses: Object.keys(state.filters.status).filter(key => state.filters.status[key] !== false).join(",") || "none",
|
||
attention: String(state.projectView === "attention"),
|
||
cursor: append ? state.dashboardCursor : undefined,
|
||
} }).then(response => {
|
||
if (generation !== dashboardGeneration) return;
|
||
// Compatibility with servers that have not gained the summary API.
|
||
if (!Array.isArray(response.data?.items)) { legacyDashboard = true; return loadLegacy(); }
|
||
state.items = (append ? state.items : []).concat(response.data.items.map(decorateSummary));
|
||
state.dashboardCursor = response.data.cursor;
|
||
state.dashboardTotals = response.data;
|
||
if (!append) updateRemovedCount();
|
||
}, error => {
|
||
if (generation !== dashboardGeneration) return;
|
||
if (error.status === 404) { legacyDashboard = true; return loadLegacy(); }
|
||
state.dashboardError = "Projects could not be loaded. Please try again.";
|
||
}).finally(() => {
|
||
if (generation === dashboardGeneration) { state.loading = false; state.loadingMore = false; }
|
||
});
|
||
}
|
||
state.loadMoreProjects = () => loadAll(true);
|
||
let reloadTimer;
|
||
state.watchGroup(["search", "typeFilter", "orderBy", "projectView"], () => {
|
||
if (legacyDashboard) return;
|
||
if (reloadTimer) timers.timeout.cancel(reloadTimer);
|
||
reloadTimer = timers.timeout(() => loadAll(), 150);
|
||
}, false);
|
||
state.watch("filters", () => {
|
||
if (!legacyDashboard) loadAll();
|
||
}, true, false);
|
||
state.on("dispose", () => { dashboardGeneration++; });
|
||
loadAll();
|
||
|
||
// Whole row opens the anonymized view; clicks on links, buttons and the
|
||
// actions menu keep their own behaviour.
|
||
state.openItem = (item, $event) => {
|
||
if (!item._viewUrl) return;
|
||
const target = $event && $event.target;
|
||
if (target && target.closest && target.closest("a, button, .dropdown, input")) return;
|
||
window.location.href = item._viewUrl;
|
||
};
|
||
|
||
state.hiddenStatusCount = () =>
|
||
Object.keys(state.filters.status).filter((k) => state.filters.status[k] === false).length;
|
||
state.hasHiddenStatus = () => state.hiddenStatusCount() > 0;
|
||
|
||
// ---- Status view (Active | Needs attention | Removed N) -------------
|
||
// "Active" is every status except removed and is the default; "Removed"
|
||
// shows only removed links. Any other mix is a custom Status filter.
|
||
const statusKeys = () => Object.keys(state.statusKeyLabels);
|
||
state.onlyRemovedShown = () => statusKeys().every(k => (k === "removed") === (state.filters.status[k] !== false));
|
||
state.isActiveDefault = () => statusKeys().every(k => (k === "removed") === (state.filters.status[k] === false));
|
||
state.statusView = () => {
|
||
if (state.projectView === "attention") return "attention";
|
||
if (state.onlyRemovedShown()) return "removed";
|
||
if (state.filters.status.removed === false) return "active";
|
||
return "custom";
|
||
};
|
||
state.setStatusView = view => {
|
||
if (view === "attention") return state.setProjectView("attention");
|
||
state.projectView = "all";
|
||
statusKeys().forEach(k => { state.filters.status[k] = view === "removed" ? k === "removed" : k !== "removed"; });
|
||
};
|
||
// Status chips for filters the view tabs do not already express.
|
||
state.statusChipKeys = () => {
|
||
const view = state.statusView();
|
||
if (view === "removed") return [];
|
||
return statusKeys().filter(k => state.filters.status[k] === false && !(k === "removed" && view !== "custom"));
|
||
};
|
||
// The summary API reports the account total and the filtered count, so the
|
||
// removed count follows from them whenever status is the only filter. Under a
|
||
// search or type filter the last known account-wide count is kept.
|
||
state.removedCount = null;
|
||
function updateRemovedCount() {
|
||
if (legacyDashboard) {
|
||
state.removedCount = state.items.filter(item => item._statusKey === "removed").length;
|
||
return;
|
||
}
|
||
const totals = state.dashboardTotals;
|
||
if (!totals || state.projectView !== "all" || state.search.trim() || state.typeFilter !== "all") return;
|
||
if (state.isActiveDefault()) state.removedCount = Math.max(0, totals.total - totals.filtered);
|
||
else if (state.onlyRemovedShown()) state.removedCount = totals.filtered;
|
||
}
|
||
state.showViewTabs = () => state.statusView() !== "active" || state.attentionCount() > 0 || state.removedCount > 0
|
||
|| (state.removedCount == null && !!state.dashboardTotals && state.dashboardTotals.total > state.dashboardTotals.filtered);
|
||
|
||
state.hasActiveFilters = () =>
|
||
state.projectView !== "all" ||
|
||
state.typeFilter !== "all" ||
|
||
state.search.trim().length > 0 ||
|
||
Object.keys(state.filters.status).some((k) => state.filters.status[k] === false);
|
||
|
||
state.showClearFilters = () => state.search.trim().length > 0
|
||
|| state.typeFilter !== "all" || state.statusChipKeys().length > 0;
|
||
// A plain view (Active, Removed, Needs attention) counts its projects;
|
||
// a search, type or extra status filter makes it "N shown".
|
||
state.isPlainView = () => !state.showClearFilters();
|
||
|
||
state.clearFilters = () => {
|
||
const view = state.statusView();
|
||
state.typeFilter = "all";
|
||
state.search = "";
|
||
state.setStatusView(view === "removed" ? "removed" : "active");
|
||
};
|
||
|
||
function waitRepoToBeReady(repoId, callback, onError, previousStatus) {
|
||
http.get("/api/repo/" + repoId).then(async (res) => {
|
||
let statusChanged = previousStatus !== undefined && previousStatus !== res.data.status;
|
||
for (const item of state.items) {
|
||
if (item._type === "repo" && item.repoId == repoId) {
|
||
statusChanged = statusChanged || item.status !== res.data.status;
|
||
const neededAttention = state.needsAttention(item);
|
||
item.status = res.data.status;
|
||
item.statusMessage = res.data.statusMessage;
|
||
item._statusKey = statusKey(item.status);
|
||
if (item._statusKey !== "progress") item._stale = false;
|
||
const needsAttention = state.needsAttention(item);
|
||
if (state.dashboardTotals && neededAttention !== needsAttention) {
|
||
state.dashboardTotals.attention = Math.max(0, state.dashboardTotals.attention + (needsAttention ? 1 : -1));
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
if (statusChanged && !legacyDashboard) {
|
||
state.dashboardCursor = null;
|
||
const selected = state.selectedProject;
|
||
await loadAll();
|
||
if (selected && state.selectedProject === selected) {
|
||
state.selectedProject = state.items.find(item => item._type === selected._type && item._id === selected._id) || null;
|
||
}
|
||
}
|
||
if (
|
||
res.data.status == "ready" ||
|
||
res.data.status == "error" ||
|
||
res.data.status == "removed" ||
|
||
res.data.status == "expired"
|
||
) {
|
||
callback(res.data);
|
||
return;
|
||
}
|
||
timers.timeout(() => waitRepoToBeReady(repoId, callback, onError, res.data.status), 2500);
|
||
}, onError);
|
||
}
|
||
|
||
const labelOf = (t) =>
|
||
t === "repo" ? "repository" : t === "gist" ? "gist" : "pull request";
|
||
const apiBaseOf = (t) =>
|
||
t === "repo" ? "/api/repo" : t === "gist" ? "/api/gist" : "/api/pr";
|
||
|
||
state.removeItem = (item) => {
|
||
const label = labelOf(item._type);
|
||
if (confirm(`Are you sure that you want to remove the ${label} ${item._id}?`)) {
|
||
const toast = reactive({
|
||
title: `Removing ${item._id}...`,
|
||
date: new Date(),
|
||
body: `The ${label} ${item._id} is going to be removed.`,
|
||
});
|
||
state.addToast(toast);
|
||
const endpoint = `${apiBaseOf(item._type)}/${item._id}`;
|
||
http.delete(endpoint).then(
|
||
() => {
|
||
if (item._type === "repo") {
|
||
waitRepoToBeReady(item._id, () => {
|
||
toast.title = `${item._id} is removed.`;
|
||
toast.body = `The ${label} ${item._id} is removed.`;
|
||
|
||
}, undefined, item.status);
|
||
} else {
|
||
toast.title = `${item._id} is removed.`;
|
||
toast.body = `The ${label} ${item._id} is removed.`;
|
||
loadAll();
|
||
}
|
||
},
|
||
(error) => {
|
||
toast.title = `Error during the removal of ${item._id}.`;
|
||
toast.body = error.body;
|
||
loadAll();
|
||
}
|
||
);
|
||
}
|
||
};
|
||
|
||
state.refreshItem = (item) => {
|
||
const label = labelOf(item._type);
|
||
const toast = reactive({
|
||
title: `Refreshing ${item._id}...`,
|
||
date: new Date(),
|
||
body: `The ${label} ${item._id} is going to be refreshed.`,
|
||
});
|
||
state.addToast(toast);
|
||
const onError = (error) => {
|
||
toast.title = `Error during the refresh of ${item._id}.`;
|
||
toast.body = error.data?.error || error.body || "The refresh could not be completed. Please try again.";
|
||
loadAll();
|
||
};
|
||
const endpoint = `${apiBaseOf(item._type)}/${item._id}/refresh`;
|
||
http.post(endpoint).then(
|
||
() => {
|
||
if (item._type === "repo") {
|
||
waitRepoToBeReady(item._id, (repo) => {
|
||
if (repo.status !== "ready") {
|
||
onError({ body: repo.statusMessage || `The repository is ${repo.status}.` });
|
||
return;
|
||
}
|
||
toast.title = `${item._id} is refreshed.`;
|
||
toast.body = `The ${label} ${item._id} is refreshed.`;
|
||
|
||
}, onError, item.status);
|
||
} else {
|
||
toast.title = `${item._id} is refreshed.`;
|
||
toast.body = `The ${label} ${item._id} is refreshed.`;
|
||
loadAll();
|
||
}
|
||
},
|
||
onError
|
||
);
|
||
};
|
||
|
||
state.extendItem = (item) => {
|
||
const label = labelOf(item._type);
|
||
const toast = reactive({
|
||
title: `Extending ${item._id}...`,
|
||
date: new Date(),
|
||
body: `The expiration of ${label} ${item._id} is being extended.`,
|
||
});
|
||
state.addToast(toast);
|
||
const endpoint = `${apiBaseOf(item._type)}/${item._id}/extend`;
|
||
http.post(endpoint).then(
|
||
(res) => {
|
||
const until = res?.data?.expirationDate ? ` until ${new Date(res.data.expirationDate).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}` : "";
|
||
if (item._type === "repo") {
|
||
waitRepoToBeReady(item._id, () => {
|
||
toast.title = `${item._id} is extended.`;
|
||
toast.body = `${label} ${item._id} is extended${until}.`;
|
||
|
||
}, undefined, item.status);
|
||
} else {
|
||
toast.title = `${item._id} is extended.`;
|
||
toast.body = `${label} ${item._id} is extended${until}.`;
|
||
loadAll();
|
||
}
|
||
},
|
||
(error) => {
|
||
toast.title = `Error during the extension of ${item._id}.`;
|
||
const code = error.data && error.data.error;
|
||
toast.body = code || error.body;
|
||
if (code && Object.prototype.hasOwnProperty.call(translations.ERRORS, code)) toast.body = translations.ERRORS[code];
|
||
loadAll();
|
||
}
|
||
);
|
||
};
|
||
|
||
state.itemFilter = (item) => {
|
||
if (state.projectView === "attention" && !state.needsAttention(item)) return false;
|
||
if (state.typeFilter !== "all" && item._type !== state.typeFilter) return false;
|
||
if (state.filters.status[item._statusKey] === false) return false;
|
||
const needle = state.search.trim().toLowerCase();
|
||
if (needle.length == 0) return true;
|
||
if (item._label && String(item._label).toLowerCase().indexOf(needle) > -1) return true;
|
||
if (item._source && String(item._source).toLowerCase().indexOf(needle) > -1) return true;
|
||
if (item._id && String(item._id).toLowerCase().indexOf(needle) > -1) return true;
|
||
if (item.conference && String(item.conference).toLowerCase().indexOf(needle) > -1) return true;
|
||
return false;
|
||
};
|
||
};
|
||
|
||
export const dashboardController = function (state, location) {
|
||
location.url("/dashboard");
|
||
};
|
||
|
||
export const prDashboardController = function (state, location) {
|
||
location.url("/dashboard");
|
||
};
|
||
|
||
export const statusController = function (state, http, params) {
|
||
const timers = createTimers();
|
||
state.repoId = params.repoId;
|
||
state.reviewUrl = window.location.origin + "/r/" + encodeURIComponent(params.repoId) + "/";
|
||
state.repo = null;
|
||
state.progress = 0;
|
||
state.rateLimitResetAt = 0;
|
||
state.rateLimitCountdown = "";
|
||
state.shareUrl = (window.location?.origin || "") + "/r/" + encodeURIComponent(state.repoId) + "/";
|
||
state.copied = false;
|
||
let copiedTimer = null;
|
||
state.copyShareUrl = async () => {
|
||
try {
|
||
await copyText(state.shareUrl);
|
||
state.copied = true;
|
||
if (copiedTimer) timers.timeout.cancel(copiedTimer);
|
||
copiedTimer = timers.timeout(() => { state.copied = false; }, 2000);
|
||
} catch {
|
||
state.addToast?.({ title: "Copy this anonymous link", body: state.shareUrl });
|
||
}
|
||
};
|
||
|
||
// Focusing the link box selects the whole URL, like the read-only field it replaces.
|
||
state.selectShareUrl = event => {
|
||
const selection = window.getSelection?.();
|
||
if (!selection || !event?.target) return;
|
||
const range = document.createRange();
|
||
range.selectNodeContents(event.target);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
};
|
||
|
||
var countdownTimer = null;
|
||
let pollTimer = null;
|
||
let destroyed = false;
|
||
function startRateLimitCountdown(resetAt) {
|
||
state.rateLimitResetAt = resetAt;
|
||
if (countdownTimer) timers.interval.cancel(countdownTimer);
|
||
function tick() {
|
||
var remaining = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000));
|
||
if (remaining <= 0) {
|
||
state.rateLimitCountdown = "";
|
||
state.rateLimitResetAt = 0;
|
||
timers.interval.cancel(countdownTimer);
|
||
countdownTimer = null;
|
||
} else {
|
||
var min = Math.floor(remaining / 60);
|
||
var sec = remaining % 60;
|
||
state.rateLimitCountdown = min > 0
|
||
? min + "m " + sec + "s"
|
||
: sec + "s";
|
||
}
|
||
|
||
}
|
||
tick();
|
||
countdownTimer = timers.interval(tick, 1000);
|
||
}
|
||
state.on("dispose", function () {
|
||
destroyed = true;
|
||
if (countdownTimer) timers.interval.cancel(countdownTimer);
|
||
if (pollTimer) timers.timeout.cancel(pollTimer);
|
||
});
|
||
|
||
function parseStatusMessage(msg) {
|
||
if (!msg) return msg;
|
||
var m = msg.match(/^rate_limited:(\d+)$/);
|
||
if (m) {
|
||
startRateLimitCountdown(parseInt(m[1], 10));
|
||
return null;
|
||
}
|
||
state.rateLimitResetAt = 0;
|
||
return msg;
|
||
}
|
||
|
||
state.getStatus = () => {
|
||
if (destroyed) return;
|
||
http
|
||
.get("/api/repo/" + state.repoId, {
|
||
repoId: state.repoId,
|
||
repoUrl: state.repoUrl,
|
||
})
|
||
.then(
|
||
(res) => {
|
||
if (destroyed) return;
|
||
state.repo = res.data;
|
||
if (res.data.rateLimitResetAt) {
|
||
startRateLimitCountdown(res.data.rateLimitResetAt);
|
||
} else {
|
||
state.repo.statusMessage = parseStatusMessage(state.repo.statusMessage);
|
||
}
|
||
if (state.repo.status == "ready") {
|
||
state.progress = 100;
|
||
} else if (state.repo.status == "queue") {
|
||
state.progress = 10;
|
||
} else if (state.repo.status == "downloaded") {
|
||
state.progress = 50;
|
||
} else if (state.repo.status == "download") {
|
||
state.progress = 25;
|
||
} else if (state.repo.status == "preparing") {
|
||
state.progress = 25;
|
||
} else if (state.repo.status == "anonymizing") {
|
||
state.progress = 75;
|
||
}
|
||
var shouldPoll = !["ready", "removed", "expired"].includes(state.repo.status);
|
||
if (state.repo.status == "error" && !state.rateLimitResetAt) {
|
||
shouldPoll = false;
|
||
}
|
||
if (shouldPoll) {
|
||
pollTimer = timers.timeout(state.getStatus, 2000);
|
||
}
|
||
},
|
||
(err) => {
|
||
state.error = err.data.error;
|
||
}
|
||
);
|
||
};
|
||
state.getStatus();
|
||
};
|
||
|
||
export const anonymizeController = function (state, http, html, params, location, translate, timeout) {
|
||
// The preview shows redactions as the same numbered slots reviewers see.
|
||
installPrismRedactionSlots();
|
||
state.renderRedacted = slotRedactions;
|
||
// Unified state
|
||
state.sourceUrl = "";
|
||
state.workspaceOpen = false;
|
||
state.sourceUrlError = "";
|
||
state.previewStatus = "idle";
|
||
state.sourceLoading = false;
|
||
state.mobilePane = "settings";
|
||
const workspaceMedia = window.matchMedia?.("(max-width: 767px)");
|
||
state.mobileWorkspace = !!workspaceMedia?.matches;
|
||
if (workspaceMedia?.addEventListener) createListeners()(workspaceMedia, "change", event => { state.mobileWorkspace = event.matches; });
|
||
state.selectMobilePane = (pane) => { state.mobilePane = pane; };
|
||
let sourceGeneration = 0;
|
||
let sourceDisposed = false;
|
||
const sourceRequests = new Set();
|
||
const isCurrentSource = (request, branch = false) => !sourceDisposed
|
||
&& request.generation === sourceGeneration
|
||
&& request.url === state.sourceUrl.trim()
|
||
&& request.connection === state.githubConnection
|
||
&& (!branch || request.branch === state.source.branch);
|
||
const beginSourceRequest = () => {
|
||
const request = { generation: sourceGeneration, url: state.sourceUrl.trim(), connection: state.githubConnection, branch: state.source.branch };
|
||
sourceRequests.add(request);
|
||
state.sourceLoading = true;
|
||
return request;
|
||
};
|
||
const finishSourceRequest = (request) => {
|
||
sourceRequests.delete(request);
|
||
if (!sourceDisposed) state.sourceLoading = [...sourceRequests].some(request => isCurrentSource(request));
|
||
};
|
||
state.on("dispose", () => { sourceDisposed = true; sourceGeneration++; });
|
||
let selectedSourceKey = null;
|
||
const sourceKey = (parsed) => parsed.gistId && !parsed.repo
|
||
? "gist:" + parsed.gistId.toLowerCase()
|
||
: (parsed.owner + "/" + parsed.repo).toLowerCase() + (parsed.pullRequestId ? "/pull/" + parsed.pullRequestId : "");
|
||
const draft = formDraft(state, ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"], "anonymize:" + (location.path?.() || ""));
|
||
let restoredCheckoutConference = null;
|
||
let checkoutDraftId = null;
|
||
let checkoutDraftSnapshot = null;
|
||
state.hostingReview = false;
|
||
state.previewExpanded = false;
|
||
state.taskValidationError = "";
|
||
state.editRepository = () => {
|
||
state.mobilePane = "settings";
|
||
state.previewExpanded = false;
|
||
nextTick(() => document.querySelector(".anonymize-fields .is-invalid")?.focus());
|
||
};
|
||
state.submitRepository = (event) => {
|
||
state.taskValidationError = "";
|
||
if (state.sourceLoading || state.billingBusy || state.hostingSaving || state.billingPending) return;
|
||
if (expirationDateInvalid() || state.anonymize?.invalid || state.sourceUrlError) {
|
||
state.taskValidationError = "Check the highlighted fields before continuing.";
|
||
state.editRepository();
|
||
return;
|
||
}
|
||
if (state.hostingNeedsPayment()) return state.checkoutRepository(isRepositoryAddon(state.hostingChoice()) ? state.hostingChoice() : "");
|
||
return state.anonymizeRepo(event);
|
||
};
|
||
state.hostingPayment = false;
|
||
state.hostingAlternatives = false;
|
||
state.hostingPrice = plan => {
|
||
if (plan.amount == null) return "Price shown at checkout";
|
||
const amount = new Intl.NumberFormat(undefined, { style: "currency", currency: plan.currency, minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(plan.amount / 100);
|
||
return amount + (plan.interval === "year" ? " billed annually" : " billed monthly");
|
||
};
|
||
state.hostingPlan = "";
|
||
state.hostingSaving = false;
|
||
state.selectedHostingPlan = () => state.authorPlans().find(p => p.id === state.hostingPlan) || state.hostingSubscriptionPlan?.();
|
||
const loadHostingQuota = () => http.get("/api/user/quota").then(res => { state.hostingQuota = res.data; });
|
||
setupBilling(state, http, async () => {
|
||
await loadHostingQuota();
|
||
state.hostingQuotaBlocked = false;
|
||
}, { confirmedMessage: "Hosting status refreshed. Your draft is ready to continue." });
|
||
loadHostingQuota().catch(() => {});
|
||
state.authorPlans = () => (state.billing?.plans || []).filter(p => ["author_monthly", "author_annual"].includes(p.id));
|
||
// A paid, open conference hosts the submission instead of the author.
|
||
state.conferenceHosting = () => !!(state.conference && state.conference_data?.hosting);
|
||
state.hasAuthorHosting = () => ["author_monthly", "author_annual"].includes(state.billing?.allowance?.plan);
|
||
state.hostingNeedsPayment = () => {
|
||
if (!state.billing?.enabled || state.detectedType !== "repo" || state.hasAuthorHosting() ||
|
||
(state.isUpdate && state.role !== "owner")) return false;
|
||
const needsLifetime = state.options?.expirationMode === "never" && !state._existingNever && !state.repositoryGrants("repo_never").length;
|
||
// Reported usage may include removed reservations; admission reclaims them.
|
||
return !!(needsLifetime || (!state.conferenceHosting() && state.hostingQuotaBlocked));
|
||
};
|
||
// One-time add-ons the current user bought for this repository.
|
||
state.repositoryGrants = planId => (state.billing?.grants || []).filter(g => g.planId === planId && g.scope === `repo:${state.repoId}` &&
|
||
["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
|
||
state.repositoryAddonPlans = () => (state.billing?.plans || []).filter(p => isRepositoryAddon(p.id) &&
|
||
(["repo_extend", "repo_pack"].includes(p.id) || !state.repositoryGrants(p.id).length));
|
||
// One-time add-ons that resolve this submission: Never expire for a
|
||
// never-expiring link, otherwise the paper pack or an extra repository
|
||
// when the free allowance is full. The first is preselected.
|
||
state.hostingOneTimePlans = () => {
|
||
const offered = ids => ids.map(id => (state.billing?.plans || []).find(p => p.id === id && !state.repositoryGrants(id).length)).filter(Boolean);
|
||
if (state.options?.expirationMode === "never" && !state._existingNever && !state.repositoryGrants("repo_never").length) return offered(["repo_never"]);
|
||
return state.hostingQuotaBlocked && !state.conferenceHosting() ? offered(["repo_slot", "repo_pack"]) : [];
|
||
};
|
||
state.hostingOneTime = () => state.hostingOneTimePlans()[0];
|
||
// Automatic updates, as the server decides them (hasAutomaticUpdates):
|
||
// free hosting and an Extra repository refresh by hand; a conference
|
||
// package, Live updates or a Paper pack for this repository, or an author
|
||
// subscription include them. Unknown (billing not loaded, someone else's
|
||
// repository) counts as included, so no paid hint flashes.
|
||
const MANUAL_REFRESH_PLANS = ["free", "repo_slot"];
|
||
state.liveUpdatesIncluded = () => {
|
||
if (!state.billing?.enabled || state.detectedType !== "repo") return true;
|
||
if (state.isUpdate && state.role !== "owner") return true;
|
||
if (state.conferenceHosting() || state.hasAuthorHosting()) return true;
|
||
const plan = state.billing?.allowance?.plan;
|
||
if (plan && !MANUAL_REFRESH_PLANS.includes(plan)) return true;
|
||
return state.repositoryGrants("author_pass").length > 0 || state.repositoryGrants("repo_pack").length > 0;
|
||
};
|
||
state.liveUpdatesPrice = () => {
|
||
const plan = (state.billing?.plans || []).find(p => p.id === "author_pass");
|
||
return plan?.amount != null ? state.billingAmount(plan) : "";
|
||
};
|
||
state.showHostingOptions = () => {
|
||
state.mobilePane = "settings";
|
||
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "start", behavior: "smooth" }));
|
||
};
|
||
state.neverExpireTag = () => {
|
||
if (state.hasAuthorHosting()) return "Included in your subscription";
|
||
if (state.repositoryGrants("repo_never").length) return "Paid for this repository";
|
||
const plan = (state.billing?.plans || []).find(p => p.id === "repo_never");
|
||
return plan?.amount != null ? `${state.billingAmount(plan)} once, or with a subscription` : "Subscription or one-time add-on";
|
||
};
|
||
// Annual before monthly: it is the cheaper subscription.
|
||
// Subscriptions appear as one option with a monthly/yearly switch.
|
||
state.hostingCycle = "author_annual";
|
||
state.hostingSubscriptionPlan = () => state.authorPlans().find(p => p.id === state.hostingCycle) || state.authorPlans().find(p => p.interval === "year") || state.authorPlans()[0];
|
||
state.hostingAnnualSavings = () => {
|
||
const monthly = state.authorPlans().find(p => p.interval === "month")?.amount;
|
||
const annual = state.authorPlans().find(p => p.interval === "year")?.amount;
|
||
return monthly && annual && annual < monthly * 12 ? Math.round((1 - annual / (monthly * 12)) * 100) : 0;
|
||
};
|
||
state.hostingOptionPlans = () => state.hostingOneTimePlans().concat(state.authorPlans().length ? [state.hostingSubscriptionPlan()] : []);
|
||
state.hostingOptionName = plan => ({ repo_slot: "Add this repository", repo_pack: "Paper pack", repo_never: "Never expire",
|
||
author_annual: "Author subscription", author_monthly: "Author subscription" }[plan.id] || plan.name);
|
||
state.hostingOptionBlurb = plan => ({
|
||
repo_slot: "Hosted outside your free repositories. Refresh it by hand.",
|
||
repo_pack: "Adds automatic updates for 180 days and keeps the link online for a year.",
|
||
repo_never: "This link never expires. No subscription.",
|
||
}[plan.id] || `${plan.repositories} repositories, ${state.billingBytes(plan.storage)}, automatic updates and links that never expire.`);
|
||
state.hostingChoice = () => {
|
||
const oneTime = state.hostingOneTimePlans();
|
||
if (oneTime.some(p => p.id === state.hostingPlan) || state.authorPlans().some(p => p.id === state.hostingPlan)) return state.hostingPlan;
|
||
return oneTime[0]?.id || state.hostingSubscriptionPlan()?.id || "";
|
||
};
|
||
state.checkoutRepository = async (addon = "") => {
|
||
if (state.hostingSaving || state.billingBusy || !state.billing?.csrf || state.sourceLoading) return;
|
||
state.hostingSaving = true;
|
||
state.billingError = "";
|
||
try {
|
||
const values = draft.checkpoint();
|
||
const serialized = JSON.stringify(values);
|
||
if (!checkoutDraftId || serialized !== checkoutDraftSnapshot) {
|
||
const saved = await http.post("/api/billing/draft", { path: location.path(), values }, { headers: { "x-csrf-token": state.billing.csrf } });
|
||
checkoutDraftId = saved.data.id;
|
||
checkoutDraftSnapshot = serialized;
|
||
}
|
||
// The subscription option shows one cycle; Checkout must open that same cycle.
|
||
const plan = state.authorPlans().find(p => p.id === state.hostingPlan) || state.hostingSubscriptionPlan();
|
||
if (!addon && !plan) throw new Error("No author plan available");
|
||
await state.checkout(addon || plan.id, addon ? state.repoId : "", { draftId: checkoutDraftId });
|
||
} catch { state.billingError = "Your draft could not be saved for checkout. Your form is unchanged. Please try again."; }
|
||
finally { state.hostingSaving = false; }
|
||
};
|
||
state.useDatedHosting = () => { state.options.expirationMode = "remove"; };
|
||
state.discardDraft = async () => {
|
||
draft.discard();
|
||
state.workspaceOpen = !!state.sourceUrl;
|
||
state.error = null;
|
||
if (state.sourceUrl) await state.urlSelected(true);
|
||
else {
|
||
sourceGeneration++;
|
||
state.sourceLoading = false;
|
||
state.detectedType = null;
|
||
state.details = null;
|
||
state.sourceUrlError = "";
|
||
scheduleReadmePreview.cancel();
|
||
refreshPrPreview.cancel();
|
||
refreshGistPreview.cancel();
|
||
state.previewStatus = "idle";
|
||
}
|
||
};
|
||
state.githubConnection = undefined;
|
||
state.githubConnections = null;
|
||
state.gistOAuthRequired = false;
|
||
state.gistAppRequired = false;
|
||
const saveGitHubDraft = () => {
|
||
const draft = {};
|
||
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) draft[key] = state[key];
|
||
sessionStorage.setItem("github-access-draft", JSON.stringify({ path: location.path(), savedAt: Date.now(), draft }));
|
||
};
|
||
state.connectGistApp = () => {
|
||
saveGitHubDraft();
|
||
window.location.href = "/github/app/login?returnTo=" + encodeURIComponent(location.path());
|
||
};
|
||
state.connectGistOAuth = () => {
|
||
saveGitHubDraft();
|
||
window.location.href = "/github/login?returnTo=" + encodeURIComponent(location.path());
|
||
};
|
||
state.grantGitHubAccess = () => {
|
||
saveGitHubDraft();
|
||
const returnTo = location.path();
|
||
const repository = parseRepoFullName(state.sourceUrl) || "";
|
||
const route = state.githubConnections?.appConnected ? "/github/app/install" : "/github/app/login";
|
||
window.location.href = route + "?returnTo=" + encodeURIComponent(returnTo) + "&repository=" + encodeURIComponent(repository) + "&install=1";
|
||
};
|
||
state.chooseGitHubConnection = async (value) => {
|
||
state.githubConnection = value;
|
||
state.readme = "";
|
||
if (state.sourceUrl) await refreshGitHubAccess();
|
||
};
|
||
state.detectedType = null; // 'repo' | 'pr' | 'gist'
|
||
state.repoId = "";
|
||
state.pullRequestId = "";
|
||
state.gistId = "";
|
||
state.terms = "";
|
||
state.defaultTerms = "";
|
||
state.branches = [];
|
||
state.source = { branch: "", commit: "" };
|
||
state.options = {
|
||
expirationMode: "remove",
|
||
expirationDate: new Date(),
|
||
update: false,
|
||
image: true,
|
||
pdf: true,
|
||
notebook: true,
|
||
link: true,
|
||
body: true,
|
||
title: true,
|
||
origin: false,
|
||
diff: true,
|
||
content: true,
|
||
comments: true,
|
||
username: true,
|
||
date: true,
|
||
};
|
||
// Default expiration: 6 months from today.
|
||
function defaultExpirationDate() {
|
||
const d = new Date();
|
||
d.setMonth(d.getMonth() + 6);
|
||
return d;
|
||
}
|
||
state.options.expirationDate = defaultExpirationDate();
|
||
// Format a Date to a "yyyy-MM-dd" string using local date parts. Using
|
||
// toISOString() here would convert to UTC and can shift the bound by a
|
||
// day for users in negative-offset timezones, which the native date
|
||
// picker (local calendar) would then render off by one.
|
||
function toLocalDateString(d) {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
// Bound the expiration date: no earlier than today. The latest date
|
||
// mirrors the server: a year without billing or with an author
|
||
// subscription, otherwise the free window, extended by this repository's
|
||
// Extended expiration add-ons or its conference's review window. A saved
|
||
// later date stays selectable.
|
||
state.minExpirationDate = toLocalDateString(new Date());
|
||
const updateExpirationLimit = () => {
|
||
const now = new Date();
|
||
let maximum = new Date(now);
|
||
maximum.setFullYear(maximum.getFullYear() + 1);
|
||
// Gists and pull requests keep their one-year limit; billing only limits repositories.
|
||
if (state.billing?.enabled && state.detectedType === "repo" && !state.hasAuthorHosting()) {
|
||
maximum = new Date(now);
|
||
maximum.setMonth(maximum.getMonth() + (state.billing.freeExpirationMonths || 5));
|
||
for (const grant of state.repositoryGrants("repo_extend")) if (new Date(grant.until) > maximum) maximum = new Date(grant.until);
|
||
if (state.conference_data?.endDate && new Date(state.conference_data.endDate) > maximum) maximum = new Date(state.conference_data.endDate);
|
||
if (state._savedExpirationDate && state._savedExpirationDate > maximum) maximum = new Date(state._savedExpirationDate);
|
||
// A default or carried-over date must fit the limit; only the saved
|
||
// date may stay later.
|
||
const date = state.options?.expirationDate;
|
||
const saved = state._savedExpirationDate && date instanceof Date && date.getTime() === state._savedExpirationDate.getTime();
|
||
if (date instanceof Date && date > maximum && !saved) state.options.expirationDate = new Date(maximum);
|
||
}
|
||
state.maxExpirationDate = toLocalDateString(maximum);
|
||
};
|
||
updateExpirationLimit();
|
||
state.watch(() => [state.billing, state.repoId, state.conference_data, state.isUpdate, state._savedExpirationDate, state.options?.expirationMode, state.detectedType], updateExpirationLimit, true, false);
|
||
state.anonymize_readme = "";
|
||
state.readme = "";
|
||
state.html_readme = "";
|
||
state.isUpdate = false;
|
||
|
||
// Landing: the user's GitHub repositories, so step 1 is a pick, not a blank field.
|
||
state.userRepos = [];
|
||
state.userReposStatus = "idle";
|
||
state.userRepoFilter = "";
|
||
state.loadUserRepos = (force = false) => {
|
||
if (state.userReposStatus === "loading") return;
|
||
state.userReposStatus = "loading";
|
||
http.get("/api/user/all_repositories", force ? { params: { force: "1" } } : {}).then(
|
||
(res) => {
|
||
if (sourceDisposed) return;
|
||
const rows = Array.isArray(res.data) ? res.data.filter(r => r && r.fullName) : [];
|
||
state.userRepos = rows.sort((x, y) => x.fullName.localeCompare(y.fullName, undefined, { sensitivity: "base" }));
|
||
state.userReposStatus = "ready";
|
||
},
|
||
() => { if (!sourceDisposed) state.userReposStatus = "error"; }
|
||
);
|
||
};
|
||
state.filteredUserRepos = () => {
|
||
const q = String(state.userRepoFilter || "").trim().toLowerCase();
|
||
const rows = state.userRepos || [];
|
||
return q ? rows.filter(r => r.fullName.toLowerCase().includes(q)) : rows;
|
||
};
|
||
state.selectUserRepo = (repo) => {
|
||
if (!repo?.fullName) return;
|
||
state.sourceUrl = "https://github.com/" + repo.fullName;
|
||
return state.urlSelected();
|
||
};
|
||
if (!params.repoId && !params.pullRequestId && !params.gistId) Promise.resolve().then(() => { if (!sourceDisposed) state.loadUserRepos(); });
|
||
|
||
state.previousConfigurations = [];
|
||
state.previousConfigurationId = "";
|
||
let previousRepositories;
|
||
const normalizedRepositoryName = name => String(name || "").replace(/\/$/, "").replace(/\.git$/i, "").toLowerCase();
|
||
async function findPreviousConfigurations() {
|
||
if (state.isUpdate || params.repoId || params.pullRequestId || params.gistId) return;
|
||
const request = { generation: sourceGeneration, url: state.sourceUrl.trim(), connection: state.githubConnection };
|
||
try {
|
||
// This endpoint only returns repositories the signed-in user can manage.
|
||
previousRepositories ||= http.get("/api/user/anonymized_repositories").then(res => res.data);
|
||
const repositories = await previousRepositories;
|
||
if (!isCurrentSource(request)) return;
|
||
const name = normalizedRepositoryName(parseRepoFullName(request.url));
|
||
state.previousConfigurations = repositories.filter(repo => repo.repoId && repo.options
|
||
&& normalizedRepositoryName(repo.source?.fullName) === name)
|
||
.sort((a, b) => (Date.parse(b.anonymizeDate) || 0) - (Date.parse(a.anonymizeDate) || 0));
|
||
state.previousConfigurationId = state.previousConfigurations[0]?.repoId || "";
|
||
} catch (_) {
|
||
// A suggestion failure must not prevent creating a repository. Retry on the next URL selection.
|
||
previousRepositories = undefined;
|
||
}
|
||
}
|
||
// Long PR and gist URLs scroll inside the field, so name the resolved
|
||
// source underneath: "owner/repo · PR #938", "owner · gist 311fc90…2351".
|
||
state.sourceSummary = () => {
|
||
const url = String(state.sourceUrl || "");
|
||
let m = /github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)/i.exec(url);
|
||
if (m) return m[1] + " · PR #" + m[2];
|
||
m = /gist\.github\.com\/([^/\s]+)\/([0-9a-f]{8,})/i.exec(url);
|
||
if (m) return m[1] + " · gist " + (m[2].length > 12 ? m[2].slice(0, 7) + "…" + m[2].slice(-4) : m[2]);
|
||
return "";
|
||
};
|
||
// "What reviewers will see": the anonymous URL being configured, shown in
|
||
// the preview window's address bar and kept in sync with the ID field.
|
||
state.previewId = () => state.detectedType === "pr" ? state.pullRequestId : state.detectedType === "gist" ? state.gistId : state.repoId;
|
||
state.previewPath = () => {
|
||
const id = state.previewId();
|
||
if (!id) return "";
|
||
return (state.detectedType === "pr" ? "/pr/" : state.detectedType === "gist" ? "/gist/" : "/r/") + encodeURIComponent(id) + "/";
|
||
};
|
||
state.previewUrl = () => {
|
||
const path = state.previewPath();
|
||
return path ? "anonymous.4open.science" + path.replace(/\/$/, "") : "";
|
||
};
|
||
// Occurrences of XXXX-n in what the preview currently shows.
|
||
state.previewRedactions = () => {
|
||
const count = (text) => typeof text === "string" ? (text.match(REDACTION_RE) || []).length : 0;
|
||
if (state.detectedType === "repo") return count(String(state.html_readme || "").replace(/<[^>]*>/g, " "));
|
||
const cache = state.detectedType === "pr" ? _prAnonCache : _gistAnonCache;
|
||
let n = 0;
|
||
cache.forEach((value) => { n += count(value); });
|
||
return n;
|
||
};
|
||
// The preview's miniature file tree, from the same tree the
|
||
// Unredacted-files picker loads. Like the explorer: folders first, then
|
||
// files, each alphabetical; folders start collapsed and open on click.
|
||
// Only the open folders' rows are built, so large repositories stay cheap.
|
||
state.previewTreeOpen = {};
|
||
const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" });
|
||
const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
|
||
state.previewTree = () => {
|
||
if (!state.preserve.commit || !preserveIndex) return [];
|
||
const readme = (preserveIndex.dirs.get("")?.files || []).find((f) => /^readme(\.|$)/i.test(f));
|
||
const rows = [];
|
||
const walk = (dir, depth) => {
|
||
const node = preserveIndex.dirs.get(dir);
|
||
if (!node) return;
|
||
for (const sub of [...node.dirs].sort((a, b) => byName(baseName(a), baseName(b)))) {
|
||
const open = !!state.previewTreeOpen[sub];
|
||
rows.push({ path: sub, name: baseName(sub), dir: true, open, depth });
|
||
if (open) walk(sub, depth + 1);
|
||
}
|
||
for (const file of [...node.files].sort((a, b) => byName(baseName(a), baseName(b)))) {
|
||
rows.push({ path: file, name: baseName(file), dir: false, readme: file === readme, depth });
|
||
}
|
||
};
|
||
walk("", 0);
|
||
return rows;
|
||
};
|
||
state.previewTreeCount = () => {
|
||
const root = state.preserve.commit && preserveIndex ? preserveIndex.dirs.get("") : null;
|
||
return root ? root.dirs.length + root.files.length : 0;
|
||
};
|
||
// Like the explorer, the selected README is scrolled into the sidebar's
|
||
// view when the list loads (only the sidebar scrolls, not the page).
|
||
function revealPreviewReadme() {
|
||
nextTick(() => {
|
||
const nav = document.querySelector(".reviewer-preview .rp-side");
|
||
const row = nav?.querySelector(".rp-files > li.active");
|
||
if (!nav || !row || !nav.clientHeight) return;
|
||
const bottom = row.getBoundingClientRect().bottom - nav.getBoundingClientRect().top + nav.scrollTop;
|
||
if (bottom > nav.scrollTop + nav.clientHeight) nav.scrollTop = bottom - nav.clientHeight + row.offsetHeight * 2;
|
||
});
|
||
}
|
||
state.togglePreviewFolder = (path) => {
|
||
state.previewTreeOpen[path] = !state.previewTreeOpen[path];
|
||
};
|
||
state.existingConfigurationUrl = () => {
|
||
const previous = state.previousConfigurations.find(repo => repo.repoId === state.previousConfigurationId);
|
||
return previous ? "/anonymize/" + encodeURIComponent(previous.repoId) : null;
|
||
};
|
||
|
||
function getDefault(cb) {
|
||
http.get("/api/user/default").then((res) => {
|
||
const data = res.data;
|
||
if (data.terms) {
|
||
state.defaultTerms = data.terms.join("\n");
|
||
}
|
||
state.options = Object.assign({}, state.options, data.options);
|
||
// Honour a server-provided default date, otherwise fall back to the
|
||
// 6-month default. (Previously this re-added the offset on top of the
|
||
// already-defaulted date, doubling it.)
|
||
state.options.expirationDate =
|
||
data.options && data.options.expirationDate
|
||
? new Date(data.options.expirationDate)
|
||
: defaultExpirationDate();
|
||
if (!params.repoId && !params.pullRequestId && !params.gistId && !state.anonymize?.terms?.dirty) state.terms = state.defaultTerms;
|
||
if (cb) cb();
|
||
});
|
||
}
|
||
|
||
// Helper to safely set validity on form fields
|
||
function setValidity(field, key, value) {
|
||
if (state.anonymize && state.anonymize[field]) {
|
||
state.anonymize[field].setValidity(key, value);
|
||
}
|
||
}
|
||
|
||
function parseRepoFullName(url) {
|
||
try {
|
||
const parsed = parseGithubUrl(url);
|
||
if (parsed && parsed.owner && parsed.repo) {
|
||
return parsed.owner + "/" + parsed.repo;
|
||
}
|
||
} catch (_) { /* sourceUrl not yet parseable */ }
|
||
return null;
|
||
}
|
||
|
||
state.accessRepairAllowed = false;
|
||
state.repairingGitHubAccess = false;
|
||
state.accessRepairMessage = "";
|
||
state.repairGitHubAccess = async () => {
|
||
if (!state.accessRepairAllowed || state.repairingGitHubAccess || !state.githubConnections?.appEnabled || !params.repoId || state.role !== "owner" ||
|
||
state.githubConnection !== "github-app" || parseRepoFullName(state.sourceUrl) !== state._originalFullName) return;
|
||
state.repairingGitHubAccess = true;
|
||
state.accessRepairMessage = "";
|
||
const request = beginSourceRequest();
|
||
try {
|
||
const connections = await http.get("/github/connections");
|
||
state.githubConnections = connections.data;
|
||
if (!connections.data.appEnabled) return;
|
||
const repaired = await http.post("/github/connections/migrate", { type: "repository", id: params.repoId, connection: "github-app" },
|
||
{ headers: { "X-CSRF-Token": connections.data.csrf } });
|
||
if (!isCurrentSource(request)) return;
|
||
state.error = null;
|
||
if (repaired.data.fullName) {
|
||
state._originalFullName = repaired.data.fullName;
|
||
state.source.fullName = repaired.data.fullName;
|
||
state.sourceUrl = "https://github.com/" + repaired.data.fullName;
|
||
}
|
||
state.accessRepairMessage = "GitHub access repaired. Your anonymous URL and published commit are unchanged.";
|
||
await state.urlSelected(true);
|
||
} catch (_) {
|
||
if (isCurrentSource(request)) state.accessRepairMessage = "Unable to repair GitHub access. Check the App's repository permissions in Manage access, then retry. Your published snapshot has not been replaced.";
|
||
} finally {
|
||
state.repairingGitHubAccess = false;
|
||
finishSourceRequest(request);
|
||
}
|
||
};
|
||
|
||
state.reconnectingSource = false;
|
||
state.reconnectRepositoryId = undefined;
|
||
state.reconnectSource = async () => {
|
||
if (!confirm("Reconnect to the repository currently at this GitHub URL? Review its branch and commit before saving. Saving will replace the published snapshot.")) return;
|
||
state.reconnectingSource = true;
|
||
state.reconnectRepositoryId = undefined;
|
||
state.source.commit = "";
|
||
state.source.branch = "";
|
||
state.readme = "";
|
||
state.branches = [];
|
||
try { await getRepoDetails(); }
|
||
catch (_) { /* getRepoDetails displays the access error. */ }
|
||
};
|
||
|
||
function sourceRepositoryID() {
|
||
if (state.reconnectingSource) return undefined;
|
||
if (!state.isUpdate || !state._originalRepositoryID) return undefined;
|
||
const currentFullName = parseRepoFullName(state.sourceUrl);
|
||
return currentFullName === state._originalFullName
|
||
? state._originalRepositoryID
|
||
: undefined;
|
||
}
|
||
|
||
async function refreshGitHubAccess() {
|
||
state._preservingDraft = true;
|
||
try { await state.urlSelected(true); }
|
||
finally { state._preservingDraft = false; }
|
||
}
|
||
|
||
async function restoreGitHubDraft() {
|
||
const billingDraft = new URLSearchParams(window.location.search).get("billingDraft");
|
||
if (billingDraft) {
|
||
draft.restore();
|
||
try {
|
||
const saved = (await http.get("/api/billing/draft/" + encodeURIComponent(billingDraft))).data;
|
||
if (saved.path !== location.path()) throw new Error("Wrong task");
|
||
restoredCheckoutConference = saved.values.conference || null;
|
||
for (const key of ["sourceUrl", "terms", "repoId", "source", "options", "conference", "githubConnection"]) {
|
||
if (Object.prototype.hasOwnProperty.call(saved.values, key)) state[key] = saved.values[key];
|
||
}
|
||
if (state.options?.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate);
|
||
checkoutDraftId = saved.id;
|
||
checkoutDraftSnapshot = JSON.stringify(draft.checkpoint());
|
||
state.draftRestored = true;
|
||
state.hostingReview = true;
|
||
if (state.sourceUrl) await refreshGitHubAccess();
|
||
// Consume the return token so revisiting the form cannot overwrite newer edits.
|
||
location.replace?.(location.path());
|
||
return true;
|
||
} catch { state.billingError = "The checkout draft could not be restored. Retry by reloading this page; any local draft is still available."; }
|
||
}
|
||
let saved;
|
||
try { saved = JSON.parse(sessionStorage.getItem("github-access-draft") || "null"); }
|
||
catch (_) { return false; }
|
||
if (!saved || saved.path !== location.path() || Date.now() - saved.savedAt >= 30 * 60000) {
|
||
if (!draft.restore()) return false;
|
||
if (state.sourceUrl) await refreshGitHubAccess();
|
||
return true;
|
||
}
|
||
draft.restore();
|
||
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) {
|
||
if (Object.prototype.hasOwnProperty.call(saved.draft, key)) state[key] = saved.draft[key];
|
||
}
|
||
if (state.options.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate);
|
||
await refreshGitHubAccess();
|
||
sessionStorage.removeItem("github-access-draft");
|
||
return true;
|
||
}
|
||
|
||
getDefault(() => {
|
||
if (!params.repoId && !params.pullRequestId && !params.gistId) timeout(restoreGitHubDraft, 0);
|
||
// Edit mode: repo
|
||
if (params.repoId && params.repoId != "") {
|
||
state.isUpdate = true;
|
||
state.detectedType = "repo";
|
||
state.repoId = params.repoId;
|
||
http.get("/api/repo/" + state.repoId).then(
|
||
async (res) => {
|
||
state.accessRepairAllowed = !["removed", "archived", "preparing", "queue", "download", "removing", "expiring"].includes(res.data.status);
|
||
state.githubConnection = res.data.connection || "oauth";
|
||
state.sourceUrl = "https://github.com/" + res.data.source.fullName;
|
||
state._originalFullName = res.data.source.fullName;
|
||
state.terms = res.data.options.terms.filter((f) => f).join("\n");
|
||
state.source = res.data.source;
|
||
state.role = res.data.role || "owner";
|
||
state._existingNever = res.data.options.expirationMode === "never";
|
||
state._savedExpirationDate = res.data.options.expirationDate ? new Date(res.data.options.expirationDate) : null;
|
||
state.coauthors = res.data.coauthors || [];
|
||
// Remember the saved branch so the source.branch watcher knows
|
||
// not to bump source.commit to GitHub HEAD on edit-page load
|
||
// (#360). Without this, just opening the Edit form silently
|
||
// pulled in any new commits and saving — even to toggle a
|
||
// checkbox — picked them up.
|
||
state._originalBranch = res.data.source.branch;
|
||
state.options = Object.assign({}, state.options, res.data.options);
|
||
state.conference = res.data.conference;
|
||
state._originalConference = res.data.conference;
|
||
state.repositoryID = res.data.source.repositoryID;
|
||
state._originalRepositoryID = res.data.source.repositoryID;
|
||
if (res.data.options.expirationDate) {
|
||
state.options.expirationDate = new Date(res.data.options.expirationDate);
|
||
}
|
||
selectedSourceKey = sourceKey(parseGithubUrl(state.sourceUrl));
|
||
if (await restoreGitHubDraft()) return;
|
||
await Promise.all([getRepoDetails(), getReadme()]);
|
||
anonymizeReadme();
|
||
|
||
},
|
||
() => { location.url("/404"); }
|
||
);
|
||
}
|
||
// Edit mode: PR
|
||
if (params.pullRequestId && params.pullRequestId != "") {
|
||
state.isUpdate = true;
|
||
state.detectedType = "pr";
|
||
state.pullRequestId = params.pullRequestId;
|
||
http.get("/api/pr/" + state.pullRequestId).then(
|
||
async (res) => {
|
||
state.githubConnection = res.data.connection || "oauth";
|
||
state.sourceUrl = "https://github.com/" + res.data.source.repositoryFullName + "/pull/" + res.data.source.pullRequestId;
|
||
state.terms = res.data.options.terms.filter((f) => f).join("\n");
|
||
state.source = res.data.source;
|
||
state.options = Object.assign({}, state.options, res.data.options);
|
||
state.conference = res.data.conference;
|
||
state._originalConference = res.data.conference;
|
||
if (res.data.options.expirationDate) {
|
||
state.options.expirationDate = new Date(res.data.options.expirationDate);
|
||
}
|
||
selectedSourceKey = sourceKey(parseGithubUrl(state.sourceUrl));
|
||
if (await restoreGitHubDraft()) return;
|
||
try { await getPrDetails(); }
|
||
catch (_) { /* The source handler displays the error. */ }
|
||
|
||
},
|
||
() => { location.url("/404"); }
|
||
);
|
||
}
|
||
// Edit mode: Gist
|
||
if (params.gistId && params.gistId != "") {
|
||
state.isUpdate = true;
|
||
state.detectedType = "gist";
|
||
state.gistId = params.gistId;
|
||
http.get("/api/gist/" + state.gistId).then(
|
||
async (res) => {
|
||
state.sourceUrl = "https://gist.github.com/" + res.data.source.gistId;
|
||
state.terms = res.data.options.terms.filter((f) => f).join("\n");
|
||
state.source = res.data.source;
|
||
state.options = Object.assign({}, state.options, res.data.options);
|
||
state.conference = res.data.conference;
|
||
state._originalConference = res.data.conference;
|
||
if (res.data.options.expirationDate) {
|
||
state.options.expirationDate = new Date(res.data.options.expirationDate);
|
||
}
|
||
selectedSourceKey = sourceKey(parseGithubUrl(state.sourceUrl));
|
||
if (await restoreGitHubDraft()) return;
|
||
await getGistDetails();
|
||
|
||
},
|
||
() => { location.url("/404"); }
|
||
);
|
||
}
|
||
});
|
||
|
||
http.get("/github/connections").then(res => {
|
||
state.githubConnections = res.data;
|
||
// Preserve saved resources and an explicit or restored draft choice.
|
||
if (!params.repoId && !params.pullRequestId && !params.gistId && state.githubConnection === undefined
|
||
&& res.data.appEnabled && res.data.appConnected) {
|
||
state.githubConnection = "github-app";
|
||
if (state.sourceUrl && state.workspaceOpen) refreshGitHubAccess();
|
||
}
|
||
}).catch(() => {});
|
||
|
||
// Keep the same input focused until a complete GitHub URL can be loaded.
|
||
state.urlSelected = async (preserveDraft = false) => {
|
||
const generation = ++sourceGeneration;
|
||
state.previousConfigurations = [];
|
||
state.previousConfigurationId = "";
|
||
let o;
|
||
try { o = parseGithubUrl(state.sourceUrl.trim()); }
|
||
catch (_) {
|
||
state.sourceLoading = false;
|
||
scheduleReadmePreview.cancel();
|
||
refreshPrPreview.cancel();
|
||
refreshGistPreview.cancel();
|
||
state.previewStatus = "idle";
|
||
state.sourceUrlError = state.sourceUrl ? "Please provide a valid GitHub URL." : "";
|
||
setValidity("sourceUrl", "github", false);
|
||
return;
|
||
}
|
||
const key = sourceKey(o);
|
||
const preserveSelection = preserveDraft || selectedSourceKey === key;
|
||
selectedSourceKey = key;
|
||
state.sourceUrlError = "";
|
||
setValidity("sourceUrl", "github", true);
|
||
const transferFocus = document.activeElement?.id === "sourceUrl-landing";
|
||
state.workspaceOpen = true;
|
||
// Show the pasted URL from its start, not scrolled to the caret.
|
||
setTimeout(() => { const el = window.document.getElementById("sourceUrl"); if (!el) return; try { el.setSelectionRange(0, 0); } catch { /* not a text input */ } el.scrollLeft = 0; }, 50);
|
||
if (transferFocus) timeout(() => document.getElementById("sourceUrl")?.focus(), 0);
|
||
state.reconnectingSource = false;
|
||
state.reconnectRepositoryId = undefined;
|
||
if (!preserveSelection && !state.isUpdate) {
|
||
state.repoId = "";
|
||
state.pullRequestId = "";
|
||
state.gistId = "";
|
||
}
|
||
state.details = null;
|
||
state.branches = [];
|
||
if (!preserveSelection) state.source = { type: "GitHubStream", branch: "", commit: "" };
|
||
scheduleReadmePreview.cancel();
|
||
refreshPrPreview.cancel();
|
||
refreshGistPreview.cancel();
|
||
_prAnonCache.clear();
|
||
_gistAnonCache.clear();
|
||
state.previewStatus = "idle";
|
||
state.anonymize_readme = "";
|
||
state.readme = "";
|
||
state.html_readme = "";
|
||
state.detectedType = null;
|
||
state.gistOAuthRequired = false;
|
||
state.gistAppRequired = false;
|
||
|
||
state._preservingDraft = preserveSelection;
|
||
try {
|
||
if (o.gistId && !o.repo) {
|
||
state.detectedType = "gist";
|
||
state.source = { gistId: o.gistId };
|
||
await getGistDetails();
|
||
} else if (o.pullRequestId) {
|
||
state.detectedType = "pr";
|
||
state.source = { repositoryFullName: o.owner + "/" + o.repo, pullRequestId: o.pullRequestId };
|
||
await getPrDetails();
|
||
} else {
|
||
state.detectedType = "repo";
|
||
void findPreviousConfigurations();
|
||
await Promise.all([getRepoDetails(), getReadme()]);
|
||
anonymizeReadme();
|
||
}
|
||
} catch (_) { /* Source errors are displayed by the request handlers. */ }
|
||
finally { if (generation === sourceGeneration) state._preservingDraft = false; }
|
||
$('[data-toggle="tooltip"]').tooltip();
|
||
};
|
||
$('[data-toggle="tooltip"]').tooltip();
|
||
|
||
// ========== REPO LOGIC ==========
|
||
|
||
state.watch("source.branch", async () => {
|
||
if (state.detectedType !== "repo") return;
|
||
const selected = state.branches.filter((f) => f.name == state.source.branch)[0];
|
||
if (!selected) return;
|
||
// In update mode, preserve the saved commit while the branch is
|
||
// unchanged — see #360. Saving the form (e.g. to turn off
|
||
// auto-update) used to bump the commit to GitHub HEAD because this
|
||
// watcher overwrote it on edit-page load.
|
||
const keepSavedCommit =
|
||
!state.reconnectingSource &&
|
||
state.isUpdate &&
|
||
state._originalBranch === state.source.branch &&
|
||
!!state.source.commit;
|
||
if (!keepSavedCommit && !(state._preservingDraft && state.source.commit)) {
|
||
state.source.commit = selected.commit;
|
||
}
|
||
state.readme = selected.readme;
|
||
await getReadme();
|
||
anonymizeReadme();
|
||
|
||
});
|
||
|
||
state.getBranches = async (force) => {
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
const request = beginSourceRequest();
|
||
try {
|
||
const branches = await http.get(`/api/repo/${o.owner}/${o.repo}/branches`, {
|
||
params: { reconnect: state.reconnectingSource ? "1" : undefined, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, force: force === true || state.reconnectingSource ? "1" : "0", repositoryID: sourceRepositoryID() },
|
||
});
|
||
if (!isCurrentSource(request)) return;
|
||
state.branches = branches.data;
|
||
state.sourceUnreachable = false;
|
||
if (!state.source.branch) {
|
||
state.source.branch = state.details.defaultBranch;
|
||
}
|
||
const selected = state.branches.filter((b) => b.name == state.source.branch);
|
||
if (selected.length > 0) {
|
||
// When the user explicitly clicks refresh (force=true), always
|
||
// update the commit to the latest on the branch. Only preserve
|
||
// the saved commit on the initial edit-page load (#360).
|
||
const keepSavedCommit =
|
||
!state.reconnectingSource &&
|
||
!force &&
|
||
state.isUpdate &&
|
||
!state.options.update &&
|
||
state._originalBranch === state.source.branch &&
|
||
!!state.source.commit;
|
||
if (!keepSavedCommit && !(state._preservingDraft && state.source.commit)) {
|
||
state.source.commit = selected[0].commit;
|
||
}
|
||
state.readme = selected[0].readme;
|
||
await getReadme(force);
|
||
}
|
||
} catch (error) {
|
||
if (!isCurrentSource(request)) return;
|
||
state.branches = [];
|
||
state.sourceUnreachable = error && (error.status === 404 || (error.data && error.data.error === "repo_not_found"));
|
||
const code = (error && error.data && error.data.error) || (error && error.status === 404 ? "repo_not_found" : "unknown_error");
|
||
translate("ERRORS." + code).then((translation) => {
|
||
state.toasts = state.toasts || [];
|
||
state.addToast({ title: "Error", date: new Date(), body: translation });
|
||
state.error = translation;
|
||
}, console.error);
|
||
if (typeof setValidity === "function") {
|
||
setValidity("sourceUrl", "missing", false);
|
||
}
|
||
} finally { finishSourceRequest(request); }
|
||
|
||
};
|
||
|
||
async function getRepoDetails() {
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
const request = beginSourceRequest();
|
||
try {
|
||
resetValidity();
|
||
// force=1 so newly enabled features (e.g. GitHub Pages — see
|
||
// #364) are reflected without waiting for the cached metadata to
|
||
// expire. The endpoint hits the GitHub API once.
|
||
const res = await http.get(`/api/repo/${o.owner}/${o.repo}/`, {
|
||
params: { reconnect: state.reconnectingSource ? "1" : undefined, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, repositoryID: sourceRepositoryID(), force: "1" },
|
||
});
|
||
if (!isCurrentSource(request)) return;
|
||
state.details = res.data;
|
||
if (state.reconnectingSource) state.reconnectRepositoryId = state.details.externalId;
|
||
if (state.details && state.details.id) {
|
||
state.repositoryID = state.details.id;
|
||
}
|
||
if (!state.repoId) {
|
||
state.repoId = state.details.repo + "-" + generateRandomId(4);
|
||
}
|
||
await state.getBranches(state.reconnectingSource);
|
||
} catch (error) {
|
||
if (!isCurrentSource(request)) return;
|
||
if (error.data) {
|
||
translate("ERRORS." + error.data.error).then((translation) => {
|
||
state.addToast({ title: "Error", date: new Date(), body: translation });
|
||
state.error = translation;
|
||
}, console.error);
|
||
displayErrorMessage(error.data.error);
|
||
}
|
||
setValidity("sourceUrl", "missing", false);
|
||
throw error;
|
||
} finally { finishSourceRequest(request); }
|
||
}
|
||
|
||
async function getReadme(force) {
|
||
if (state.readme && !force) return state.readme;
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
const request = beginSourceRequest();
|
||
try {
|
||
const res = await http.get(`/api/repo/${o.owner}/${o.repo}/readme`, {
|
||
params: { reconnect: state.reconnectingSource ? "1" : undefined, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, force: force === true ? "1" : "0", branch: state.source.branch, repositoryID: sourceRepositoryID() },
|
||
});
|
||
if (isCurrentSource(request, true)) state.readme = res.data;
|
||
} catch (error) {
|
||
if (isCurrentSource(request, true)) state.readme = "";
|
||
} finally { finishSourceRequest(request); }
|
||
}
|
||
|
||
// Both anonymizeReadme() and anonymizePrContent() used to reimplement
|
||
// ContentAnonimizer client-side, which drifted from the backend (term
|
||
// boundary fixes, accent matching, custom replacements all only landed
|
||
// in the server). Send the snippets to /api/anonymize-preview instead so
|
||
// the preview matches what reviewers see byte-for-byte. Calls are
|
||
// debounced and the in-flight request is dropped on the next change so
|
||
// typing in the form stays responsive.
|
||
|
||
// ---- "Leave files unredacted" picker (#941) ----
|
||
// Picked files are saved first, as root-anchored paths with glob characters
|
||
// (braces included) escaped by a backslash; folders and anything typed by
|
||
// hand follow as patterns. Patterns apply in order, so a later "!pattern"
|
||
// can exclude a picked file, exactly as on the server.
|
||
const globOpts = { magicalBraces: true };
|
||
// A picked file: a root-anchored path with no glob characters. Folder rules
|
||
// ("/paper/") stay patterns.
|
||
const isPickedPattern = (p) => p.startsWith("/") && !p.endsWith("/") && globEscape(globUnescape(p.slice(1), globOpts), globOpts) === p.slice(1);
|
||
const pickedRules = () => Object.keys(state.preserve.picked).filter((f) => state.preserve.picked[f]).sort()
|
||
.map((f) => "/" + globEscape(f, globOpts));
|
||
const folderPattern = (dir) => "/" + globEscape(dir, globOpts) + "/**";
|
||
const folderExclusion = (dir) => "!/" + globEscape(dir, globOpts) + "/";
|
||
const patternLines = () => String(state.preserve.patterns || "").split("\n").map((l) => l.trim()).filter(Boolean);
|
||
let preserveIndex = null;
|
||
// The checkboxes use the exact rule list that will be saved (picks first,
|
||
// then patterns) through the server's matcher, so they cannot disagree.
|
||
let pickVersion = 0;
|
||
let matcherKey = null;
|
||
let matcherFn = () => false;
|
||
function preserveMatcher() {
|
||
const key = pickVersion + "\u0000" + state.preserve.patterns;
|
||
if (key !== matcherKey) {
|
||
matcherKey = key;
|
||
try { matcherFn = compilePreserveMatcher(dedupePreservePatterns([...pickedRules(), ...patternLines()])); } catch { matcherFn = () => false; }
|
||
}
|
||
return matcherFn;
|
||
}
|
||
function buildPreserveIndex(files) {
|
||
const dirs = new Map([["", { dirs: [], files: [] }]]);
|
||
for (const file of files) {
|
||
const parts = file.split("/");
|
||
let parent = "";
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
const dir = parts.slice(0, i + 1).join("/");
|
||
if (!dirs.has(dir)) { dirs.set(dir, { dirs: [], files: [] }); dirs.get(parent).dirs.push(dir); }
|
||
parent = dir;
|
||
}
|
||
dirs.get(parent).files.push(file);
|
||
}
|
||
for (const entry of dirs.values()) entry.dirs.sort();
|
||
return { files, dirs, descendants: new Map() };
|
||
}
|
||
function descendants(dir) {
|
||
if (!preserveIndex.descendants.has(dir)) {
|
||
preserveIndex.descendants.set(dir, preserveIndex.files.filter((f) => f.startsWith(dir + "/")));
|
||
}
|
||
return preserveIndex.descendants.get(dir);
|
||
}
|
||
const fileChecked = (file) => preserveMatcher()(file);
|
||
function dirState(dir) {
|
||
const files = descendants(dir);
|
||
let checked = 0;
|
||
for (const f of files) if (fileChecked(f)) checked++;
|
||
return checked === 0 ? "none" : checked === files.length ? "all" : "some";
|
||
}
|
||
state.preserve = { open: false, loading: false, error: "", truncated: false, commit: "", search: "", expanded: {}, picked: {}, patterns: "", patternsOpen: false };
|
||
state.preserveSummary = () => {
|
||
const list = (state.options?.preserveFiles || []).map((p) => String(p).trim()).filter(Boolean)
|
||
.map((p) => isPickedPattern(p) ? globUnescape(p.slice(1), globOpts) : p);
|
||
if (!list.length) return "None. Every file is redacted.";
|
||
return list.slice(0, 2).join(", ") + (list.length > 2 ? ` and ${list.length - 2} more` : "");
|
||
};
|
||
async function loadPreserveTree() {
|
||
const commit = state.source?.commit;
|
||
if (preserveIndex && state.preserve.commit === commit) return;
|
||
state.preserve.loading = true;
|
||
state.preserve.error = "";
|
||
preserveIndex = null;
|
||
try {
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
const res = await http.get(`/api/repo/${o.owner}/${o.repo}/tree`, {
|
||
params: { commit, connection: state.githubConnection, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, repositoryID: sourceRepositoryID() },
|
||
});
|
||
if (state.source?.commit !== commit) return;
|
||
preserveIndex = markRaw(buildPreserveIndex(res.data.files || []));
|
||
state.preserve.commit = commit;
|
||
state.preserve.truncated = !!res.data.truncated;
|
||
state.previewTreeOpen = {};
|
||
revealPreviewReadme();
|
||
} catch (error) {
|
||
state.preserve.error = "The file list could not be loaded from GitHub. You can still use patterns.";
|
||
state.preserve.patternsOpen = true;
|
||
} finally {
|
||
state.preserve.loading = false;
|
||
}
|
||
}
|
||
// The preview window shows the repository's top-level files once the
|
||
// commit is known (one tree request, shared with the picker).
|
||
state.watch("source.commit", (commit) => {
|
||
if (state.detectedType === "repo" && commit && state.sourceUrl) loadPreserveTree().catch(() => {});
|
||
});
|
||
state.openPreserveFiles = () => {
|
||
// Picks are saved first, so only the leading run of plain paths can be
|
||
// shown as picks; a plain path after another rule stays a pattern to
|
||
// keep its place in the order.
|
||
const picked = {};
|
||
const patterns = [];
|
||
for (const raw of state.options?.preserveFiles || []) {
|
||
const p = String(raw).trim();
|
||
if (!p) continue;
|
||
if (!patterns.length && isPickedPattern(p)) picked[globUnescape(p.slice(1), globOpts)] = true;
|
||
else patterns.push(p);
|
||
}
|
||
const expanded = {};
|
||
for (const file of Object.keys(picked)) {
|
||
const parts = file.split("/");
|
||
for (let i = 1; i < parts.length; i++) expanded[parts.slice(0, i).join("/")] = true;
|
||
}
|
||
Object.assign(state.preserve, { open: true, search: "", picked, expanded, patterns: patterns.join("\n"), patternsOpen: patterns.length > 0 });
|
||
pickVersion++;
|
||
timeout(() => {
|
||
const dialog = document.getElementById("preserve-files-dialog");
|
||
if (dialog?.showModal && !dialog.open) dialog.showModal();
|
||
document.getElementById("preserve-files-search")?.focus();
|
||
}, 0);
|
||
loadPreserveTree();
|
||
};
|
||
state.closePreserveFiles = () => {
|
||
document.getElementById("preserve-files-dialog")?.close?.();
|
||
state.preserve.open = false;
|
||
};
|
||
state.savePreserveFiles = () => {
|
||
state.options.preserveFiles = dedupePreservePatterns([...pickedRules(), ...patternLines()]);
|
||
state.closePreserveFiles();
|
||
};
|
||
state.preserveRows = () => {
|
||
if (!preserveIndex) return [];
|
||
const matcher = preserveMatcher();
|
||
const fileRow = (file, depth, label) => {
|
||
const picked = !!state.preserve.picked[file];
|
||
const checked = matcher(file);
|
||
const note = !picked && checked ? "matched by a pattern" : picked && !checked ? "excluded by a pattern" : "";
|
||
return { kind: "file", path: file, label, depth, state: checked ? "all" : "none", locked: !picked && checked, note };
|
||
};
|
||
const search = String(state.preserve.search || "").trim().toLowerCase();
|
||
if (search) {
|
||
return preserveIndex.files.filter((f) => f.toLowerCase().includes(search)).slice(0, 300).map((f) => fileRow(f, 0, f));
|
||
}
|
||
const rows = [];
|
||
const walk = (dir, depth) => {
|
||
const entry = preserveIndex.dirs.get(dir);
|
||
for (const sub of entry.dirs) {
|
||
const open = !!state.preserve.expanded[sub];
|
||
const ownPattern = patternLines().includes(folderPattern(sub));
|
||
rows.push({ kind: "dir", path: sub, label: sub.slice(dir ? dir.length + 1 : 0) + "/", depth, open, state: dirState(sub), locked: false, note: ownPattern ? "whole folder" : "" });
|
||
if (open) walk(sub, depth + 1);
|
||
}
|
||
for (const file of entry.files) rows.push(fileRow(file, depth, file.slice(dir ? dir.length + 1 : 0)));
|
||
};
|
||
walk("", 0);
|
||
return rows;
|
||
};
|
||
state.preserveCount = () => preserveIndex ? preserveIndex.files.filter(fileChecked).length : 0;
|
||
state.togglePreserveDir = (dir) => { state.preserve.expanded[dir] = !state.preserve.expanded[dir]; };
|
||
state.togglePreserveFile = (file) => { state.preserve.picked[file] = !state.preserve.picked[file]; pickVersion++; };
|
||
// Unchecking a folder must win over broader rules such as "*.bib", so a
|
||
// folder exclusion is appended when files inside would stay checked.
|
||
state.togglePreserveFolder = (dir) => {
|
||
const own = folderPattern(dir);
|
||
const exclusion = folderExclusion(dir);
|
||
const lines = patternLines().filter((l) => l !== own && l !== exclusion);
|
||
const wasChecked = dirState(dir) === "all" || patternLines().includes(own);
|
||
for (const f of descendants(dir)) state.preserve.picked[f] = false;
|
||
pickVersion++;
|
||
state.preserve.patterns = (wasChecked ? lines : [...lines, own]).join("\n");
|
||
if (wasChecked && descendants(dir).some(fileChecked)) {
|
||
state.preserve.patterns = [...lines, exclusion].join("\n");
|
||
}
|
||
};
|
||
|
||
// GitHub's README lookup order. The exact path is not known here, so the
|
||
// preview treats the README as unredacted if any candidate is selected:
|
||
// when unsure, it shows the author more exposure, not less.
|
||
const README_CANDIDATES = [".github", "", "docs"].flatMap((dir) =>
|
||
["README.md", "README", "README.markdown", "README.rst", "README.txt", "readme.md"].map((name) => (dir ? dir + "/" : "") + name));
|
||
state.readmePreserved = () => {
|
||
if (state.detectedType !== "repo") return false;
|
||
const patterns = (state.options?.preserveFiles || []).map((p) => String(p).trim()).filter(Boolean);
|
||
if (!patterns.length) return false;
|
||
try {
|
||
const matcher = compilePreserveMatcher(patterns);
|
||
return README_CANDIDATES.some((path) => matcher(path));
|
||
} catch { return false; }
|
||
};
|
||
function previewOptions() {
|
||
const opts = {
|
||
terms: state.terms && !state.readmePreserved() ? state.terms.split("\n") : [],
|
||
image: !!state.options.image,
|
||
link: !!state.options.link,
|
||
repoId: state.repoId,
|
||
};
|
||
if (state.source && state.source.branch) {
|
||
opts.branchName = state.source.branch;
|
||
}
|
||
try {
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
opts.repoName = `${o.owner}/${o.repo}`;
|
||
} catch (_) { /* sourceUrl not yet parseable */ }
|
||
return opts;
|
||
}
|
||
|
||
// Invalidate as soon as an option changes, before the debounce expires.
|
||
function makePreviewBatcher(kind, buildBody, applyResult) {
|
||
let pendingTimer = null;
|
||
let generation = 0;
|
||
const schedule = () => {
|
||
if (state.detectedType !== kind) return;
|
||
if (pendingTimer) timeout.cancel(pendingTimer);
|
||
const current = ++generation;
|
||
state.previewStatus = "loading";
|
||
pendingTimer = timeout(() => {
|
||
pendingTimer = null;
|
||
const body = buildBody();
|
||
if (!body) { state.previewStatus = "idle"; return; }
|
||
http.post("/api/anonymize-preview", body).then(res => {
|
||
if (current !== generation) return;
|
||
applyResult(res.data, body);
|
||
state.previewStatus = "ready";
|
||
}).catch(() => {
|
||
if (current === generation) state.previewStatus = "error";
|
||
});
|
||
}, 200);
|
||
};
|
||
schedule.cancel = () => {
|
||
generation++;
|
||
if (pendingTimer) timeout.cancel(pendingTimer);
|
||
pendingTimer = null;
|
||
};
|
||
state.on("dispose", schedule.cancel);
|
||
return schedule;
|
||
}
|
||
state.retryPreview = () => {
|
||
if (state.detectedType === "repo") scheduleReadmePreview();
|
||
if (state.detectedType === "pr") refreshPrPreview();
|
||
if (state.detectedType === "gist") refreshGistPreview();
|
||
};
|
||
|
||
const scheduleReadmePreview = makePreviewBatcher(
|
||
"repo",
|
||
() => {
|
||
if (!state.readme) return null;
|
||
return { content: state.readme, options: previewOptions() };
|
||
},
|
||
(data) => {
|
||
if (typeof data?.content !== "string") throw Error("Invalid preview response");
|
||
state.anonymize_readme = data.content;
|
||
let baseUrl = "";
|
||
try {
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
// Fall back to the repo's default branch when source.branch
|
||
// hasn't loaded yet — without this, relative <img src="./X">
|
||
// resolved against a baseUrl like ".../raw//" (no branch
|
||
// segment), so the browser fetched ".../raw/X" and 404'd
|
||
// (#407).
|
||
const branch =
|
||
state.source.branch ||
|
||
(state.details && state.details.defaultBranch) ||
|
||
"main";
|
||
baseUrl = `https://github.com/${o.owner}/${o.repo}/raw/${branch}/`;
|
||
} catch (_) { /* fall through with empty base */ }
|
||
const html = renderMD(state.anonymize_readme, baseUrl);
|
||
state.html_readme = html;
|
||
timeout(() => {
|
||
Prism.highlightAll();
|
||
// Wrapped README code in the preview continues under a hanging indent.
|
||
if (typeof hangCodeLines === "function") document.querySelectorAll(".reviewer-preview .anonymize-preview-body").forEach(hangCodeLines);
|
||
}, 150);
|
||
}
|
||
);
|
||
|
||
function anonymizeReadme() {
|
||
if (!state.anonymize || !state.anonymize.terms) return;
|
||
// The "regex characters detected" hint is informational, not a blocker
|
||
// — IP addresses, escaped chars, etc. are all legitimate terms (#430).
|
||
scheduleReadmePreview();
|
||
}
|
||
|
||
// ========== PR LOGIC ==========
|
||
async function getPrDetails() {
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
const request = beginSourceRequest();
|
||
try {
|
||
resetValidity();
|
||
const res = await http.get(`/api/pr/${o.owner}/${o.repo}/${o.pullRequestId}`, { params: { connection: state.githubConnection } });
|
||
if (!isCurrentSource(request)) return;
|
||
state.details = res.data;
|
||
if (!state.pullRequestId) {
|
||
state.pullRequestId = o.repo + "-PR" + o.pullRequestId + "-" + generateRandomId(4);
|
||
}
|
||
} catch (error) {
|
||
if (!isCurrentSource(request)) return;
|
||
if (error.data) {
|
||
translate("ERRORS." + error.data.error).then((translation) => {
|
||
state.addToast({ title: "Error", date: new Date(), body: translation });
|
||
state.error = translation;
|
||
}, console.error);
|
||
displayErrorMessage(error.data.error);
|
||
}
|
||
setValidity("sourceUrl", "missing", false);
|
||
throw error;
|
||
} finally { finishSourceRequest(request); }
|
||
}
|
||
|
||
// Original text is never substituted for a pending or failed preview.
|
||
const _prAnonCache = reactive(new Map());
|
||
|
||
function collectPrContents() {
|
||
const out = new Set();
|
||
const d = state.details && state.details.pullRequest;
|
||
if (!d) return out;
|
||
if (typeof d.title === "string") out.add(d.title);
|
||
if (typeof d.body === "string") out.add(d.body);
|
||
if (typeof d.diff === "string") out.add(d.diff);
|
||
const comments =
|
||
d.comments || [];
|
||
for (const c of comments) {
|
||
if (typeof c.author === "string") out.add(c.author);
|
||
if (typeof c.body === "string") out.add(c.body);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const refreshPrPreview = makePreviewBatcher(
|
||
"pr",
|
||
() => {
|
||
const seen = collectPrContents();
|
||
const list = Array.from(seen);
|
||
if (list.length === 0) return null;
|
||
return { contents: list, options: previewOptions() };
|
||
},
|
||
(data, body) => {
|
||
if (!Array.isArray(data?.contents) || data.contents.length !== body.contents.length || data.contents.some(value => typeof value !== "string")) throw Error("Invalid preview response");
|
||
const seen = body.contents;
|
||
const next = new Map();
|
||
for (let i = 0; i < seen.length && i < data.contents.length; i++) {
|
||
next.set(seen[i], data.contents[i]);
|
||
}
|
||
_prAnonCache.clear();
|
||
next.forEach((value, key) => _prAnonCache.set(key, value));
|
||
}
|
||
);
|
||
|
||
state.anonymizePrContent = function (content) {
|
||
if (!content) return content;
|
||
if (_prAnonCache.has(content)) return _prAnonCache.get(content);
|
||
return "";
|
||
};
|
||
|
||
// ========== GIST LOGIC ==========
|
||
async function getGistDetails() {
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
const request = beginSourceRequest();
|
||
try {
|
||
resetValidity();
|
||
state.gistOAuthRequired = false;
|
||
state.gistAppRequired = false;
|
||
const res = await http.get(`/api/gist/source/${o.gistId}`);
|
||
if (!isCurrentSource(request)) return;
|
||
state.details = res.data;
|
||
if (!state.gistId) {
|
||
state.gistId = "gist-" + o.gistId.substring(0, 6) + "-" + generateRandomId(4);
|
||
}
|
||
} catch (error) {
|
||
if (!isCurrentSource(request)) return;
|
||
if (error.data?.error === "github_app_reconnect_required") {
|
||
state.gistAppRequired = true;
|
||
setValidity("sourceUrl", "missing", false);
|
||
return;
|
||
}
|
||
if (error.data?.error === "github_oauth_required") {
|
||
state.gistOAuthRequired = true;
|
||
setValidity("sourceUrl", "missing", false);
|
||
return;
|
||
}
|
||
if (error.data) {
|
||
translate("ERRORS." + error.data.error).then((translation) => {
|
||
state.addToast({ title: "Error", date: new Date(), body: translation });
|
||
state.error = translation;
|
||
}, console.error);
|
||
displayErrorMessage(error.data.error);
|
||
}
|
||
setValidity("sourceUrl", "missing", false);
|
||
throw error;
|
||
} finally { finishSourceRequest(request); }
|
||
}
|
||
|
||
const _gistAnonCache = reactive(new Map());
|
||
|
||
function collectGistContents() {
|
||
const out = new Set();
|
||
const d = state.details && state.details.gist;
|
||
if (!d) return out;
|
||
if (typeof d.description === "string") out.add(d.description);
|
||
if (typeof d.ownerLogin === "string") out.add(d.ownerLogin);
|
||
const files = (d.files) || [];
|
||
for (const f of files) {
|
||
if (typeof f.filename === "string") out.add(f.filename);
|
||
if (typeof f.content === "string") out.add(f.content);
|
||
}
|
||
const comments = d.comments || [];
|
||
for (const c of comments) {
|
||
if (typeof c.author === "string") out.add(c.author);
|
||
if (typeof c.body === "string") out.add(c.body);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const refreshGistPreview = makePreviewBatcher(
|
||
"gist",
|
||
() => {
|
||
const seen = collectGistContents();
|
||
const list = Array.from(seen);
|
||
if (list.length === 0) return null;
|
||
return { contents: list, options: previewOptions() };
|
||
},
|
||
(data, body) => {
|
||
if (!Array.isArray(data?.contents) || data.contents.length !== body.contents.length || data.contents.some(value => typeof value !== "string")) throw Error("Invalid preview response");
|
||
const seen = body.contents;
|
||
const next = new Map();
|
||
for (let i = 0; i < seen.length && i < data.contents.length; i++) {
|
||
next.set(seen[i], data.contents[i]);
|
||
}
|
||
_gistAnonCache.clear();
|
||
next.forEach((value, key) => _gistAnonCache.set(key, value));
|
||
rebuildPreviewGistFiles();
|
||
}
|
||
);
|
||
|
||
state.anonymizeGistContent = function (content) {
|
||
if (!content) return content;
|
||
if (_gistAnonCache.has(content)) return _gistAnonCache.get(content);
|
||
return "";
|
||
};
|
||
|
||
// Precomputed file objects for the preview pane so <gist-file>'s
|
||
// props have a stable reference. Recomputes when the source
|
||
// files change OR when the anonymization cache turns over.
|
||
state.previewGistFiles = [];
|
||
function rebuildPreviewGistFiles() {
|
||
const files =
|
||
(state.details && state.details.gist && state.details.gist.files) || [];
|
||
state.previewGistFiles = files.map((f) => ({
|
||
filename: state.anonymizeGistContent(f.filename),
|
||
content: state.anonymizeGistContent(f.content),
|
||
language: f.language,
|
||
}));
|
||
}
|
||
// _prAnonCache turns over inside refreshGistPreview's applyResult; the
|
||
// simplest signal we have is the digest cycle, so re-derive each digest.
|
||
// Cheap when _gistAnonCache hits.
|
||
state.watch("details.gist.files", rebuildPreviewGistFiles, true);
|
||
state.watch("terms", rebuildPreviewGistFiles);
|
||
|
||
// ========== SHARED LOGIC ==========
|
||
function getConference() {
|
||
const conference = state.conference;
|
||
state.conference_data = null;
|
||
if (!conference) return;
|
||
const preserveSavedOptions =
|
||
conference === restoredCheckoutConference ||
|
||
(state.isUpdate && conference === state._originalConference);
|
||
if (conference !== restoredCheckoutConference) restoredCheckoutConference = null;
|
||
http.get("/api/conferences/" + conference).then(
|
||
(res) => {
|
||
if (state.conference !== conference) return;
|
||
state.conference_data = res.data;
|
||
state.conference_data.startDate = new Date(state.conference_data.startDate);
|
||
state.conference_data.endDate = new Date(state.conference_data.endDate);
|
||
// Conference defaults must not overwrite an existing submission's
|
||
// saved settings when its edit form loads (#791).
|
||
if (preserveSavedOptions) return;
|
||
state.options.expirationDate = new Date(state.conference_data.endDate);
|
||
state.options.expirationMode = "remove";
|
||
state.options.update = state.conference_data.options.update;
|
||
state.options.image = state.conference_data.options.image;
|
||
state.options.pdf = state.conference_data.options.pdf;
|
||
state.options.notebook = state.conference_data.options.notebook;
|
||
state.options.link = state.conference_data.options.link;
|
||
},
|
||
() => {
|
||
if (state.conference === conference) state.conference_data = null;
|
||
}
|
||
);
|
||
}
|
||
|
||
function resetValidity() {
|
||
setValidity("repoId", "used", true);
|
||
setValidity("repoId", "format", true);
|
||
setValidity("pullRequestId", "used", true);
|
||
setValidity("pullRequestId", "format", true);
|
||
setValidity("gistId", "used", true);
|
||
setValidity("gistId", "format", true);
|
||
setValidity("sourceUrl", "used", true);
|
||
setValidity("sourceUrl", "missing", true);
|
||
setValidity("sourceUrl", "access", true);
|
||
setValidity("sourceUrl", "github", true);
|
||
setValidity("commit", "exists", true);
|
||
setValidity("conference", "activated", true);
|
||
setValidity("terms", "format", true);
|
||
state.termHints = [];
|
||
}
|
||
|
||
// Guards against submitting a missing or out-of-range expiration date.
|
||
// When the picked date fails min/max validation AngularJS sets the model
|
||
// to undefined, so we check both the field validity and the model value.
|
||
function expirationDateInvalid() {
|
||
if (state.detectedType === "repo" && state.options.expirationMode === "never") return false;
|
||
const field = state.anonymize && state.anonymize.expirationDate;
|
||
if (!state.options.expirationDate || (field && field.invalid)) {
|
||
if (field && field.setDirty) field.setDirty();
|
||
state.error = "Please choose a valid expiration date.";
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function displayErrorMessage(message) {
|
||
const idField =
|
||
state.detectedType === "pr"
|
||
? "pullRequestId"
|
||
: state.detectedType === "gist"
|
||
? "gistId"
|
||
: "repoId";
|
||
switch (message) {
|
||
case "repoId_already_used": setValidity(idField, "used", false); break;
|
||
case "invalid_repoId": setValidity(idField, "format", false); break;
|
||
case "options_not_provided": setValidity(idField, "format", false); break;
|
||
case "repo_already_anonymized": setValidity("sourceUrl", "used", false); break;
|
||
case "invalid_terms_format": setValidity("terms", "format", false); break;
|
||
case "repo_not_found": setValidity("sourceUrl", "missing", false); break;
|
||
case "repo_not_accessible": setValidity("sourceUrl", "access", false); break;
|
||
case "commit_not_found": setValidity("commit", "exists", false); break;
|
||
case "conf_not_activated": setValidity("conference", "activated", false); break;
|
||
}
|
||
}
|
||
|
||
// ========== CO-AUTHORS ==========
|
||
state.coauthors = state.coauthors || [];
|
||
state.coauthorResults = [];
|
||
state.coauthorError = "";
|
||
|
||
state.searchCoauthors = () => {
|
||
const q = (state.coauthorSearch || "").trim();
|
||
state.coauthorError = "";
|
||
if (q.length < 2) {
|
||
state.coauthorResults = [];
|
||
return;
|
||
}
|
||
http.get("/api/user/search/github-users", { params: { q } }).then(
|
||
(res) => {
|
||
const existing = new Set(
|
||
(state.coauthors || []).map((c) => (c.username || "").toLowerCase())
|
||
);
|
||
state.coauthorResults = (res.data || []).filter(
|
||
(u) => !existing.has((u.username || "").toLowerCase())
|
||
);
|
||
},
|
||
() => { state.coauthorResults = []; }
|
||
);
|
||
};
|
||
|
||
state.addCoauthor = (u, event) => {
|
||
if (event) event.preventDefault();
|
||
if (!u || !u.username) return;
|
||
http
|
||
.post("/api/repo/" + state.repoId + "/coauthors", {
|
||
username: u.username,
|
||
})
|
||
.then(
|
||
(res) => {
|
||
state.coauthors = res.data || [];
|
||
state.coauthorResults = [];
|
||
state.coauthorSearch = "";
|
||
state.coauthorError = "";
|
||
},
|
||
(err) => {
|
||
const code = (err && err.data && err.data.error) || "unknown_error";
|
||
state.coauthorError = code;
|
||
}
|
||
);
|
||
};
|
||
|
||
state.removeCoauthor = (c) => {
|
||
if (!c || !c.username) return;
|
||
if (!confirm("Remove co-author " + c.username + "?")) return;
|
||
http
|
||
.delete(
|
||
"/api/repo/" +
|
||
state.repoId +
|
||
"/coauthors/" +
|
||
encodeURIComponent(c.username)
|
||
)
|
||
.then(
|
||
(res) => { state.coauthors = res.data || []; },
|
||
(err) => {
|
||
const code = (err && err.data && err.data.error) || "unknown_error";
|
||
state.coauthorError = code;
|
||
}
|
||
);
|
||
};
|
||
|
||
// Submit: repo
|
||
state.anonymizeRepo = (event) => {
|
||
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
|
||
if (state.reconnectingSource && !state.reconnectRepositoryId) return;
|
||
if (expirationDateInvalid()) { state.editRepository(); return; }
|
||
if (state.anonymize?.invalid || state.sourceUrlError) {
|
||
state.editRepository();
|
||
state.error = "Please correct the highlighted fields before saving.";
|
||
return;
|
||
}
|
||
// Capacity is enforced on the server, which can release removed reservations.
|
||
if (state.hostingNeedsPayment() && state.options.expirationMode === "never") {
|
||
state.hostingReview = true;
|
||
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "center" }));
|
||
return;
|
||
}
|
||
const button = event.currentTarget || event.target.closest?.("button") || event.target;
|
||
button.disabled = true;
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
const payload = {
|
||
repoId: state.repoId,
|
||
terms: state.terms.trim().split("\n").filter((f) => f),
|
||
connection: state.githubConnection,
|
||
fullName: `${o.owner}/${o.repo}`,
|
||
repository: state.sourceUrl,
|
||
reconnectRepositoryId: state.reconnectRepositoryId,
|
||
options: state.options,
|
||
source: state.source,
|
||
conference: state.conference,
|
||
};
|
||
if (state.details) payload.options.pageSource = state.details.pageSource;
|
||
resetValidity();
|
||
const url = state.isUpdate ? "/api/repo/" + state.repoId : "/api/repo/";
|
||
http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
|
||
() => { draft.clear(); window.location.href = "/status/" + state.repoId; },
|
||
(error) => {
|
||
if (error.data) {
|
||
// The hosting section explains a hosting limit and offers the fix.
|
||
const hostingLimit = ["quota_exceeded", "subscription_required_for_never_expiration"].includes(error.data.error);
|
||
if (!hostingLimit) translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
|
||
displayErrorMessage(error.data.error);
|
||
if (hostingLimit) {
|
||
if (error.data.error === "subscription_required_for_never_expiration") state._existingNever = false;
|
||
state.hostingQuotaBlocked = true;
|
||
state.hostingReview = true;
|
||
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "center" }));
|
||
}
|
||
}
|
||
}
|
||
).finally(() => { button.disabled = false; });
|
||
};
|
||
|
||
// Submit: Gist
|
||
state.anonymizeGist = (event) => {
|
||
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
|
||
if (expirationDateInvalid()) return;
|
||
if (state.anonymize?.invalid || state.sourceUrlError) {
|
||
state.error = "Please correct the highlighted fields before saving.";
|
||
return;
|
||
}
|
||
const button = event.currentTarget || event.target.closest?.("button") || event.target;
|
||
button.disabled = true;
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
const payload = {
|
||
gistId: state.gistId,
|
||
terms: state.terms.trim().split("\n").filter((f) => f),
|
||
source: { gistId: o.gistId },
|
||
options: state.options,
|
||
conference: state.conference,
|
||
};
|
||
resetValidity();
|
||
const url = state.isUpdate ? "/api/gist/" + state.gistId : "/api/gist/";
|
||
http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
|
||
() => { draft.clear(); window.location.href = "/gist/" + state.gistId; },
|
||
(error) => {
|
||
if (error.data) {
|
||
translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
|
||
displayErrorMessage(error.data.error);
|
||
}
|
||
}
|
||
).finally(() => { button.disabled = false; });
|
||
};
|
||
|
||
// Submit: PR
|
||
state.anonymizePullRequest = (event) => {
|
||
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
|
||
if (expirationDateInvalid()) return;
|
||
if (state.anonymize?.invalid || state.sourceUrlError) {
|
||
state.error = "Please correct the highlighted fields before saving.";
|
||
return;
|
||
}
|
||
const button = event.currentTarget || event.target.closest?.("button") || event.target;
|
||
button.disabled = true;
|
||
const o = parseGithubUrl(state.sourceUrl);
|
||
const payload = {
|
||
pullRequestId: state.pullRequestId,
|
||
connection: state.githubConnection,
|
||
terms: state.terms.trim().split("\n").filter((f) => f),
|
||
source: { repositoryFullName: `${o.owner}/${o.repo}`, pullRequestId: o.pullRequestId },
|
||
options: state.options,
|
||
conference: state.conference,
|
||
};
|
||
resetValidity();
|
||
const url = state.isUpdate ? "/api/pr/" + state.pullRequestId : "/api/pr/";
|
||
http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
|
||
() => { draft.clear(); window.location.href = "/pr/" + state.pullRequestId; },
|
||
(error) => {
|
||
if (error.data) {
|
||
translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
|
||
displayErrorMessage(error.data.error);
|
||
}
|
||
}
|
||
).finally(() => { button.disabled = false; });
|
||
};
|
||
|
||
state.watch("conference", () => { getConference(); });
|
||
state.termHints = [];
|
||
state.matchTermAsWritten = (line) => {
|
||
state.terms = escapeTermLine(state.terms, line);
|
||
};
|
||
state.watch("options.preserveFiles", () => {
|
||
if (state.detectedType === "repo") scheduleReadmePreview();
|
||
});
|
||
state.watch("terms", () => {
|
||
state.termHints = termHints(state.terms);
|
||
if (state.detectedType === "repo") anonymizeReadme();
|
||
if (state.detectedType === "pr") refreshPrPreview();
|
||
if (state.detectedType === "gist") refreshGistPreview();
|
||
});
|
||
state.watch("options.image", () => {
|
||
if (state.detectedType === "repo") anonymizeReadme();
|
||
if (state.detectedType === "pr") refreshPrPreview();
|
||
if (state.detectedType === "gist") refreshGistPreview();
|
||
});
|
||
state.watch("options.link", () => {
|
||
if (state.detectedType === "repo") anonymizeReadme();
|
||
if (state.detectedType === "pr") refreshPrPreview();
|
||
if (state.detectedType === "gist") refreshGistPreview();
|
||
});
|
||
state.watch("details", () => {
|
||
if (state.detectedType === "pr") refreshPrPreview();
|
||
if (state.detectedType === "gist") refreshGistPreview();
|
||
}, true);
|
||
};
|
||
|
||
export const exploreController = function (state, http, location, params, html, promises) {
|
||
const timers = createTimers();
|
||
installPrismRedactionSlots();
|
||
const listen = createListeners();
|
||
state.on("dark-mode", (event, on) => {
|
||
if (!state.aceOption) return;
|
||
if (on) {
|
||
state.aceOption.theme = "nord_dark";
|
||
} else {
|
||
state.aceOption.theme = "chrome";
|
||
}
|
||
});
|
||
let contentGeneration = 0;
|
||
let contentCanceller = null;
|
||
let loadingNoticeTimer = null;
|
||
state.fileLoadingSlow = false;
|
||
state.fileReceivedBytes = 0;
|
||
function cancelContentLoad() {
|
||
if (contentCanceller) { contentCanceller.resolve(); contentCanceller = null; }
|
||
if (loadingNoticeTimer) { timers.timeout.cancel(loadingNoticeTimer); loadingNoticeTimer = null; }
|
||
}
|
||
let destroyed = false;
|
||
state.on("dispose", () => {
|
||
destroyed = true;
|
||
contentGeneration++;
|
||
cancelContentLoad();
|
||
if (searchCanceller) searchCanceller.resolve();
|
||
});
|
||
state.files = [];
|
||
state.isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
|
||
state.fileSearchQuery = "";
|
||
state.fileSearchResults = null;
|
||
state.fileSearchLoading = false;
|
||
|
||
listen(document, "click", (e) => {
|
||
if (state.symbolsOpen && !e.target.closest?.(".symbols")) state.symbolsOpen = false;
|
||
if (state.moreOpen && !e.target.closest?.(".tb-more")) state.moreOpen = false;
|
||
if (state.tocOpen && !e.target.closest?.(".tb-toc-m")) state.tocOpen = false;
|
||
// The ⋯ menu item that opens it is removed on click, so ignore detached targets.
|
||
if (state.gotoOpen && e.target.isConnected && !e.target.closest?.(".goto-pop, .btn-goto, .tb-more")) state.gotoOpen = false;
|
||
if (state.accountOpen && !e.target.closest?.(".acct")) state.accountOpen = false;
|
||
if (state.helpOpen && !e.target.closest?.(".vb-help")) state.helpOpen = false;
|
||
});
|
||
listen(document, "keydown", (e) => {
|
||
if (e.key === "Escape") { state.symbolsOpen = false; state.moreOpen = false; state.tocOpen = false; state.gotoOpen = false; state.accountOpen = false; state.helpOpen = false; }
|
||
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key && e.key.toLowerCase() === "f" && state.type === "html" && window.document.querySelector(".reading-layout")) {
|
||
e.preventDefault();
|
||
state.toggleMdFind(true);
|
||
}
|
||
});
|
||
|
||
listen(document, "keydown", function (e) {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||
e.preventDefault();
|
||
var input = document.querySelector(".tree-search-input");
|
||
if (input) {
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
}
|
||
});
|
||
var searchCanceller = null;
|
||
state.onFileSearchChange = function () {
|
||
// Cancel any in-flight search request
|
||
if (searchCanceller) {
|
||
searchCanceller.resolve();
|
||
searchCanceller = null;
|
||
}
|
||
const query = state.fileSearchQuery;
|
||
if (!query || query.length < 2) {
|
||
state.fileSearchResults = null;
|
||
state.fileSearchLoading = false;
|
||
return;
|
||
}
|
||
state.fileSearchLoading = true;
|
||
const requestCanceller = promises.defer();
|
||
searchCanceller = requestCanceller;
|
||
http.get(
|
||
`/api/repo/${state.repoId}/files/search?q=${encodeURIComponent(query)}`,
|
||
{ timeout: requestCanceller.promise }
|
||
).then(function (res) {
|
||
if (destroyed || searchCanceller !== requestCanceller) return;
|
||
searchCanceller = null;
|
||
state.fileSearchLoading = false;
|
||
// Merge search results into state.files so the tree can render them.
|
||
// Ancestor folders must appear before their children for toArray() to work.
|
||
var existing = {};
|
||
state.files.forEach(function(f) {
|
||
existing[(f.path || "") + "/" + f.name] = true;
|
||
});
|
||
// First pass: collect ancestor folders (shallow to deep)
|
||
var foldersToAdd = [];
|
||
var folderSeen = {};
|
||
for (var i = 0; i < res.data.length; i++) {
|
||
var f = res.data[i];
|
||
if (f.path) {
|
||
var segments = f.path.split("/");
|
||
var acc = "";
|
||
for (var j = 0; j < segments.length; j++) {
|
||
var parent = acc;
|
||
acc = acc ? acc + "/" + segments[j] : segments[j];
|
||
var folderKey = parent + "/" + segments[j];
|
||
if (!existing[folderKey] && !folderSeen[folderKey]) {
|
||
folderSeen[folderKey] = true;
|
||
foldersToAdd.push({ name: segments[j], path: parent });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Sort folders by depth (shallow first)
|
||
foldersToAdd.sort(function(a, b) {
|
||
return (a.path || "").split("/").length - (b.path || "").split("/").length;
|
||
});
|
||
// Add folders first, then files
|
||
if (foldersToAdd.length > 0) {
|
||
state.files.push.apply(state.files, foldersToAdd);
|
||
}
|
||
var filesToAdd = [];
|
||
for (var k = 0; k < res.data.length; k++) {
|
||
var rf = res.data[k];
|
||
var key = (rf.path || "") + "/" + rf.name;
|
||
if (!existing[key] && rf.size != null) {
|
||
filesToAdd.push(rf);
|
||
existing[key] = true;
|
||
}
|
||
}
|
||
if (filesToAdd.length > 0) {
|
||
state.files.push.apply(state.files, filesToAdd);
|
||
}
|
||
state.fileSearchResults = res.data;
|
||
}, function () {
|
||
if (!destroyed && searchCanceller === requestCanceller) {
|
||
searchCanceller = null;
|
||
state.fileSearchLoading = false;
|
||
state.fileSearchResults = [];
|
||
}
|
||
});
|
||
};
|
||
const extensionModes = {
|
||
yml: "yaml",
|
||
txt: "text",
|
||
py: "python",
|
||
js: "javascript",
|
||
ts: "typescript",
|
||
};
|
||
const textFiles = ["license", "txt"];
|
||
const imageFiles = [
|
||
"png",
|
||
"jpg",
|
||
"jpeg",
|
||
"gif",
|
||
"svg",
|
||
"ico",
|
||
"bmp",
|
||
"tiff",
|
||
"tif",
|
||
"webp",
|
||
"avif",
|
||
"heif",
|
||
"heic",
|
||
];
|
||
const audioFiles = ["wav", "mp3", "ogg", "wma", "flac", "aac", "m4a"];
|
||
const mediaFiles = [
|
||
"mp4",
|
||
"avi",
|
||
"webm",
|
||
"mov",
|
||
"mpg",
|
||
"mpeg",
|
||
"mkv",
|
||
"flv",
|
||
"wmv",
|
||
"3gp",
|
||
"3g2",
|
||
"m4v",
|
||
"f4v",
|
||
"f4p",
|
||
"f4a",
|
||
"f4b",
|
||
];
|
||
|
||
state.on("routeUpdate", function (event, current) {
|
||
if (state.repoId != params.repoId) return init();
|
||
if ((params.path || "") == state.filePath) {
|
||
return;
|
||
}
|
||
// On phones the file tree is a drawer: close it once a file is opened.
|
||
if (window.matchMedia?.("(max-width: 767px)").matches) state.sidebarCollapsed = true;
|
||
state.filePath = params.path || "";
|
||
state.paths = state.filePath
|
||
.split("/")
|
||
.filter((f) => f && f.trim().length > 0);
|
||
|
||
|
||
updateContent();
|
||
|
||
// #510 — if we navigated into a subdirectory whose file listing
|
||
// hasn't been fetched, lazy-load the parent directories in the
|
||
// background so getSelectedFile() can populate state.file with the
|
||
// right sha for the next interaction. Done after updateContent so
|
||
// the request fires immediately (getContent falls back to sha "0").
|
||
for (let i = 0; i < state.paths.length - 1; i++) {
|
||
const dirPath = i > 0 ? state.paths.slice(0, i).join("/") : "";
|
||
const alreadyLoaded = state.files.some((f) => f.path === dirPath);
|
||
if (!alreadyLoaded) {
|
||
state.getFiles(dirPath);
|
||
}
|
||
}
|
||
});
|
||
|
||
function selectFile() {
|
||
if (state.paths[0] != "") {
|
||
return;
|
||
}
|
||
const readmePriority = [
|
||
"readme.md",
|
||
"readme.txt",
|
||
"readme.org",
|
||
"readme.1st",
|
||
"readme",
|
||
];
|
||
const readmeCandidates = {};
|
||
for (const file of state.files) {
|
||
if (file.name.toLowerCase().indexOf("readme") > -1) {
|
||
readmeCandidates[file.name.toLowerCase()] = file.name;
|
||
}
|
||
}
|
||
let best_match = null;
|
||
for (const p of readmePriority) {
|
||
if (readmeCandidates[p]) {
|
||
best_match = p;
|
||
break;
|
||
}
|
||
}
|
||
if (!best_match && Object.keys(readmeCandidates).length > 0)
|
||
best_match = Object.keys(readmeCandidates)[0];
|
||
if (best_match) {
|
||
let uri = location.url();
|
||
if (uri[uri.length - 1] != "/") {
|
||
uri += "/";
|
||
}
|
||
|
||
// redirect to readme
|
||
location.url(
|
||
uri + encodePathForUrl(readmeCandidates[best_match])
|
||
);
|
||
}
|
||
}
|
||
state.fileCounts = null;
|
||
let listingGeneration = 0;
|
||
let countRequests = new Set();
|
||
let countsUnavailable = false;
|
||
state.getFiles = function (path, background = false) {
|
||
const repoId = state.repoId;
|
||
const generation = listingGeneration;
|
||
return http.get(
|
||
`/api/repo/${state.repoId}/files/?path=${encodeURIComponent(path)}&v=${state.options.lastUpdateDate}`
|
||
).then(function (res) {
|
||
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return [];
|
||
const normalized = path || "";
|
||
state.files = state.files.filter((f) => f.path !== normalized);
|
||
state.files.push(...res.data);
|
||
state.loadedFolders = { ...state.loadedFolders, [normalized]: true };
|
||
fetchFileCounts(normalized, generation);
|
||
return res.data;
|
||
}, function (err) {
|
||
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return [];
|
||
if (!background) {
|
||
state.type = "error";
|
||
state.content = viewerErrorCode(err);
|
||
state.files = [];
|
||
}
|
||
});
|
||
};
|
||
function fetchFileCounts(path, generation) {
|
||
if (countsUnavailable || countRequests.has(path)) return;
|
||
countRequests.add(path);
|
||
const repoId = state.repoId;
|
||
http.get(
|
||
`/api/repo/${state.repoId}/files/counts?path=${encodeURIComponent(path)}`
|
||
).then(function (res) {
|
||
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return;
|
||
state.fileCounts = { ...state.fileCounts, ...res.data };
|
||
}, function () {
|
||
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return;
|
||
// Counts are optional. Do not retry a guarded or rate-limited query
|
||
// every time a visitor opens another directory.
|
||
countsUnavailable = true;
|
||
});
|
||
}
|
||
|
||
function getSelectedFile() {
|
||
return state.files.filter(
|
||
(f) =>
|
||
f.name == state.paths[state.paths.length - 1] &&
|
||
f.path == state.paths.slice(0, state.paths.length - 1).join("/")
|
||
)[0];
|
||
}
|
||
|
||
var rlCountdownTimer = null;
|
||
state.on("dispose", function () { if (rlCountdownTimer) timers.interval.cancel(rlCountdownTimer); });
|
||
|
||
function getOptions(callback) {
|
||
if (destroyed) return;
|
||
const repoId = state.repoId;
|
||
http.get(`/api/repo/${state.repoId}/options`).then(
|
||
(res) => {
|
||
if (destroyed || repoId !== state.repoId) return;
|
||
state.options = res.data;
|
||
if (state.options.url) {
|
||
window.location = state.options.url;
|
||
return;
|
||
}
|
||
if (callback) {
|
||
callback(res.data);
|
||
}
|
||
},
|
||
(err) => {
|
||
if (destroyed || repoId !== state.repoId) return;
|
||
var data = err.data || {};
|
||
if (data.error === "rate_limited" && data.resetAt) {
|
||
state.type = "rate_limited";
|
||
state.rateLimitResetAt = data.resetAt;
|
||
if (rlCountdownTimer) timers.interval.cancel(rlCountdownTimer);
|
||
function rlTick() {
|
||
var remaining = Math.max(0, Math.ceil((state.rateLimitResetAt - Date.now()) / 1000));
|
||
if (remaining <= 0) {
|
||
state.rateLimitCountdown = "";
|
||
state.rateLimitResetAt = 0;
|
||
if (rlCountdownTimer) { timers.interval.cancel(rlCountdownTimer); rlCountdownTimer = null; }
|
||
getOptions(callback);
|
||
} else {
|
||
var min = Math.floor(remaining / 60);
|
||
var sec = remaining % 60;
|
||
state.rateLimitCountdown = min > 0 ? min + "m " + sec + "s" : sec + "s";
|
||
}
|
||
|
||
}
|
||
rlTick();
|
||
rlCountdownTimer = timers.interval(rlTick, 1000);
|
||
} else if (data.error === "repository_not_ready") {
|
||
state.type = "loading";
|
||
timers.timeout(function () { getOptions(callback); }, 3000);
|
||
} else {
|
||
state.type = "error";
|
||
state.content = viewerErrorCode(err);
|
||
}
|
||
}
|
||
);
|
||
}
|
||
|
||
// Defined as methods rather than inline `showSource = !showSource` in the
|
||
// template: the toolbar and the file view sit in different child scopes,
|
||
// so an inline assignment would shadow the value on one of them instead
|
||
// of updating the controller's.
|
||
state.toggleSource = function () {
|
||
state.showSource = !state.showSource;
|
||
};
|
||
|
||
state.toggleAllowScripts = function () {
|
||
state.allowScripts = !state.allowScripts;
|
||
};
|
||
|
||
// Wrap on wide screens; on phones code scrolls sideways so tokens stay intact.
|
||
const storedWrap = (() => { try { return window.localStorage?.getItem("wrapLines"); } catch { return null; } })();
|
||
state.wrapLines = (storedWrap ?? (window.matchMedia?.("(max-width: 767px)").matches ? "false" : "true")) !== "false";
|
||
state.toggleWrap = function () {
|
||
state.wrapLines = !state.wrapLines;
|
||
try { window.localStorage?.setItem("wrapLines", String(state.wrapLines)); } catch { /* storage unavailable */ }
|
||
state.codeEditor?.session.setUseWrapMode(state.wrapLines);
|
||
};
|
||
// Find opens as an overlay at the top right of the code, like an editor.
|
||
// Find on rendered Markdown pages: highlight matches inside the reading column.
|
||
function mdFindRoot() { return window.document.querySelector(".reading-layout .markdown-body"); }
|
||
function clearMdFind() {
|
||
const root = mdFindRoot();
|
||
if (!root) return;
|
||
root.querySelectorAll("mark.md-find").forEach((m) => { m.replaceWith(window.document.createTextNode(m.textContent)); });
|
||
root.normalize();
|
||
}
|
||
state.runMdFind = function () {
|
||
clearMdFind();
|
||
state.mdFindTotal = 0;
|
||
state.mdFindIndex = 0;
|
||
const q = (state.mdFindQuery || "").trim();
|
||
const root = mdFindRoot();
|
||
if (!q || !root) return;
|
||
const cs = !!state.mdFindCase;
|
||
const needle = cs ? q : q.toLowerCase();
|
||
const walker = window.document.createTreeWalker(root, 4);
|
||
const nodes = [];
|
||
while (walker.nextNode()) if ((cs ? walker.currentNode.nodeValue : walker.currentNode.nodeValue.toLowerCase()).includes(needle)) nodes.push(walker.currentNode);
|
||
let total = 0;
|
||
for (const node of nodes) {
|
||
const text = node.nodeValue, lower = cs ? text : text.toLowerCase();
|
||
const frag = window.document.createDocumentFragment();
|
||
let last = 0, i;
|
||
while ((i = lower.indexOf(needle, last)) !== -1) {
|
||
frag.appendChild(window.document.createTextNode(text.slice(last, i)));
|
||
const m = window.document.createElement("mark");
|
||
m.className = "md-find";
|
||
m.textContent = text.slice(i, i + q.length);
|
||
frag.appendChild(m);
|
||
total++;
|
||
last = i + q.length;
|
||
}
|
||
frag.appendChild(window.document.createTextNode(text.slice(last)));
|
||
node.parentNode.replaceChild(frag, node);
|
||
}
|
||
state.mdFindTotal = total;
|
||
if (total) state.stepMdFind(0);
|
||
};
|
||
state.stepMdFind = function (delta) {
|
||
const root = mdFindRoot();
|
||
if (!root || !state.mdFindTotal) return;
|
||
const marks = root.querySelectorAll("mark.md-find");
|
||
state.mdFindIndex = (state.mdFindIndex + delta + marks.length) % marks.length;
|
||
marks.forEach((m, i) => m.classList.toggle("is-current", i === state.mdFindIndex));
|
||
marks[state.mdFindIndex]?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||
};
|
||
state.toggleMdFind = function (open) {
|
||
state.mdFindOpen = open === undefined ? !state.mdFindOpen : open;
|
||
if (!state.mdFindOpen) { clearMdFind(); state.mdFindTotal = 0; return; }
|
||
nextTick(() => window.document.querySelector(".md-find-input")?.focus());
|
||
};
|
||
state.findInFile = function () {
|
||
const editor = state.codeEditor;
|
||
if (!editor) return;
|
||
editor.focus();
|
||
editor.execCommand("find");
|
||
// Docked under the toolbar: push the code down while the find bar is open.
|
||
const dock = () => {
|
||
const box = editor.searchBox?.element;
|
||
if (!box) return;
|
||
editor.renderer.setScrollMargin(52, 12, 0, 0);
|
||
if (box._dockObserver) return;
|
||
box._dockObserver = new MutationObserver(() => {
|
||
editor.renderer.setScrollMargin(box.style.display !== "none" ? 52 : 12, 12, 0, 0);
|
||
});
|
||
box._dockObserver.observe(box, { attributes: true, attributeFilter: ["style"] });
|
||
};
|
||
dock();
|
||
setTimeout(dock, 150);
|
||
};
|
||
// Lightweight outline: top-level classes, functions and exported constants.
|
||
const symbolPatterns = [
|
||
[/^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, "class"],
|
||
[/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/, "function"],
|
||
[/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/, "function"],
|
||
[/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/, "function"],
|
||
[/^\s*class\s+([A-Za-z_]\w*)/, "class"],
|
||
[/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/, "function"],
|
||
[/^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)/, "function"],
|
||
[/^\s*(?:public|private|protected|static|\s)+[\w<>\[\],\s]+\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{?\s*$/, "function"],
|
||
];
|
||
let symbolCache = { content: null, list: [] };
|
||
state.symbolsOpen = false;
|
||
state.symbols = function () {
|
||
if (typeof state.content !== "string" || state.content.length > 2_000_000) return [];
|
||
if (symbolCache.content === state.content) return symbolCache.list;
|
||
const list = [];
|
||
state.content.split("\n").forEach((text, index) => {
|
||
for (const [re, kind] of symbolPatterns) {
|
||
const m = re.exec(text);
|
||
if (m && !/^(if|for|while|switch|return|catch)$/.test(m[1])) { list.push({ name: m[1], kind, line: index + 1 }); break; }
|
||
}
|
||
});
|
||
symbolCache = { content: state.content, list: list.slice(0, 300) };
|
||
return symbolCache.list;
|
||
};
|
||
state.goToSymbol = function (line) {
|
||
state.symbolsOpen = false;
|
||
const editor = state.codeEditor;
|
||
if (!editor) return;
|
||
editor.scrollToLine(line - 1, true, true, () => {});
|
||
editor.gotoLine(line, 0, true);
|
||
window.history.replaceState(null, "", window.location.pathname + window.location.search + "#L" + line);
|
||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||
};
|
||
|
||
// Go to line: a small popover in the file header (Ace's own prompt ignores the site theme).
|
||
state.gotoLine = function () {
|
||
if (!state.codeEditor) return;
|
||
state.moreOpen = false;
|
||
state.gotoError = "";
|
||
state.gotoValue = String(state.codeEditor.getCursorPosition().row + 1);
|
||
state.gotoOpen = true;
|
||
setTimeout(() => { const el = window.document.getElementById("goto-input"); if (el) { el.focus(); el.select(); } }, 0);
|
||
};
|
||
state.closeGoto = function () {
|
||
state.gotoOpen = false;
|
||
state.gotoError = "";
|
||
state.codeEditor?.focus();
|
||
};
|
||
state.submitGoto = function () {
|
||
const editor = state.codeEditor;
|
||
if (!editor) return;
|
||
const m = /^\s*:?\s*(\d+)\s*(?:[:,]\s*(\d+))?\s*$/.exec(state.gotoValue || "");
|
||
const lines = editor.session.getLength();
|
||
if (!m || +m[1] < 1 || +m[1] > lines) { state.gotoError = "Enter a line from 1 to " + lines; return; }
|
||
editor.gotoLine(+m[1], m[2] ? Math.max(0, +m[2] - 1) : 0, true);
|
||
state.gotoOpen = false;
|
||
state.gotoError = "";
|
||
editor.focus();
|
||
};
|
||
// Root listing above the README: folders first, then files.
|
||
state.rootEntries = function () {
|
||
return [...(state.files || [])].sort((a, b) => {
|
||
const af = a.size == null, bf = b.size == null;
|
||
if (af !== bf) return af ? -1 : 1;
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
};
|
||
state.formatSize = function (bytes) {
|
||
if (bytes == null) return "";
|
||
if (bytes < 1024) return bytes + " B";
|
||
const kb = bytes / 1024;
|
||
const one = (n) => n.toFixed(1).replace(/\.0$/, "");
|
||
return kb < 1024 ? one(kb) + " KB" : kb < 1024 * 1024 ? one(kb / 1024) + " MB" : one(kb / 1024 / 1024) + " GB";
|
||
};
|
||
state.fileSize = function (path) {
|
||
if (!path) return null;
|
||
const clean = path.replace(/^\/+/, "");
|
||
const entry = (state.files || []).find((f) => (f.path ? f.path + "/" : "") + f.name === clean);
|
||
return entry && entry.size != null ? entry.size : null;
|
||
};
|
||
state.revealFolder = function (name) {
|
||
state.sidebarHidden = false;
|
||
state.sidebarCollapsed = false;
|
||
nextTick(() => {
|
||
const link = window.document.querySelector('.files a[data-path="/' + (window.CSS?.escape ? window.CSS.escape(name) : name) + '"]');
|
||
if (!link) return;
|
||
if (!link.closest("li")?.classList.contains("open")) link.click();
|
||
link.scrollIntoView({ block: "nearest" });
|
||
link.focus();
|
||
});
|
||
};
|
||
// Notebooks: count the same markers in cell sources and text outputs (not in the JSON keys or image data).
|
||
let nbCount = { content: null, n: 0 };
|
||
const notebookRedactions = (content) => {
|
||
if (nbCount.content === content) return nbCount.n;
|
||
let nb = content;
|
||
if (typeof nb === "string") { try { nb = JSON.parse(nb); } catch { nb = null; } }
|
||
const texts = [];
|
||
const add = (v) => { if (Array.isArray(v)) texts.push(v.join("")); else if (typeof v === "string") texts.push(v); };
|
||
((nb && (nb.cells || (nb.worksheets || []).flatMap((w) => w.cells || []))) || []).forEach((cell) => {
|
||
add(cell?.source ?? cell?.input);
|
||
(cell?.outputs || []).forEach((o) => {
|
||
add(o?.text);
|
||
if (o?.data) { add(o.data["text/plain"]); add(o.data["text/markdown"]); add(o.data["text/html"]); }
|
||
add(o?.traceback);
|
||
});
|
||
});
|
||
const n = texts.reduce((sum, t) => sum + (t.match(REDACTION_RE) || []).length, 0);
|
||
nbCount = { content, n };
|
||
return n;
|
||
};
|
||
state.redactionCount = function () {
|
||
if (state.type === "IPython") return notebookRedactions(state.content);
|
||
if (typeof state.content !== "string") return 0;
|
||
const text = state.type === "html" ? state.content.replace(/<[^>]*>/g, " ") : state.content;
|
||
return (text.match(REDACTION_RE) || []).length;
|
||
};
|
||
state.renderRedacted = slotRedactions;
|
||
// Reading mode: slugged heading ids + an "On this page" outline (like Mintlify/Stripe docs).
|
||
const slug = (t) => t.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").slice(0, 60) || "section";
|
||
state.renderReadme = function (html) {
|
||
const out = state.renderRedacted(html);
|
||
if (!out || typeof window.DOMParser !== "function") return out;
|
||
const doc = new window.DOMParser().parseFromString("<div>" + out + "</div>", "text/html");
|
||
const seen = {};
|
||
doc.querySelectorAll("h1, h2, h3").forEach((h) => {
|
||
let id = h.id || slug(h.textContent);
|
||
if (seen[id]) id += "-" + (++seen[id]); else seen[id] = 1;
|
||
h.id = id;
|
||
});
|
||
return doc.body.firstChild.innerHTML;
|
||
};
|
||
let tocCache = { content: null, list: [] };
|
||
state.readmeToc = function () {
|
||
if (state.type !== "html" || typeof state.content !== "string") return [];
|
||
if (tocCache.content === state.content) return tocCache.list;
|
||
const doc = new window.DOMParser().parseFromString("<div>" + state.renderReadme(state.sanitize ? state.sanitize(state.content) : state.content) + "</div>", "text/html");
|
||
const list = [...doc.querySelectorAll("h2, h3")].map((h) => ({ id: h.id, text: h.textContent.trim(), level: h.tagName === "H3" ? 3 : 2 })).slice(0, 40);
|
||
tocCache = { content: state.content, list };
|
||
return list;
|
||
};
|
||
state.activeHeading = null;
|
||
listen(window, "scroll", () => {
|
||
const toc = state.readmeToc ? state.readmeToc() : [];
|
||
if (!toc.length) return;
|
||
// Active = the first heading visible below the sticky chrome; if none is on screen,
|
||
// the last heading already scrolled past (the reader is inside that section).
|
||
const chrome = 150;
|
||
let current = "", firstVisible = "";
|
||
for (const h of toc) {
|
||
const el = window.document.getElementById(h.id);
|
||
if (!el) continue;
|
||
const top = el.getBoundingClientRect().top;
|
||
if (top < chrome) current = h.id;
|
||
else if (!firstVisible && top < window.innerHeight - 80) firstVisible = h.id;
|
||
}
|
||
if (firstVisible) current = firstVisible;
|
||
if (state.activeHeading !== current) state.activeHeading = current;
|
||
}, { capture: true, passive: true });
|
||
state.scrollToHeading = function (id) {
|
||
if (!id) {
|
||
const sc = window.document.querySelector(".explorer-content, .file-card-body, .main-pane");
|
||
(sc || window).scrollTo({ top: 0, behavior: "smooth" });
|
||
state.activeHeading = "";
|
||
return;
|
||
}
|
||
const el = window.document.getElementById(id);
|
||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
state.activeHeading = id;
|
||
};
|
||
// CSV/TSV files up to 1 MB render as a table (sticky header, rules, tabular
|
||
// figures) with a Raw toggle back to the code view.
|
||
state.csvView = "table";
|
||
let csvCache = { content: null, rows: [] };
|
||
const parseDelimited = (text, sep) => {
|
||
const rows = [];
|
||
let row = [], cell = "", quoted = false;
|
||
for (let i = 0; i < text.length; i++) {
|
||
const ch = text[i];
|
||
if (quoted) {
|
||
if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else quoted = false; }
|
||
else cell += ch;
|
||
} else if (ch === '"' && cell === "") quoted = true;
|
||
else if (ch === sep) { row.push(cell); cell = ""; }
|
||
else if (ch === "\n" || ch === "\r") {
|
||
if (ch === "\r" && text[i + 1] === "\n") i++;
|
||
row.push(cell); rows.push(row); row = []; cell = "";
|
||
} else cell += ch;
|
||
}
|
||
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
|
||
return rows.filter((r) => r.length > 1 || r[0] !== "");
|
||
};
|
||
state.isCsv = () => typeof state.content === "string" && state.type !== "html"
|
||
&& /\.(csv|tsv)$/i.test(state.filePath || "") && state.content.length <= 1024 * 1024;
|
||
state.csvRows = () => {
|
||
if (csvCache.content !== state.content) {
|
||
const sep = /\.tsv$/i.test(state.filePath || "") ? "\t" : ",";
|
||
csvCache = { content: state.content, rows: parseDelimited(state.content, sep) };
|
||
}
|
||
return csvCache.rows;
|
||
};
|
||
state.csvNumCol = (ci) => {
|
||
const rows = state.csvRows().slice(1).map((r) => r[ci]).filter((v) => v != null && String(v).trim() !== "");
|
||
return rows.length > 0 && rows.every((v) => state.csvNumeric(v));
|
||
};
|
||
state.csvNumeric = (value) => /^\s*[-+]?(\d[\d,]*)?(\.\d+)?(e[-+]?\d+)?%?\s*$/i.test(value) && /\d/.test(value);
|
||
state.redactParts = function (text) {
|
||
const out = []; let last = 0; let m; const re = /\bXXXX-(\d+)\b/g;
|
||
text = String(text || "");
|
||
while ((m = re.exec(text))) { if (m.index > last) out.push({ t: text.slice(last, m.index) }); out.push({ n: m[1] }); last = m.index + m[0].length; }
|
||
if (last < text.length) out.push({ t: text.slice(last) });
|
||
return out;
|
||
};
|
||
state.pathCopied = false;
|
||
state.copyPath = async function () {
|
||
if (!window.navigator.clipboard?.writeText) return;
|
||
await window.navigator.clipboard.writeText(state.filePath || "");
|
||
state.pathCopied = true;
|
||
setTimeout(() => { state.pathCopied = false; }, 1600);
|
||
};
|
||
state.contentCopied = false;
|
||
state.copyContent = async function () {
|
||
if (typeof state.content !== "string" || !window.navigator.clipboard?.writeText) return;
|
||
// Rendered Markdown: copy the source, not the HTML.
|
||
let text = state.content;
|
||
if (state.type === "html" && state.url) {
|
||
try { text = await (await window.fetch(state.url, { credentials: "same-origin" })).text(); } catch (_) { return; }
|
||
}
|
||
await window.navigator.clipboard.writeText(text);
|
||
state.contentCopied = true;
|
||
setTimeout(() => { state.contentCopied = false; }, 1600);
|
||
};
|
||
|
||
function getMode(extension) {
|
||
if (extensionModes[extension]) {
|
||
return extensionModes[extension];
|
||
}
|
||
return extension;
|
||
}
|
||
|
||
function getType(extension) {
|
||
if (extension == "pdf") {
|
||
return "pdf";
|
||
}
|
||
// Rendered as a document in a sandboxed frame rather than as source —
|
||
// see html-doc.js. "html" is reserved for markup we generated
|
||
// ourselves (rendered markdown/org) and inject directly.
|
||
if (extension == "html" || extension == "htm") {
|
||
return "html-doc";
|
||
}
|
||
if (extension == "md") {
|
||
return "md";
|
||
}
|
||
if (extension == "org") {
|
||
return "org";
|
||
}
|
||
if (extension == "ipynb") {
|
||
return "IPython";
|
||
}
|
||
if (textFiles.indexOf(extension) > -1) {
|
||
return "text";
|
||
}
|
||
if (imageFiles.indexOf(extension) > -1) {
|
||
return "image";
|
||
}
|
||
if (mediaFiles.indexOf(extension) > -1) {
|
||
return "media";
|
||
}
|
||
if (audioFiles.indexOf(extension) > -1) {
|
||
return "audio";
|
||
}
|
||
return "code";
|
||
}
|
||
|
||
function getContent(path, fileInfo) {
|
||
const generation = contentGeneration;
|
||
if (!path) {
|
||
state.type = "error";
|
||
state.content = "no_file_selected";
|
||
return;
|
||
}
|
||
const originalType = state.type;
|
||
// Load the editor alongside content, not after the response finishes.
|
||
// Its mounting path still reports errors and can retry a failed load.
|
||
if (originalType === "code" || originalType === "text") loadEditor().catch(() => {});
|
||
state.type = "loading";
|
||
state.content = "loading";
|
||
state.fileLoadingSlow = false;
|
||
state.fileReceivedBytes = 0;
|
||
const canceller = promises.defer();
|
||
contentCanceller = canceller;
|
||
loadingNoticeTimer = timers.timeout(() => {
|
||
if (!destroyed && generation === contentGeneration && state.type === "loading") state.fileLoadingSlow = true;
|
||
}, 1500);
|
||
// fileInfo can be undefined when the user navigates (e.g. clicks a
|
||
// markdown link into a subdir whose file list hasn't loaded yet) —
|
||
// see #510. Fall back to "0" so the request still goes through; the
|
||
// server returns a fresh ETag on first hit either way.
|
||
const sha = (fileInfo && fileInfo.sha) || "0";
|
||
http
|
||
.get(
|
||
`/api/repo/${state.repoId}/file/${encodePathForUrl(path)}?v=` +
|
||
sha,
|
||
{
|
||
timeout: canceller.promise,
|
||
onDownloadProgress: bytes => {
|
||
if (!destroyed && generation === contentGeneration) state.fileReceivedBytes = bytes;
|
||
},
|
||
transformResponse: (data) => {
|
||
return data;
|
||
},
|
||
}
|
||
)
|
||
.then(
|
||
(res) => {
|
||
if (destroyed || generation !== contentGeneration) return;
|
||
cancelContentLoad();
|
||
state.type = originalType;
|
||
state.content = res.data;
|
||
if (state.content == "") {
|
||
state.content = null;
|
||
}
|
||
|
||
if (state.type == "md") {
|
||
state.content = renderMD(res.data, location.url() + "/../");
|
||
state.type = "html";
|
||
}
|
||
if (state.type == "org") {
|
||
const content = contentAbs2Relative(res.data);
|
||
|
||
const orgParser = new Org.Parser();
|
||
const orgDocument = orgParser.parse(content);
|
||
var orgHTMLDocument = orgDocument.convert(Org.ConverterHTML, {
|
||
headerOffset: 1,
|
||
exportFromLineNumber: false,
|
||
suppressSubScriptHandling: true,
|
||
suppressAutoLink: false,
|
||
});
|
||
state.content = DOMPurify.sanitize(orgHTMLDocument.toString());
|
||
state.type = "html";
|
||
}
|
||
if (
|
||
state.type == "code" &&
|
||
res.headers("content-type") == "application/octet-stream"
|
||
) {
|
||
state.type = "binary";
|
||
state.content = "binary";
|
||
}
|
||
timers.timeout(() => {
|
||
Prism.highlightAll();
|
||
}, 50);
|
||
},
|
||
(err) => {
|
||
if (destroyed || generation !== contentGeneration) return;
|
||
cancelContentLoad();
|
||
state.type = "error";
|
||
state.content = viewerErrorCode(err);
|
||
}
|
||
);
|
||
}
|
||
|
||
function updateContent() {
|
||
contentGeneration++;
|
||
cancelContentLoad();
|
||
state.content = "";
|
||
state.file = getSelectedFile();
|
||
let fileVersion = "0";
|
||
if (state.file && state.file.sha) {
|
||
fileVersion = state.file.sha;
|
||
}
|
||
state.url = `/api/repo/${state.repoId}/file/${encodePathForUrl(
|
||
state.filePath
|
||
)}?v=${fileVersion}`;
|
||
// Directory the file lives in, used as the <base> for a rendered HTML
|
||
// document so its relative images/stylesheets still resolve.
|
||
const dirPath = state.filePath.substring(
|
||
0,
|
||
state.filePath.lastIndexOf("/") + 1
|
||
);
|
||
state.fileBaseUrl = `/api/repo/${state.repoId}/file/${
|
||
dirPath ? encodePathForUrl(dirPath) : ""
|
||
}`;
|
||
state.showSource = false;
|
||
state.imageSize = "";
|
||
// Scripts in a repository's HTML are opt-in, per file — see
|
||
// html-doc.js. Reset on navigation so trust never carries over from
|
||
// one file to the next.
|
||
state.allowScripts = false;
|
||
|
||
let extension = state.filePath.toLowerCase();
|
||
const extensionIndex = extension.lastIndexOf(".");
|
||
if (extensionIndex > -1) {
|
||
extension = extension.substring(extensionIndex + 1);
|
||
}
|
||
|
||
const languageNames = { javascript: "JavaScript", typescript: "TypeScript", tsx: "TSX", jsx: "JSX", python: "Python", java: "Java", c_cpp: "C/C++", csharp: "C#", golang: "Go", rust: "Rust", ruby: "Ruby", php: "PHP", html: "HTML", css: "CSS", scss: "SCSS", json: "JSON", yaml: "YAML", markdown: "Markdown", sh: "Shell", dockerfile: "Dockerfile", xml: "XML", sql: "SQL", kotlin: "Kotlin", swift: "Swift", scala: "Scala", r: "R", julia: "Julia", latex: "LaTeX", toml: "TOML", csv: "CSV", tsv: "TSV", text: "" };
|
||
const detectedMode = getMode(extension);
|
||
state.languageName = languageNames[detectedMode] ?? (detectedMode ? detectedMode.charAt(0).toUpperCase() + detectedMode.slice(1) : "");
|
||
state.aceOption = {
|
||
readOnly: true,
|
||
useWrapMode: true,
|
||
showGutter: true,
|
||
theme: "chrome",
|
||
useSoftTab: true,
|
||
tabSize: 2,
|
||
fontSize: window.matchMedia?.("(max-width: 767px)").matches ? 12 : 13,
|
||
keyBinding: "vscode",
|
||
fullLineSelection: true,
|
||
highlightActiveLine: false,
|
||
highlightGutterLine: false,
|
||
cursor: "hide",
|
||
showInvisibles: false,
|
||
showIndentGuides: false,
|
||
showPrintMargin: false,
|
||
highlightSelectedWord: false,
|
||
enableBehaviours: true,
|
||
fadeFoldWidgets: false,
|
||
mode: getMode(extension),
|
||
|
||
onLoad: function (_editor) {
|
||
state.codeEditor = markRaw(_editor);
|
||
{
|
||
// Render each XXXX-n as its own "redacted" token so the text layer
|
||
// draws it exactly like the Markdown chip (teal text on a teal tint).
|
||
const session = _editor.session;
|
||
const origGetTokens = session.getTokens.bind(session);
|
||
const re = /\bXXXX-\d+\b/g;
|
||
session.getTokens = function (row) {
|
||
const tokens = origGetTokens(row);
|
||
if (!tokens.some((t) => t.value.indexOf("XXXX-") !== -1)) return tokens;
|
||
const out = [];
|
||
for (const t of tokens) {
|
||
if (t.value.indexOf("XXXX-") === -1) { out.push(t); continue; }
|
||
let last = 0;
|
||
re.lastIndex = 0;
|
||
let m;
|
||
while ((m = re.exec(t.value))) {
|
||
if (m.index > last) out.push({ type: t.type, value: t.value.slice(last, m.index) });
|
||
out.push({ type: "redacted.n" + m[0].slice(5), value: m[0] });
|
||
last = m.index + m[0].length;
|
||
}
|
||
if (last < t.value.length) out.push({ type: t.type, value: t.value.slice(last) });
|
||
}
|
||
return out;
|
||
};
|
||
// Each redacted token shows its term number inside the slot.
|
||
if (!window.document.getElementById("redact-index-rules")) {
|
||
const st = window.document.createElement("style");
|
||
st.id = "redact-index-rules";
|
||
st.textContent = Array.from({ length: 99 }, (_, i) => `.ace_redacted.ace_n${i + 1}::after{content:"${i + 1}"}`).join("");
|
||
window.document.head.appendChild(st);
|
||
}
|
||
_editor.renderer.updateFull(true);
|
||
}
|
||
_editor.session.setUseWrapMode(state.wrapLines !== false);
|
||
// The right-edge fade is a cue only while there is more code to the right.
|
||
const card = _editor.container.closest?.(".file-card");
|
||
const syncEdge = () => {
|
||
if (!card) return;
|
||
const r = _editor.renderer;
|
||
const max = (r.layerConfig?.width || 0) - (r.$size?.scrollerWidth || 0);
|
||
card.classList.toggle("at-right-edge", max <= 2 || r.getScrollLeft() >= max - 2);
|
||
};
|
||
_editor.session.on("changeScrollLeft", syncEdge);
|
||
_editor.renderer.on("afterRender", syncEdge);
|
||
// On phones code scrolls sideways; keep the scrollbar visible as a cue.
|
||
if (window.matchMedia?.("(max-width: 767px)").matches) _editor.setOption("hScrollBarAlwaysVisible", true);
|
||
const Range = ace.require("ace/range").Range;
|
||
let activeLineMarker = null;
|
||
|
||
function highlightLines(from, to) {
|
||
if (activeLineMarker !== null) {
|
||
_editor.session.removeMarker(activeLineMarker);
|
||
activeLineMarker = null;
|
||
}
|
||
if (from === null || from === undefined) return;
|
||
activeLineMarker = _editor.session.addMarker(
|
||
new Range(from, 0, to, 1),
|
||
"highlighted-line",
|
||
"fullLine"
|
||
);
|
||
}
|
||
|
||
function applyHashFromUrl(scroll) {
|
||
const m = window.location.hash.match(/^#L(\d+)(?:-L(\d+))?/);
|
||
if (!m) {
|
||
highlightLines(null);
|
||
return;
|
||
}
|
||
const from = parseInt(m[1]) - 1;
|
||
const to = m[2] ? parseInt(m[2]) - 1 : from;
|
||
highlightLines(from, to);
|
||
if (scroll) {
|
||
timers.timeout(() => {
|
||
_editor.scrollToLine(from, true, true, function () {});
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
applyHashFromUrl(true);
|
||
|
||
// #392 — clicking a gutter line updates the URL to #L<n> and
|
||
// shift-clicking extends to #L<from>-L<to> so the user can copy
|
||
// a stable link to a specific line. Use replaceState to avoid
|
||
// polluting history with every click.
|
||
let anchorRow = null;
|
||
_editor.on("guttermousedown", function (e) {
|
||
const row = e.getDocumentPosition().row;
|
||
const shift = e.domEvent && e.domEvent.shiftKey;
|
||
let from = row;
|
||
let to = row;
|
||
if (shift && anchorRow !== null) {
|
||
from = Math.min(anchorRow, row);
|
||
to = Math.max(anchorRow, row);
|
||
} else {
|
||
anchorRow = row;
|
||
}
|
||
const hash =
|
||
from === to
|
||
? `#L${from + 1}`
|
||
: `#L${from + 1}-L${to + 1}`;
|
||
const url =
|
||
window.location.pathname + window.location.search + hash;
|
||
window.history.replaceState(null, "", url);
|
||
highlightLines(from, to);
|
||
e.stop();
|
||
});
|
||
|
||
const removeHashListener = listen(window, "hashchange", () => applyHashFromUrl(false));
|
||
|
||
_editor.setFontSize(state.aceOption.fontSize);
|
||
_editor.setOptions({
|
||
fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
|
||
});
|
||
_editor.container.style.lineHeight = "20px";
|
||
_editor.renderer.setPadding(16);
|
||
_editor.renderer.setScrollMargin(12, 12, 0, 0);
|
||
_editor.renderer.updateFontSize();
|
||
_editor.setReadOnly(state.aceOption.readOnly);
|
||
_editor.setKeyboardHandler(state.aceOption.keyBinding);
|
||
_editor.setSelectionStyle(
|
||
state.aceOption.fullLineSelection ? "line" : "text"
|
||
);
|
||
_editor.setOption("displayIndentGuides", false);
|
||
_editor.setHighlightActiveLine(
|
||
state.aceOption.highlightActiveLine
|
||
);
|
||
if (state.aceOption.cursor == "hide") {
|
||
_editor.renderer.$cursorLayer.element.style.display = "none";
|
||
}
|
||
_editor.setHighlightGutterLine(
|
||
state.aceOption.highlightGutterLine
|
||
);
|
||
_editor.setShowInvisibles(state.aceOption.showInvisibles);
|
||
_editor.setDisplayIndentGuides(state.aceOption.showIndentGuides);
|
||
|
||
_editor.renderer.setShowPrintMargin(
|
||
state.aceOption.showPrintMargin
|
||
);
|
||
_editor.setHighlightSelectedWord(
|
||
state.aceOption.highlightSelectedWord
|
||
);
|
||
_editor.session.setUseSoftTabs(state.aceOption.useSoftTab);
|
||
_editor.session.setTabSize(state.aceOption.tabSize);
|
||
_editor.setBehavioursEnabled(state.aceOption.enableBehaviours);
|
||
_editor.setFadeFoldWidgets(state.aceOption.fadeFoldWidgets);
|
||
// Ctrl/Cmd+F searches inside the file (Ace searchbox), like GitHub's blob view.
|
||
const removeFindListener = listen(window, "keydown", (event) => {
|
||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "g" && document.body.contains(_editor.container)) {
|
||
event.preventDefault();
|
||
state.gotoLine();
|
||
return;
|
||
}
|
||
if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "z" && document.body.contains(_editor.container)) {
|
||
event.preventDefault();
|
||
state.toggleWrap();
|
||
return;
|
||
}
|
||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "f" && document.body.contains(_editor.container)) {
|
||
event.preventDefault();
|
||
state.findInFile();
|
||
}
|
||
});
|
||
return () => { removeHashListener(); removeFindListener(); };
|
||
},
|
||
};
|
||
if (state.isDarkMode) {
|
||
state.aceOption.theme = "nord_dark";
|
||
}
|
||
state.type = getType(extension);
|
||
|
||
if (state.type == "pdf") {
|
||
// The viewer streams the file itself from state.url, so fetching
|
||
// the bytes again here only to hold them as a JS string wastes a
|
||
// request and a lot of memory on a large report. Content stays
|
||
// non-null so the Raw/Download actions remain available.
|
||
state.content = "pdf";
|
||
return;
|
||
}
|
||
if (state.type == "media" || state.type == "audio") {
|
||
// The player streams (and range-requests) the file itself; reading it
|
||
// as text first would download a whole video twice.
|
||
state.content = state.type;
|
||
return;
|
||
}
|
||
|
||
getContent(state.filePath, state.file);
|
||
}
|
||
|
||
// Files without a text body (media, notebooks, binaries, empty files and
|
||
// per-file errors) share the code view's framed card and header.
|
||
const FRAMED_TYPES = ["image", "media", "audio", "pdf", "IPython", "binary"];
|
||
const FILE_ERRORS = ["file_too_big", "file_not_found", "file_not_accessible", "file_not_supported", "folder_not_supported"];
|
||
state.fileFrame = () => {
|
||
if (!state.filePath) return false;
|
||
if (FRAMED_TYPES.includes(state.type)) return state.content != null;
|
||
if (state.type === "error") return FILE_ERRORS.includes(state.content);
|
||
return state.content == null && ["code", "text"].includes(state.type);
|
||
};
|
||
state.fileKind = () => {
|
||
const name = (state.paths || [])[(state.paths || []).length - 1] || "";
|
||
const dot = name.lastIndexOf(".");
|
||
const ext = dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
|
||
if (state.type === "image") return (ext === "JPG" ? "JPEG" : ext) + " image";
|
||
const MEDIA_NAMES = { WEBM: "WebM", OGG: "Ogg", OGV: "Ogg", MOV: "QuickTime", M4V: "M4V", MKV: "Matroska" };
|
||
if (state.type === "media") return (MEDIA_NAMES[ext] || ext) + " video";
|
||
if (state.type === "audio") return ext + " audio";
|
||
if (state.type === "pdf") return "PDF";
|
||
if (state.type === "IPython") return "Jupyter notebook";
|
||
if (state.type === "binary") return "Binary file";
|
||
return state.languageName || (ext ? ext + " file" : "File");
|
||
};
|
||
state.imageSize = "";
|
||
state.onImageLoad = (event) => {
|
||
const img = event.target;
|
||
if (img?.naturalWidth) state.imageSize = `${img.naturalWidth} × ${img.naturalHeight}`;
|
||
};
|
||
|
||
state.isSourceAccessError = () => ["repo_not_found", "token_expired", "github_app_access_required",
|
||
"github_app_reconnect_required", "repo_saml_enforcement", "repo_access_limited"].includes(state.content);
|
||
state.retryContent = () => init();
|
||
|
||
function init() {
|
||
contentGeneration++;
|
||
cancelContentLoad();
|
||
state.files = [];
|
||
state.loadedFolders = {};
|
||
state.content = null;
|
||
state.fileCounts = null;
|
||
listingGeneration++;
|
||
countRequests = new Set();
|
||
countsUnavailable = false;
|
||
state.fileSearchQuery = "";
|
||
state.onFileSearchChange();
|
||
state.repoId = params.repoId;
|
||
state.type = "loading";
|
||
state.filePath = params.path || "";
|
||
state.paths = state.filePath.split("/");
|
||
|
||
const repoId = state.repoId;
|
||
getOptions(function (options) {
|
||
if (state.filePath) {
|
||
// File URLs are independently authorized by the content endpoint.
|
||
// The tree is navigation metadata, not a prerequisite for reading.
|
||
updateContent();
|
||
const directories = [...new Set(state.paths.map((_, i) => state.paths.slice(0, i).join("/")))];
|
||
promises.all(directories.map(path => state.getFiles(path, true)));
|
||
return;
|
||
}
|
||
var chain = promises.resolve();
|
||
for (let i = 0; i < state.paths.length; i++) {
|
||
const path = i > 0 ? state.paths.slice(0, i).join("/") : "";
|
||
chain = chain.then(function () {
|
||
return state.getFiles(path);
|
||
}).then(function () {
|
||
if (state.type === "error") {
|
||
return promises.reject("error");
|
||
}
|
||
});
|
||
}
|
||
chain.then(function () {
|
||
if (destroyed || repoId !== state.repoId) return;
|
||
if (state.files.length == 1 && state.files[0].name == "") {
|
||
state.files = [];
|
||
state.type = "empty";
|
||
} else {
|
||
selectFile();
|
||
updateContent();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
init();
|
||
};
|
||
|
||
export const pullRequestController = function (state, http, location, params, html) {
|
||
async function getOption(callback) {
|
||
http.get(`/api/pr/${state.pullRequestId}/options`).then(
|
||
(res) => {
|
||
state.options = res.data;
|
||
if (state.options.url) {
|
||
// the repository is expired with redirect option
|
||
window.location = state.options.url;
|
||
return;
|
||
}
|
||
if (callback) {
|
||
callback(res.data);
|
||
}
|
||
},
|
||
(err) => {
|
||
state.type = "error";
|
||
state.content = err.data.error;
|
||
}
|
||
);
|
||
}
|
||
async function getPullRequest(callback) {
|
||
http.get(`/api/pr/${state.pullRequestId}/content`).then(
|
||
(res) => {
|
||
state.details = res.data;
|
||
state.tabState = { active: res.data.diff ? "diff" : "comments" };
|
||
if (callback) {
|
||
callback(res.data);
|
||
}
|
||
},
|
||
(err) => {
|
||
state.type = "error";
|
||
state.content = err.data.error;
|
||
}
|
||
);
|
||
}
|
||
|
||
function init() {
|
||
state.pullRequestId = params.pullRequestId;
|
||
state.type = "loading";
|
||
|
||
getOption((_) => {
|
||
getPullRequest();
|
||
});
|
||
}
|
||
|
||
init();
|
||
};
|
||
|
||
export const gistController = function (state, http, location, params, html) {
|
||
async function getOption(callback) {
|
||
http.get(`/api/gist/${state.gistId}/options`).then(
|
||
(res) => {
|
||
state.options = res.data;
|
||
if (state.options.url) {
|
||
window.location = state.options.url;
|
||
return;
|
||
}
|
||
if (callback) callback(res.data);
|
||
},
|
||
(err) => {
|
||
state.type = "error";
|
||
state.content = err.data.error;
|
||
}
|
||
);
|
||
}
|
||
async function getGist(callback) {
|
||
http.get(`/api/gist/${state.gistId}/content`).then(
|
||
(res) => {
|
||
state.details = res.data;
|
||
// Choose the visible tab after the asynchronous content arrives.
|
||
const hasFiles = res.data && res.data.files && res.data.files.length;
|
||
state.tabState = { active: hasFiles ? "files" : "comments" };
|
||
if (callback) callback(res.data);
|
||
},
|
||
(err) => {
|
||
state.type = "error";
|
||
state.content = err.data.error;
|
||
}
|
||
);
|
||
}
|
||
|
||
function init() {
|
||
state.gistId = params.gistId;
|
||
state.type = "loading";
|
||
getOption(() => { getGist(); });
|
||
}
|
||
|
||
init();
|
||
};
|
||
|
||
export const conferencesController = function (state, http, location) {
|
||
state.watch("user.status", () => {
|
||
if (state.user == null) {
|
||
location.url("/");
|
||
}
|
||
});
|
||
if (state.user == null) {
|
||
location.url("/");
|
||
}
|
||
|
||
state.conferences = [];
|
||
state.loading = true;
|
||
state.error = "";
|
||
state.search = "";
|
||
|
||
const conferencesPrefsKey = "conferences.filterPrefs";
|
||
const conferencesPrefDefaults = {
|
||
filters: { status: { ready: true, expired: false, removed: false } },
|
||
orderBy: "name",
|
||
};
|
||
const savedConferencesPrefs = loadFilterPrefs(conferencesPrefsKey) || {};
|
||
state.filters = {
|
||
status: Object.assign(
|
||
{},
|
||
conferencesPrefDefaults.filters.status,
|
||
(savedConferencesPrefs.filters && savedConferencesPrefs.filters.status) || {}
|
||
),
|
||
};
|
||
state.orderBy = savedConferencesPrefs.orderBy || conferencesPrefDefaults.orderBy;
|
||
|
||
state.watch("orderBy", () => {
|
||
saveFilterPrefs(conferencesPrefsKey, {
|
||
filters: state.filters,
|
||
orderBy: state.orderBy,
|
||
});
|
||
});
|
||
state.watch(
|
||
"filters",
|
||
() => {
|
||
saveFilterPrefs(conferencesPrefsKey, {
|
||
filters: state.filters,
|
||
orderBy: state.orderBy,
|
||
});
|
||
},
|
||
true
|
||
);
|
||
|
||
state.removeConference = function (conf) {
|
||
if (conf.removing) return;
|
||
if (
|
||
confirm(
|
||
`Are you sure that you want to remove the conference ${conf.name}? All the repositories linked to this conference will expire.`
|
||
)
|
||
) {
|
||
conf.removing = true;
|
||
const toast = reactive({
|
||
title: `Removing ${conf.name}...`,
|
||
date: new Date(),
|
||
body: `The conference ${conf.name} is going to be removed.`,
|
||
});
|
||
state.addToast(toast);
|
||
http.delete(`/api/conferences/${conf.conferenceID}`).then(() => {
|
||
toast.title = `${conf.name} is removed.`;
|
||
toast.body = `The conference ${conf.name} is removed.`;
|
||
state.loadConferences();
|
||
}).catch(() => {
|
||
toast.title = "Unable to remove conference";
|
||
toast.body = "Please try again. The conference has not been removed.";
|
||
}).finally(() => { conf.removing = false; });
|
||
}
|
||
};
|
||
|
||
state.loadConferences = () => {
|
||
state.loading = true;
|
||
state.error = "";
|
||
return http.get("/api/conferences/").then(
|
||
(res) => {
|
||
state.conferences = res.data || [];
|
||
},
|
||
(err) => {
|
||
state.error = "Unable to load your conferences. Please try again.";
|
||
}
|
||
).finally(() => { state.loading = false; });
|
||
};
|
||
state.loadConferences();
|
||
|
||
state.conferenceFilter = (conference) => {
|
||
if (state.filters.status[conference.status] == false) return false;
|
||
|
||
const query = state.search.trim().toLowerCase();
|
||
if (!query) return true;
|
||
|
||
if ((conference.name || "").toLowerCase().includes(query)) return true;
|
||
if ((conference.conferenceID || "").toLowerCase().includes(query)) return true;
|
||
|
||
return false;
|
||
};
|
||
};
|
||
|
||
export const newConferenceController = function (state, http, location, params) {
|
||
state.watch("user.status", () => {
|
||
if (state.user == null) {
|
||
location.url("/");
|
||
}
|
||
});
|
||
if (state.user == null) {
|
||
location.url("/");
|
||
}
|
||
|
||
// Named-month echo under the review-window dates ("Starts Oct 8, 2025 · in 3 weeks").
|
||
// The partial stays markup-only, so the echo nodes are placed from here.
|
||
const renderDateEchoes = () => {
|
||
for (const [id, prefix, key] of [["startDate", "Starts", "startDate"], ["endDate", "Ends", "endDate"]]) {
|
||
const input = document.getElementById(id);
|
||
if (!input) continue;
|
||
let echo = input.parentElement.querySelector(":scope > .date-echo");
|
||
if (!echo) {
|
||
echo = document.createElement("p");
|
||
echo.className = "date-echo";
|
||
echo.setAttribute("aria-live", "polite");
|
||
input.insertAdjacentElement("afterend", echo);
|
||
}
|
||
const text = dateEcho(state.options?.[key]);
|
||
echo.textContent = text ? prefix + " " + text : "";
|
||
echo.hidden = !text;
|
||
}
|
||
};
|
||
if (typeof state.watchGroup === "function") state.watchGroup(["options.startDate", "options.endDate"], () => nextTick(renderDateEchoes));
|
||
// A short example that fits the field at every width (the label already names it).
|
||
const shortNameExample = (tries = 20) => {
|
||
const input = document.querySelector("input#name.form-control");
|
||
const idInput = document.querySelector("input#conferenceID.form-control");
|
||
if (idInput) idInput.placeholder = "e.g. ICSE27";
|
||
if (input) input.placeholder = "e.g. ICSE 2027";
|
||
else if (tries > 0) setTimeout(() => shortNameExample(tries - 1), 50);
|
||
};
|
||
setTimeout(() => shortNameExample(), 0);
|
||
state.plans = [];
|
||
state.plansLoaded = false;
|
||
// With billing on, the review packages (one per venue size) are the only
|
||
// plans; their prices come from Stripe through the billing endpoint.
|
||
state.conferenceBilling = null;
|
||
readBilling(http).then(res => { state.conferenceBilling = res.data; }).catch(() => {});
|
||
state.conferenceTiers = () => state.plans.filter(p => p.id !== "free_conference").map(p => {
|
||
const priced = (state.conferenceBilling?.plans || []).find(b => b.id === p.id) || {};
|
||
return { ...p, storage: p.storagePerRepo, ...priced, id: p.id, name: p.name };
|
||
});
|
||
state.conferenceAudience = id => ({ conference_workshop: "Workshops and small venues", conference: "A typical conference", conference_large: "Large or multi-track venues" }[id] || "");
|
||
state.conferencePlanName = id => conferencePlanName(id);
|
||
state.conferenceAmount = (plan, cents = plan?.amount) => {
|
||
return new Intl.NumberFormat(undefined, { style: "currency", currency: plan?.currency || "eur",
|
||
minimumFractionDigits: cents % 100 ? 2 : 0, maximumFractionDigits: 2 }).format(cents / 100);
|
||
};
|
||
state.conferenceBytes = value => {
|
||
const bytes = Number(value) || 0;
|
||
const exponent = Math.min(4, Math.floor(Math.log2(Math.max(bytes, 1)) / 10));
|
||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(bytes / 1024 ** exponent)} ${["B", "KB", "MB", "GB", "TB"][exponent]}`;
|
||
};
|
||
state.editionMode = false;
|
||
state.saving = false;
|
||
state.error = "";
|
||
|
||
function getConference() {
|
||
return http
|
||
.get("/api/conferences/" + params.conferenceId)
|
||
.then((res) => {
|
||
state.options = { ...state.options, ...res.data, billing: res.data.billing || {} };
|
||
state.options.startDate = new Date(state.options.startDate);
|
||
state.options.endDate = new Date(state.options.endDate);
|
||
});
|
||
}
|
||
if (params.conferenceId) {
|
||
state.editionMode = true;
|
||
getConference();
|
||
}
|
||
|
||
function getPlans() {
|
||
http.get("/api/conferences/plans").then((res) => {
|
||
state.plans = res.data;
|
||
state.plansLoaded = true;
|
||
// New conferences default to the middle package when it is offered.
|
||
if (!state.editionMode && state.plans.length && !state.plans.some(p => p.id == state.options.plan.planID))
|
||
state.options.plan.planID = (state.plans.find(p => p.id === "conference") || state.plans[0]).id;
|
||
|
||
state.plan = state.plans.filter(
|
||
(f) => f.id == state.options.plan.planID
|
||
)[0];
|
||
});
|
||
}
|
||
getPlans();
|
||
const start = new Date();
|
||
start.setDate(1);
|
||
start.setMonth(start.getMonth() + 1);
|
||
const end = new Date(start);
|
||
end.setTime(start.getTime() + 179 * 86400000);
|
||
state.options = {
|
||
billing: {},
|
||
startDate: start,
|
||
endDate: end,
|
||
plan: {
|
||
planID: "free_conference",
|
||
},
|
||
options: {
|
||
link: true,
|
||
image: true,
|
||
pdf: true,
|
||
notebook: true,
|
||
update: true,
|
||
page: true,
|
||
},
|
||
};
|
||
state.plan = null;
|
||
|
||
state.watch("options.plan.planID", () => {
|
||
state.plan = state.plans.filter(
|
||
(f) => f.id == state.options.plan.planID
|
||
)[0];
|
||
});
|
||
|
||
function resetValidity() {
|
||
state.conference.name.setValidity("required", true);
|
||
state.conference.conferenceID.setValidity("pattern", true);
|
||
state.conference.conferenceID.setValidity("required", true);
|
||
state.conference.conferenceID.setValidity("used", true);
|
||
state.conference.startDate.setValidity("required", true);
|
||
state.conference.startDate.setValidity("invalid", true);
|
||
state.conference.endDate.setValidity("required", true);
|
||
state.conference.endDate.setValidity("invalid", true);
|
||
state.conference.setValidity("error", true);
|
||
}
|
||
|
||
function displayErrorMessage(message) {
|
||
switch (message) {
|
||
case "conf_name_missing":
|
||
state.conference.name.setValidity("required", false);
|
||
break;
|
||
case "conf_id_missing":
|
||
state.conference.conferenceID.setValidity("required", false);
|
||
break;
|
||
case "conf_id_format":
|
||
state.conference.conferenceID.setValidity("pattern", false);
|
||
break;
|
||
case "conf_id_used":
|
||
state.conference.conferenceID.setValidity("used", false);
|
||
break;
|
||
case "conf_start_date_missing":
|
||
state.conference.startDate.setValidity("required", false);
|
||
break;
|
||
case "conf_end_date_missing":
|
||
state.conference.endDate.setValidity("required", false);
|
||
break;
|
||
case "conf_start_date_invalid":
|
||
state.conference.startDate.setValidity("invalid", false);
|
||
break;
|
||
case "conf_end_date_invalid":
|
||
state.conference.endDate.setValidity("invalid", false);
|
||
break;
|
||
default:
|
||
state.conference.setValidity("error", false);
|
||
break;
|
||
}
|
||
}
|
||
|
||
state.submit = function () {
|
||
if (state.saving) return;
|
||
state.saving = true;
|
||
state.error = "";
|
||
const submitted = JSON.parse(JSON.stringify(state.options));
|
||
const toast = reactive({
|
||
title: `Creating ${submitted.name}...`,
|
||
date: new Date(),
|
||
body: `The conference ${submitted.conferenceID} is in creation.`,
|
||
});
|
||
if (state.editionMode) {
|
||
toast.title = `Updating ${submitted.name}...`;
|
||
toast.body = `The conference '${submitted.conferenceID}' is updating.`;
|
||
}
|
||
state.addToast(toast);
|
||
resetValidity();
|
||
http
|
||
.post(
|
||
"/api/conferences/" +
|
||
(state.editionMode ? submitted.conferenceID : ""),
|
||
submitted
|
||
)
|
||
.then(
|
||
() => {
|
||
if (!state.editionMode) {
|
||
toast.title = `${submitted.name} created`;
|
||
toast.body = `The conference '${submitted.conferenceID}' is created.`;
|
||
} else {
|
||
toast.title = `${submitted.name} updated`;
|
||
toast.body = `The conference '${submitted.conferenceID}' is updated.`;
|
||
}
|
||
location.url("/conference/" + submitted.conferenceID);
|
||
},
|
||
(error) => {
|
||
displayErrorMessage(error.data?.error);
|
||
state.error = "Unable to save the conference. Please correct any highlighted fields and try again.";
|
||
state.removeToast(toast);
|
||
}
|
||
).finally(() => { state.saving = false; });
|
||
};
|
||
};
|
||
|
||
function conferencePlanName(id) {
|
||
return { free_conference: "Free (no longer offered to new conferences)", conference_workshop: "Workshop package", conference: "Conference package", conference_large: "Large conference package", premium_conference: "Premium (legacy)", unlimited_conference: "Unlimited (legacy)" }[id] || id || "Free";
|
||
}
|
||
|
||
export const conferenceController = function (state, http, location, params) {
|
||
setupBilling(state, http, () => getConference(), { confirmedMessage: "Payment status checked. Your conference is ready for submissions once payment is confirmed." });
|
||
state.conferencePlanName = id => conferencePlanName(id);
|
||
state.watch("user.status", () => {
|
||
if (state.user == null) {
|
||
location.url("/");
|
||
}
|
||
});
|
||
if (state.user == null) {
|
||
location.url("/");
|
||
}
|
||
state.conference = null;
|
||
|
||
state.search = "";
|
||
state.filters = {
|
||
status: { ready: true, preparing: true, error: true, expired: true, removed: false },
|
||
};
|
||
state.orderBy = "-anonymizeDate";
|
||
// Same view tabs as the author dashboard: Current (everything except
|
||
// removed, the default) | Needs attention (errors) | Removed.
|
||
const conferenceStatusGroup = statusGroup;
|
||
const confKeys = ["ready", "preparing", "error", "expired", "removed"];
|
||
const confViewKeys = { current: k => k !== "removed", attention: k => k === "error", removed: k => k === "removed" };
|
||
state.confView = () => Object.keys(confViewKeys).find(v => confKeys.every(k => confViewKeys[v](k) === (state.filters.status[k] !== false))) || "custom";
|
||
state.setConfView = (view) => { confKeys.forEach(k => { state.filters.status[k] = (confViewKeys[view] || confViewKeys.current)(k); }); };
|
||
state.confCount = (view) => (state.conference?.repositories || []).filter((r) => confViewKeys[view](conferenceStatusGroup(r.status))).length;
|
||
|
||
state.copyingId = null;
|
||
state.copyRepoLink = async (repo) => {
|
||
if (!repo?.repoId || state.copyingId) return;
|
||
const link = new URL("/r/" + repo.repoId + "/", window.location.origin).href;
|
||
state.copyingId = repo.repoId;
|
||
try {
|
||
await copyText(link);
|
||
state.addToast({ title: "Link copied", body: "The anonymous link is ready to share." });
|
||
} catch {
|
||
state.addToast({ title: "Copy this anonymous link", body: link });
|
||
} finally { state.copyingId = null; }
|
||
};
|
||
|
||
// Expired rows carry the same "Extend 6 months" remedy as My work. The API lets the repository's authors
|
||
// and admins extend; anyone else gets an explanation instead of a silent failure.
|
||
state.extendingId = null;
|
||
state.extendRepo = (repo) => {
|
||
if (!repo?.repoId || state.extendingId) return;
|
||
state.extendingId = repo.repoId;
|
||
http.post("/api/repo/" + repo.repoId + "/extend").then(
|
||
(res) => {
|
||
const until = res?.data?.expirationDate ? ` until ${new Date(res.data.expirationDate).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}` : "";
|
||
state.addToast({ title: `${repo.repoId} is extended.`, body: `Reviewers can open it again${until}.` });
|
||
repo.status = "ready";
|
||
if (res?.data?.expirationDate) repo.options = { ...(repo.options || {}), expirationDate: res.data.expirationDate };
|
||
state.extendingId = null;
|
||
},
|
||
(error) => {
|
||
const code = error?.status || error?.data?.status;
|
||
state.addToast({ title: `Couldn't extend ${repo.repoId}.`, body: code === 401 || code === 403 ? "Only the repository's authors can extend this link. Ask them to extend it from My work." : "Try again in a moment." });
|
||
state.extendingId = null;
|
||
}
|
||
);
|
||
};
|
||
|
||
state.repoFiler = (repo) => {
|
||
if (state.filters.status[conferenceStatusGroup(repo.status)] == false) return false;
|
||
|
||
const query = state.search.trim().toLowerCase();
|
||
if (!query) return true;
|
||
|
||
if ((repo.source?.fullName || "").toLowerCase().includes(query)) return true;
|
||
if ((repo.repoId || "").toLowerCase().includes(query)) return true;
|
||
|
||
return false;
|
||
};
|
||
|
||
function getConference() {
|
||
http
|
||
.get("/api/conferences/" + params.conferenceId)
|
||
.then((res) => {
|
||
state.conference = res.data;
|
||
});
|
||
}
|
||
getConference();
|
||
};
|
||
|
||
|
||
export const connectionsController = function (state, http) {
|
||
state.notificationEmail = "";
|
||
state.emailSaved = false;
|
||
state.saveNotificationEmail = async () => {
|
||
state.busy = true;
|
||
state.emailSaved = false;
|
||
state.connectionError = "";
|
||
try {
|
||
const res = await http.post("/github/connections/email", { email: state.notificationEmail },
|
||
{ headers: { "X-CSRF-Token": state.connections.csrf } });
|
||
state.notificationEmail = res.data.notificationEmail;
|
||
if (state.user) state.user.notificationEmail = res.data.notificationEmail;
|
||
state.emailSaved = true;
|
||
} catch (error) { state.connectionError = error.data?.error || "Unable to save email address."; }
|
||
finally { state.busy = false; }
|
||
};
|
||
state.connections = null;
|
||
state.connectionError = "";
|
||
state.busy = false;
|
||
state.loadConnections = () => http.get("/github/connections").then(res => {
|
||
state.connections = res.data;
|
||
state.notificationEmail = res.data.notificationEmail || "";
|
||
}).catch(error => { state.connectionError = error.data?.error || "Unable to load connections."; });
|
||
state.changeConnection = async (resource, connection, preview) => {
|
||
state.busy = true;
|
||
state.connectionError = "";
|
||
try {
|
||
const result = await http.post("/github/connections/migrate", { type: resource.type, id: resource.id, connection, preview },
|
||
{ headers: { "X-CSRF-Token": state.connections.csrf } });
|
||
if (preview) resource.eligible = result.data.eligible;
|
||
else await state.loadConnections();
|
||
} catch (error) { state.connectionError = error.data?.error || "Unable to change connection."; }
|
||
finally { state.busy = false; }
|
||
};
|
||
state.disconnectOAuth = async () => {
|
||
state.busy = true;
|
||
try {
|
||
await http.post("/github/connections/disconnect-oauth", {}, { headers: { "X-CSRF-Token": state.connections.csrf } });
|
||
await state.loadConnections();
|
||
} catch (error) { state.connectionError = error.data?.error || "Unable to disconnect OAuth."; }
|
||
finally { state.busy = false; }
|
||
};
|
||
state.loadConnections();
|
||
};
|
||
|
||
export const signinController = function (state, http) {
|
||
state.accountRecovery = false;
|
||
http.get("/github/account-recovery").then(res => { state.accountRecovery = res.data.required; }).catch(() => {});
|
||
};
|
||
|
||
// Repository picker: the bottom fade means "more below", so it goes away once the list is scrolled to the end.
|
||
if (typeof document !== "undefined") {
|
||
document.addEventListener("scroll", event => {
|
||
const list = event.target;
|
||
if (!list || !list.classList || !list.classList.contains("repo-picker-list")) return;
|
||
list.classList.toggle("is-end", list.scrollTop + list.clientHeight >= list.scrollHeight - 2);
|
||
}, true);
|
||
}
|