mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 08:17:26 +01:00
Backend, scripts, docs and their tests take main's newer code where both sides changed the same lines (the shared blob cache, GitHubStream and markdown fixes landed on main after the Go migration branch was cut). The Dockerfile keeps the Go build from the migration, plus main's owner-alert-protocol label; .gitignore keeps both sides' entries. Frontend keeps the redesign and folds in main's features: Stripe billing in the New anonymization submit bar, GitHub access repair, redaction syntax help, the preview expand toggle, configurable redaction mask, folder counts, reviewer-route header rules, link expiry (now in the viewer's snapshot details) and the explorer error card. main's narrow-only wrap logic is dropped because the redesign wraps on desktop and scrolls sideways on phones. Tests: 1534 passing with SESSION_SECRET set (as in CI). Three main tests were updated for the agreed viewer design (no site header in the viewer, no repository-wide file count). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4035 lines
168 KiB
JavaScript
4035 lines
168 KiB
JavaScript
import { setupBilling, readBilling } from "./billing.js";
|
|
import { reactive, nextTick, markRaw } from "vue";
|
|
import { loadEditor } from "./lazy-assets.js";
|
|
import { createTimers, createListeners } from "./state.js";
|
|
import { formDraft } from "./drafts.js";
|
|
import translations from "../i18n/locale-en.json";
|
|
|
|
// A README image that fails to load becomes a quiet placeholder, not a broken-image glyph.
|
|
if (typeof window !== "undefined" && window.document?.addEventListener) {
|
|
window.document.addEventListener("error", (event) => {
|
|
const img = event.target;
|
|
if (!img || img.tagName !== "IMG" || img.dataset.failed || !img.closest?.(".markdown-body, .anonymize-preview-body")) return;
|
|
img.dataset.failed = "1";
|
|
const ph = window.document.createElement("span");
|
|
ph.className = "img-unavailable";
|
|
ph.textContent = img.alt ? "Image unavailable: " + img.alt : "Image unavailable";
|
|
img.replaceWith(ph);
|
|
}, true);
|
|
}
|
|
|
|
function viewerErrorCode(error) {
|
|
let data = error?.data;
|
|
if (typeof data === "string") {
|
|
try { data = JSON.parse(data); } catch (_) { data = null; }
|
|
}
|
|
if (typeof data?.error === "string" && Object.prototype.hasOwnProperty.call(translations.ERRORS, data.error)) return data.error;
|
|
if (error?.status === -1 || error?.status === 0) return "request_error";
|
|
if ([502, 503, 504].includes(error?.status)) return "unreachable";
|
|
return "unknown_error";
|
|
}
|
|
|
|
// The mask comes from /api/options; mainController sets it once loaded.
|
|
let redactionMask = "XXXX";
|
|
export const setRedactionMask = (mask) => { redactionMask = mask || "XXXX"; };
|
|
|
|
// Matches the replacements the anonymizer writes: MASK and MASK-<n>.
|
|
export const redactionPattern = (mask = redactionMask) => {
|
|
if (!mask) return null;
|
|
const escaped = mask.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
return new RegExp(escaped + "(?:-\\d+)?", "g");
|
|
};
|
|
|
|
// Wraps redacted terms under root in <mark class="redaction">, touching text
|
|
// nodes only so attributes and URLs stay intact. Mermaid sources are left
|
|
// alone (Mermaid parses their text), and so are code blocks Prism will
|
|
// rewrite unless root is that highlighted block itself.
|
|
export const markRedactionsIn = (root, mask = redactionMask) => {
|
|
const pattern = redactionPattern(mask);
|
|
if (!pattern || !root || !root.textContent.includes(mask)) return;
|
|
const doc = root.ownerDocument;
|
|
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
const nodes = [];
|
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
for (const node of nodes) {
|
|
const text = node.nodeValue;
|
|
const parent = node.parentElement;
|
|
if (!text.includes(mask) || !parent || parent.closest("script, style, mark.redaction, .mermaid")) continue;
|
|
const highlighted = parent.closest('code[class*="language-"]');
|
|
if (highlighted && !highlighted.contains(root)) continue;
|
|
const fragment = doc.createDocumentFragment();
|
|
let last = 0;
|
|
for (const match of text.matchAll(pattern)) {
|
|
fragment.append(text.slice(last, match.index));
|
|
const mark = doc.createElement("mark");
|
|
mark.className = "redaction";
|
|
mark.title = "Redacted by Anonymous GitHub";
|
|
mark.textContent = match[0];
|
|
fragment.append(mark);
|
|
last = match.index + match[0].length;
|
|
}
|
|
fragment.append(text.slice(last));
|
|
node.replaceWith(fragment);
|
|
}
|
|
};
|
|
|
|
export const highlightRedactions = (html, mask = redactionMask) => {
|
|
if (!html || !mask || !html.includes(mask)) return html;
|
|
const doc = new DOMParser().parseFromString("<body>" + html + "</body>", "text/html");
|
|
markRedactionsIn(doc.body, mask);
|
|
return doc.body.innerHTML;
|
|
};
|
|
|
|
async function copyText(text) {
|
|
if (window.navigator.clipboard?.writeText) {
|
|
await window.navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
const field = document.createElement("textarea");
|
|
field.value = text;
|
|
field.setAttribute("readonly", "");
|
|
field.style.position = "fixed";
|
|
field.style.opacity = "0";
|
|
const focused = document.activeElement;
|
|
document.body.appendChild(field);
|
|
try {
|
|
field.select();
|
|
if (!document.execCommand?.("copy")) throw Error("Copy unavailable");
|
|
} finally { field.remove(); focused?.focus(); }
|
|
}
|
|
|
|
function previewTabKeydown(event) {
|
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
const tabs = [...event.currentTarget.closest('[role="tablist"]').querySelectorAll('[role="tab"]')];
|
|
const index = tabs.indexOf(event.currentTarget);
|
|
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1
|
|
: (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
|
event.preventDefault();
|
|
tabs[next]?.focus();
|
|
tabs[next]?.click();
|
|
}
|
|
|
|
// Page setup functions run inside Vue effect scopes.
|
|
export const mainController = function (state, http, location, timeout) {
|
|
state.previewTabKeydown = previewTabKeydown;
|
|
state.headerCanUpgrade = false;
|
|
state.headerShowSupport = false;
|
|
state.headerBillingPending = ["success", "portal"].includes(new URLSearchParams(window.location.search).get("billing"));
|
|
const updateHeaderBilling = billing => {
|
|
state.headerShowSupport = !(billing?.grants || []).some(g => ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
|
|
const subscribed = (billing?.grants || []).some(g => ["author_monthly", "author_annual"].includes(g.planId) && ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
|
|
state.headerCanUpgrade = !!billing?.enabled && billing.checkoutEnabled !== false && !subscribed && (billing.plans || []).some(p => ["author_monthly", "author_annual"].includes(p.id));
|
|
};
|
|
state.on("billing:updated", (_, billing) => updateHeaderBilling(billing));
|
|
state.on("billing:pending", (_, pending) => { state.headerBillingPending = pending; });
|
|
state.openHeaderUpgrade = () => {
|
|
if (location.path() === "/profile") state.emit("settings:upgrade");
|
|
else location.url("/profile?upgrade=1");
|
|
};
|
|
|
|
state.title = "Main";
|
|
state.user = { status: "connection" };
|
|
state.site_options;
|
|
|
|
state.toasts = [];
|
|
|
|
state.removeToast = function (toast) {
|
|
const index = state.toasts.indexOf(toast);
|
|
if (index === -1) return;
|
|
state.toasts.splice(index, 1);
|
|
};
|
|
|
|
// Auto-dismiss toasts after a fixed delay so they don't pile up across
|
|
// navigations (e.g. the "README not found" toast re-fired every time the
|
|
// edit screen was reopened — see #246). Long-running operations that
|
|
// mutate the toast (remove/refresh) will simply disappear once the
|
|
// delay elapses; users can re-check status from the dashboard.
|
|
state.addToast = function (toast) {
|
|
state.toasts.push(toast);
|
|
timeout(function () {
|
|
state.removeToast(toast);
|
|
}, 8000);
|
|
return toast;
|
|
};
|
|
|
|
// Anonymized content that signed-out reviewers browse.
|
|
const reviewerRoutes = /^\/(r|repository|pr|gist)\//;
|
|
state.path = location.url();
|
|
state.paths = location.path().substring(1).split("/");
|
|
state.isReviewerRoute = reviewerRoutes.test(location.path());
|
|
|
|
state.darkMode = function (on) {
|
|
localStorage.setItem("darkMode", on);
|
|
state.isDarkMode = on;
|
|
const darkPrismLink = "/css/prism-okaidia.css";
|
|
const lightPrismLink = "/css/prism.css";
|
|
if (on) {
|
|
$("body").addClass("dark-mode");
|
|
let link = document.createElement("link");
|
|
link.href = darkPrismLink;
|
|
link.rel = "stylesheet";
|
|
document.head.append(link);
|
|
$(`link[href='${lightPrismLink}']`).remove();
|
|
} else {
|
|
$("body").removeClass("dark-mode");
|
|
let link = document.createElement("link");
|
|
link.href = lightPrismLink;
|
|
link.rel = "stylesheet";
|
|
document.head.append(link);
|
|
$(`link[href='${darkPrismLink}']`).remove();
|
|
}
|
|
state.emit("dark-mode", on);
|
|
};
|
|
|
|
state.darkMode(localStorage.getItem("darkMode") == "true");
|
|
|
|
state.emailPromptOpen = false;
|
|
state.emailPromptAddress = "";
|
|
state.emailPromptBusy = false;
|
|
state.emailPromptError = "";
|
|
let emailPromptFocus;
|
|
state.respondToEmailPrompt = async (action) => {
|
|
if (state.emailPromptBusy) return;
|
|
state.emailPromptBusy = true;
|
|
state.emailPromptError = "";
|
|
try {
|
|
const res = await http.post("/github/connections/email", { action, email: state.emailPromptAddress },
|
|
{ headers: { "X-CSRF-Token": state.user.emailPromptCSRF } });
|
|
if (action === "save") state.user.notificationEmail = res.data.notificationEmail;
|
|
state.emailPromptOpen = false;
|
|
state.user.showEmailPrompt = false;
|
|
emailPromptFocus?.focus();
|
|
} catch (error) { state.emailPromptError = error.data?.error || "Unable to save your preference. Please try again."; }
|
|
finally { state.emailPromptBusy = false; }
|
|
};
|
|
|
|
function getUser() {
|
|
http.get("/api/user").then(
|
|
(res) => {
|
|
if (res) state.user = res.data;
|
|
if (state.user?.username) readBilling(http).then(res => updateHeaderBilling(res.data)).catch(() => {});
|
|
else state.headerShowSupport = true;
|
|
if (state.user?.emailNotificationsEnabled && state.user.showEmailPrompt && !state.user.notificationEmail) {
|
|
emailPromptFocus = document.activeElement;
|
|
state.emailPromptOpen = true;
|
|
timeout(() => {
|
|
const dialog = document.getElementById("email-prompt");
|
|
if (dialog?.showModal) dialog.showModal();
|
|
else dialog?.setAttribute("open", "");
|
|
document.getElementById("email-prompt-address")?.focus();
|
|
}, 0);
|
|
}
|
|
},
|
|
() => {
|
|
state.user = null;
|
|
state.headerShowSupport = true;
|
|
}
|
|
);
|
|
}
|
|
getUser();
|
|
|
|
function getOptions() {
|
|
http.get("/api/options").then(
|
|
(res) => {
|
|
if (res) state.site_options = res.data;
|
|
setRedactionMask(res?.data?.ANONYMIZATION_MASK);
|
|
state.emit("site-options");
|
|
},
|
|
() => {
|
|
state.site_options = null;
|
|
}
|
|
);
|
|
}
|
|
getOptions();
|
|
|
|
function getMessage() {
|
|
http.get("/api/message").then(
|
|
(res) => {
|
|
if (res) state.generalMessage = res.data;
|
|
},
|
|
() => {
|
|
state.generalMessage = null;
|
|
}
|
|
);
|
|
}
|
|
getMessage();
|
|
|
|
function changedUrl(_, current) {
|
|
if (current) {
|
|
state.title = current.title;
|
|
}
|
|
state.path = location.url();
|
|
state.paths = location.path().substring(1).split("/");
|
|
state.isReviewerRoute = reviewerRoutes.test(location.path());
|
|
}
|
|
|
|
state.on("routeChange", changedUrl);
|
|
state.on("routeUpdate", changedUrl);
|
|
};
|
|
|
|
export const faqController = function (state, http) {
|
|
const listen = createListeners();
|
|
const timers = createTimers();
|
|
const revealAnswer = () => {
|
|
const id = window.location.hash.slice(1);
|
|
const answer = document.getElementById(id);
|
|
if (!answer?.classList.contains("panel-collapse")) return;
|
|
$(answer).collapse("show");
|
|
const heading = document.getElementById(answer.getAttribute("aria-labelledby"));
|
|
heading?.setAttribute("tabindex", "-1");
|
|
heading?.focus({ preventScroll: true });
|
|
heading?.scrollIntoView?.({ block: "start" });
|
|
};
|
|
// Router fragments do not emit hashchange, and answers mount after navigation.
|
|
state.on("routeUpdate", () => timers.timeout(revealAnswer, 0));
|
|
listen(window, "hashchange", revealAnswer);
|
|
timers.timeout(revealAnswer, 0);
|
|
};
|
|
|
|
export const profileController = function (state, http, translate, timeout, quotaService) {
|
|
state.settingsTab = /(?:billing|upgrade)=/.test(window.location.search) ? "billing" : "defaults";
|
|
state.confirmDeleteAccount = false;
|
|
state.deleteUsername = "";
|
|
state.profilePastSubscription = () => !state.profileAuthorGrant() && (state.billing?.grants || []).some(g => ["author_monthly", "author_annual"].includes(g.planId));
|
|
state.showAuthorPlans = new URLSearchParams(window.location.search).get("upgrade") === "1";
|
|
state.showRepositoryPass = false;
|
|
state.profilePlan = "author_monthly";
|
|
state.openProfileUpgrade = () => {
|
|
state.settingsTab = "billing";
|
|
state.showAuthorPlans = true;
|
|
nextTick(() => document.getElementById("settings-plan-comparison")?.focus());
|
|
};
|
|
state.on("settings:upgrade", () => state.openProfileUpgrade());
|
|
state.profileAuthorPlans = () => (state.billing?.plans || []).filter(p => ["author_monthly", "author_annual"].includes(p.id));
|
|
state.profileSelectedPlan = () => state.profileAuthorPlans().find(p => p.id === state.profilePlan) || state.profileAuthorPlans()[0];
|
|
state.profilePassPlan = () => (state.billing?.plans || []).find(p => p.id === "author_pass");
|
|
state.profileAuthorGrant = () => (state.billing?.grants || []).find(g => ["author_monthly", "author_annual"].includes(g.planId) && ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
|
|
state.profileGrantName = grant => ({ author_monthly: "Author monthly", author_annual: "Author annual", author_pass: "Repository pass", conference: "Conference hosting" }[grant.planId] || grant.planId);
|
|
|
|
setupBilling(state, http, () => quotaService.load().then(quota => { state.quota = quota; }, console.error));
|
|
state.terms = "";
|
|
state.options = {
|
|
expirationMode: "remove",
|
|
update: false,
|
|
image: true,
|
|
pdf: true,
|
|
notebook: true,
|
|
loc: true,
|
|
link: true,
|
|
};
|
|
state.saving = false;
|
|
state.message = null;
|
|
state.error = null;
|
|
|
|
state.accountEmail = "";
|
|
state.emailLoading = true;
|
|
state.emailCSRFReady = false;
|
|
state.emailSaving = false;
|
|
state.emailSaved = false;
|
|
state.emailError = "";
|
|
let emailCSRF = "";
|
|
state.loadAccountEmail = async () => {
|
|
state.emailLoading = true;
|
|
state.emailError = "";
|
|
try {
|
|
const res = await http.get("/github/connections/email");
|
|
state.accountEmail = res.data.notificationEmail || "";
|
|
emailCSRF = res.data.csrf;
|
|
state.emailCSRFReady = !!emailCSRF;
|
|
} catch {
|
|
state.emailError = "Unable to load your email address. Please try again.";
|
|
} finally { state.emailLoading = false; }
|
|
};
|
|
state.saveAccountEmail = async () => {
|
|
if (state.emailSaving || state.emailLoading || !emailCSRF) return;
|
|
state.emailSaving = true;
|
|
state.emailSaved = false;
|
|
state.emailError = "";
|
|
try {
|
|
const res = await http.post("/github/connections/email", { email: state.accountEmail },
|
|
{ headers: { "X-CSRF-Token": emailCSRF } });
|
|
state.accountEmail = res.data.notificationEmail;
|
|
if (state.user) state.user.notificationEmail = res.data.notificationEmail;
|
|
state.emailSaved = true;
|
|
} catch {
|
|
state.emailError = "Unable to save your email address. Check the address and try again.";
|
|
} finally { state.emailSaving = false; }
|
|
};
|
|
state.loadAccountEmail();
|
|
|
|
quotaService.load().then((quota) => {
|
|
state.quota = quota;
|
|
}, console.error);
|
|
|
|
const draft = formDraft(state, ["terms", "options"], "profile");
|
|
state.discardDraft = () => draft.discard();
|
|
function getDefault() {
|
|
http.get("/api/user/default").then((res) => {
|
|
const data = res.data || {};
|
|
if (data.terms) {
|
|
state.terms = data.terms.join("\n");
|
|
}
|
|
state.options = Object.assign({}, state.options, data.options);
|
|
draft.restore();
|
|
});
|
|
}
|
|
getDefault();
|
|
|
|
let savedTimer = null;
|
|
state.saveDefault = ($event) => {
|
|
if ($event && $event.preventDefault) $event.preventDefault();
|
|
if (state.saving) return;
|
|
const submitted = draft.checkpoint();
|
|
const params = {
|
|
terms: state.terms
|
|
.split("\n")
|
|
.map((t) => t.trim())
|
|
.filter((t) => t.length > 0),
|
|
options: state.options,
|
|
};
|
|
state.saving = true;
|
|
state.error = null;
|
|
http.post("/api/user/default", params).then(
|
|
() => {
|
|
draft.accept(submitted);
|
|
state.saving = false;
|
|
state.message = state.unsavedChanges ? null : "Saved";
|
|
if (savedTimer) timeout.cancel(savedTimer);
|
|
savedTimer = timeout(() => {
|
|
state.message = null;
|
|
}, 2500);
|
|
},
|
|
(error) => {
|
|
state.saving = false;
|
|
const code = error && error.data && error.data.error;
|
|
const fallback = "Unable to save your defaults. Please try again.";
|
|
// Unknown codes translate to the generic unknown_error message;
|
|
// this page has a more specific one.
|
|
Promise.all([translate("ERRORS." + code), translate("ERRORS.unknown_error")]).then(([translation, unknown]) => {
|
|
state.error = code && translation !== unknown ? translation : fallback;
|
|
}, () => {
|
|
state.error = fallback;
|
|
});
|
|
}
|
|
);
|
|
};
|
|
|
|
state.deleteAccount = () => {
|
|
if (state.deletingAccount || !state.confirmDeleteAccount || state.deleteUsername !== state.user?.username) return;
|
|
state.deletingAccount = true;
|
|
http.delete("/api/user").then(
|
|
() => {
|
|
window.location.href = "/";
|
|
},
|
|
() => {
|
|
state.deletingAccount = false;
|
|
state.deleteError =
|
|
"Unable to delete the account. Please try again.";
|
|
}
|
|
);
|
|
};
|
|
};
|
|
|
|
export const claimController = function (state, http, location) {
|
|
state.repoId = null;
|
|
state.repoUrl = null;
|
|
state.claim = () => {
|
|
http
|
|
.post("/api/repo/claim", {
|
|
repoId: state.repoId,
|
|
repoUrl: state.repoUrl,
|
|
})
|
|
.then(
|
|
(res) => {
|
|
location.url("/dashboard");
|
|
},
|
|
(err) => {
|
|
state.error = err.data;
|
|
state.claimForm.repoUrl.setValidity("not_found", false);
|
|
state.claimForm.repoId.setValidity("not_found", false);
|
|
}
|
|
);
|
|
};
|
|
};
|
|
|
|
export const homeController = function (state, http, location, window, timeout) {
|
|
if (state.user && !state.user.status) {
|
|
location.url("/dashboard");
|
|
}
|
|
state.watch("user.status", () => {
|
|
if (state.user && !state.user.status) {
|
|
location.url("/dashboard");
|
|
}
|
|
});
|
|
|
|
// "What you get": one screenshot, three tabs. The selected key drives
|
|
// both the expanded description and the visible panel.
|
|
state.features = [
|
|
{
|
|
key: "anonymize",
|
|
num: "01",
|
|
eyebrow: "Anonymize",
|
|
title: "Double-anonymous,",
|
|
accent: "your rules.",
|
|
text:
|
|
"Choose what reviewers may see: links, images, PDFs, notebooks, GitHub Pages. Add your own terms, with regex if you need it, and pick the expiration date.",
|
|
cta: "Start an anonymization",
|
|
href: "/anonymize",
|
|
url: "anonymous.4open.science/anonymize",
|
|
img: "/imgs/anonymize.png",
|
|
alt: "The anonymize form: source repository, terms to redact, options and a live README preview",
|
|
},
|
|
{
|
|
key: "review",
|
|
num: "02",
|
|
eyebrow: "Review",
|
|
title: "Reviewers browse",
|
|
accent: "the real thing.",
|
|
text:
|
|
"Highlighted source code, rendered PDFs, images, and notebooks, in a familiar file explorer. GitHub Pages is also supported.",
|
|
cta: "Open the example",
|
|
href: "https://anonymous.4open.science/r/840c8c57-3c32-451e-bf12-0e20be300389/",
|
|
target: "_self",
|
|
url: "anonymous.4open.science/r/840c8c57-…",
|
|
img: "/imgs/explorer.png",
|
|
alt: "The repository explorer with a file tree and a rendered README",
|
|
},
|
|
{
|
|
key: "manage",
|
|
num: "03",
|
|
eyebrow: "Manage",
|
|
title: "One dashboard,",
|
|
accent: "until the decision.",
|
|
text:
|
|
"Monitor views, edit configuration, remove or update your repository. Program chairs can group submissions under a conference with one shared expiry.",
|
|
cta: "Open the dashboard",
|
|
href: "/dashboard",
|
|
needsUser: true,
|
|
url: "anonymous.4open.science/dashboard",
|
|
img: "/imgs/dashboard.png",
|
|
alt: "The dashboard listing anonymized repositories with status, views and expiry",
|
|
},
|
|
];
|
|
state.feature = state.features[0].key;
|
|
state.selectFeature = function (key) {
|
|
state.feature = key;
|
|
};
|
|
// Signed-out visitors cannot open the dashboard; send them to sign in.
|
|
state.featureHref = function (f) {
|
|
return f.needsUser && !state.user ? "/signin" : f.href;
|
|
};
|
|
state.featureTarget = function (f) {
|
|
return f.needsUser && !state.user ? "_self" : f.target || undefined;
|
|
};
|
|
state.featureKeydown = function ($event, index) {
|
|
const step = {
|
|
ArrowDown: 1,
|
|
ArrowRight: 1,
|
|
ArrowUp: -1,
|
|
ArrowLeft: -1,
|
|
Home: "first",
|
|
End: "last",
|
|
}[$event.key];
|
|
if (step === undefined) return;
|
|
$event.preventDefault();
|
|
const n = state.features.length;
|
|
const next =
|
|
step === "first" ? 0 : step === "last" ? n - 1 : (index + step + n) % n;
|
|
state.feature = state.features[next].key;
|
|
timeout(() => {
|
|
const el = window.document.getElementById("feature-tab-" + state.feature);
|
|
if (el) el.focus();
|
|
});
|
|
};
|
|
|
|
// Live redaction demo on the landing page: same masking rule as the product (XXXX-n per distinct term).
|
|
state.demoText = "# DeepLearnUtils\nDeveloped by Jane Smith at MIT CSAIL.\nSee Smith et al., \"Attention That Works,\" 2026.\nContact: jane.smith@mit.edu";
|
|
state.demoTerms = "Jane Smith, Smith, MIT CSAIL, jane.smith@mit.edu";
|
|
state.demoParts = function () {
|
|
const terms = (state.demoTerms || "").split(/[,\n]/).map((t) => t.trim()).filter(Boolean).sort((a, b) => b.length - a.length);
|
|
const escape = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
if (!terms.length) return [{ text: state.demoText }];
|
|
const re = new RegExp("(" + terms.map(escape).join("|") + ")", "gi");
|
|
const index = {};
|
|
let n = 0;
|
|
return (state.demoText || "").split(re).filter((p) => p !== "").map((p) => {
|
|
const key = terms.find((t) => t.toLowerCase() === p.toLowerCase());
|
|
if (!key) return { text: p };
|
|
if (!index[key.toLowerCase()]) index[key.toLowerCase()] = ++n;
|
|
return { text: String(index[key.toLowerCase()]), redacted: true, original: p };
|
|
});
|
|
};
|
|
state.demoCount = () => state.demoParts().filter((p) => p.redacted).length;
|
|
|
|
state.cards = [
|
|
{ key: "repositories", total: 0, label: "repositories anonymized" },
|
|
{ key: "users", total: 0, label: "researchers" },
|
|
{ key: "pageViews", total: 0, label: "page views" },
|
|
{ key: "pullRequests", total: 0, label: "pull requests" },
|
|
];
|
|
function getStat() {
|
|
http.get("/api/stat/").then((res) => {
|
|
state.stat = res.data;
|
|
state.cards[0].total = res.data.nbRepositories;
|
|
state.cards[1].total = res.data.nbUsers;
|
|
state.cards[2].total = res.data.nbPageViews;
|
|
state.cards[3].total = res.data.nbPullRequests;
|
|
}).catch(() => {
|
|
// Leave the placeholders in place rather than showing zeros.
|
|
});
|
|
}
|
|
getStat();
|
|
|
|
function buildSeriesView(series) {
|
|
const view = {
|
|
series: series,
|
|
bars: [],
|
|
viewW: 100,
|
|
deltaToday: 0,
|
|
pctChange: 0,
|
|
pctAbs: 0,
|
|
isUp: true,
|
|
};
|
|
if (!series || series.length < 2) return view;
|
|
// Bars represent the *daily increment* (today - yesterday), not the
|
|
// cumulative total. The big number above the chart shows the total.
|
|
const deltas = new Array(series.length - 1);
|
|
for (let i = 1; i < series.length; i++) {
|
|
deltas[i - 1] = series[i] - series[i - 1];
|
|
}
|
|
const n = deltas.length;
|
|
const max = Math.max.apply(null, deltas);
|
|
const min = Math.min.apply(null, deltas);
|
|
// Anchor scale to zero so visually small days look small even when all
|
|
// deltas are positive; only fall back to min when there are negatives.
|
|
const base = Math.min(0, min);
|
|
const range = max - base || 1;
|
|
view.viewW = n * 2;
|
|
view.bars = new Array(n);
|
|
for (let i = 0; i < n; i++) {
|
|
const norm = (deltas[i] - base) / range;
|
|
const h = Math.max(1.5, norm * 34);
|
|
view.bars[i] = {
|
|
x: (i * 2 + 0.25).toFixed(2),
|
|
y: (36 - h).toFixed(2),
|
|
w: "1.5",
|
|
h: h.toFixed(2),
|
|
};
|
|
}
|
|
view.deltaToday = deltas[n - 1];
|
|
if (n >= 2) {
|
|
const prior = deltas[n - 2];
|
|
if (prior) {
|
|
view.pctChange = ((view.deltaToday - prior) / prior) * 100;
|
|
}
|
|
}
|
|
view.pctAbs = Math.round(Math.abs(view.pctChange));
|
|
view.isUp = view.pctChange >= 0;
|
|
return view;
|
|
}
|
|
|
|
state.history = {
|
|
repositories: buildSeriesView([]),
|
|
users: buildSeriesView([]),
|
|
pageViews: buildSeriesView([]),
|
|
pullRequests: buildSeriesView([]),
|
|
};
|
|
http.get("/api/stat/history?days=60").then((res) => {
|
|
const rows = res.data || [];
|
|
state.history = {
|
|
repositories: buildSeriesView(rows.map((r) => r.nbRepositories || 0)),
|
|
users: buildSeriesView(rows.map((r) => r.nbUsers || 0)),
|
|
pageViews: buildSeriesView(rows.map((r) => r.nbPageViews || 0)),
|
|
pullRequests: buildSeriesView(rows.map((r) => r.nbPullRequests || 0)),
|
|
};
|
|
}).catch(() => {});
|
|
};
|
|
|
|
export const unifiedDashboardController = function (state, http, location, promises, window, quotaService) {
|
|
const timers = createTimers();
|
|
state.on("routeLeave", function () {
|
|
$('[data-toggle="tooltip"]').tooltip("dispose");
|
|
});
|
|
state.watch("user.status", () => {
|
|
if (state.user == null) {
|
|
location.url("/");
|
|
}
|
|
});
|
|
if (state.user == null) {
|
|
location.url("/");
|
|
}
|
|
|
|
timers.timeout(() => {
|
|
$('[data-toggle="tooltip"]').tooltip();
|
|
}, 250);
|
|
|
|
state.quotaExpanded = false;
|
|
state.closeQuota = () => {
|
|
state.quotaExpanded = false;
|
|
nextTick(() => document.querySelector(".dashboard-quota > summary")?.focus());
|
|
};
|
|
createListeners()(document, "click", event => {
|
|
if (state.quotaExpanded && !event.target.closest?.(".dashboard-quota")) state.quotaExpanded = false;
|
|
});
|
|
state.filtersExpanded = false;
|
|
state.projectView = "all";
|
|
state.selectedProject = null;
|
|
state.projectName = "";
|
|
state.nameSaving = false;
|
|
state.nameError = "";
|
|
state.nameMessage = "";
|
|
state.needsAttention = item => item.status === "error" || item._broken
|
|
|| (statusKey(item.status) === "progress" && item._stale);
|
|
state.attentionCount = () => state.dashboardTotals?.attention ?? state.items.filter(state.needsAttention).length;
|
|
state.hasProjects = () => (state.dashboardTotals?.total ?? state.items.length) > 0;
|
|
state.setProjectView = view => {
|
|
state.projectView = view;
|
|
if (view === "attention") {
|
|
state.filters.status.error = true;
|
|
state.filters.status.progress = true;
|
|
}
|
|
};
|
|
state.projectDetailsId = item => "project-details-" + encodeURIComponent(item._type + ":" + (item._id || item._source || "unknown"));
|
|
state.showProjectDetails = async item => {
|
|
state.selectedProject = item;
|
|
state.projectName = item.projectName || "";
|
|
state.nameError = "";
|
|
state.nameMessage = "";
|
|
await nextTick();
|
|
const panel = document.getElementById(state.projectDetailsId(item));
|
|
panel?.focus();
|
|
panel?.scrollIntoView?.({ block: "nearest" });
|
|
};
|
|
state.closeProjectDetails = item => {
|
|
state.selectedProject = null;
|
|
nextTick(() => document.querySelector(`[data-project-actions="${encodeURIComponent(item._type + ":" + item._id)}"]`)?.focus());
|
|
};
|
|
state.saveProjectName = async item => {
|
|
if (state.nameSaving || !item._id) return;
|
|
const name = state.projectName.trim();
|
|
if (name.length > 100) { state.nameError = "Use 100 characters or fewer."; return; }
|
|
state.nameSaving = true;
|
|
state.nameError = "";
|
|
state.nameMessage = "";
|
|
try {
|
|
const res = await http.post("/api/user/project-name", { type: item._type, id: item._id, name });
|
|
item.projectName = res.data.name;
|
|
item._label = item.projectName || item._fallbackLabel;
|
|
if (state.selectedProject === item) state.nameMessage = name ? "Project name saved." : "Default project name restored.";
|
|
if (!legacyDashboard) {
|
|
state.dashboardCursor = null;
|
|
await loadAll();
|
|
if (state.selectedProject === item) {
|
|
state.selectedProject = state.items.find(current => current._type === item._type && current._id === item._id) || null;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
if (state.selectedProject === item) state.nameError = "The project name could not be saved. Try again.";
|
|
} finally { state.nameSaving = false; }
|
|
};
|
|
state.copyingId = null;
|
|
state.copyLink = async (item) => {
|
|
if (!item._viewUrl || state.copyingId) return;
|
|
const link = new URL(item._viewUrl, window.location.origin).href;
|
|
state.copyingId = item._type + ":" + item._id;
|
|
try {
|
|
await copyText(link);
|
|
state.addToast({ title: "Link copied", body: "The anonymous link is ready to share." });
|
|
} catch {
|
|
state.addToast({ title: "Copy this anonymous link", body: link });
|
|
} finally { state.copyingId = null; }
|
|
};
|
|
state.items = [];
|
|
state.search = "";
|
|
state.loading = true;
|
|
|
|
// Status buckets used by the Status filter. Raw statuses are mapped
|
|
// onto these keys so in-progress and error items can be filtered too.
|
|
state.statusKeyLabels = {
|
|
ready: "Ready",
|
|
progress: "In progress",
|
|
error: "Error",
|
|
expired: "Expired",
|
|
removed: "Removed",
|
|
};
|
|
const inProgress = ["queue", "download", "downloaded", "preparing", "anonymizing"];
|
|
function statusKey(status) {
|
|
if (status === "ready" || status === "error") return status;
|
|
if (status === "expired" || status === "expiring") return "expired";
|
|
if (status === "removed" || status === "removing") return "removed";
|
|
if (inProgress.indexOf(status) > -1) return "progress";
|
|
return "progress";
|
|
}
|
|
// An in-progress item whose last activity is older than this is
|
|
// probably stuck; the row says so instead of showing "Downloading".
|
|
const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
|
|
|
|
const dashboardPrefsKey = "dashboard.filterPrefs";
|
|
const dashboardPrefDefaults = {
|
|
typeFilter: "all",
|
|
filters: {
|
|
status: { ready: true, progress: true, error: true, expired: true, removed: false },
|
|
},
|
|
orderBy: "-anonymizeDate",
|
|
};
|
|
const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {};
|
|
state.typeFilter = savedDashboardPrefs.typeFilter || dashboardPrefDefaults.typeFilter;
|
|
state.filters = {
|
|
status: Object.assign(
|
|
{},
|
|
dashboardPrefDefaults.filters.status,
|
|
(savedDashboardPrefs.filters && savedDashboardPrefs.filters.status) || {}
|
|
),
|
|
};
|
|
state.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy;
|
|
|
|
// ---- Sorting -------------------------------------------------------
|
|
// `orderBy` is kept as the Angular orderBy expression ("-field" for
|
|
// descending) so saved preferences stay compatible.
|
|
const sortFields = {
|
|
_name: { label: "Anonymous ID", defaultDesc: false },
|
|
_label: { label: "Project", defaultDesc: false },
|
|
anonymizeDate: { label: "Anonymize date", defaultDesc: true },
|
|
status: { label: "Status", defaultDesc: false },
|
|
lastView: { label: "Last view", defaultDesc: true },
|
|
pageView: { label: "Views", defaultDesc: true },
|
|
"options.expirationDate": { label: "Expiration", defaultDesc: false },
|
|
};
|
|
state.sortFields = sortFields;
|
|
state.sortField = () => state.orderBy.replace(/^-/, "");
|
|
state.sortDesc = () => state.orderBy.charAt(0) === "-";
|
|
state.sortLabel = () => {
|
|
const f = sortFields[state.sortField()];
|
|
return f ? f.label : "Custom";
|
|
};
|
|
state.isSortedBy = (field) => state.sortField() === field;
|
|
state.setSort = (field, desc) => {
|
|
if (typeof desc !== "boolean") {
|
|
desc = state.isSortedBy(field)
|
|
? !state.sortDesc()
|
|
: !!(sortFields[field] && sortFields[field].defaultDesc);
|
|
}
|
|
state.orderBy = (desc ? "-" : "") + field;
|
|
};
|
|
state.toggleSortDirection = () => {
|
|
state.setSort(state.sortField(), !state.sortDesc());
|
|
};
|
|
|
|
state.watchGroup(
|
|
["typeFilter", "orderBy"],
|
|
() => {
|
|
saveFilterPrefs(dashboardPrefsKey, {
|
|
typeFilter: state.typeFilter,
|
|
filters: state.filters,
|
|
orderBy: state.orderBy,
|
|
});
|
|
}
|
|
);
|
|
state.watch(
|
|
"filters",
|
|
() => {
|
|
saveFilterPrefs(dashboardPrefsKey, {
|
|
typeFilter: state.typeFilter,
|
|
filters: state.filters,
|
|
orderBy: state.orderBy,
|
|
});
|
|
},
|
|
true
|
|
);
|
|
|
|
// ---- Quota (shared with the settings page via quotaService) --------
|
|
quotaService.load().then((quota) => {
|
|
state.quota = quota;
|
|
}, console.error);
|
|
|
|
// ---- Items ---------------------------------------------------------
|
|
// Fields shared by repositories, pull requests and gists. Records that
|
|
// lost their identifier (legacy data) are flagged as broken instead of
|
|
// rendering an empty link to /pr/undefined/.
|
|
function decorateItem(item, id, name, source, editUrl, viewUrl) {
|
|
if (!item.pageView) item.pageView = 0;
|
|
if (!item.lastView) item.lastView = "";
|
|
item.options = item.options || {};
|
|
item.options.terms = (item.options.terms || []).filter((f) => f);
|
|
item._id = id || "";
|
|
item._source = source;
|
|
item._broken = !id;
|
|
item._name = id || source || "(unnamed)";
|
|
item._fallbackLabel = item._type === "gist" ? "Gist " + (id || source || "without an ID") : source || name || item._name;
|
|
item._label = item.projectName || item._fallbackLabel;
|
|
item._editUrl = id ? editUrl : null;
|
|
item._viewUrl = id ? viewUrl : null;
|
|
if (item._broken) {
|
|
item.status = "error";
|
|
item.statusMessage = "incomplete_record";
|
|
}
|
|
item._statusKey = statusKey(item.status);
|
|
const last = item.anonymizeDate || item.lastView;
|
|
item._stale =
|
|
item._statusKey === "progress" &&
|
|
!!last &&
|
|
Date.now() - new Date(last).getTime() > STALE_AFTER_MS;
|
|
// What the Expires column should show.
|
|
if (item.status === "expired" || item.status === "expiring") {
|
|
item._expiry = { kind: "expired", date: item.options.expirationDate };
|
|
} else if (item.status !== "ready") {
|
|
item._expiry = { kind: "none" };
|
|
} else if (item.options.expirationMode === "never" || !item.options.expirationDate) {
|
|
item._expiry = { kind: "never" };
|
|
} else {
|
|
item._expiry = { kind: "date", date: item.options.expirationDate };
|
|
}
|
|
return item;
|
|
}
|
|
|
|
function safeGet(url) {
|
|
return http.get(url).then(
|
|
(res) => res.data || [],
|
|
(err) => {
|
|
console.error(err);
|
|
return [];
|
|
}
|
|
);
|
|
}
|
|
|
|
// All three lists load in parallel and are merged once, so the table
|
|
// does not re-sort three times while it fills in.
|
|
function loadLegacy() {
|
|
state.loading = true;
|
|
return promises
|
|
.all([
|
|
safeGet("/api/user/anonymized_repositories"),
|
|
safeGet("/api/user/anonymized_pull_requests"),
|
|
safeGet("/api/user/anonymized_gists"),
|
|
])
|
|
.then((results) => {
|
|
const repos = results[0];
|
|
const prs = results[1];
|
|
const gists = results[2];
|
|
const items = [];
|
|
repos.forEach((repo) => {
|
|
repo._type = "repo";
|
|
const src = repo.source || {};
|
|
items.push(
|
|
decorateItem(
|
|
repo,
|
|
repo.repoId,
|
|
repo.repoId,
|
|
src.fullName,
|
|
"/anonymize/" + repo.repoId,
|
|
"/r/" + repo.repoId + "/"
|
|
)
|
|
);
|
|
});
|
|
prs.forEach((pr) => {
|
|
pr._type = "pr";
|
|
const src = pr.source || {};
|
|
items.push(
|
|
decorateItem(
|
|
pr,
|
|
pr.pullRequestId,
|
|
pr.pullRequestId,
|
|
src.repositoryFullName ? src.repositoryFullName + "#" + src.pullRequestId : undefined,
|
|
"/pull-request-anonymize/" + pr.pullRequestId,
|
|
"/pr/" + pr.pullRequestId + "/"
|
|
)
|
|
);
|
|
});
|
|
gists.forEach((g) => {
|
|
g._type = "gist";
|
|
const src = g.source || {};
|
|
items.push(
|
|
decorateItem(
|
|
g,
|
|
g.gistId,
|
|
g.gistId,
|
|
src.gistId,
|
|
"/gist-anonymize/" + g.gistId,
|
|
"/gist/" + g.gistId + "/"
|
|
)
|
|
);
|
|
});
|
|
state.items = items;
|
|
state.loading = false;
|
|
});
|
|
}
|
|
let dashboardGeneration = 0;
|
|
let legacyDashboard = false;
|
|
state.dashboardCursor = null;
|
|
state.dashboardError = "";
|
|
function decorateSummary(item) {
|
|
const src = item.source || {};
|
|
if (item._type === "repo") return decorateItem(item, item.repoId, item.repoId, src.fullName, "/anonymize/" + item.repoId, "/r/" + item.repoId + "/");
|
|
if (item._type === "pr") return decorateItem(item, item.pullRequestId, item.pullRequestId,
|
|
src.repositoryFullName ? src.repositoryFullName + "#" + src.pullRequestId : undefined,
|
|
"/pull-request-anonymize/" + item.pullRequestId, "/pr/" + item.pullRequestId + "/");
|
|
return decorateItem(item, item.gistId, item.gistId, src.gistId, "/gist-anonymize/" + item.gistId, "/gist/" + item.gistId + "/");
|
|
}
|
|
function loadAll(append = false) {
|
|
if (legacyDashboard) return loadLegacy();
|
|
const generation = ++dashboardGeneration;
|
|
state.loading = !append;
|
|
state.loadingMore = append;
|
|
state.dashboardError = "";
|
|
return http.get("/api/user/dashboard", { params: {
|
|
q: state.search, type: state.typeFilter, sort: state.orderBy,
|
|
statuses: Object.keys(state.filters.status).filter(key => state.filters.status[key] !== false).join(",") || "none",
|
|
attention: String(state.projectView === "attention"),
|
|
cursor: append ? state.dashboardCursor : undefined,
|
|
} }).then(response => {
|
|
if (generation !== dashboardGeneration) return;
|
|
// Compatibility with servers that have not gained the summary API.
|
|
if (!Array.isArray(response.data?.items)) { legacyDashboard = true; return loadLegacy(); }
|
|
state.items = (append ? state.items : []).concat(response.data.items.map(decorateSummary));
|
|
state.dashboardCursor = response.data.cursor;
|
|
state.dashboardTotals = response.data;
|
|
}, error => {
|
|
if (generation !== dashboardGeneration) return;
|
|
if (error.status === 404) { legacyDashboard = true; return loadLegacy(); }
|
|
state.dashboardError = "Projects could not be loaded. Please try again.";
|
|
}).finally(() => {
|
|
if (generation === dashboardGeneration) { state.loading = false; state.loadingMore = false; }
|
|
});
|
|
}
|
|
state.loadMoreProjects = () => loadAll(true);
|
|
let reloadTimer;
|
|
state.watchGroup(["search", "typeFilter", "orderBy", "projectView"], () => {
|
|
if (legacyDashboard) return;
|
|
if (reloadTimer) timers.timeout.cancel(reloadTimer);
|
|
reloadTimer = timers.timeout(() => loadAll(), 150);
|
|
}, false);
|
|
state.watch("filters", () => {
|
|
if (!legacyDashboard) loadAll();
|
|
}, true, false);
|
|
state.on("dispose", () => { dashboardGeneration++; });
|
|
loadAll();
|
|
|
|
// Whole row opens the anonymized view; clicks on links, buttons and the
|
|
// actions menu keep their own behaviour.
|
|
state.openItem = (item, $event) => {
|
|
if (!item._viewUrl) return;
|
|
const target = $event && $event.target;
|
|
if (target && target.closest && target.closest("a, button, .dropdown, input")) return;
|
|
window.location.href = item._viewUrl;
|
|
};
|
|
|
|
state.hiddenStatusCount = () =>
|
|
Object.keys(state.filters.status).filter((k) => state.filters.status[k] === false).length;
|
|
state.hasHiddenStatus = () => state.hiddenStatusCount() > 0;
|
|
|
|
state.hasActiveFilters = () =>
|
|
state.projectView !== "all" ||
|
|
state.typeFilter !== "all" ||
|
|
state.search.trim().length > 0 ||
|
|
Object.keys(state.filters.status).some((k) => state.filters.status[k] === false);
|
|
|
|
state.showClearFilters = () => state.search.trim().length > 0
|
|
|| state.typeFilter !== "all" || state.hiddenStatusCount() > 1;
|
|
|
|
state.clearFilters = () => {
|
|
state.projectView = "all";
|
|
state.typeFilter = "all";
|
|
state.search = "";
|
|
Object.keys(state.filters.status).forEach((k) => {
|
|
state.filters.status[k] = true;
|
|
});
|
|
};
|
|
|
|
function waitRepoToBeReady(repoId, callback, onError, previousStatus) {
|
|
http.get("/api/repo/" + repoId).then(async (res) => {
|
|
let statusChanged = previousStatus !== undefined && previousStatus !== res.data.status;
|
|
for (const item of state.items) {
|
|
if (item._type === "repo" && item.repoId == repoId) {
|
|
statusChanged = statusChanged || item.status !== res.data.status;
|
|
const neededAttention = state.needsAttention(item);
|
|
item.status = res.data.status;
|
|
item.statusMessage = res.data.statusMessage;
|
|
item._statusKey = statusKey(item.status);
|
|
if (item._statusKey !== "progress") item._stale = false;
|
|
const needsAttention = state.needsAttention(item);
|
|
if (state.dashboardTotals && neededAttention !== needsAttention) {
|
|
state.dashboardTotals.attention = Math.max(0, state.dashboardTotals.attention + (needsAttention ? 1 : -1));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (statusChanged && !legacyDashboard) {
|
|
state.dashboardCursor = null;
|
|
const selected = state.selectedProject;
|
|
await loadAll();
|
|
if (selected && state.selectedProject === selected) {
|
|
state.selectedProject = state.items.find(item => item._type === selected._type && item._id === selected._id) || null;
|
|
}
|
|
}
|
|
if (
|
|
res.data.status == "ready" ||
|
|
res.data.status == "error" ||
|
|
res.data.status == "removed" ||
|
|
res.data.status == "expired"
|
|
) {
|
|
callback(res.data);
|
|
return;
|
|
}
|
|
timers.timeout(() => waitRepoToBeReady(repoId, callback, onError, res.data.status), 2500);
|
|
}, onError);
|
|
}
|
|
|
|
const labelOf = (t) =>
|
|
t === "repo" ? "repository" : t === "gist" ? "gist" : "pull request";
|
|
const apiBaseOf = (t) =>
|
|
t === "repo" ? "/api/repo" : t === "gist" ? "/api/gist" : "/api/pr";
|
|
|
|
state.removeItem = (item) => {
|
|
const label = labelOf(item._type);
|
|
if (confirm(`Are you sure that you want to remove the ${label} ${item._id}?`)) {
|
|
const toast = reactive({
|
|
title: `Removing ${item._id}...`,
|
|
date: new Date(),
|
|
body: `The ${label} ${item._id} is going to be removed.`,
|
|
});
|
|
state.addToast(toast);
|
|
const endpoint = `${apiBaseOf(item._type)}/${item._id}`;
|
|
http.delete(endpoint).then(
|
|
() => {
|
|
if (item._type === "repo") {
|
|
waitRepoToBeReady(item._id, () => {
|
|
toast.title = `${item._id} is removed.`;
|
|
toast.body = `The ${label} ${item._id} is removed.`;
|
|
|
|
}, undefined, item.status);
|
|
} else {
|
|
toast.title = `${item._id} is removed.`;
|
|
toast.body = `The ${label} ${item._id} is removed.`;
|
|
loadAll();
|
|
}
|
|
},
|
|
(error) => {
|
|
toast.title = `Error during the removal of ${item._id}.`;
|
|
toast.body = error.body;
|
|
loadAll();
|
|
}
|
|
);
|
|
}
|
|
};
|
|
|
|
state.refreshItem = (item) => {
|
|
const label = labelOf(item._type);
|
|
const toast = reactive({
|
|
title: `Refreshing ${item._id}...`,
|
|
date: new Date(),
|
|
body: `The ${label} ${item._id} is going to be refreshed.`,
|
|
});
|
|
state.addToast(toast);
|
|
const onError = (error) => {
|
|
toast.title = `Error during the refresh of ${item._id}.`;
|
|
toast.body = error.data?.error || error.body || "The refresh could not be completed. Please try again.";
|
|
loadAll();
|
|
};
|
|
const endpoint = `${apiBaseOf(item._type)}/${item._id}/refresh`;
|
|
http.post(endpoint).then(
|
|
() => {
|
|
if (item._type === "repo") {
|
|
waitRepoToBeReady(item._id, (repo) => {
|
|
if (repo.status !== "ready") {
|
|
onError({ body: repo.statusMessage || `The repository is ${repo.status}.` });
|
|
return;
|
|
}
|
|
toast.title = `${item._id} is refreshed.`;
|
|
toast.body = `The ${label} ${item._id} is refreshed.`;
|
|
|
|
}, onError, item.status);
|
|
} else {
|
|
toast.title = `${item._id} is refreshed.`;
|
|
toast.body = `The ${label} ${item._id} is refreshed.`;
|
|
loadAll();
|
|
}
|
|
},
|
|
onError
|
|
);
|
|
};
|
|
|
|
state.extendItem = (item) => {
|
|
const label = labelOf(item._type);
|
|
const toast = reactive({
|
|
title: `Extending ${item._id}...`,
|
|
date: new Date(),
|
|
body: `The expiration of ${label} ${item._id} is going to be extended by 6 months.`,
|
|
});
|
|
state.addToast(toast);
|
|
const endpoint = `${apiBaseOf(item._type)}/${item._id}/extend`;
|
|
http.post(endpoint).then(
|
|
() => {
|
|
if (item._type === "repo") {
|
|
waitRepoToBeReady(item._id, () => {
|
|
toast.title = `${item._id} is extended.`;
|
|
toast.body = `The expiration of ${label} ${item._id} is extended by 6 months.`;
|
|
|
|
}, undefined, item.status);
|
|
} else {
|
|
toast.title = `${item._id} is extended.`;
|
|
toast.body = `The expiration of ${label} ${item._id} is extended by 6 months.`;
|
|
loadAll();
|
|
}
|
|
},
|
|
(error) => {
|
|
toast.title = `Error during the extension of ${item._id}.`;
|
|
toast.body = (error.data && error.data.error) || error.body;
|
|
loadAll();
|
|
}
|
|
);
|
|
};
|
|
|
|
state.itemFilter = (item) => {
|
|
if (state.projectView === "attention" && !state.needsAttention(item)) return false;
|
|
if (state.typeFilter !== "all" && item._type !== state.typeFilter) return false;
|
|
if (state.filters.status[item._statusKey] === false) return false;
|
|
const needle = state.search.trim().toLowerCase();
|
|
if (needle.length == 0) return true;
|
|
if (item._label && String(item._label).toLowerCase().indexOf(needle) > -1) return true;
|
|
if (item._source && String(item._source).toLowerCase().indexOf(needle) > -1) return true;
|
|
if (item._id && String(item._id).toLowerCase().indexOf(needle) > -1) return true;
|
|
if (item.conference && String(item.conference).toLowerCase().indexOf(needle) > -1) return true;
|
|
return false;
|
|
};
|
|
};
|
|
|
|
export const dashboardController = function (state, location) {
|
|
location.url("/dashboard");
|
|
};
|
|
|
|
export const prDashboardController = function (state, location) {
|
|
location.url("/dashboard");
|
|
};
|
|
|
|
export const statusController = function (state, http, params) {
|
|
const timers = createTimers();
|
|
state.repoId = params.repoId;
|
|
state.reviewUrl = window.location.origin + "/r/" + encodeURIComponent(params.repoId) + "/";
|
|
state.repo = null;
|
|
state.progress = 0;
|
|
state.rateLimitResetAt = 0;
|
|
state.rateLimitCountdown = "";
|
|
state.shareUrl = (window.location?.origin || "") + "/r/" + encodeURIComponent(state.repoId) + "/";
|
|
state.copied = false;
|
|
let copiedTimer = null;
|
|
state.copyShareUrl = async () => {
|
|
try {
|
|
await copyText(state.shareUrl);
|
|
state.copied = true;
|
|
if (copiedTimer) timers.timeout.cancel(copiedTimer);
|
|
copiedTimer = timers.timeout(() => { state.copied = false; }, 2000);
|
|
} catch {
|
|
state.addToast?.({ title: "Copy this anonymous link", body: state.shareUrl });
|
|
}
|
|
};
|
|
|
|
var countdownTimer = null;
|
|
let pollTimer = null;
|
|
let destroyed = false;
|
|
function startRateLimitCountdown(resetAt) {
|
|
state.rateLimitResetAt = resetAt;
|
|
if (countdownTimer) timers.interval.cancel(countdownTimer);
|
|
function tick() {
|
|
var remaining = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000));
|
|
if (remaining <= 0) {
|
|
state.rateLimitCountdown = "";
|
|
state.rateLimitResetAt = 0;
|
|
timers.interval.cancel(countdownTimer);
|
|
countdownTimer = null;
|
|
} else {
|
|
var min = Math.floor(remaining / 60);
|
|
var sec = remaining % 60;
|
|
state.rateLimitCountdown = min > 0
|
|
? min + "m " + sec + "s"
|
|
: sec + "s";
|
|
}
|
|
|
|
}
|
|
tick();
|
|
countdownTimer = timers.interval(tick, 1000);
|
|
}
|
|
state.on("dispose", function () {
|
|
destroyed = true;
|
|
if (countdownTimer) timers.interval.cancel(countdownTimer);
|
|
if (pollTimer) timers.timeout.cancel(pollTimer);
|
|
});
|
|
|
|
function parseStatusMessage(msg) {
|
|
if (!msg) return msg;
|
|
var m = msg.match(/^rate_limited:(\d+)$/);
|
|
if (m) {
|
|
startRateLimitCountdown(parseInt(m[1], 10));
|
|
return null;
|
|
}
|
|
state.rateLimitResetAt = 0;
|
|
return msg;
|
|
}
|
|
|
|
state.getStatus = () => {
|
|
if (destroyed) return;
|
|
http
|
|
.get("/api/repo/" + state.repoId, {
|
|
repoId: state.repoId,
|
|
repoUrl: state.repoUrl,
|
|
})
|
|
.then(
|
|
(res) => {
|
|
if (destroyed) return;
|
|
state.repo = res.data;
|
|
if (res.data.rateLimitResetAt) {
|
|
startRateLimitCountdown(res.data.rateLimitResetAt);
|
|
} else {
|
|
state.repo.statusMessage = parseStatusMessage(state.repo.statusMessage);
|
|
}
|
|
if (state.repo.status == "ready") {
|
|
state.progress = 100;
|
|
} else if (state.repo.status == "queue") {
|
|
state.progress = 10;
|
|
} else if (state.repo.status == "downloaded") {
|
|
state.progress = 50;
|
|
} else if (state.repo.status == "download") {
|
|
state.progress = 25;
|
|
} else if (state.repo.status == "preparing") {
|
|
state.progress = 25;
|
|
} else if (state.repo.status == "anonymizing") {
|
|
state.progress = 75;
|
|
}
|
|
var shouldPoll = !["ready", "removed", "expired"].includes(state.repo.status);
|
|
if (state.repo.status == "error" && !state.rateLimitResetAt) {
|
|
shouldPoll = false;
|
|
}
|
|
if (shouldPoll) {
|
|
pollTimer = timers.timeout(state.getStatus, 2000);
|
|
}
|
|
},
|
|
(err) => {
|
|
state.error = err.data.error;
|
|
}
|
|
);
|
|
};
|
|
state.getStatus();
|
|
};
|
|
|
|
export const anonymizeController = function (state, http, html, params, location, translate, timeout) {
|
|
// Unified state
|
|
state.sourceUrl = "";
|
|
state.workspaceOpen = false;
|
|
state.sourceUrlError = "";
|
|
state.previewStatus = "idle";
|
|
state.sourceLoading = false;
|
|
state.mobilePane = "settings";
|
|
const workspaceMedia = window.matchMedia?.("(max-width: 767px)");
|
|
state.mobileWorkspace = !!workspaceMedia?.matches;
|
|
if (workspaceMedia?.addEventListener) createListeners()(workspaceMedia, "change", event => { state.mobileWorkspace = event.matches; });
|
|
state.selectMobilePane = (pane) => { state.mobilePane = pane; };
|
|
let sourceGeneration = 0;
|
|
let sourceDisposed = false;
|
|
const sourceRequests = new Set();
|
|
const isCurrentSource = (request, branch = false) => !sourceDisposed
|
|
&& request.generation === sourceGeneration
|
|
&& request.url === state.sourceUrl.trim()
|
|
&& request.connection === state.githubConnection
|
|
&& (!branch || request.branch === state.source.branch);
|
|
const beginSourceRequest = () => {
|
|
const request = { generation: sourceGeneration, url: state.sourceUrl.trim(), connection: state.githubConnection, branch: state.source.branch };
|
|
sourceRequests.add(request);
|
|
state.sourceLoading = true;
|
|
return request;
|
|
};
|
|
const finishSourceRequest = (request) => {
|
|
sourceRequests.delete(request);
|
|
if (!sourceDisposed) state.sourceLoading = [...sourceRequests].some(request => isCurrentSource(request));
|
|
};
|
|
state.on("dispose", () => { sourceDisposed = true; sourceGeneration++; });
|
|
let selectedSourceKey = null;
|
|
const sourceKey = (parsed) => parsed.gistId && !parsed.repo
|
|
? "gist:" + parsed.gistId.toLowerCase()
|
|
: (parsed.owner + "/" + parsed.repo).toLowerCase() + (parsed.pullRequestId ? "/pull/" + parsed.pullRequestId : "");
|
|
const draft = formDraft(state, ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"], "anonymize:" + (location.path?.() || ""));
|
|
let restoredCheckoutConference = null;
|
|
let checkoutDraftId = null;
|
|
let checkoutDraftSnapshot = null;
|
|
state.hostingReview = false;
|
|
state.previewExpanded = false;
|
|
state.taskValidationError = "";
|
|
state.editRepository = () => {
|
|
state.mobilePane = "settings";
|
|
state.previewExpanded = false;
|
|
nextTick(() => document.querySelector(".anonymize-fields .is-invalid")?.focus());
|
|
};
|
|
state.submitRepository = (event) => {
|
|
state.taskValidationError = "";
|
|
if (state.sourceLoading || state.billingBusy || state.hostingSaving || state.billingPending) return;
|
|
if (expirationDateInvalid() || state.anonymize?.invalid || state.sourceUrlError) {
|
|
state.taskValidationError = "Check the highlighted fields before continuing.";
|
|
state.editRepository();
|
|
return;
|
|
}
|
|
if (state.hostingNeedsPayment()) return state.checkoutRepository();
|
|
return state.anonymizeRepo(event);
|
|
};
|
|
state.hostingPayment = false;
|
|
state.hostingAlternatives = false;
|
|
state.hostingPrice = plan => {
|
|
if (plan.amount == null) return "Price shown at checkout";
|
|
const amount = new Intl.NumberFormat(undefined, { style: "currency", currency: plan.currency, minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(plan.amount / 100);
|
|
return amount + (plan.interval === "year" ? " billed annually" : " billed monthly");
|
|
};
|
|
state.hostingPlan = "author_monthly";
|
|
state.hostingSaving = false;
|
|
state.selectedHostingPlan = () => state.authorPlans().find(p => p.id === state.hostingPlan) || state.authorPlans()[0];
|
|
const loadHostingQuota = () => http.get("/api/user/quota").then(res => { state.hostingQuota = res.data; });
|
|
setupBilling(state, http, async () => {
|
|
await loadHostingQuota();
|
|
state.hostingQuotaBlocked = false;
|
|
}, { confirmedMessage: "Hosting status refreshed. Your draft is ready to continue." });
|
|
loadHostingQuota().catch(() => {});
|
|
state.authorPlans = () => (state.billing?.plans || []).filter(p => ["author_monthly", "author_annual"].includes(p.id));
|
|
state.hasAuthorHosting = () => ["author_monthly", "author_annual"].includes(state.billing?.allowance?.plan);
|
|
state.hostingNeedsPayment = () => {
|
|
if (!state.billing?.enabled || state.detectedType !== "repo" || state.hasAuthorHosting() ||
|
|
(state.isUpdate && state.role !== "owner")) return false;
|
|
const needsLifetime = state.options?.expirationMode === "never" && !state._existingNever;
|
|
// Reported usage may include removed reservations; admission reclaims them.
|
|
return !!(needsLifetime || (!state.conference && state.hostingQuotaBlocked));
|
|
};
|
|
state.checkoutRepository = async (pass = false) => {
|
|
if (state.hostingSaving || state.billingBusy || !state.billing?.csrf || state.sourceLoading) return;
|
|
state.hostingSaving = true;
|
|
state.billingError = "";
|
|
try {
|
|
const values = draft.checkpoint();
|
|
const serialized = JSON.stringify(values);
|
|
if (!checkoutDraftId || serialized !== checkoutDraftSnapshot) {
|
|
const saved = await http.post("/api/billing/draft", { path: location.path(), values }, { headers: { "x-csrf-token": state.billing.csrf } });
|
|
checkoutDraftId = saved.data.id;
|
|
checkoutDraftSnapshot = serialized;
|
|
}
|
|
const plan = state.authorPlans().find(p => p.id === state.hostingPlan) || state.authorPlans()[0];
|
|
if (!pass && !plan) throw new Error("No author plan available");
|
|
await state.checkout(pass ? "author_pass" : plan.id, pass ? state.repoId : "", { draftId: checkoutDraftId });
|
|
} catch { state.billingError = "Your draft could not be saved for checkout. Your form is unchanged. Please try again."; }
|
|
finally { state.hostingSaving = false; }
|
|
};
|
|
state.useDatedHosting = () => { state.options.expirationMode = "remove"; };
|
|
state.discardDraft = async () => {
|
|
draft.discard();
|
|
state.workspaceOpen = !!state.sourceUrl;
|
|
state.error = null;
|
|
if (state.sourceUrl) await state.urlSelected(true);
|
|
else {
|
|
sourceGeneration++;
|
|
state.sourceLoading = false;
|
|
state.detectedType = null;
|
|
state.details = null;
|
|
state.sourceUrlError = "";
|
|
scheduleReadmePreview.cancel();
|
|
refreshPrPreview.cancel();
|
|
refreshGistPreview.cancel();
|
|
state.previewStatus = "idle";
|
|
}
|
|
};
|
|
state.githubConnection = undefined;
|
|
state.githubConnections = null;
|
|
state.gistOAuthRequired = false;
|
|
state.gistAppRequired = false;
|
|
const saveGitHubDraft = () => {
|
|
const draft = {};
|
|
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) draft[key] = state[key];
|
|
sessionStorage.setItem("github-access-draft", JSON.stringify({ path: location.path(), savedAt: Date.now(), draft }));
|
|
};
|
|
state.connectGistApp = () => {
|
|
saveGitHubDraft();
|
|
window.location.href = "/github/app/login?returnTo=" + encodeURIComponent(location.path());
|
|
};
|
|
state.connectGistOAuth = () => {
|
|
saveGitHubDraft();
|
|
window.location.href = "/github/login?returnTo=" + encodeURIComponent(location.path());
|
|
};
|
|
state.grantGitHubAccess = () => {
|
|
saveGitHubDraft();
|
|
const returnTo = location.path();
|
|
const repository = parseRepoFullName(state.sourceUrl) || "";
|
|
const route = state.githubConnections?.appConnected ? "/github/app/install" : "/github/app/login";
|
|
window.location.href = route + "?returnTo=" + encodeURIComponent(returnTo) + "&repository=" + encodeURIComponent(repository) + "&install=1";
|
|
};
|
|
state.chooseGitHubConnection = async (value) => {
|
|
state.githubConnection = value;
|
|
state.readme = "";
|
|
if (state.sourceUrl) await refreshGitHubAccess();
|
|
};
|
|
state.detectedType = null; // 'repo' | 'pr' | 'gist'
|
|
state.repoId = "";
|
|
state.pullRequestId = "";
|
|
state.gistId = "";
|
|
state.terms = "";
|
|
state.defaultTerms = "";
|
|
state.branches = [];
|
|
state.source = { branch: "", commit: "" };
|
|
state.options = {
|
|
expirationMode: "remove",
|
|
expirationDate: new Date(),
|
|
update: false,
|
|
image: true,
|
|
pdf: true,
|
|
notebook: true,
|
|
link: true,
|
|
body: true,
|
|
title: true,
|
|
origin: false,
|
|
diff: true,
|
|
content: true,
|
|
comments: true,
|
|
username: true,
|
|
date: true,
|
|
};
|
|
// Default expiration: 6 months from today.
|
|
function defaultExpirationDate() {
|
|
const d = new Date();
|
|
d.setMonth(d.getMonth() + 6);
|
|
return d;
|
|
}
|
|
state.options.expirationDate = defaultExpirationDate();
|
|
// Format a Date to a "yyyy-MM-dd" string using local date parts. Using
|
|
// toISOString() here would convert to UTC and can shift the bound by a
|
|
// day for users in negative-offset timezones, which the native date
|
|
// picker (local calendar) would then render off by one.
|
|
function toLocalDateString(d) {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
// Bound the expiration date: no earlier than today, no later than 1 year
|
|
// out.
|
|
state.minExpirationDate = toLocalDateString(new Date());
|
|
const maxDate = new Date();
|
|
maxDate.setFullYear(maxDate.getFullYear() + 1);
|
|
state.maxExpirationDate = toLocalDateString(maxDate);
|
|
state.anonymize_readme = "";
|
|
state.readme = "";
|
|
state.html_readme = "";
|
|
state.isUpdate = false;
|
|
|
|
state.previousConfigurations = [];
|
|
state.previousConfigurationId = "";
|
|
let previousRepositories;
|
|
const normalizedRepositoryName = name => String(name || "").replace(/\/$/, "").replace(/\.git$/i, "").toLowerCase();
|
|
async function findPreviousConfigurations() {
|
|
if (state.isUpdate || params.repoId || params.pullRequestId || params.gistId) return;
|
|
const request = { generation: sourceGeneration, url: state.sourceUrl.trim(), connection: state.githubConnection };
|
|
try {
|
|
// This endpoint only returns repositories the signed-in user can manage.
|
|
previousRepositories ||= http.get("/api/user/anonymized_repositories").then(res => res.data);
|
|
const repositories = await previousRepositories;
|
|
if (!isCurrentSource(request)) return;
|
|
const name = normalizedRepositoryName(parseRepoFullName(request.url));
|
|
state.previousConfigurations = repositories.filter(repo => repo.repoId && repo.options
|
|
&& normalizedRepositoryName(repo.source?.fullName) === name)
|
|
.sort((a, b) => (Date.parse(b.anonymizeDate) || 0) - (Date.parse(a.anonymizeDate) || 0));
|
|
state.previousConfigurationId = state.previousConfigurations[0]?.repoId || "";
|
|
} catch (_) {
|
|
// A suggestion failure must not prevent creating a repository. Retry on the next URL selection.
|
|
previousRepositories = undefined;
|
|
}
|
|
}
|
|
state.existingConfigurationUrl = () => {
|
|
const previous = state.previousConfigurations.find(repo => repo.repoId === state.previousConfigurationId);
|
|
return previous ? "/anonymize/" + encodeURIComponent(previous.repoId) : null;
|
|
};
|
|
|
|
function getDefault(cb) {
|
|
http.get("/api/user/default").then((res) => {
|
|
const data = res.data;
|
|
if (data.terms) {
|
|
state.defaultTerms = data.terms.join("\n");
|
|
}
|
|
state.options = Object.assign({}, state.options, data.options);
|
|
// Honour a server-provided default date, otherwise fall back to the
|
|
// 6-month default. (Previously this re-added the offset on top of the
|
|
// already-defaulted date, doubling it.)
|
|
state.options.expirationDate =
|
|
data.options && data.options.expirationDate
|
|
? new Date(data.options.expirationDate)
|
|
: defaultExpirationDate();
|
|
if (!params.repoId && !params.pullRequestId && !params.gistId && !state.anonymize?.terms?.dirty) state.terms = state.defaultTerms;
|
|
if (cb) cb();
|
|
});
|
|
}
|
|
|
|
// Helper to safely set validity on form fields
|
|
function setValidity(field, key, value) {
|
|
if (state.anonymize && state.anonymize[field]) {
|
|
state.anonymize[field].setValidity(key, value);
|
|
}
|
|
}
|
|
|
|
function parseRepoFullName(url) {
|
|
try {
|
|
const parsed = parseGithubUrl(url);
|
|
if (parsed && parsed.owner && parsed.repo) {
|
|
return parsed.owner + "/" + parsed.repo;
|
|
}
|
|
} catch (_) { /* sourceUrl not yet parseable */ }
|
|
return null;
|
|
}
|
|
|
|
state.accessRepairAllowed = false;
|
|
state.repairingGitHubAccess = false;
|
|
state.accessRepairMessage = "";
|
|
state.repairGitHubAccess = async () => {
|
|
if (!state.accessRepairAllowed || state.repairingGitHubAccess || !state.githubConnections?.appEnabled || !params.repoId || state.role !== "owner" ||
|
|
state.githubConnection !== "github-app" || parseRepoFullName(state.sourceUrl) !== state._originalFullName) return;
|
|
state.repairingGitHubAccess = true;
|
|
state.accessRepairMessage = "";
|
|
const request = beginSourceRequest();
|
|
try {
|
|
const connections = await http.get("/github/connections");
|
|
state.githubConnections = connections.data;
|
|
if (!connections.data.appEnabled) return;
|
|
const repaired = await http.post("/github/connections/migrate", { type: "repository", id: params.repoId, connection: "github-app" },
|
|
{ headers: { "X-CSRF-Token": connections.data.csrf } });
|
|
if (!isCurrentSource(request)) return;
|
|
state.error = null;
|
|
if (repaired.data.fullName) {
|
|
state._originalFullName = repaired.data.fullName;
|
|
state.source.fullName = repaired.data.fullName;
|
|
state.sourceUrl = "https://github.com/" + repaired.data.fullName;
|
|
}
|
|
state.accessRepairMessage = "GitHub access repaired. Your anonymous URL and published commit are unchanged.";
|
|
await state.urlSelected(true);
|
|
} catch (_) {
|
|
if (isCurrentSource(request)) state.accessRepairMessage = "Unable to repair GitHub access. Check the App's repository permissions in Manage access, then retry. Your published snapshot has not been replaced.";
|
|
} finally {
|
|
state.repairingGitHubAccess = false;
|
|
finishSourceRequest(request);
|
|
}
|
|
};
|
|
|
|
state.reconnectingSource = false;
|
|
state.reconnectRepositoryId = undefined;
|
|
state.reconnectSource = async () => {
|
|
if (!confirm("Reconnect to the repository currently at this GitHub URL? Review its branch and commit before saving. Saving will replace the published snapshot.")) return;
|
|
state.reconnectingSource = true;
|
|
state.reconnectRepositoryId = undefined;
|
|
state.source.commit = "";
|
|
state.source.branch = "";
|
|
state.readme = "";
|
|
state.branches = [];
|
|
try { await getRepoDetails(); }
|
|
catch (_) { /* getRepoDetails displays the access error. */ }
|
|
};
|
|
|
|
function sourceRepositoryID() {
|
|
if (state.reconnectingSource) return undefined;
|
|
if (!state.isUpdate || !state._originalRepositoryID) return undefined;
|
|
const currentFullName = parseRepoFullName(state.sourceUrl);
|
|
return currentFullName === state._originalFullName
|
|
? state._originalRepositoryID
|
|
: undefined;
|
|
}
|
|
|
|
async function refreshGitHubAccess() {
|
|
state._preservingDraft = true;
|
|
try { await state.urlSelected(true); }
|
|
finally { state._preservingDraft = false; }
|
|
}
|
|
|
|
async function restoreGitHubDraft() {
|
|
const billingDraft = new URLSearchParams(window.location.search).get("billingDraft");
|
|
if (billingDraft) {
|
|
draft.restore();
|
|
try {
|
|
const saved = (await http.get("/api/billing/draft/" + encodeURIComponent(billingDraft))).data;
|
|
if (saved.path !== location.path()) throw new Error("Wrong task");
|
|
restoredCheckoutConference = saved.values.conference || null;
|
|
for (const key of ["sourceUrl", "terms", "repoId", "source", "options", "conference", "githubConnection"]) {
|
|
if (Object.prototype.hasOwnProperty.call(saved.values, key)) state[key] = saved.values[key];
|
|
}
|
|
if (state.options?.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate);
|
|
checkoutDraftId = saved.id;
|
|
checkoutDraftSnapshot = JSON.stringify(draft.checkpoint());
|
|
state.draftRestored = true;
|
|
state.hostingReview = true;
|
|
if (state.sourceUrl) await refreshGitHubAccess();
|
|
// Consume the return token so revisiting the form cannot overwrite newer edits.
|
|
location.replace?.(location.path());
|
|
return true;
|
|
} catch { state.billingError = "The checkout draft could not be restored. Retry by reloading this page; any local draft is still available."; }
|
|
}
|
|
let saved;
|
|
try { saved = JSON.parse(sessionStorage.getItem("github-access-draft") || "null"); }
|
|
catch (_) { return false; }
|
|
if (!saved || saved.path !== location.path() || Date.now() - saved.savedAt >= 30 * 60000) {
|
|
if (!draft.restore()) return false;
|
|
if (state.sourceUrl) await refreshGitHubAccess();
|
|
return true;
|
|
}
|
|
draft.restore();
|
|
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) {
|
|
if (Object.prototype.hasOwnProperty.call(saved.draft, key)) state[key] = saved.draft[key];
|
|
}
|
|
if (state.options.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate);
|
|
await refreshGitHubAccess();
|
|
sessionStorage.removeItem("github-access-draft");
|
|
return true;
|
|
}
|
|
|
|
getDefault(() => {
|
|
if (!params.repoId && !params.pullRequestId && !params.gistId) timeout(restoreGitHubDraft, 0);
|
|
// Edit mode: repo
|
|
if (params.repoId && params.repoId != "") {
|
|
state.isUpdate = true;
|
|
state.detectedType = "repo";
|
|
state.repoId = params.repoId;
|
|
http.get("/api/repo/" + state.repoId).then(
|
|
async (res) => {
|
|
state.accessRepairAllowed = !["removed", "archived", "preparing", "queue", "download", "removing", "expiring"].includes(res.data.status);
|
|
state.githubConnection = res.data.connection || "oauth";
|
|
state.sourceUrl = "https://github.com/" + res.data.source.fullName;
|
|
state._originalFullName = res.data.source.fullName;
|
|
state.terms = res.data.options.terms.filter((f) => f).join("\n");
|
|
state.source = res.data.source;
|
|
state.role = res.data.role || "owner";
|
|
state._existingNever = res.data.options.expirationMode === "never";
|
|
state.coauthors = res.data.coauthors || [];
|
|
// Remember the saved branch so the source.branch watcher knows
|
|
// not to bump source.commit to GitHub HEAD on edit-page load
|
|
// (#360). Without this, just opening the Edit form silently
|
|
// pulled in any new commits and saving — even to toggle a
|
|
// checkbox — picked them up.
|
|
state._originalBranch = res.data.source.branch;
|
|
state.options = Object.assign({}, state.options, res.data.options);
|
|
state.conference = res.data.conference;
|
|
state._originalConference = res.data.conference;
|
|
state.repositoryID = res.data.source.repositoryID;
|
|
state._originalRepositoryID = res.data.source.repositoryID;
|
|
if (res.data.options.expirationDate) {
|
|
state.options.expirationDate = new Date(res.data.options.expirationDate);
|
|
}
|
|
selectedSourceKey = sourceKey(parseGithubUrl(state.sourceUrl));
|
|
if (await restoreGitHubDraft()) return;
|
|
await Promise.all([getRepoDetails(), getReadme()]);
|
|
anonymizeReadme();
|
|
|
|
},
|
|
() => { location.url("/404"); }
|
|
);
|
|
}
|
|
// Edit mode: PR
|
|
if (params.pullRequestId && params.pullRequestId != "") {
|
|
state.isUpdate = true;
|
|
state.detectedType = "pr";
|
|
state.pullRequestId = params.pullRequestId;
|
|
http.get("/api/pr/" + state.pullRequestId).then(
|
|
async (res) => {
|
|
state.githubConnection = res.data.connection || "oauth";
|
|
state.sourceUrl = "https://github.com/" + res.data.source.repositoryFullName + "/pull/" + res.data.source.pullRequestId;
|
|
state.terms = res.data.options.terms.filter((f) => f).join("\n");
|
|
state.source = res.data.source;
|
|
state.options = Object.assign({}, state.options, res.data.options);
|
|
state.conference = res.data.conference;
|
|
state._originalConference = res.data.conference;
|
|
if (res.data.options.expirationDate) {
|
|
state.options.expirationDate = new Date(res.data.options.expirationDate);
|
|
}
|
|
selectedSourceKey = sourceKey(parseGithubUrl(state.sourceUrl));
|
|
if (await restoreGitHubDraft()) return;
|
|
try { await getPrDetails(); }
|
|
catch (_) { /* The source handler displays the error. */ }
|
|
|
|
},
|
|
() => { location.url("/404"); }
|
|
);
|
|
}
|
|
// Edit mode: Gist
|
|
if (params.gistId && params.gistId != "") {
|
|
state.isUpdate = true;
|
|
state.detectedType = "gist";
|
|
state.gistId = params.gistId;
|
|
http.get("/api/gist/" + state.gistId).then(
|
|
async (res) => {
|
|
state.sourceUrl = "https://gist.github.com/" + res.data.source.gistId;
|
|
state.terms = res.data.options.terms.filter((f) => f).join("\n");
|
|
state.source = res.data.source;
|
|
state.options = Object.assign({}, state.options, res.data.options);
|
|
state.conference = res.data.conference;
|
|
state._originalConference = res.data.conference;
|
|
if (res.data.options.expirationDate) {
|
|
state.options.expirationDate = new Date(res.data.options.expirationDate);
|
|
}
|
|
selectedSourceKey = sourceKey(parseGithubUrl(state.sourceUrl));
|
|
if (await restoreGitHubDraft()) return;
|
|
await getGistDetails();
|
|
|
|
},
|
|
() => { location.url("/404"); }
|
|
);
|
|
}
|
|
});
|
|
|
|
http.get("/github/connections").then(res => {
|
|
state.githubConnections = res.data;
|
|
// Preserve saved resources and an explicit or restored draft choice.
|
|
if (!params.repoId && !params.pullRequestId && !params.gistId && state.githubConnection === undefined
|
|
&& res.data.appEnabled && res.data.appConnected) {
|
|
state.githubConnection = "github-app";
|
|
if (state.sourceUrl && state.workspaceOpen) refreshGitHubAccess();
|
|
}
|
|
}).catch(() => {});
|
|
|
|
// Keep the same input focused until a complete GitHub URL can be loaded.
|
|
state.urlSelected = async (preserveDraft = false) => {
|
|
const generation = ++sourceGeneration;
|
|
state.previousConfigurations = [];
|
|
state.previousConfigurationId = "";
|
|
let o;
|
|
try { o = parseGithubUrl(state.sourceUrl.trim()); }
|
|
catch (_) {
|
|
state.sourceLoading = false;
|
|
scheduleReadmePreview.cancel();
|
|
refreshPrPreview.cancel();
|
|
refreshGistPreview.cancel();
|
|
state.previewStatus = "idle";
|
|
state.sourceUrlError = state.sourceUrl ? "Please provide a valid GitHub URL." : "";
|
|
setValidity("sourceUrl", "github", false);
|
|
return;
|
|
}
|
|
const key = sourceKey(o);
|
|
const preserveSelection = preserveDraft || selectedSourceKey === key;
|
|
selectedSourceKey = key;
|
|
state.sourceUrlError = "";
|
|
setValidity("sourceUrl", "github", true);
|
|
const transferFocus = document.activeElement?.id === "sourceUrl-landing";
|
|
state.workspaceOpen = true;
|
|
// Show the pasted URL from its start, not scrolled to the caret.
|
|
setTimeout(() => { const el = window.document.getElementById("sourceUrl"); if (!el) return; try { el.setSelectionRange(0, 0); } catch { /* not a text input */ } el.scrollLeft = 0; }, 50);
|
|
if (transferFocus) timeout(() => document.getElementById("sourceUrl")?.focus(), 0);
|
|
state.reconnectingSource = false;
|
|
state.reconnectRepositoryId = undefined;
|
|
if (!preserveSelection && !state.isUpdate) {
|
|
state.repoId = "";
|
|
state.pullRequestId = "";
|
|
state.gistId = "";
|
|
}
|
|
state.details = null;
|
|
state.branches = [];
|
|
if (!preserveSelection) state.source = { type: "GitHubStream", branch: "", commit: "" };
|
|
scheduleReadmePreview.cancel();
|
|
refreshPrPreview.cancel();
|
|
refreshGistPreview.cancel();
|
|
_prAnonCache.clear();
|
|
_gistAnonCache.clear();
|
|
state.previewStatus = "idle";
|
|
state.anonymize_readme = "";
|
|
state.readme = "";
|
|
state.html_readme = "";
|
|
state.detectedType = null;
|
|
state.gistOAuthRequired = false;
|
|
state.gistAppRequired = false;
|
|
|
|
state._preservingDraft = preserveSelection;
|
|
try {
|
|
if (o.gistId && !o.repo) {
|
|
state.detectedType = "gist";
|
|
state.source = { gistId: o.gistId };
|
|
await getGistDetails();
|
|
} else if (o.pullRequestId) {
|
|
state.detectedType = "pr";
|
|
state.source = { repositoryFullName: o.owner + "/" + o.repo, pullRequestId: o.pullRequestId };
|
|
await getPrDetails();
|
|
} else {
|
|
state.detectedType = "repo";
|
|
void findPreviousConfigurations();
|
|
await Promise.all([getRepoDetails(), getReadme()]);
|
|
anonymizeReadme();
|
|
}
|
|
} catch (_) { /* Source errors are displayed by the request handlers. */ }
|
|
finally { if (generation === sourceGeneration) state._preservingDraft = false; }
|
|
$('[data-toggle="tooltip"]').tooltip();
|
|
};
|
|
$('[data-toggle="tooltip"]').tooltip();
|
|
|
|
// ========== REPO LOGIC ==========
|
|
|
|
state.watch("source.branch", async () => {
|
|
if (state.detectedType !== "repo") return;
|
|
const selected = state.branches.filter((f) => f.name == state.source.branch)[0];
|
|
if (!selected) return;
|
|
// In update mode, preserve the saved commit while the branch is
|
|
// unchanged — see #360. Saving the form (e.g. to turn off
|
|
// auto-update) used to bump the commit to GitHub HEAD because this
|
|
// watcher overwrote it on edit-page load.
|
|
const keepSavedCommit =
|
|
!state.reconnectingSource &&
|
|
state.isUpdate &&
|
|
state._originalBranch === state.source.branch &&
|
|
!!state.source.commit;
|
|
if (!keepSavedCommit && !(state._preservingDraft && state.source.commit)) {
|
|
state.source.commit = selected.commit;
|
|
}
|
|
state.readme = selected.readme;
|
|
await getReadme();
|
|
anonymizeReadme();
|
|
|
|
});
|
|
|
|
state.getBranches = async (force) => {
|
|
const o = parseGithubUrl(state.sourceUrl);
|
|
const request = beginSourceRequest();
|
|
try {
|
|
const branches = await http.get(`/api/repo/${o.owner}/${o.repo}/branches`, {
|
|
params: { reconnect: state.reconnectingSource ? "1" : undefined, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, force: force === true || state.reconnectingSource ? "1" : "0", repositoryID: sourceRepositoryID() },
|
|
});
|
|
if (!isCurrentSource(request)) return;
|
|
state.branches = branches.data;
|
|
state.sourceUnreachable = false;
|
|
if (!state.source.branch) {
|
|
state.source.branch = state.details.defaultBranch;
|
|
}
|
|
const selected = state.branches.filter((b) => b.name == state.source.branch);
|
|
if (selected.length > 0) {
|
|
// When the user explicitly clicks refresh (force=true), always
|
|
// update the commit to the latest on the branch. Only preserve
|
|
// the saved commit on the initial edit-page load (#360).
|
|
const keepSavedCommit =
|
|
!state.reconnectingSource &&
|
|
!force &&
|
|
state.isUpdate &&
|
|
!state.options.update &&
|
|
state._originalBranch === state.source.branch &&
|
|
!!state.source.commit;
|
|
if (!keepSavedCommit && !(state._preservingDraft && state.source.commit)) {
|
|
state.source.commit = selected[0].commit;
|
|
}
|
|
state.readme = selected[0].readme;
|
|
await getReadme(force);
|
|
}
|
|
} catch (error) {
|
|
if (!isCurrentSource(request)) return;
|
|
state.branches = [];
|
|
state.sourceUnreachable = error && (error.status === 404 || (error.data && error.data.error === "repo_not_found"));
|
|
const code = (error && error.data && error.data.error) || (error && error.status === 404 ? "repo_not_found" : "unknown_error");
|
|
translate("ERRORS." + code).then((translation) => {
|
|
state.toasts = state.toasts || [];
|
|
state.addToast({ title: "Error", date: new Date(), body: translation });
|
|
state.error = translation;
|
|
}, console.error);
|
|
if (typeof setValidity === "function") {
|
|
setValidity("sourceUrl", "missing", false);
|
|
}
|
|
} finally { finishSourceRequest(request); }
|
|
|
|
};
|
|
|
|
async function getRepoDetails() {
|
|
const o = parseGithubUrl(state.sourceUrl);
|
|
const request = beginSourceRequest();
|
|
try {
|
|
resetValidity();
|
|
// force=1 so newly enabled features (e.g. GitHub Pages — see
|
|
// #364) are reflected without waiting for the cached metadata to
|
|
// expire. The endpoint hits the GitHub API once.
|
|
const res = await http.get(`/api/repo/${o.owner}/${o.repo}/`, {
|
|
params: { reconnect: state.reconnectingSource ? "1" : undefined, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, repositoryID: sourceRepositoryID(), force: "1" },
|
|
});
|
|
if (!isCurrentSource(request)) return;
|
|
state.details = res.data;
|
|
if (state.reconnectingSource) state.reconnectRepositoryId = state.details.externalId;
|
|
if (state.details && state.details.id) {
|
|
state.repositoryID = state.details.id;
|
|
}
|
|
if (!state.repoId) {
|
|
state.repoId = state.details.repo + "-" + generateRandomId(4);
|
|
}
|
|
await state.getBranches(state.reconnectingSource);
|
|
} catch (error) {
|
|
if (!isCurrentSource(request)) return;
|
|
if (error.data) {
|
|
translate("ERRORS." + error.data.error).then((translation) => {
|
|
state.addToast({ title: "Error", date: new Date(), body: translation });
|
|
state.error = translation;
|
|
}, console.error);
|
|
displayErrorMessage(error.data.error);
|
|
}
|
|
setValidity("sourceUrl", "missing", false);
|
|
throw error;
|
|
} finally { finishSourceRequest(request); }
|
|
}
|
|
|
|
async function getReadme(force) {
|
|
if (state.readme && !force) return state.readme;
|
|
const o = parseGithubUrl(state.sourceUrl);
|
|
const request = beginSourceRequest();
|
|
try {
|
|
const res = await http.get(`/api/repo/${o.owner}/${o.repo}/readme`, {
|
|
params: { reconnect: state.reconnectingSource ? "1" : undefined, anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, force: force === true ? "1" : "0", branch: state.source.branch, repositoryID: sourceRepositoryID() },
|
|
});
|
|
if (isCurrentSource(request, true)) state.readme = res.data;
|
|
} catch (error) {
|
|
if (isCurrentSource(request, true)) state.readme = "";
|
|
} finally { finishSourceRequest(request); }
|
|
}
|
|
|
|
// Both anonymizeReadme() and anonymizePrContent() used to reimplement
|
|
// ContentAnonimizer client-side, which drifted from the backend (term
|
|
// boundary fixes, accent matching, custom replacements all only landed
|
|
// in the server). Send the snippets to /api/anonymize-preview instead so
|
|
// the preview matches what reviewers see byte-for-byte. Calls are
|
|
// debounced and the in-flight request is dropped on the next change so
|
|
// typing in the form stays responsive.
|
|
|
|
function previewOptions() {
|
|
const opts = {
|
|
terms: state.terms ? state.terms.split("\n") : [],
|
|
image: !!state.options.image,
|
|
link: !!state.options.link,
|
|
repoId: state.repoId,
|
|
};
|
|
if (state.source && state.source.branch) {
|
|
opts.branchName = state.source.branch;
|
|
}
|
|
try {
|
|
const o = parseGithubUrl(state.sourceUrl);
|
|
opts.repoName = `${o.owner}/${o.repo}`;
|
|
} catch (_) { /* sourceUrl not yet parseable */ }
|
|
return opts;
|
|
}
|
|
|
|
// Invalidate as soon as an option changes, before the debounce expires.
|
|
function makePreviewBatcher(kind, buildBody, applyResult) {
|
|
let pendingTimer = null;
|
|
let generation = 0;
|
|
const schedule = () => {
|
|
if (state.detectedType !== kind) return;
|
|
if (pendingTimer) timeout.cancel(pendingTimer);
|
|
const current = ++generation;
|
|
state.previewStatus = "loading";
|
|
pendingTimer = timeout(() => {
|
|
pendingTimer = null;
|
|
const body = buildBody();
|
|
if (!body) { state.previewStatus = "idle"; return; }
|
|
http.post("/api/anonymize-preview", body).then(res => {
|
|
if (current !== generation) return;
|
|
applyResult(res.data, body);
|
|
state.previewStatus = "ready";
|
|
}).catch(() => {
|
|
if (current === generation) state.previewStatus = "error";
|
|
});
|
|
}, 200);
|
|
};
|
|
schedule.cancel = () => {
|
|
generation++;
|
|
if (pendingTimer) timeout.cancel(pendingTimer);
|
|
pendingTimer = null;
|
|
};
|
|
state.on("dispose", schedule.cancel);
|
|
return schedule;
|
|
}
|
|
state.retryPreview = () => {
|
|
if (state.detectedType === "repo") scheduleReadmePreview();
|
|
if (state.detectedType === "pr") refreshPrPreview();
|
|
if (state.detectedType === "gist") refreshGistPreview();
|
|
};
|
|
|
|
const scheduleReadmePreview = makePreviewBatcher(
|
|
"repo",
|
|
() => {
|
|
if (!state.readme) return null;
|
|
return { content: state.readme, options: previewOptions() };
|
|
},
|
|
(data) => {
|
|
if (typeof data?.content !== "string") throw Error("Invalid preview response");
|
|
state.anonymize_readme = data.content;
|
|
let baseUrl = "";
|
|
try {
|
|
const o = parseGithubUrl(state.sourceUrl);
|
|
// Fall back to the repo's default branch when source.branch
|
|
// hasn't loaded yet — without this, relative <img src="./X">
|
|
// resolved against a baseUrl like ".../raw//" (no branch
|
|
// segment), so the browser fetched ".../raw/X" and 404'd
|
|
// (#407).
|
|
const branch =
|
|
state.source.branch ||
|
|
(state.details && state.details.defaultBranch) ||
|
|
"main";
|
|
baseUrl = `https://github.com/${o.owner}/${o.repo}/raw/${branch}/`;
|
|
} catch (_) { /* fall through with empty base */ }
|
|
const html = renderMD(state.anonymize_readme, baseUrl);
|
|
state.html_readme = html;
|
|
timeout(Prism.highlightAll, 150);
|
|
}
|
|
);
|
|
|
|
function anonymizeReadme() {
|
|
if (!state.anonymize || !state.anonymize.terms) return;
|
|
// The "regex characters detected" hint is informational, not a blocker
|
|
// — IP addresses, escaped chars, etc. are all legitimate terms (#430).
|
|
state.termsRegexWarning =
|
|
!!state.terms && !!state.terms.match(/[-[\]{}()*+?.,\\^$|#]/g);
|
|
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.termsRegexWarning = false;
|
|
}
|
|
|
|
// Guards against submitting a missing or out-of-range expiration date.
|
|
// When the picked date fails min/max validation AngularJS sets the model
|
|
// to undefined, so we check both the field validity and the model value.
|
|
function expirationDateInvalid() {
|
|
if (state.detectedType === "repo" && state.options.expirationMode === "never") return false;
|
|
const field = state.anonymize && state.anonymize.expirationDate;
|
|
if (!state.options.expirationDate || (field && field.invalid)) {
|
|
if (field && field.setDirty) field.setDirty();
|
|
state.error = "Please choose a valid expiration date.";
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function displayErrorMessage(message) {
|
|
const idField =
|
|
state.detectedType === "pr"
|
|
? "pullRequestId"
|
|
: state.detectedType === "gist"
|
|
? "gistId"
|
|
: "repoId";
|
|
switch (message) {
|
|
case "repoId_already_used": setValidity(idField, "used", false); break;
|
|
case "invalid_repoId": setValidity(idField, "format", false); break;
|
|
case "options_not_provided": setValidity(idField, "format", false); break;
|
|
case "repo_already_anonymized": setValidity("sourceUrl", "used", false); break;
|
|
case "invalid_terms_format": setValidity("terms", "format", false); break;
|
|
case "repo_not_found": setValidity("sourceUrl", "missing", false); break;
|
|
case "repo_not_accessible": setValidity("sourceUrl", "access", false); break;
|
|
case "commit_not_found": setValidity("commit", "exists", false); break;
|
|
case "conf_not_activated": setValidity("conference", "activated", false); break;
|
|
}
|
|
}
|
|
|
|
// ========== CO-AUTHORS ==========
|
|
state.coauthors = state.coauthors || [];
|
|
state.coauthorResults = [];
|
|
state.coauthorError = "";
|
|
|
|
state.searchCoauthors = () => {
|
|
const q = (state.coauthorSearch || "").trim();
|
|
state.coauthorError = "";
|
|
if (q.length < 2) {
|
|
state.coauthorResults = [];
|
|
return;
|
|
}
|
|
http.get("/api/user/search/github-users", { params: { q } }).then(
|
|
(res) => {
|
|
const existing = new Set(
|
|
(state.coauthors || []).map((c) => (c.username || "").toLowerCase())
|
|
);
|
|
state.coauthorResults = (res.data || []).filter(
|
|
(u) => !existing.has((u.username || "").toLowerCase())
|
|
);
|
|
},
|
|
() => { state.coauthorResults = []; }
|
|
);
|
|
};
|
|
|
|
state.addCoauthor = (u, event) => {
|
|
if (event) event.preventDefault();
|
|
if (!u || !u.username) return;
|
|
http
|
|
.post("/api/repo/" + state.repoId + "/coauthors", {
|
|
username: u.username,
|
|
})
|
|
.then(
|
|
(res) => {
|
|
state.coauthors = res.data || [];
|
|
state.coauthorResults = [];
|
|
state.coauthorSearch = "";
|
|
state.coauthorError = "";
|
|
},
|
|
(err) => {
|
|
const code = (err && err.data && err.data.error) || "unknown_error";
|
|
state.coauthorError = code;
|
|
}
|
|
);
|
|
};
|
|
|
|
state.removeCoauthor = (c) => {
|
|
if (!c || !c.username) return;
|
|
if (!confirm("Remove co-author " + c.username + "?")) return;
|
|
http
|
|
.delete(
|
|
"/api/repo/" +
|
|
state.repoId +
|
|
"/coauthors/" +
|
|
encodeURIComponent(c.username)
|
|
)
|
|
.then(
|
|
(res) => { state.coauthors = res.data || []; },
|
|
(err) => {
|
|
const code = (err && err.data && err.data.error) || "unknown_error";
|
|
state.coauthorError = code;
|
|
}
|
|
);
|
|
};
|
|
|
|
// Submit: repo
|
|
state.anonymizeRepo = (event) => {
|
|
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
|
|
if (state.reconnectingSource && !state.reconnectRepositoryId) return;
|
|
if (expirationDateInvalid()) { state.editRepository(); return; }
|
|
if (state.anonymize?.invalid || state.sourceUrlError) {
|
|
state.editRepository();
|
|
state.error = "Please correct the highlighted fields before saving.";
|
|
return;
|
|
}
|
|
// Capacity is enforced on the server, which can release removed reservations.
|
|
if (state.hostingNeedsPayment() && state.options.expirationMode === "never") {
|
|
state.hostingReview = true;
|
|
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "center" }));
|
|
return;
|
|
}
|
|
const button = event.currentTarget || event.target.closest?.("button") || event.target;
|
|
button.disabled = true;
|
|
const o = parseGithubUrl(state.sourceUrl);
|
|
const payload = {
|
|
repoId: state.repoId,
|
|
terms: state.terms.trim().split("\n").filter((f) => f),
|
|
connection: state.githubConnection,
|
|
fullName: `${o.owner}/${o.repo}`,
|
|
repository: state.sourceUrl,
|
|
reconnectRepositoryId: state.reconnectRepositoryId,
|
|
options: state.options,
|
|
source: state.source,
|
|
conference: state.conference,
|
|
};
|
|
if (state.details) payload.options.pageSource = state.details.pageSource;
|
|
resetValidity();
|
|
const url = state.isUpdate ? "/api/repo/" + state.repoId : "/api/repo/";
|
|
http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
|
|
() => { draft.clear(); window.location.href = "/status/" + state.repoId; },
|
|
(error) => {
|
|
if (error.data) {
|
|
translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
|
|
displayErrorMessage(error.data.error);
|
|
if (["quota_exceeded", "subscription_required_for_never_expiration"].includes(error.data.error)) {
|
|
if (error.data.error === "subscription_required_for_never_expiration") state._existingNever = false;
|
|
state.hostingQuotaBlocked = true;
|
|
state.hostingReview = true;
|
|
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "center" }));
|
|
}
|
|
}
|
|
}
|
|
).finally(() => { button.disabled = false; });
|
|
};
|
|
|
|
// Submit: Gist
|
|
state.anonymizeGist = (event) => {
|
|
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
|
|
if (expirationDateInvalid()) return;
|
|
if (state.anonymize?.invalid || state.sourceUrlError) {
|
|
state.error = "Please correct the highlighted fields before saving.";
|
|
return;
|
|
}
|
|
const button = event.currentTarget || event.target.closest?.("button") || event.target;
|
|
button.disabled = true;
|
|
const o = parseGithubUrl(state.sourceUrl);
|
|
const payload = {
|
|
gistId: state.gistId,
|
|
terms: state.terms.trim().split("\n").filter((f) => f),
|
|
source: { gistId: o.gistId },
|
|
options: state.options,
|
|
conference: state.conference,
|
|
};
|
|
resetValidity();
|
|
const url = state.isUpdate ? "/api/gist/" + state.gistId : "/api/gist/";
|
|
http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
|
|
() => { draft.clear(); window.location.href = "/gist/" + state.gistId; },
|
|
(error) => {
|
|
if (error.data) {
|
|
translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
|
|
displayErrorMessage(error.data.error);
|
|
}
|
|
}
|
|
).finally(() => { button.disabled = false; });
|
|
};
|
|
|
|
// Submit: PR
|
|
state.anonymizePullRequest = (event) => {
|
|
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
|
|
if (expirationDateInvalid()) return;
|
|
if (state.anonymize?.invalid || state.sourceUrlError) {
|
|
state.error = "Please correct the highlighted fields before saving.";
|
|
return;
|
|
}
|
|
const button = event.currentTarget || event.target.closest?.("button") || event.target;
|
|
button.disabled = true;
|
|
const o = parseGithubUrl(state.sourceUrl);
|
|
const payload = {
|
|
pullRequestId: state.pullRequestId,
|
|
connection: state.githubConnection,
|
|
terms: state.terms.trim().split("\n").filter((f) => f),
|
|
source: { repositoryFullName: `${o.owner}/${o.repo}`, pullRequestId: o.pullRequestId },
|
|
options: state.options,
|
|
conference: state.conference,
|
|
};
|
|
resetValidity();
|
|
const url = state.isUpdate ? "/api/pr/" + state.pullRequestId : "/api/pr/";
|
|
http.post(url, payload, { headers: { "Content-Type": "application/json" } }).then(
|
|
() => { draft.clear(); window.location.href = "/pr/" + state.pullRequestId; },
|
|
(error) => {
|
|
if (error.data) {
|
|
translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
|
|
displayErrorMessage(error.data.error);
|
|
}
|
|
}
|
|
).finally(() => { button.disabled = false; });
|
|
};
|
|
|
|
state.watch("conference", () => { getConference(); });
|
|
state.watch("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();
|
|
// Prism rewrites fenced code blocks, so mark redactions once it is done.
|
|
if (window.Prism?.hooks && !window.Prism.redactionHook) {
|
|
window.Prism.hooks.add("complete", (env) => markRedactionsIn(env.element));
|
|
window.Prism.redactionHook = true;
|
|
}
|
|
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.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.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;
|
|
}
|
|
state.filePath = params.path || "";
|
|
state.paths = state.filePath
|
|
.split("/")
|
|
.filter((f) => f && f.trim().length > 0);
|
|
|
|
|
|
updateContent();
|
|
|
|
// #510 — if we navigated into a subdirectory whose file listing
|
|
// hasn't been fetched, lazy-load the parent directories in the
|
|
// background so getSelectedFile() can populate state.file with the
|
|
// right sha for the next interaction. Done after updateContent so
|
|
// the request fires immediately (getContent falls back to sha "0").
|
|
for (let i = 0; i < state.paths.length - 1; i++) {
|
|
const dirPath = i > 0 ? state.paths.slice(0, i).join("/") : "";
|
|
const alreadyLoaded = state.files.some((f) => f.path === dirPath);
|
|
if (!alreadyLoaded) {
|
|
state.getFiles(dirPath);
|
|
}
|
|
}
|
|
});
|
|
|
|
function selectFile() {
|
|
if (state.paths[0] != "") {
|
|
return;
|
|
}
|
|
const readmePriority = [
|
|
"readme.md",
|
|
"readme.txt",
|
|
"readme.org",
|
|
"readme.1st",
|
|
"readme",
|
|
];
|
|
const readmeCandidates = {};
|
|
for (const file of state.files) {
|
|
if (file.name.toLowerCase().indexOf("readme") > -1) {
|
|
readmeCandidates[file.name.toLowerCase()] = file.name;
|
|
}
|
|
}
|
|
let best_match = null;
|
|
for (const p of readmePriority) {
|
|
if (readmeCandidates[p]) {
|
|
best_match = p;
|
|
break;
|
|
}
|
|
}
|
|
if (!best_match && Object.keys(readmeCandidates).length > 0)
|
|
best_match = Object.keys(readmeCandidates)[0];
|
|
if (best_match) {
|
|
let uri = location.url();
|
|
if (uri[uri.length - 1] != "/") {
|
|
uri += "/";
|
|
}
|
|
|
|
// redirect to readme
|
|
location.url(
|
|
uri + encodePathForUrl(readmeCandidates[best_match])
|
|
);
|
|
}
|
|
}
|
|
state.fileCounts = null;
|
|
let listingGeneration = 0;
|
|
let countRequests = new Set();
|
|
let countsUnavailable = false;
|
|
state.getFiles = function (path, background = false) {
|
|
const repoId = state.repoId;
|
|
const generation = listingGeneration;
|
|
return http.get(
|
|
`/api/repo/${state.repoId}/files/?path=${encodeURIComponent(path)}&v=${state.options.lastUpdateDate}`
|
|
).then(function (res) {
|
|
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return [];
|
|
const normalized = path || "";
|
|
state.files = state.files.filter((f) => f.path !== normalized);
|
|
state.files.push(...res.data);
|
|
fetchFileCounts(normalized, generation);
|
|
return res.data;
|
|
}, function (err) {
|
|
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return [];
|
|
if (!background) {
|
|
state.type = "error";
|
|
state.content = viewerErrorCode(err);
|
|
state.files = [];
|
|
}
|
|
});
|
|
};
|
|
function fetchFileCounts(path, generation) {
|
|
if (countsUnavailable || countRequests.has(path)) return;
|
|
countRequests.add(path);
|
|
const repoId = state.repoId;
|
|
http.get(
|
|
`/api/repo/${state.repoId}/files/counts?path=${encodeURIComponent(path)}`
|
|
).then(function (res) {
|
|
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return;
|
|
state.fileCounts = { ...state.fileCounts, ...res.data };
|
|
}, function () {
|
|
if (destroyed || repoId !== state.repoId || generation !== listingGeneration) return;
|
|
// Counts are optional. Do not retry a guarded or rate-limited query
|
|
// every time a visitor opens another directory.
|
|
countsUnavailable = true;
|
|
});
|
|
}
|
|
|
|
function getSelectedFile() {
|
|
return state.files.filter(
|
|
(f) =>
|
|
f.name == state.paths[state.paths.length - 1] &&
|
|
f.path == state.paths.slice(0, state.paths.length - 1).join("/")
|
|
)[0];
|
|
}
|
|
|
|
var rlCountdownTimer = null;
|
|
state.on("dispose", function () { if (rlCountdownTimer) timers.interval.cancel(rlCountdownTimer); });
|
|
|
|
function getOptions(callback) {
|
|
if (destroyed) return;
|
|
const repoId = state.repoId;
|
|
http.get(`/api/repo/${state.repoId}/options`).then(
|
|
(res) => {
|
|
if (destroyed || repoId !== state.repoId) return;
|
|
state.options = res.data;
|
|
if (state.options.url) {
|
|
window.location = state.options.url;
|
|
return;
|
|
}
|
|
if (callback) {
|
|
callback(res.data);
|
|
}
|
|
},
|
|
(err) => {
|
|
if (destroyed || repoId !== state.repoId) return;
|
|
var data = err.data || {};
|
|
if (data.error === "rate_limited" && data.resetAt) {
|
|
state.type = "rate_limited";
|
|
state.rateLimitResetAt = data.resetAt;
|
|
if (rlCountdownTimer) timers.interval.cancel(rlCountdownTimer);
|
|
function rlTick() {
|
|
var remaining = Math.max(0, Math.ceil((state.rateLimitResetAt - Date.now()) / 1000));
|
|
if (remaining <= 0) {
|
|
state.rateLimitCountdown = "";
|
|
state.rateLimitResetAt = 0;
|
|
if (rlCountdownTimer) { timers.interval.cancel(rlCountdownTimer); rlCountdownTimer = null; }
|
|
getOptions(callback);
|
|
} else {
|
|
var min = Math.floor(remaining / 60);
|
|
var sec = remaining % 60;
|
|
state.rateLimitCountdown = min > 0 ? min + "m " + sec + "s" : sec + "s";
|
|
}
|
|
|
|
}
|
|
rlTick();
|
|
rlCountdownTimer = timers.interval(rlTick, 1000);
|
|
} else if (data.error === "repository_not_ready") {
|
|
state.type = "loading";
|
|
timers.timeout(function () { getOptions(callback); }, 3000);
|
|
} else {
|
|
state.type = "error";
|
|
state.content = viewerErrorCode(err);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Defined as methods rather than inline `showSource = !showSource` in the
|
|
// template: the toolbar and the file view sit in different child scopes,
|
|
// so an inline assignment would shadow the value on one of them instead
|
|
// of updating the controller's.
|
|
state.toggleSource = function () {
|
|
state.showSource = !state.showSource;
|
|
};
|
|
|
|
state.toggleAllowScripts = function () {
|
|
state.allowScripts = !state.allowScripts;
|
|
};
|
|
|
|
// Wrap on wide screens; on phones code scrolls sideways so tokens stay intact.
|
|
const storedWrap = (() => { try { return window.localStorage?.getItem("wrapLines"); } catch { return null; } })();
|
|
state.wrapLines = (storedWrap ?? (window.matchMedia?.("(max-width: 767px)").matches ? "false" : "true")) !== "false";
|
|
state.toggleWrap = function () {
|
|
state.wrapLines = !state.wrapLines;
|
|
try { window.localStorage?.setItem("wrapLines", String(state.wrapLines)); } catch { /* storage unavailable */ }
|
|
state.codeEditor?.session.setUseWrapMode(state.wrapLines);
|
|
};
|
|
// Find opens as an overlay at the top right of the code, like an editor.
|
|
// Find on rendered Markdown pages: highlight matches inside the reading column.
|
|
function mdFindRoot() { return window.document.querySelector(".reading-layout .markdown-body"); }
|
|
function clearMdFind() {
|
|
const root = mdFindRoot();
|
|
if (!root) return;
|
|
root.querySelectorAll("mark.md-find").forEach((m) => { m.replaceWith(window.document.createTextNode(m.textContent)); });
|
|
root.normalize();
|
|
}
|
|
state.runMdFind = function () {
|
|
clearMdFind();
|
|
state.mdFindTotal = 0;
|
|
state.mdFindIndex = 0;
|
|
const q = (state.mdFindQuery || "").trim();
|
|
const root = mdFindRoot();
|
|
if (!q || !root) return;
|
|
const cs = !!state.mdFindCase;
|
|
const needle = cs ? q : q.toLowerCase();
|
|
const walker = window.document.createTreeWalker(root, 4);
|
|
const nodes = [];
|
|
while (walker.nextNode()) if ((cs ? walker.currentNode.nodeValue : walker.currentNode.nodeValue.toLowerCase()).includes(needle)) nodes.push(walker.currentNode);
|
|
let total = 0;
|
|
for (const node of nodes) {
|
|
const text = node.nodeValue, lower = cs ? text : text.toLowerCase();
|
|
const frag = window.document.createDocumentFragment();
|
|
let last = 0, i;
|
|
while ((i = lower.indexOf(needle, last)) !== -1) {
|
|
frag.appendChild(window.document.createTextNode(text.slice(last, i)));
|
|
const m = window.document.createElement("mark");
|
|
m.className = "md-find";
|
|
m.textContent = text.slice(i, i + q.length);
|
|
frag.appendChild(m);
|
|
total++;
|
|
last = i + q.length;
|
|
}
|
|
frag.appendChild(window.document.createTextNode(text.slice(last)));
|
|
node.parentNode.replaceChild(frag, node);
|
|
}
|
|
state.mdFindTotal = total;
|
|
if (total) state.stepMdFind(0);
|
|
};
|
|
state.stepMdFind = function (delta) {
|
|
const root = mdFindRoot();
|
|
if (!root || !state.mdFindTotal) return;
|
|
const marks = root.querySelectorAll("mark.md-find");
|
|
state.mdFindIndex = (state.mdFindIndex + delta + marks.length) % marks.length;
|
|
marks.forEach((m, i) => m.classList.toggle("is-current", i === state.mdFindIndex));
|
|
marks[state.mdFindIndex]?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
};
|
|
state.toggleMdFind = function (open) {
|
|
state.mdFindOpen = open === undefined ? !state.mdFindOpen : open;
|
|
if (!state.mdFindOpen) { clearMdFind(); state.mdFindTotal = 0; return; }
|
|
nextTick(() => window.document.querySelector(".md-find-input")?.focus());
|
|
};
|
|
state.findInFile = function () {
|
|
const editor = state.codeEditor;
|
|
if (!editor) return;
|
|
editor.focus();
|
|
editor.execCommand("find");
|
|
// Docked under the toolbar: push the code down while the find bar is open.
|
|
const dock = () => {
|
|
const box = editor.searchBox?.element;
|
|
if (!box) return;
|
|
editor.renderer.setScrollMargin(52, 12, 0, 0);
|
|
if (box._dockObserver) return;
|
|
box._dockObserver = new MutationObserver(() => {
|
|
editor.renderer.setScrollMargin(box.style.display !== "none" ? 52 : 12, 12, 0, 0);
|
|
});
|
|
box._dockObserver.observe(box, { attributes: true, attributeFilter: ["style"] });
|
|
};
|
|
dock();
|
|
setTimeout(dock, 150);
|
|
};
|
|
// Lightweight outline: top-level classes, functions and exported constants.
|
|
const symbolPatterns = [
|
|
[/^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, "class"],
|
|
[/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/, "function"],
|
|
[/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/, "function"],
|
|
[/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/, "function"],
|
|
[/^\s*class\s+([A-Za-z_]\w*)/, "class"],
|
|
[/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/, "function"],
|
|
[/^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)/, "function"],
|
|
[/^\s*(?:public|private|protected|static|\s)+[\w<>\[\],\s]+\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{?\s*$/, "function"],
|
|
];
|
|
let symbolCache = { content: null, list: [] };
|
|
state.symbolsOpen = false;
|
|
state.symbols = function () {
|
|
if (typeof state.content !== "string" || state.content.length > 2_000_000) return [];
|
|
if (symbolCache.content === state.content) return symbolCache.list;
|
|
const list = [];
|
|
state.content.split("\n").forEach((text, index) => {
|
|
for (const [re, kind] of symbolPatterns) {
|
|
const m = re.exec(text);
|
|
if (m && !/^(if|for|while|switch|return|catch)$/.test(m[1])) { list.push({ name: m[1], kind, line: index + 1 }); break; }
|
|
}
|
|
});
|
|
symbolCache = { content: state.content, list: list.slice(0, 300) };
|
|
return symbolCache.list;
|
|
};
|
|
state.goToSymbol = function (line) {
|
|
state.symbolsOpen = false;
|
|
const editor = state.codeEditor;
|
|
if (!editor) return;
|
|
editor.scrollToLine(line - 1, true, true, () => {});
|
|
editor.gotoLine(line, 0, true);
|
|
window.history.replaceState(null, "", window.location.pathname + window.location.search + "#L" + line);
|
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
|
};
|
|
|
|
state.gotoLine = function () {
|
|
const editor = state.codeEditor;
|
|
if (!editor) return;
|
|
editor.focus();
|
|
editor.execCommand("gotoline");
|
|
};
|
|
// Root listing above the README: folders first, then files.
|
|
state.rootEntries = function () {
|
|
return [...(state.files || [])].sort((a, b) => {
|
|
const af = a.size == null, bf = b.size == null;
|
|
if (af !== bf) return af ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
};
|
|
state.formatSize = function (bytes) {
|
|
if (bytes == null) return "";
|
|
if (bytes < 1024) return bytes + " B";
|
|
const kb = bytes / 1024;
|
|
return kb < 1024 ? kb.toFixed(1) + " KB" : (kb / 1024).toFixed(1) + " MB";
|
|
};
|
|
state.fileSize = function (path) {
|
|
if (!path) return null;
|
|
const clean = path.replace(/^\/+/, "");
|
|
const entry = (state.files || []).find((f) => (f.path ? f.path + "/" : "") + f.name === clean);
|
|
return entry && entry.size != null ? entry.size : null;
|
|
};
|
|
state.revealFolder = function (name) {
|
|
state.sidebarHidden = false;
|
|
state.sidebarCollapsed = false;
|
|
nextTick(() => {
|
|
const link = window.document.querySelector('.files a[data-path="/' + (window.CSS?.escape ? window.CSS.escape(name) : name) + '"]');
|
|
if (!link) return;
|
|
if (!link.closest("li")?.classList.contains("open")) link.click();
|
|
link.scrollIntoView({ block: "nearest" });
|
|
link.focus();
|
|
});
|
|
};
|
|
// Redactions are the product's signature: show every XXXX-n mask as a chip.
|
|
const REDACTION_RE = /\bXXXX-\d+\b/g;
|
|
state.redactionCount = function () {
|
|
if (typeof state.content !== "string") return 0;
|
|
const text = state.type === "html" ? state.content.replace(/<[^>]*>/g, " ") : state.content;
|
|
return (text.match(REDACTION_RE) || []).length;
|
|
};
|
|
state.renderRedacted = 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()) 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;
|
|
};
|
|
// Reading mode: slugged heading ids + an "On this page" outline (like Mintlify/Stripe docs).
|
|
const slug = (t) => t.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").slice(0, 60) || "section";
|
|
state.renderReadme = function (html) {
|
|
const out = state.renderRedacted(html);
|
|
if (!out || typeof window.DOMParser !== "function") return out;
|
|
const doc = new window.DOMParser().parseFromString("<div>" + out + "</div>", "text/html");
|
|
const seen = {};
|
|
doc.querySelectorAll("h1, h2, h3").forEach((h) => {
|
|
let id = h.id || slug(h.textContent);
|
|
if (seen[id]) id += "-" + (++seen[id]); else seen[id] = 1;
|
|
h.id = id;
|
|
});
|
|
return doc.body.firstChild.innerHTML;
|
|
};
|
|
let tocCache = { content: null, list: [] };
|
|
state.readmeToc = function () {
|
|
if (state.type !== "html" || typeof state.content !== "string") return [];
|
|
if (tocCache.content === state.content) return tocCache.list;
|
|
const doc = new window.DOMParser().parseFromString("<div>" + state.renderReadme(state.sanitize ? state.sanitize(state.content) : state.content) + "</div>", "text/html");
|
|
const list = [...doc.querySelectorAll("h2, h3")].map((h) => ({ id: h.id, text: h.textContent.trim(), level: h.tagName === "H3" ? 3 : 2 })).slice(0, 40);
|
|
tocCache = { content: state.content, list };
|
|
return list;
|
|
};
|
|
state.activeHeading = null;
|
|
listen(window, "scroll", () => {
|
|
const toc = state.readmeToc ? state.readmeToc() : [];
|
|
if (!toc.length) return;
|
|
// Active = the first heading visible below the sticky chrome; if none is on screen,
|
|
// the last heading already scrolled past (the reader is inside that section).
|
|
const chrome = 150;
|
|
let current = "", firstVisible = "";
|
|
for (const h of toc) {
|
|
const el = window.document.getElementById(h.id);
|
|
if (!el) continue;
|
|
const top = el.getBoundingClientRect().top;
|
|
if (top < chrome) current = h.id;
|
|
else if (!firstVisible && top < window.innerHeight - 80) firstVisible = h.id;
|
|
}
|
|
if (firstVisible) current = firstVisible;
|
|
if (state.activeHeading !== current) state.activeHeading = current;
|
|
}, { capture: true, passive: true });
|
|
state.scrollToHeading = function (id) {
|
|
if (!id) {
|
|
const sc = window.document.querySelector(".explorer-content, .file-card-body, .main-pane");
|
|
(sc || window).scrollTo({ top: 0, behavior: "smooth" });
|
|
state.activeHeading = "";
|
|
return;
|
|
}
|
|
const el = window.document.getElementById(id);
|
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
state.activeHeading = id;
|
|
};
|
|
state.redactParts = function (text) {
|
|
const out = []; let last = 0; let m; const re = /\bXXXX-(\d+)\b/g;
|
|
text = String(text || "");
|
|
while ((m = re.exec(text))) { if (m.index > last) out.push({ t: text.slice(last, m.index) }); out.push({ n: m[1] }); last = m.index + m[0].length; }
|
|
if (last < text.length) out.push({ t: text.slice(last) });
|
|
return out;
|
|
};
|
|
state.pathCopied = false;
|
|
state.copyPath = async function () {
|
|
if (!window.navigator.clipboard?.writeText) return;
|
|
await window.navigator.clipboard.writeText(state.filePath || "");
|
|
state.pathCopied = true;
|
|
setTimeout(() => { state.pathCopied = false; }, 1600);
|
|
};
|
|
state.contentCopied = false;
|
|
state.copyContent = async function () {
|
|
if (typeof state.content !== "string" || !window.navigator.clipboard?.writeText) return;
|
|
// Rendered Markdown: copy the source, not the HTML.
|
|
let text = state.content;
|
|
if (state.type === "html" && state.url) {
|
|
try { text = await (await window.fetch(state.url, { credentials: "same-origin" })).text(); } catch (_) { return; }
|
|
}
|
|
await window.navigator.clipboard.writeText(text);
|
|
state.contentCopied = true;
|
|
setTimeout(() => { state.contentCopied = false; }, 1600);
|
|
};
|
|
|
|
function getMode(extension) {
|
|
if (extensionModes[extension]) {
|
|
return extensionModes[extension];
|
|
}
|
|
return extension;
|
|
}
|
|
|
|
function getType(extension) {
|
|
if (extension == "pdf") {
|
|
return "pdf";
|
|
}
|
|
// Rendered as a document in a sandboxed frame rather than as source —
|
|
// see html-doc.js. "html" is reserved for markup we generated
|
|
// ourselves (rendered markdown/org) and inject directly.
|
|
if (extension == "html" || extension == "htm") {
|
|
return "html-doc";
|
|
}
|
|
if (extension == "md") {
|
|
return "md";
|
|
}
|
|
if (extension == "org") {
|
|
return "org";
|
|
}
|
|
if (extension == "ipynb") {
|
|
return "IPython";
|
|
}
|
|
if (textFiles.indexOf(extension) > -1) {
|
|
return "text";
|
|
}
|
|
if (imageFiles.indexOf(extension) > -1) {
|
|
return "image";
|
|
}
|
|
if (mediaFiles.indexOf(extension) > -1) {
|
|
return "media";
|
|
}
|
|
if (audioFiles.indexOf(extension) > -1) {
|
|
return "audio";
|
|
}
|
|
return "code";
|
|
}
|
|
|
|
function getContent(path, fileInfo) {
|
|
const generation = contentGeneration;
|
|
if (!path) {
|
|
state.type = "error";
|
|
state.content = "no_file_selected";
|
|
return;
|
|
}
|
|
const originalType = state.type;
|
|
// Load the editor alongside content, not after the response finishes.
|
|
// Its mounting path still reports errors and can retry a failed load.
|
|
if (originalType === "code" || originalType === "text") loadEditor().catch(() => {});
|
|
state.type = "loading";
|
|
state.content = "loading";
|
|
state.fileLoadingSlow = false;
|
|
state.fileReceivedBytes = 0;
|
|
const canceller = promises.defer();
|
|
contentCanceller = canceller;
|
|
loadingNoticeTimer = timers.timeout(() => {
|
|
if (!destroyed && generation === contentGeneration && state.type === "loading") state.fileLoadingSlow = true;
|
|
}, 1500);
|
|
// fileInfo can be undefined when the user navigates (e.g. clicks a
|
|
// markdown link into a subdir whose file list hasn't loaded yet) —
|
|
// see #510. Fall back to "0" so the request still goes through; the
|
|
// server returns a fresh ETag on first hit either way.
|
|
const sha = (fileInfo && fileInfo.sha) || "0";
|
|
http
|
|
.get(
|
|
`/api/repo/${state.repoId}/file/${encodePathForUrl(path)}?v=` +
|
|
sha,
|
|
{
|
|
timeout: canceller.promise,
|
|
onDownloadProgress: bytes => {
|
|
if (!destroyed && generation === contentGeneration) state.fileReceivedBytes = bytes;
|
|
},
|
|
transformResponse: (data) => {
|
|
return data;
|
|
},
|
|
}
|
|
)
|
|
.then(
|
|
(res) => {
|
|
if (destroyed || generation !== contentGeneration) return;
|
|
cancelContentLoad();
|
|
state.type = originalType;
|
|
state.content = res.data;
|
|
if (state.content == "") {
|
|
state.content = null;
|
|
}
|
|
|
|
if (state.type == "md") {
|
|
state.content = renderMD(res.data, location.url() + "/../");
|
|
state.type = "html";
|
|
}
|
|
if (state.type == "org") {
|
|
const content = contentAbs2Relative(res.data);
|
|
|
|
const orgParser = new Org.Parser();
|
|
const orgDocument = orgParser.parse(content);
|
|
var orgHTMLDocument = orgDocument.convert(Org.ConverterHTML, {
|
|
headerOffset: 1,
|
|
exportFromLineNumber: false,
|
|
suppressSubScriptHandling: true,
|
|
suppressAutoLink: false,
|
|
});
|
|
state.content = DOMPurify.sanitize(orgHTMLDocument.toString());
|
|
state.type = "html";
|
|
}
|
|
if (
|
|
state.type == "code" &&
|
|
res.headers("content-type") == "application/octet-stream"
|
|
) {
|
|
state.type = "binary";
|
|
state.content = "binary";
|
|
}
|
|
timers.timeout(() => {
|
|
Prism.highlightAll();
|
|
}, 50);
|
|
},
|
|
(err) => {
|
|
if (destroyed || generation !== contentGeneration) return;
|
|
cancelContentLoad();
|
|
state.type = "error";
|
|
state.content = viewerErrorCode(err);
|
|
}
|
|
);
|
|
}
|
|
|
|
function updateContent() {
|
|
contentGeneration++;
|
|
cancelContentLoad();
|
|
state.content = "";
|
|
state.file = getSelectedFile();
|
|
let fileVersion = "0";
|
|
if (state.file && state.file.sha) {
|
|
fileVersion = state.file.sha;
|
|
}
|
|
state.url = `/api/repo/${state.repoId}/file/${encodePathForUrl(
|
|
state.filePath
|
|
)}?v=${fileVersion}`;
|
|
// Directory the file lives in, used as the <base> for a rendered HTML
|
|
// document so its relative images/stylesheets still resolve.
|
|
const dirPath = state.filePath.substring(
|
|
0,
|
|
state.filePath.lastIndexOf("/") + 1
|
|
);
|
|
state.fileBaseUrl = `/api/repo/${state.repoId}/file/${
|
|
dirPath ? encodePathForUrl(dirPath) : ""
|
|
}`;
|
|
state.showSource = false;
|
|
// Scripts in a repository's HTML are opt-in, per file — see
|
|
// html-doc.js. Reset on navigation so trust never carries over from
|
|
// one file to the next.
|
|
state.allowScripts = false;
|
|
|
|
let extension = state.filePath.toLowerCase();
|
|
const extensionIndex = extension.lastIndexOf(".");
|
|
if (extensionIndex > -1) {
|
|
extension = extension.substring(extensionIndex + 1);
|
|
}
|
|
|
|
const languageNames = { javascript: "JavaScript", typescript: "TypeScript", tsx: "TSX", jsx: "JSX", python: "Python", java: "Java", c_cpp: "C/C++", csharp: "C#", golang: "Go", rust: "Rust", ruby: "Ruby", php: "PHP", html: "HTML", css: "CSS", scss: "SCSS", json: "JSON", yaml: "YAML", markdown: "Markdown", sh: "Shell", dockerfile: "Dockerfile", xml: "XML", sql: "SQL", kotlin: "Kotlin", swift: "Swift", scala: "Scala", r: "R", julia: "Julia", latex: "LaTeX", toml: "TOML", text: "" };
|
|
const detectedMode = getMode(extension);
|
|
state.languageName = languageNames[detectedMode] ?? (detectedMode ? detectedMode.charAt(0).toUpperCase() + detectedMode.slice(1) : "");
|
|
state.aceOption = {
|
|
readOnly: true,
|
|
useWrapMode: true,
|
|
showGutter: true,
|
|
theme: "chrome",
|
|
useSoftTab: true,
|
|
tabSize: 2,
|
|
fontSize: 13,
|
|
keyBinding: "vscode",
|
|
fullLineSelection: true,
|
|
highlightActiveLine: false,
|
|
highlightGutterLine: false,
|
|
cursor: "hide",
|
|
showInvisibles: false,
|
|
showIndentGuides: false,
|
|
showPrintMargin: false,
|
|
highlightSelectedWord: false,
|
|
enableBehaviours: true,
|
|
fadeFoldWidgets: false,
|
|
mode: getMode(extension),
|
|
|
|
onLoad: function (_editor) {
|
|
state.codeEditor = markRaw(_editor);
|
|
{
|
|
// Render each XXXX-n as its own "redacted" token so the text layer
|
|
// draws it exactly like the Markdown chip (teal text on a teal tint).
|
|
const session = _editor.session;
|
|
const origGetTokens = session.getTokens.bind(session);
|
|
const re = /\bXXXX-\d+\b/g;
|
|
session.getTokens = function (row) {
|
|
const tokens = origGetTokens(row);
|
|
if (!tokens.some((t) => t.value.indexOf("XXXX-") !== -1)) return tokens;
|
|
const out = [];
|
|
for (const t of tokens) {
|
|
if (t.value.indexOf("XXXX-") === -1) { out.push(t); continue; }
|
|
let last = 0;
|
|
re.lastIndex = 0;
|
|
let m;
|
|
while ((m = re.exec(t.value))) {
|
|
if (m.index > last) out.push({ type: t.type, value: t.value.slice(last, m.index) });
|
|
out.push({ type: "redacted.n" + m[0].slice(5), value: m[0] });
|
|
last = m.index + m[0].length;
|
|
}
|
|
if (last < t.value.length) out.push({ type: t.type, value: t.value.slice(last) });
|
|
}
|
|
return out;
|
|
};
|
|
// Each redacted token shows its term number inside the slot.
|
|
if (!window.document.getElementById("redact-index-rules")) {
|
|
const st = window.document.createElement("style");
|
|
st.id = "redact-index-rules";
|
|
st.textContent = Array.from({ length: 99 }, (_, i) => `.ace_redacted.ace_n${i + 1}::after{content:"${i + 1}"}`).join("");
|
|
window.document.head.appendChild(st);
|
|
}
|
|
_editor.renderer.updateFull(true);
|
|
}
|
|
_editor.session.setUseWrapMode(state.wrapLines !== false);
|
|
// The right-edge fade is a cue only while there is more code to the right.
|
|
const card = _editor.container.closest?.(".file-card");
|
|
const syncEdge = () => {
|
|
if (!card) return;
|
|
const r = _editor.renderer;
|
|
const max = (r.layerConfig?.width || 0) - (r.$size?.scrollerWidth || 0);
|
|
card.classList.toggle("at-right-edge", max <= 2 || r.getScrollLeft() >= max - 2);
|
|
};
|
|
_editor.session.on("changeScrollLeft", syncEdge);
|
|
_editor.renderer.on("afterRender", syncEdge);
|
|
// On phones code scrolls sideways; keep the scrollbar visible as a cue.
|
|
if (window.matchMedia?.("(max-width: 767px)").matches) _editor.setOption("hScrollBarAlwaysVisible", true);
|
|
const Range = ace.require("ace/range").Range;
|
|
let activeLineMarker = null;
|
|
|
|
function highlightLines(from, to) {
|
|
if (activeLineMarker !== null) {
|
|
_editor.session.removeMarker(activeLineMarker);
|
|
activeLineMarker = null;
|
|
}
|
|
if (from === null || from === undefined) return;
|
|
activeLineMarker = _editor.session.addMarker(
|
|
new Range(from, 0, to, 1),
|
|
"highlighted-line",
|
|
"fullLine"
|
|
);
|
|
}
|
|
|
|
function applyHashFromUrl(scroll) {
|
|
const m = window.location.hash.match(/^#L(\d+)(?:-L(\d+))?/);
|
|
if (!m) {
|
|
highlightLines(null);
|
|
return;
|
|
}
|
|
const from = parseInt(m[1]) - 1;
|
|
const to = m[2] ? parseInt(m[2]) - 1 : from;
|
|
highlightLines(from, to);
|
|
if (scroll) {
|
|
timers.timeout(() => {
|
|
_editor.scrollToLine(from, true, true, function () {});
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
applyHashFromUrl(true);
|
|
|
|
// #392 — clicking a gutter line updates the URL to #L<n> and
|
|
// shift-clicking extends to #L<from>-L<to> so the user can copy
|
|
// a stable link to a specific line. Use replaceState to avoid
|
|
// polluting history with every click.
|
|
let anchorRow = null;
|
|
_editor.on("guttermousedown", function (e) {
|
|
const row = e.getDocumentPosition().row;
|
|
const shift = e.domEvent && e.domEvent.shiftKey;
|
|
let from = row;
|
|
let to = row;
|
|
if (shift && anchorRow !== null) {
|
|
from = Math.min(anchorRow, row);
|
|
to = Math.max(anchorRow, row);
|
|
} else {
|
|
anchorRow = row;
|
|
}
|
|
const hash =
|
|
from === to
|
|
? `#L${from + 1}`
|
|
: `#L${from + 1}-L${to + 1}`;
|
|
const url =
|
|
window.location.pathname + window.location.search + hash;
|
|
window.history.replaceState(null, "", url);
|
|
highlightLines(from, to);
|
|
e.stop();
|
|
});
|
|
|
|
const removeHashListener = listen(window, "hashchange", () => applyHashFromUrl(false));
|
|
|
|
_editor.setFontSize(state.aceOption.fontSize);
|
|
_editor.setOptions({
|
|
fontFamily: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
});
|
|
_editor.container.style.lineHeight = "20px";
|
|
_editor.renderer.setPadding(16);
|
|
_editor.renderer.setScrollMargin(12, 12, 0, 0);
|
|
_editor.renderer.updateFontSize();
|
|
_editor.setReadOnly(state.aceOption.readOnly);
|
|
_editor.setKeyboardHandler(state.aceOption.keyBinding);
|
|
_editor.setSelectionStyle(
|
|
state.aceOption.fullLineSelection ? "line" : "text"
|
|
);
|
|
_editor.setOption("displayIndentGuides", false);
|
|
_editor.setHighlightActiveLine(
|
|
state.aceOption.highlightActiveLine
|
|
);
|
|
if (state.aceOption.cursor == "hide") {
|
|
_editor.renderer.$cursorLayer.element.style.display = "none";
|
|
}
|
|
_editor.setHighlightGutterLine(
|
|
state.aceOption.highlightGutterLine
|
|
);
|
|
_editor.setShowInvisibles(state.aceOption.showInvisibles);
|
|
_editor.setDisplayIndentGuides(state.aceOption.showIndentGuides);
|
|
|
|
_editor.renderer.setShowPrintMargin(
|
|
state.aceOption.showPrintMargin
|
|
);
|
|
_editor.setHighlightSelectedWord(
|
|
state.aceOption.highlightSelectedWord
|
|
);
|
|
_editor.session.setUseSoftTabs(state.aceOption.useSoftTab);
|
|
_editor.session.setTabSize(state.aceOption.tabSize);
|
|
_editor.setBehavioursEnabled(state.aceOption.enableBehaviours);
|
|
_editor.setFadeFoldWidgets(state.aceOption.fadeFoldWidgets);
|
|
// Ctrl/Cmd+F searches inside the file (Ace searchbox), like GitHub's blob view.
|
|
const removeFindListener = listen(window, "keydown", (event) => {
|
|
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "g" && document.body.contains(_editor.container)) {
|
|
event.preventDefault();
|
|
state.gotoLine();
|
|
return;
|
|
}
|
|
if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "z" && document.body.contains(_editor.container)) {
|
|
event.preventDefault();
|
|
state.toggleWrap();
|
|
return;
|
|
}
|
|
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "f" && document.body.contains(_editor.container)) {
|
|
event.preventDefault();
|
|
state.findInFile();
|
|
}
|
|
});
|
|
return () => { removeHashListener(); removeFindListener(); };
|
|
},
|
|
};
|
|
if (state.isDarkMode) {
|
|
state.aceOption.theme = "nord_dark";
|
|
}
|
|
state.type = getType(extension);
|
|
|
|
if (state.type == "pdf") {
|
|
// The viewer streams the file itself from state.url, so fetching
|
|
// the bytes again here only to hold them as a JS string wastes a
|
|
// request and a lot of memory on a large report. Content stays
|
|
// non-null so the Raw/Download actions remain available.
|
|
state.content = "pdf";
|
|
return;
|
|
}
|
|
|
|
getContent(state.filePath, state.file);
|
|
}
|
|
|
|
state.isSourceAccessError = () => ["repo_not_found", "token_expired", "github_app_access_required",
|
|
"github_app_reconnect_required", "repo_saml_enforcement", "repo_access_limited"].includes(state.content);
|
|
state.retryContent = () => init();
|
|
|
|
function init() {
|
|
contentGeneration++;
|
|
cancelContentLoad();
|
|
state.files = [];
|
|
state.content = null;
|
|
state.fileCounts = null;
|
|
listingGeneration++;
|
|
countRequests = new Set();
|
|
countsUnavailable = false;
|
|
state.fileSearchQuery = "";
|
|
state.onFileSearchChange();
|
|
state.repoId = params.repoId;
|
|
state.type = "loading";
|
|
state.filePath = params.path || "";
|
|
state.paths = state.filePath.split("/");
|
|
|
|
const repoId = state.repoId;
|
|
getOptions(function (options) {
|
|
if (state.filePath) {
|
|
// File URLs are independently authorized by the content endpoint.
|
|
// The tree is navigation metadata, not a prerequisite for reading.
|
|
updateContent();
|
|
const directories = [...new Set(state.paths.map((_, i) => state.paths.slice(0, i).join("/")))];
|
|
promises.all(directories.map(path => state.getFiles(path, true)));
|
|
return;
|
|
}
|
|
var chain = promises.resolve();
|
|
for (let i = 0; i < state.paths.length; i++) {
|
|
const path = i > 0 ? state.paths.slice(0, i).join("/") : "";
|
|
chain = chain.then(function () {
|
|
return state.getFiles(path);
|
|
}).then(function () {
|
|
if (state.type === "error") {
|
|
return promises.reject("error");
|
|
}
|
|
});
|
|
}
|
|
chain.then(function () {
|
|
if (destroyed || repoId !== state.repoId) return;
|
|
if (state.files.length == 1 && state.files[0].name == "") {
|
|
state.files = [];
|
|
state.type = "empty";
|
|
} else {
|
|
selectFile();
|
|
updateContent();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
init();
|
|
};
|
|
|
|
export const pullRequestController = function (state, http, location, params, html) {
|
|
async function getOption(callback) {
|
|
http.get(`/api/pr/${state.pullRequestId}/options`).then(
|
|
(res) => {
|
|
state.options = res.data;
|
|
if (state.options.url) {
|
|
// the repository is expired with redirect option
|
|
window.location = state.options.url;
|
|
return;
|
|
}
|
|
if (callback) {
|
|
callback(res.data);
|
|
}
|
|
},
|
|
(err) => {
|
|
state.type = "error";
|
|
state.content = err.data.error;
|
|
}
|
|
);
|
|
}
|
|
async function getPullRequest(callback) {
|
|
http.get(`/api/pr/${state.pullRequestId}/content`).then(
|
|
(res) => {
|
|
state.details = res.data;
|
|
state.tabState = { active: res.data.diff ? "diff" : "comments" };
|
|
if (callback) {
|
|
callback(res.data);
|
|
}
|
|
},
|
|
(err) => {
|
|
state.type = "error";
|
|
state.content = err.data.error;
|
|
}
|
|
);
|
|
}
|
|
|
|
function init() {
|
|
state.pullRequestId = params.pullRequestId;
|
|
state.type = "loading";
|
|
|
|
getOption((_) => {
|
|
getPullRequest();
|
|
});
|
|
}
|
|
|
|
init();
|
|
};
|
|
|
|
export const gistController = function (state, http, location, params, html) {
|
|
async function getOption(callback) {
|
|
http.get(`/api/gist/${state.gistId}/options`).then(
|
|
(res) => {
|
|
state.options = res.data;
|
|
if (state.options.url) {
|
|
window.location = state.options.url;
|
|
return;
|
|
}
|
|
if (callback) callback(res.data);
|
|
},
|
|
(err) => {
|
|
state.type = "error";
|
|
state.content = err.data.error;
|
|
}
|
|
);
|
|
}
|
|
async function getGist(callback) {
|
|
http.get(`/api/gist/${state.gistId}/content`).then(
|
|
(res) => {
|
|
state.details = res.data;
|
|
// Choose the visible tab after the asynchronous content arrives.
|
|
const hasFiles = res.data && res.data.files && res.data.files.length;
|
|
state.tabState = { active: hasFiles ? "files" : "comments" };
|
|
if (callback) callback(res.data);
|
|
},
|
|
(err) => {
|
|
state.type = "error";
|
|
state.content = err.data.error;
|
|
}
|
|
);
|
|
}
|
|
|
|
function init() {
|
|
state.gistId = params.gistId;
|
|
state.type = "loading";
|
|
getOption(() => { getGist(); });
|
|
}
|
|
|
|
init();
|
|
};
|
|
|
|
export const conferencesController = function (state, http, location) {
|
|
state.watch("user.status", () => {
|
|
if (state.user == null) {
|
|
location.url("/");
|
|
}
|
|
});
|
|
if (state.user == null) {
|
|
location.url("/");
|
|
}
|
|
|
|
state.conferences = [];
|
|
state.loading = true;
|
|
state.error = "";
|
|
state.search = "";
|
|
|
|
const conferencesPrefsKey = "conferences.filterPrefs";
|
|
const conferencesPrefDefaults = {
|
|
filters: { status: { ready: true, expired: false, removed: false } },
|
|
orderBy: "name",
|
|
};
|
|
const savedConferencesPrefs = loadFilterPrefs(conferencesPrefsKey) || {};
|
|
state.filters = {
|
|
status: Object.assign(
|
|
{},
|
|
conferencesPrefDefaults.filters.status,
|
|
(savedConferencesPrefs.filters && savedConferencesPrefs.filters.status) || {}
|
|
),
|
|
};
|
|
state.orderBy = savedConferencesPrefs.orderBy || conferencesPrefDefaults.orderBy;
|
|
|
|
state.watch("orderBy", () => {
|
|
saveFilterPrefs(conferencesPrefsKey, {
|
|
filters: state.filters,
|
|
orderBy: state.orderBy,
|
|
});
|
|
});
|
|
state.watch(
|
|
"filters",
|
|
() => {
|
|
saveFilterPrefs(conferencesPrefsKey, {
|
|
filters: state.filters,
|
|
orderBy: state.orderBy,
|
|
});
|
|
},
|
|
true
|
|
);
|
|
|
|
state.removeConference = function (conf) {
|
|
if (conf.removing) return;
|
|
if (
|
|
confirm(
|
|
`Are you sure that you want to remove the conference ${conf.name}? All the repositories linked to this conference will expire.`
|
|
)
|
|
) {
|
|
conf.removing = true;
|
|
const toast = reactive({
|
|
title: `Removing ${conf.name}...`,
|
|
date: new Date(),
|
|
body: `The conference ${conf.name} is going to be removed.`,
|
|
});
|
|
state.addToast(toast);
|
|
http.delete(`/api/conferences/${conf.conferenceID}`).then(() => {
|
|
toast.title = `${conf.name} is removed.`;
|
|
toast.body = `The conference ${conf.name} is removed.`;
|
|
state.loadConferences();
|
|
}).catch(() => {
|
|
toast.title = "Unable to remove conference";
|
|
toast.body = "Please try again. The conference has not been removed.";
|
|
}).finally(() => { conf.removing = false; });
|
|
}
|
|
};
|
|
|
|
state.loadConferences = () => {
|
|
state.loading = true;
|
|
state.error = "";
|
|
return http.get("/api/conferences/").then(
|
|
(res) => {
|
|
state.conferences = res.data || [];
|
|
},
|
|
(err) => {
|
|
state.error = "Unable to load your conferences. Please try again.";
|
|
}
|
|
).finally(() => { state.loading = false; });
|
|
};
|
|
state.loadConferences();
|
|
|
|
state.conferenceFilter = (conference) => {
|
|
if (state.filters.status[conference.status] == false) return false;
|
|
|
|
const query = state.search.trim().toLowerCase();
|
|
if (!query) return true;
|
|
|
|
if ((conference.name || "").toLowerCase().includes(query)) return true;
|
|
if ((conference.conferenceID || "").toLowerCase().includes(query)) return true;
|
|
|
|
return false;
|
|
};
|
|
};
|
|
|
|
export const newConferenceController = function (state, http, location, params) {
|
|
state.watch("user.status", () => {
|
|
if (state.user == null) {
|
|
location.url("/");
|
|
}
|
|
});
|
|
if (state.user == null) {
|
|
location.url("/");
|
|
}
|
|
|
|
state.plans = [];
|
|
state.editionMode = false;
|
|
state.saving = false;
|
|
state.error = "";
|
|
|
|
function getConference() {
|
|
return http
|
|
.get("/api/conferences/" + params.conferenceId)
|
|
.then((res) => {
|
|
state.options = { ...state.options, ...res.data, billing: res.data.billing || {} };
|
|
state.options.startDate = new Date(state.options.startDate);
|
|
state.options.endDate = new Date(state.options.endDate);
|
|
});
|
|
}
|
|
if (params.conferenceId) {
|
|
state.editionMode = true;
|
|
getConference();
|
|
}
|
|
|
|
function getPlans() {
|
|
http.get("/api/conferences/plans").then((res) => {
|
|
state.plans = res.data;
|
|
|
|
state.plan = state.plans.filter(
|
|
(f) => f.id == state.options.plan.planID
|
|
)[0];
|
|
});
|
|
}
|
|
getPlans();
|
|
const start = new Date();
|
|
start.setDate(1);
|
|
start.setMonth(start.getMonth() + 1);
|
|
const end = new Date(start);
|
|
end.setTime(start.getTime() + 179 * 86400000);
|
|
state.options = {
|
|
billing: {},
|
|
startDate: start,
|
|
endDate: end,
|
|
plan: {
|
|
planID: "free_conference",
|
|
},
|
|
options: {
|
|
link: true,
|
|
image: true,
|
|
pdf: true,
|
|
notebook: true,
|
|
update: true,
|
|
page: true,
|
|
},
|
|
};
|
|
state.plan = null;
|
|
|
|
state.watch("options.plan.planID", () => {
|
|
state.plan = state.plans.filter(
|
|
(f) => f.id == state.options.plan.planID
|
|
)[0];
|
|
});
|
|
|
|
function resetValidity() {
|
|
state.conference.name.setValidity("required", true);
|
|
state.conference.conferenceID.setValidity("pattern", true);
|
|
state.conference.conferenceID.setValidity("required", true);
|
|
state.conference.conferenceID.setValidity("used", true);
|
|
state.conference.startDate.setValidity("required", true);
|
|
state.conference.startDate.setValidity("invalid", true);
|
|
state.conference.endDate.setValidity("required", true);
|
|
state.conference.endDate.setValidity("invalid", true);
|
|
state.conference.setValidity("error", true);
|
|
}
|
|
|
|
function displayErrorMessage(message) {
|
|
switch (message) {
|
|
case "conf_name_missing":
|
|
state.conference.name.setValidity("required", false);
|
|
break;
|
|
case "conf_id_missing":
|
|
state.conference.conferenceID.setValidity("required", false);
|
|
break;
|
|
case "conf_id_format":
|
|
state.conference.conferenceID.setValidity("pattern", false);
|
|
break;
|
|
case "conf_id_used":
|
|
state.conference.conferenceID.setValidity("used", false);
|
|
break;
|
|
case "conf_start_date_missing":
|
|
state.conference.startDate.setValidity("required", false);
|
|
break;
|
|
case "conf_end_date_missing":
|
|
state.conference.endDate.setValidity("required", false);
|
|
break;
|
|
case "conf_start_date_invalid":
|
|
state.conference.startDate.setValidity("invalid", false);
|
|
break;
|
|
case "conf_end_date_invalid":
|
|
state.conference.endDate.setValidity("invalid", false);
|
|
break;
|
|
default:
|
|
state.conference.setValidity("error", false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
state.submit = function () {
|
|
if (state.saving) return;
|
|
state.saving = true;
|
|
state.error = "";
|
|
const submitted = JSON.parse(JSON.stringify(state.options));
|
|
const toast = reactive({
|
|
title: `Creating ${submitted.name}...`,
|
|
date: new Date(),
|
|
body: `The conference ${submitted.conferenceID} is in creation.`,
|
|
});
|
|
if (state.editionMode) {
|
|
toast.title = `Updating ${submitted.name}...`;
|
|
toast.body = `The conference '${submitted.conferenceID}' is updating.`;
|
|
}
|
|
state.addToast(toast);
|
|
resetValidity();
|
|
http
|
|
.post(
|
|
"/api/conferences/" +
|
|
(state.editionMode ? submitted.conferenceID : ""),
|
|
submitted
|
|
)
|
|
.then(
|
|
() => {
|
|
if (!state.editionMode) {
|
|
toast.title = `${submitted.name} created`;
|
|
toast.body = `The conference '${submitted.conferenceID}' is created.`;
|
|
} else {
|
|
toast.title = `${submitted.name} updated`;
|
|
toast.body = `The conference '${submitted.conferenceID}' is updated.`;
|
|
}
|
|
location.url("/conference/" + submitted.conferenceID);
|
|
},
|
|
(error) => {
|
|
displayErrorMessage(error.data?.error);
|
|
state.error = "Unable to save the conference. Please correct any highlighted fields and try again.";
|
|
state.removeToast(toast);
|
|
}
|
|
).finally(() => { state.saving = false; });
|
|
};
|
|
};
|
|
|
|
export const conferenceController = function (state, http, location, params) {
|
|
setupBilling(state, http, () => getConference(), { confirmedMessage: "Payment status checked. Your conference is ready for submissions once payment is confirmed." });
|
|
state.watch("user.status", () => {
|
|
if (state.user == null) {
|
|
location.url("/");
|
|
}
|
|
});
|
|
if (state.user == null) {
|
|
location.url("/");
|
|
}
|
|
state.conference = null;
|
|
|
|
state.search = "";
|
|
state.filters = {
|
|
status: { ready: true, expired: false, removed: false },
|
|
};
|
|
state.orderBy = "-anonymizeDate";
|
|
|
|
state.repoFiler = (repo) => {
|
|
if (state.filters.status[repo.status] == false) return false;
|
|
|
|
const query = state.search.trim().toLowerCase();
|
|
if (!query) return true;
|
|
|
|
if ((repo.source?.fullName || "").toLowerCase().includes(query)) return true;
|
|
if ((repo.repoId || "").toLowerCase().includes(query)) return true;
|
|
|
|
return false;
|
|
};
|
|
|
|
function getConference() {
|
|
http
|
|
.get("/api/conferences/" + params.conferenceId)
|
|
.then((res) => {
|
|
state.conference = res.data;
|
|
});
|
|
}
|
|
getConference();
|
|
};
|
|
|
|
|
|
export const connectionsController = function (state, http) {
|
|
state.notificationEmail = "";
|
|
state.emailSaved = false;
|
|
state.saveNotificationEmail = async () => {
|
|
state.busy = true;
|
|
state.emailSaved = false;
|
|
state.connectionError = "";
|
|
try {
|
|
const res = await http.post("/github/connections/email", { email: state.notificationEmail },
|
|
{ headers: { "X-CSRF-Token": state.connections.csrf } });
|
|
state.notificationEmail = res.data.notificationEmail;
|
|
if (state.user) state.user.notificationEmail = res.data.notificationEmail;
|
|
state.emailSaved = true;
|
|
} catch (error) { state.connectionError = error.data?.error || "Unable to save email address."; }
|
|
finally { state.busy = false; }
|
|
};
|
|
state.connections = null;
|
|
state.connectionError = "";
|
|
state.busy = false;
|
|
state.loadConnections = () => http.get("/github/connections").then(res => {
|
|
state.connections = res.data;
|
|
state.notificationEmail = res.data.notificationEmail || "";
|
|
}).catch(error => { state.connectionError = error.data?.error || "Unable to load connections."; });
|
|
state.changeConnection = async (resource, connection, preview) => {
|
|
state.busy = true;
|
|
state.connectionError = "";
|
|
try {
|
|
const result = await http.post("/github/connections/migrate", { type: resource.type, id: resource.id, connection, preview },
|
|
{ headers: { "X-CSRF-Token": state.connections.csrf } });
|
|
if (preview) resource.eligible = result.data.eligible;
|
|
else await state.loadConnections();
|
|
} catch (error) { state.connectionError = error.data?.error || "Unable to change connection."; }
|
|
finally { state.busy = false; }
|
|
};
|
|
state.disconnectOAuth = async () => {
|
|
state.busy = true;
|
|
try {
|
|
await http.post("/github/connections/disconnect-oauth", {}, { headers: { "X-CSRF-Token": state.connections.csrf } });
|
|
await state.loadConnections();
|
|
} catch (error) { state.connectionError = error.data?.error || "Unable to disconnect OAuth."; }
|
|
finally { state.busy = false; }
|
|
};
|
|
state.loadConnections();
|
|
};
|
|
|
|
export const signinController = function (state, http) {
|
|
state.accountRecovery = false;
|
|
http.get("/github/account-recovery").then(res => { state.accountRecovery = res.data.required; }).catch(() => {});
|
|
};
|