AtCoder の復習問題を登録し、今日の一問を提案する userscript
// ==UserScript==
// @name ac-revisit
// @namespace https://github.com/yiwiy9/ac-revisit
// @homepageURL https://github.com/yiwiy9/ac-revisit
// @author yiwiy9
// @license MIT
// @version 0.1.0
// @description AtCoder の復習問題を登録し、今日の一問を提案する userscript
// @match https://atcoder.jp/*
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-end
// ==/UserScript==
"use strict";
(() => {
// src/shared/date.ts
var MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1e3;
var DEFAULT_REVIEW_INTERVAL_DAYS = 7;
var REVIEW_INTERVAL_DAYS = Number.isInteger(7) && 7 >= 0 ? 7 : DEFAULT_REVIEW_INTERVAL_DAYS;
function formatLocalDate(date) {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function parseLocalDateKey(value) {
const [year, month, day] = value.split("-").map((part) => Number(part));
return new Date(Date.UTC(year, month - 1, day));
}
function createLocalDateProvider(now = () => /* @__PURE__ */ new Date()) {
return {
today() {
return formatLocalDate(now());
}
};
}
function createLocalDateMath() {
return {
isSameDay(left, right) {
return left !== null && left === right;
},
elapsedDays(from, to) {
const start = parseLocalDateKey(from);
const end = parseLocalDateKey(to);
return Math.round((end.getTime() - start.getTime()) / MILLISECONDS_PER_DAY);
},
isDue(registeredOn, today) {
return this.elapsedDays(registeredOn, today) >= REVIEW_INTERVAL_DAYS;
}
};
}
// src/domain/candidate-selection.ts
function createCandidateSelectionService({
localDateMath = createLocalDateMath(),
random = Math.random
} = {}) {
return {
listDueCandidates(input) {
return input.reviewItems.filter(
(item) => localDateMath.isDue(item.registeredOn, input.today)
);
},
pickOneCandidate(input) {
const dueCandidates = this.listDueCandidates(input);
if (dueCandidates.length === 0) {
return {
ok: false,
error: { kind: "no_due_candidates" }
};
}
const index = Math.min(dueCandidates.length - 1, Math.floor(random() * dueCandidates.length));
return {
ok: true,
value: dueCandidates[index]
};
}
};
}
// src/domain/daily-suggestion.ts
function createDailySuggestionService({
reviewStore,
localDateMath,
candidateSelectionService
}) {
return {
ensureTodaySuggestion(input) {
const latestWorkspace = reviewStore.readWorkspace();
if (!latestWorkspace.ok) {
return latestWorkspace;
}
if (localDateMath.isSameDay(latestWorkspace.value.dailyState.lastDailyEvaluatedOn, input.today)) {
return success(latestWorkspace.value, false);
}
const nextCandidate = candidateSelectionService.pickOneCandidate({
today: input.today,
reviewItems: latestWorkspace.value.reviewItems
});
const nextDailyState = nextCandidate.ok ? {
activeProblemId: nextCandidate.value.problemId,
status: "incomplete",
lastDailyEvaluatedOn: input.today
} : {
activeProblemId: null,
status: "complete",
lastDailyEvaluatedOn: input.today
};
const nextWorkspace = {
reviewItems: latestWorkspace.value.reviewItems,
dailyState: nextDailyState
};
const persistedWorkspace = reviewStore.writeWorkspace(nextWorkspace);
if (!persistedWorkspace.ok) {
return persistedWorkspace;
}
return success(
persistedWorkspace.value,
input.trigger === "bootstrap" && persistedWorkspace.value.dailyState.status === "incomplete" && localDateMath.isSameDay(
persistedWorkspace.value.dailyState.lastDailyEvaluatedOn,
input.today
)
);
}
};
}
function success(reviewWorkspace, shouldAutoOpenPopup) {
return {
ok: true,
value: {
reviewWorkspace,
dailyState: reviewWorkspace.dailyState,
shouldAutoOpenPopup
}
};
}
// src/domain/interaction-session.ts
function createInteractionSessionValidator({
localDateMath = createLocalDateMath()
} = {}) {
return {
validate(input) {
const statesMatch = input.expectedDailyState.activeProblemId === input.actualDailyState.activeProblemId && input.expectedDailyState.status === input.actualDailyState.status && input.expectedDailyState.lastDailyEvaluatedOn === input.actualDailyState.lastDailyEvaluatedOn;
if (!statesMatch) {
return { kind: "stale" };
}
if (!localDateMath.isSameDay(input.actualDailyState.lastDailyEvaluatedOn, input.today)) {
return { kind: "stale" };
}
return { kind: "valid" };
}
};
}
// src/domain/review-mutation.ts
function createReviewMutationService({
reviewStore,
candidateSelectionService = createCandidateSelectionService(),
interactionSessionValidator = createInteractionSessionValidator()
}) {
return {
registerProblem(input) {
const latestWorkspace = readLatestWorkspace(reviewStore);
if (!latestWorkspace.ok) {
return latestWorkspace;
}
const alreadyTracked = latestWorkspace.value.reviewItems.some(
(item) => item.problemId === input.problemId
);
if (alreadyTracked) {
return success2(latestWorkspace.value);
}
return writeWorkspace(reviewStore, {
reviewItems: [
...latestWorkspace.value.reviewItems,
{
problemId: input.problemId,
problemTitle: input.problemTitle,
registeredOn: input.today
}
],
dailyState: latestWorkspace.value.dailyState
});
},
unregisterProblem(input) {
const latestWorkspace = readLatestWorkspace(reviewStore);
if (!latestWorkspace.ok) {
return latestWorkspace;
}
const targetExists = latestWorkspace.value.reviewItems.some(
(item) => item.problemId === input.problemId
);
if (!targetExists) {
return success2(latestWorkspace.value);
}
const reviewItems = latestWorkspace.value.reviewItems.filter(
(item) => item.problemId !== input.problemId
);
const isActiveProblem = latestWorkspace.value.dailyState.activeProblemId === input.problemId;
return writeWorkspace(reviewStore, {
reviewItems,
dailyState: isActiveProblem ? {
activeProblemId: null,
status: "complete",
lastDailyEvaluatedOn: latestWorkspace.value.dailyState.lastDailyEvaluatedOn
} : latestWorkspace.value.dailyState
});
},
completeTodayProblem(input) {
const latestWorkspace = readLatestWorkspace(reviewStore);
if (!latestWorkspace.ok) {
return latestWorkspace;
}
if (interactionSessionValidator.validate({
expectedDailyState: input.expectedDailyState,
actualDailyState: latestWorkspace.value.dailyState,
today: input.today
}).kind === "stale") {
return failure({ kind: "stale_session" });
}
const activeProblemId = latestWorkspace.value.dailyState.activeProblemId;
if (latestWorkspace.value.dailyState.status !== "incomplete" || activeProblemId === null) {
return failure({ kind: "today_problem_absent" });
}
const activeProblem = latestWorkspace.value.reviewItems.find(
(item) => item.problemId === activeProblemId
);
if (activeProblem === void 0) {
return failure({ kind: "today_problem_absent" });
}
const reviewItems = latestWorkspace.value.reviewItems.filter((item) => item.problemId !== activeProblem.problemId).concat({
problemId: activeProblem.problemId,
problemTitle: activeProblem.problemTitle,
registeredOn: input.today
});
return writeWorkspace(reviewStore, {
reviewItems,
dailyState: {
activeProblemId: activeProblem.problemId,
status: "complete",
lastDailyEvaluatedOn: latestWorkspace.value.dailyState.lastDailyEvaluatedOn
}
});
},
fetchNextTodayProblem(input) {
const latestWorkspace = readLatestWorkspace(reviewStore);
if (!latestWorkspace.ok) {
return latestWorkspace;
}
if (interactionSessionValidator.validate({
expectedDailyState: input.expectedDailyState,
actualDailyState: latestWorkspace.value.dailyState,
today: input.today
}).kind === "stale") {
return failure({ kind: "stale_session" });
}
if (latestWorkspace.value.dailyState.status !== "complete") {
return failure({ kind: "today_problem_incomplete" });
}
const nextCandidate = candidateSelectionService.pickOneCandidate({
today: input.today,
reviewItems: latestWorkspace.value.reviewItems
});
if (!nextCandidate.ok) {
return failure({ kind: "candidate_unavailable" });
}
return writeWorkspace(reviewStore, {
reviewItems: latestWorkspace.value.reviewItems,
dailyState: {
activeProblemId: nextCandidate.value.problemId,
status: "incomplete",
lastDailyEvaluatedOn: latestWorkspace.value.dailyState.lastDailyEvaluatedOn
}
});
}
};
}
function readLatestWorkspace(reviewStore) {
const result = reviewStore.readWorkspace();
if (!result.ok) {
return {
ok: false,
error: result.error
};
}
return result;
}
function writeWorkspace(reviewStore, reviewWorkspace) {
const result = reviewStore.writeWorkspace(reviewWorkspace);
if (!result.ok) {
return failure(result.error);
}
return success2(result.value);
}
function success2(reviewWorkspace) {
return {
ok: true,
value: {
reviewWorkspace
}
};
}
function failure(error) {
return {
ok: false,
error
};
}
// src/persistence/review-store.ts
var SCHEMA_VERSION = 1;
var PROBLEM_ID_PATTERN = /^[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+$/;
var LOCAL_DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
var REVIEW_STORE_KEY = "ac_revisit_workspace_v1";
function createCanonicalReviewWorkspace() {
return {
reviewItems: [],
dailyState: {
activeProblemId: null,
status: "complete",
lastDailyEvaluatedOn: null
}
};
}
function createReviewStoreAdapter(storage) {
return {
readWorkspace() {
let rawValue;
try {
rawValue = storage.get(REVIEW_STORE_KEY);
} catch {
return storageUnavailable();
}
if (rawValue === null) {
return success3(createCanonicalReviewWorkspace());
}
const parsed = parseSchemaEnvelope(rawValue);
return success3(parsed ?? createCanonicalReviewWorkspace());
},
writeWorkspace(input) {
const normalizedWorkspace = normalizeWorkspace(
validateWorkspace(input) ?? createCanonicalReviewWorkspace()
);
const payload = JSON.stringify({
version: SCHEMA_VERSION,
payload: normalizedWorkspace
});
try {
storage.set(REVIEW_STORE_KEY, payload);
} catch {
return storageUnavailable();
}
return success3(normalizedWorkspace);
}
};
}
function parseSchemaEnvelope(rawValue) {
let parsed;
try {
parsed = JSON.parse(rawValue);
} catch {
return null;
}
if (!isRecord(parsed)) {
return null;
}
if (parsed.version !== SCHEMA_VERSION) {
return null;
}
return validateWorkspace(parsed.payload);
}
function validateWorkspace(value) {
if (!isRecord(value)) {
return null;
}
const { reviewItems, dailyState } = value;
if (!Array.isArray(reviewItems) || !isRecord(dailyState)) {
return null;
}
const normalizedReviewItems = [];
const knownProblemIds = /* @__PURE__ */ new Set();
for (const item of reviewItems) {
const normalizedItem = validateReviewItem(item);
if (normalizedItem === null || knownProblemIds.has(normalizedItem.problemId)) {
return null;
}
knownProblemIds.add(normalizedItem.problemId);
normalizedReviewItems.push(normalizedItem);
}
const normalizedDailyState = validateDailyState(dailyState, knownProblemIds);
if (normalizedDailyState === null) {
return null;
}
return {
reviewItems: normalizedReviewItems,
dailyState: normalizedDailyState
};
}
function validateReviewItem(value) {
if (!isRecord(value)) {
return null;
}
if (!isProblemId(value.problemId) || !isProblemTitle(value.problemTitle) || !isLocalDateKey(value.registeredOn)) {
return null;
}
return {
problemId: value.problemId,
problemTitle: value.problemTitle,
registeredOn: value.registeredOn
};
}
function validateDailyState(value, problemIds) {
const lastDailyEvaluatedOn = value.lastDailyEvaluatedOn;
if (!(lastDailyEvaluatedOn === null || isLocalDateKey(lastDailyEvaluatedOn))) {
return null;
}
if (value.status === "complete") {
if (value.activeProblemId !== null && (!isProblemId(value.activeProblemId) || !problemIds.has(value.activeProblemId))) {
return null;
}
return {
activeProblemId: value.activeProblemId,
status: "complete",
lastDailyEvaluatedOn
};
}
if (value.status === "incomplete") {
if (!isProblemId(value.activeProblemId) || !problemIds.has(value.activeProblemId)) {
return null;
}
return {
activeProblemId: value.activeProblemId,
status: "incomplete",
lastDailyEvaluatedOn
};
}
return null;
}
function normalizeWorkspace(input) {
return {
reviewItems: [...input.reviewItems].map((item) => ({
problemId: item.problemId,
problemTitle: item.problemTitle,
registeredOn: item.registeredOn
})).sort((left, right) => left.problemId.localeCompare(right.problemId)),
dailyState: {
activeProblemId: input.dailyState.activeProblemId,
status: input.dailyState.status,
lastDailyEvaluatedOn: input.dailyState.lastDailyEvaluatedOn
}
};
}
function isProblemId(value) {
return typeof value === "string" && PROBLEM_ID_PATTERN.test(value);
}
function isProblemTitle(value) {
return typeof value === "string" && value.trim().length > 0;
}
function isLocalDateKey(value) {
if (typeof value !== "string" || !LOCAL_DATE_KEY_PATTERN.test(value)) {
return false;
}
const [year, month, day] = value.split("-").map((part) => Number(part));
const date = new Date(Date.UTC(year, month - 1, day));
const normalized = [
String(date.getUTCFullYear()).padStart(4, "0"),
String(date.getUTCMonth() + 1).padStart(2, "0"),
String(date.getUTCDate()).padStart(2, "0")
].join("-");
return normalized === value;
}
function isRecord(value) {
return typeof value === "object" && value !== null;
}
function success3(value) {
return { ok: true, value };
}
function storageUnavailable() {
return { ok: false, error: { kind: "storage_unavailable" } };
}
// src/presentation/popup-view-model.ts
function createPopupViewModelFactory() {
return {
build(input) {
const activeReviewItem = input.reviewItems.find(
(item) => item.problemId === input.dailyState.activeProblemId
);
const hasActiveIncompleteSuggestion = input.dailyState.status === "incomplete" && input.dailyState.activeProblemId !== null;
const hasCompletedTodaySuggestion = input.dailyState.status === "complete" && input.dailyState.activeProblemId !== null;
const canFetchNextSuggestion = input.dailyState.status === "complete" && input.hasDueCandidates === true;
const primaryActionKind = hasActiveIncompleteSuggestion === true ? "complete" : hasCompletedTodaySuggestion === true || canFetchNextSuggestion === true ? "fetch_next" : "complete";
const primaryActionEnabled = hasActiveIncompleteSuggestion === true || canFetchNextSuggestion === true;
return {
todayLink: hasActiveIncompleteSuggestion ? { enabled: true, presentation: "normal" } : { enabled: false, presentation: "grayed" },
todayLinkLabel: activeReviewItem?.problemTitle ?? "今日の一問はありません",
description: hasActiveIncompleteSuggestion === true ? "今日の復習対象です。解き終えたら完了で記録できます。" : canFetchNextSuggestion === true ? "今日の一問は完了済みです。必要ならもう一問で次に進めます。" : hasCompletedTodaySuggestion === true ? "今日の一問は完了済みです。次に進める復習対象はありません。" : `復習対象がありません。追加した問題は${REVIEW_INTERVAL_DAYS}日後に今日の一問として表示されます。`,
primaryAction: primaryActionEnabled ? { enabled: true, presentation: "normal" } : { enabled: false, presentation: "grayed" },
primaryActionLabel: hasActiveIncompleteSuggestion === true ? "完了" : hasCompletedTodaySuggestion === true || canFetchNextSuggestion === true ? "もう一問" : "完了",
primaryActionKind
};
}
};
}
// src/presentation/popup-shell.ts
var POPUP_ROOT_ID = "ac-revisit-popup-root";
var POPUP_OVERLAY_ID = "ac-revisit-popup-overlay";
var POPUP_PANEL_ID = "ac-revisit-popup-panel";
var POPUP_HEADER_ID = "ac-revisit-popup-header";
var POPUP_BODY_ID = "ac-revisit-popup-body";
var POPUP_FOOTER_ID = "ac-revisit-popup-footer";
var POPUP_TITLE_ID = "ac-revisit-popup-title";
var POPUP_SECTION_TITLE_ID = "ac-revisit-popup-section-title";
var POPUP_DESCRIPTION_ID = "ac-revisit-popup-description";
var POPUP_CLOSE_ID = "ac-revisit-popup-close";
var POPUP_DISMISS_ID = "ac-revisit-popup-dismiss";
var POPUP_TODAY_LINK_ID = "ac-revisit-popup-today-link";
var POPUP_ACTION_ID = "ac-revisit-popup-action";
function createPopupStateLoader(dependencies) {
const popupViewModelFactory = createPopupViewModelFactory();
return {
load(input) {
const workspaceResult = input.mode === "workspace" ? success4(input.reviewWorkspace) : dependencies.readWorkspace?.() ?? failure2({
kind: "storage_unavailable"
});
if (!workspaceResult.ok) {
return workspaceResult;
}
const reviewWorkspace = workspaceResult.value;
const hasDueCandidates = dependencies.listDueCandidates({
today: input.today,
reviewItems: reviewWorkspace.reviewItems
}).length > 0;
return success4({
source: input.source,
today: input.today,
reviewWorkspace,
viewModel: popupViewModelFactory.build({
reviewItems: reviewWorkspace.reviewItems,
dailyState: reviewWorkspace.dailyState,
hasDueCandidates
})
});
}
};
}
function createPopupShellPresenter(documentRef = document, interactions = {}) {
const interactionSessionValidator = createInteractionSessionValidator();
let lastFocusedElement = null;
let previousBodyPaddingRight = null;
const presentPopup = (input) => {
let popup = documentRef.getElementById(POPUP_ROOT_ID);
if (popup === null) {
popup = documentRef.createElement("section");
popup.id = POPUP_ROOT_ID;
popup.tabIndex = -1;
popup.setAttribute("role", "dialog");
popup.setAttribute("aria-modal", "true");
popup.className = "modal fade";
popup.style.display = "none";
popup.style.paddingRight = "12px";
const panel = documentRef.createElement("div");
panel.id = POPUP_PANEL_ID;
panel.className = "modal-dialog";
panel.setAttribute("role", "document");
popup.append(panel);
const content = documentRef.createElement("div");
content.className = "modal-content";
panel.append(content);
const header = documentRef.createElement("div");
header.id = POPUP_HEADER_ID;
header.className = "modal-header";
content.append(header);
const body = documentRef.createElement("div");
body.id = POPUP_BODY_ID;
body.className = "modal-body";
content.append(body);
const footer = documentRef.createElement("div");
footer.id = POPUP_FOOTER_ID;
footer.className = "modal-footer";
content.append(footer);
const overlay2 = documentRef.createElement("div");
overlay2.id = POPUP_OVERLAY_ID;
overlay2.className = "modal-backdrop fade";
const title = documentRef.createElement("h4");
title.id = POPUP_TITLE_ID;
title.textContent = "ac-revisit";
title.className = "modal-title";
const closeButton2 = documentRef.createElement("button");
closeButton2.id = POPUP_CLOSE_ID;
closeButton2.type = "button";
closeButton2.className = "close";
closeButton2.setAttribute("aria-label", "閉じる");
closeButton2.setAttribute("data-dismiss", "modal");
const closeMark = documentRef.createElement("span");
closeMark.setAttribute("aria-hidden", "true");
closeMark.textContent = "×";
closeButton2.append(closeMark);
header.append(closeButton2);
header.append(title);
const sectionTitle = documentRef.createElement("h3");
sectionTitle.id = POPUP_SECTION_TITLE_ID;
sectionTitle.textContent = "今日の一問";
sectionTitle.className = "h5";
sectionTitle.style.marginTop = "0";
sectionTitle.style.marginBottom = "0.375rem";
body.append(sectionTitle);
const description2 = documentRef.createElement("p");
description2.id = POPUP_DESCRIPTION_ID;
description2.textContent = "解く問題がある日は完了、終わった日はもう一問で次へ進めます。";
description2.className = "small text-muted";
description2.style.marginTop = "0";
description2.style.marginBottom = "1.25rem";
body.append(description2);
const todayLink2 = documentRef.createElement("a");
todayLink2.id = POPUP_TODAY_LINK_ID;
todayLink2.className = "";
todayLink2.setAttribute("href", "#");
todayLink2.style.whiteSpace = "normal";
todayLink2.style.wordBreak = "break-word";
todayLink2.style.display = "inline-block";
todayLink2.style.marginTop = "0";
todayLink2.style.marginBottom = "1.25rem";
todayLink2.style.lineHeight = "1.4";
body.append(todayLink2);
const actionButton2 = documentRef.createElement("button");
actionButton2.id = POPUP_ACTION_ID;
actionButton2.type = "button";
actionButton2.className = "btn btn-primary";
actionButton2.style.display = "block";
body.append(actionButton2);
const dismissButton2 = documentRef.createElement("button");
dismissButton2.id = POPUP_DISMISS_ID;
dismissButton2.type = "button";
dismissButton2.textContent = "close";
dismissButton2.className = "btn btn-default";
dismissButton2.setAttribute("data-dismiss", "modal");
footer.append(dismissButton2);
documentRef.body.append(popup);
documentRef.body.append(overlay2);
}
popup.dataset.source = input.source;
popup.dataset.status = input.reviewWorkspace.dailyState.status;
popup.dataset.activeProblemId = input.reviewWorkspace.dailyState.activeProblemId ?? "";
popup.dataset.lastDailyEvaluatedOn = input.reviewWorkspace.dailyState.lastDailyEvaluatedOn ?? "";
popup.dataset.state = "open";
popup.setAttribute("aria-labelledby", POPUP_TITLE_ID);
const overlay = documentRef.getElementById(POPUP_OVERLAY_ID);
const closeButton = popup.querySelector(`#${POPUP_CLOSE_ID}`);
const dismissButton = popup.querySelector(`#${POPUP_DISMISS_ID}`);
const description = popup.querySelector(`#${POPUP_DESCRIPTION_ID}`);
const todayLink = popup.querySelector(`#${POPUP_TODAY_LINK_ID}`);
const actionButton = popup.querySelector(`#${POPUP_ACTION_ID}`);
if (documentRef.activeElement instanceof HTMLElement && !popup.contains(documentRef.activeElement)) {
lastFocusedElement = documentRef.activeElement;
}
if (todayLink !== null) {
todayLink.textContent = input.viewModel.todayLinkLabel;
if (input.viewModel.todayLink.enabled && input.reviewWorkspace.dailyState.activeProblemId !== null) {
todayLink.href = toProblemPath(input.reviewWorkspace.dailyState.activeProblemId);
todayLink.removeAttribute("aria-disabled");
todayLink.removeAttribute("data-muted");
todayLink.className = "";
todayLink.style.pointerEvents = "";
todayLink.style.color = "";
} else {
todayLink.removeAttribute("href");
todayLink.setAttribute("aria-disabled", "true");
todayLink.dataset.muted = "true";
todayLink.className = "text-muted";
todayLink.style.pointerEvents = "none";
todayLink.style.color = "#777777";
}
}
if (description !== null) {
description.textContent = input.viewModel.description;
}
if (actionButton !== null) {
actionButton.textContent = input.viewModel.primaryActionLabel;
actionButton.disabled = !input.viewModel.primaryAction.enabled;
if (input.viewModel.primaryAction.enabled) {
actionButton.className = "btn btn-primary";
} else {
actionButton.className = "btn btn-default";
}
actionButton.style.cursor = input.viewModel.primaryAction.enabled ? "pointer" : "not-allowed";
}
if (overlay !== null) {
overlay.onclick = () => {
dismissPopup(popup);
};
}
if (closeButton !== null) {
closeButton.onclick = () => {
dismissPopup(popup);
};
}
if (dismissButton !== null) {
dismissButton.onclick = () => {
dismissPopup(popup);
};
}
if (todayLink !== null) {
todayLink.onclick = (event) => {
const interactionState = revalidateInteraction({
renderedInput: input,
currentSource: input.source
});
if (interactionState.kind === "stale") {
event.preventDefault();
}
};
}
if (actionButton !== null) {
actionButton.onclick = (event) => {
event.preventDefault();
if (!input.viewModel.primaryAction.enabled) {
return;
}
const interactionState = revalidateInteraction({
renderedInput: input,
currentSource: input.source
});
if (interactionState.kind === "stale") {
return;
}
const nextPopup = interactions.runPrimaryAction?.({
action: input.viewModel.primaryActionKind,
source: interactionState.source,
today: interactionState.today,
expectedDailyState: interactionState.currentSnapshot.reviewWorkspace.dailyState
});
if (nextPopup !== null && nextPopup !== void 0) {
presentPopup(nextPopup);
}
};
}
popup.onkeydown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
dismissPopup(popup);
}
};
showModal(popup, overlay);
popup.focus();
};
return presentPopup;
function revalidateInteraction({
renderedInput,
currentSource
}) {
const today = interactions.getToday?.() ?? renderedInput.today;
const maybeLatestSnapshot = interactions.loadReadonly?.({
source: currentSource,
today
});
if (interactions.loadReadonly !== void 0 && (maybeLatestSnapshot === null || maybeLatestSnapshot === void 0)) {
return { kind: "stale" };
}
const latestSnapshot = maybeLatestSnapshot ?? renderedInput;
if (interactionSessionValidator.validate({
expectedDailyState: renderedInput.reviewWorkspace.dailyState,
actualDailyState: latestSnapshot.reviewWorkspace.dailyState,
today
}).kind === "stale") {
const refreshedSnapshot = interactions.refreshPopup?.({
source: currentSource,
today
});
if (refreshedSnapshot !== null && refreshedSnapshot !== void 0) {
presentPopup(refreshedSnapshot);
}
return { kind: "stale" };
}
return {
kind: "valid",
currentSnapshot: latestSnapshot,
source: currentSource,
today
};
}
function dismissPopup(popup) {
if (popup.dataset.state === "closing") {
return;
}
popup.dataset.state = "closing";
const overlay = documentRef.getElementById(POPUP_OVERLAY_ID);
popup.classList.remove("in");
overlay?.classList.remove("in");
const removePopup = () => {
if (popup.isConnected) {
popup.remove();
}
overlay?.remove();
popup.style.display = "none";
unlockPageScroll();
if (lastFocusedElement !== null && documentRef.contains(lastFocusedElement)) {
lastFocusedElement.focus();
}
lastFocusedElement = null;
};
const timerHost = documentRef.defaultView ?? window;
timerHost.setTimeout(removePopup, 300);
}
function showModal(popup, overlay) {
popup.style.display = "block";
popup.classList.remove("in");
overlay?.classList.remove("in");
lockPageScroll();
void popup.offsetWidth;
popup.classList.add("in");
overlay?.classList.add("in");
}
function lockPageScroll() {
if (!documentRef.body.classList.contains("modal-open")) {
documentRef.body.classList.add("modal-open");
previousBodyPaddingRight = documentRef.body.style.paddingRight;
}
documentRef.body.style.paddingRight = "12px";
}
function unlockPageScroll() {
documentRef.body.classList.remove("modal-open");
documentRef.body.style.paddingRight = previousBodyPaddingRight ?? "";
}
}
function success4(value) {
return {
ok: true,
value
};
}
function failure2(error) {
return {
ok: false,
error
};
}
function toProblemPath(problemId) {
const [contestId, taskId] = problemId.split("/");
if (contestId === void 0 || taskId === void 0) {
return "#";
}
return `/contests/${contestId}/tasks/${taskId}`;
}
// src/runtime/atcoder-shell.ts
var LEGACY_USER_HANDLE_SELECTOR = ".navbar-right .dropdown > .dropdown-toggle";
var TOP_PAGE_MYPAGE_SELECTOR = ".header-mypage";
var TOP_PAGE_MENU_SELECTOR = ".header-mypage_detail .header-mypage_list";
var PROBLEM_HEADING_SELECTOR = ".col-sm-12 > span.h2";
var PROBLEM_COMMENTARY_LINK_SELECTOR = `${PROBLEM_HEADING_SELECTOR} > a.btn`;
var MENU_ENTRY_ID = "ac-revisit-menu-entry";
var MENU_ENTRY_LINK_ID = "ac-revisit-menu-entry-link";
var TOGGLE_BUTTON_ID = "ac-revisit-toggle-button";
var TOGGLE_BUTTON_CLASS = "ac-revisit-toggle-button";
function createAtCoderPageAdapter(windowRef = window, documentRef = document) {
return {
detectPage() {
const path = windowRef.location.pathname;
if (/^\/contests\/[^/]+\/tasks\/[^/]+$/.test(path)) {
return { kind: "problem", path };
}
if (/^\/contests\/[^/]+\/submissions\/\d+$/.test(path)) {
return { kind: "submission_detail", path };
}
return { kind: "other", path };
},
inspectHeaderShell() {
const legacyMenuAnchor = findLegacyUserMenuAnchor(documentRef);
const topPageMenuAnchor = findElement(documentRef, TOP_PAGE_MENU_SELECTOR);
const hasLegacyUserMenu = legacyMenuAnchor !== null;
const hasTopPageUserMenu = findElement(documentRef, TOP_PAGE_MYPAGE_SELECTOR) !== null && topPageMenuAnchor !== null;
return {
hasLegacyUserMenu,
hasTopPageUserMenu,
menuAnchor: legacyMenuAnchor === null ? topPageMenuAnchor === null ? { kind: "missing" } : { kind: "found", element: topPageMenuAnchor, insertMode: "append" } : { kind: "found", element: legacyMenuAnchor, insertMode: "append" },
menuUserHandle: readTrimmedText(documentRef, LEGACY_USER_HANDLE_SELECTOR) ?? readTrimmedText(documentRef, TOP_PAGE_MYPAGE_SELECTOR)
};
},
findToggleAnchor() {
const page = this.detectPage();
if (page.kind === "problem") {
const commentaryLink = findElement(documentRef, PROBLEM_COMMENTARY_LINK_SELECTOR);
if (commentaryLink !== null) {
return {
kind: "found",
element: commentaryLink,
insertMode: "afterend"
};
}
const heading = findElement(documentRef, PROBLEM_HEADING_SELECTOR);
return heading === null ? { kind: "missing" } : { kind: "found", element: heading, insertMode: "append" };
}
if (page.kind !== "submission_detail") {
return { kind: "missing" };
}
const taskLink = findSubmissionProblemTaskLink(documentRef);
return taskLink === null ? { kind: "missing" } : { kind: "found", element: taskLink, insertMode: "afterend" };
},
readProblemContextSource() {
const page = this.detectPage();
if (page.kind === "problem") {
return {
kind: "problem",
pathname: page.path,
problemTitleText: readOwnTextContent(documentRef, PROBLEM_HEADING_SELECTOR)
};
}
if (page.kind === "submission_detail") {
const taskLink = findSubmissionProblemTaskLink(documentRef);
return {
kind: "submission_detail",
taskHref: taskLink?.getAttribute("href") ?? null,
taskTitleText: readElementText(taskLink)
};
}
return { kind: "other" };
}
};
}
function createAuthSessionGuard(pageAdapter) {
return {
resolveSession() {
const headerShell = pageAdapter.inspectHeaderShell();
if (headerShell.hasLegacyUserMenu || headerShell.hasTopPageUserMenu) {
return {
kind: "authenticated",
userHandle: headerShell.menuUserHandle
};
}
return { kind: "anonymous" };
}
};
}
function createMenuEntryAdapter(dependencies) {
const documentRef = dependencies.documentRef ?? document;
return {
ensureEntryMounted() {
if (documentRef.getElementById(MENU_ENTRY_ID) !== null) {
return {
ok: true,
value: { mounted: false }
};
}
const headerShell = dependencies.pageAdapter.inspectHeaderShell();
if (headerShell.menuAnchor.kind === "missing") {
return {
ok: false,
error: { kind: "anchor_missing" }
};
}
const item = documentRef.createElement("li");
item.id = MENU_ENTRY_ID;
const link = documentRef.createElement("a");
link.id = MENU_ENTRY_LINK_ID;
link.href = "#";
appendMenuLinkContents(link, documentRef, headerShell.menuAnchor.element);
link.addEventListener("click", (event) => {
event.preventDefault();
dependencies.openPopup({
source: "menu",
today: dependencies.getToday()
});
});
item.append(link);
insertMenuItem(headerShell.menuAnchor.element, item);
return {
ok: true,
value: { mounted: true }
};
}
};
}
function createProblemContextResolver(pageAdapter) {
return {
resolveCurrentProblem() {
const source = pageAdapter.readProblemContextSource();
if (source.kind === "other") {
return { kind: "not_applicable" };
}
if (source.kind === "problem") {
if (source.problemTitleText === null) {
return { kind: "unresolvable" };
}
const parsed2 = parseProblemPath(source.pathname);
return parsed2 === null ? { kind: "unresolvable" } : {
kind: "resolved",
contestId: parsed2.contestId,
problemId: parsed2.problemId,
problemTitle: source.problemTitleText
};
}
if (source.taskHref === null || source.taskTitleText === null) {
return { kind: "unresolvable" };
}
const parsed = parseProblemPath(source.taskHref);
return parsed === null ? { kind: "unresolvable" } : {
kind: "resolved",
contestId: parsed.contestId,
problemId: parsed.problemId,
problemTitle: source.taskTitleText
};
}
};
}
function createToggleMountCoordinator(dependencies) {
const documentRef = dependencies.documentRef ?? document;
const problemContextResolver = createProblemContextResolver(dependencies.pageAdapter);
return {
mount() {
const existingButton = documentRef.getElementById(TOGGLE_BUTTON_ID);
const anchor = dependencies.pageAdapter.findToggleAnchor();
if (anchor.kind === "missing") {
return {
ok: false,
error: { kind: "anchor_missing" }
};
}
const problem = problemContextResolver.resolveCurrentProblem();
if (problem.kind !== "resolved") {
return {
ok: false,
error: { kind: "problem_unresolvable" }
};
}
const isRegistered = dependencies.resolveIsRegistered?.(problem.problemId) ?? false;
if (existingButton instanceof HTMLButtonElement) {
syncToggleButton(existingButton, isRegistered, problem.problemId);
return {
ok: true,
value: {
mounted: false,
isRegistered
}
};
}
const button = documentRef.createElement("button");
button.type = "button";
button.id = TOGGLE_BUTTON_ID;
syncToggleButton(button, isRegistered, problem.problemId);
const getToday = dependencies.getToday;
const onToggle = dependencies.onToggle;
if (onToggle !== void 0 && getToday !== void 0) {
button.addEventListener("click", () => {
const currentRegistration = button.dataset.state === "registered";
const nextRegistration = onToggle({
problemId: problem.problemId,
problemTitle: problem.problemTitle,
today: getToday(),
isRegistered: currentRegistration
});
if (typeof nextRegistration === "boolean") {
syncToggleButton(button, nextRegistration, problem.problemId);
}
});
}
insertRelative(anchor, button);
return {
ok: true,
value: {
mounted: true,
isRegistered
}
};
}
};
}
function findElement(root, selector) {
const element = root.querySelector(selector);
return element instanceof HTMLElement ? element : null;
}
function findLegacyUserMenuAnchor(documentRef) {
const candidates = Array.from(documentRef.querySelectorAll("ul.dropdown-menu")).filter(
(candidate) => candidate instanceof HTMLElement
);
for (const candidate of candidates) {
const hasLogoutEntry = findMenuItem(candidate, (href, label) => {
return href.startsWith("/logout") || href.includes("form_logout") || label.includes("ログアウト");
});
const hasSettingsEntry = findMenuItem(candidate, (href, label) => {
return href.startsWith("/settings") || label.includes("設定");
});
if (hasLogoutEntry !== void 0 || hasSettingsEntry !== void 0) {
return candidate;
}
}
return candidates.at(0) ?? null;
}
function findSubmissionProblemTaskLink(root) {
const rows = Array.from(root.querySelectorAll(".col-sm-12 table tr"));
for (const row of rows) {
if (!(row instanceof HTMLTableRowElement)) {
continue;
}
const headingCell = row.querySelector("th");
if (readElementText(headingCell) !== "問題") {
continue;
}
const taskLink = row.querySelector('a[href*="/tasks/"]');
if (taskLink instanceof HTMLAnchorElement) {
return taskLink;
}
}
return null;
}
function readTrimmedText(root, selector) {
const element = root.querySelector(selector);
return readElementText(element);
}
function readElementText(element) {
if (!(element instanceof HTMLElement)) {
return null;
}
const text = element.textContent?.trim();
return text && text.length > 0 ? text : null;
}
function readOwnTextContent(root, selector) {
const element = root.querySelector(selector);
if (!(element instanceof HTMLElement)) {
return null;
}
const text = Array.from(element.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
return text && text.length > 0 ? text : null;
}
function parseProblemPath(path) {
const match = /^\/contests\/([A-Za-z0-9_-]+)\/tasks\/([A-Za-z0-9_-]+)$/.exec(path);
if (match === null) {
return null;
}
const [, contestId, taskId] = match;
return {
contestId,
problemId: `${contestId}/${taskId}`
};
}
function syncToggleButton(button, isRegistered, problemId) {
button.className = [
"btn",
isRegistered ? "btn-warning" : "btn-default",
"btn-sm",
TOGGLE_BUTTON_CLASS
].join(" ");
button.style.marginLeft = "0.5rem";
button.style.verticalAlign = "middle";
button.dataset.state = isRegistered ? "registered" : "unregistered";
button.dataset.problemId = problemId;
button.textContent = isRegistered ? "ac-revisit 解除" : "ac-revisit 追加";
}
function appendMenuLinkContents(link, documentRef, menuElement) {
const icon = createMenuIcon(documentRef, menuElement);
icon.dataset.icon = "true";
icon.setAttribute("aria-hidden", "true");
const spacer = documentRef.createTextNode(" ");
const label = documentRef.createElement("span");
label.textContent = "ac-revisit 操作";
label.dataset.label = "true";
link.append(icon, spacer, label);
}
function insertMenuItem(menuElement, item) {
const logoutItem = findMenuItem(menuElement, (href, label) => {
return href.startsWith("/logout") || href.includes("form_logout") || label.includes("ログアウト");
});
if (logoutItem instanceof HTMLElement) {
const trailingDivider = logoutItem.previousElementSibling instanceof HTMLLIElement && logoutItem.previousElementSibling.classList.contains("divider") ? logoutItem.previousElementSibling : null;
const insertionAnchor = trailingDivider ?? logoutItem;
applyMenuItemStyling(item, insertionAnchor);
insertionAnchor.insertAdjacentElement("beforebegin", item);
return;
}
const settingsItem = findMenuItem(menuElement, (href, label) => {
return href.startsWith("/settings") || label.includes("設定");
});
if (settingsItem instanceof HTMLElement) {
applyMenuItemStyling(item, settingsItem);
settingsItem.insertAdjacentElement("beforebegin", item);
return;
}
const referenceItem = menuElement.querySelector("li");
if (referenceItem instanceof HTMLElement) {
applyMenuItemStyling(item, referenceItem);
}
menuElement.append(item);
}
function createMenuIcon(documentRef, menuElement) {
const settingsIcon = findSettingsIcon(menuElement) ?? findSettingsIcon(documentRef);
if (settingsIcon !== null) {
return settingsIcon;
}
const fallbackIcon = documentRef.createElement("span");
fallbackIcon.className = "glyphicon glyphicon-cog";
return fallbackIcon;
}
function findSettingsIcon(root) {
const settingsLink = findMenuItem(
root,
(href, label) => href.startsWith("/settings") || label.includes("設定")
)?.querySelector("span.glyphicon, span.fa, i.glyphicon, i.fa, i.a-icon, span.a-icon");
if (!(settingsLink instanceof HTMLElement)) {
return null;
}
return settingsLink.cloneNode(true);
}
function applyMenuItemStyling(item, referenceItem) {
const referenceLink = referenceItem.querySelector("a");
const link = item.querySelector("a");
if (referenceLink instanceof HTMLAnchorElement && link instanceof HTMLAnchorElement && !referenceItem.classList.contains("divider")) {
link.className = referenceLink.className;
}
}
function findMenuItem(root, predicate) {
return Array.from(root.querySelectorAll("li")).find((candidate) => {
const link = candidate.querySelector("a");
const href = link?.getAttribute("href") ?? "";
const label = link?.textContent?.trim() ?? "";
return predicate(href, label);
});
}
function insertRelative(anchor, element) {
if (anchor.insertMode === "afterend") {
anchor.element.insertAdjacentElement("afterend", element);
return;
}
anchor.element.append(element);
}
// src/bootstrap/platform-ports.ts
function createUserscriptPlatformPorts({
rng = Math.random,
dev = false,
consoleRef = console,
gmGetValue,
gmSetValue
} = {}) {
const resolvedGMGetValue = gmGetValue ?? globalThis.GM_getValue;
const resolvedGMSetValue = gmSetValue ?? globalThis.GM_setValue;
return {
rng,
reviewStorage: {
get(key) {
return resolvedGMGetValue(key, null);
},
set(key, value) {
resolvedGMSetValue(key, value);
}
},
diagnosticSink: createDiagnosticSink({ dev, consoleRef })
};
}
function createDiagnosticSink({
dev,
consoleRef
}) {
if (!dev) {
return () => void 0;
}
return (event) => {
consoleRef.debug(`ac-revisit:${event.code}`, event.component, event.operation);
};
}
// src/bootstrap/userscript.ts
function bootstrapUserscript(dependencies = {}) {
const platform = resolvePlatformPorts(dependencies);
const recordDiagnostic = createDiagnosticRecorder(platform.diagnosticSink);
const pageAdapter = createAtCoderPageAdapter();
const sessionGuard = createAuthSessionGuard(pageAdapter);
const session = sessionGuard.resolveSession();
if (session.kind === "anonymous") {
return {
session: "anonymous",
menuEntryMounted: false,
toggleMounted: false
};
}
const page = pageAdapter.detectPage();
const localDateProvider = createLocalDateProvider();
const getToday = dependencies.getToday ?? (() => localDateProvider.today());
const reviewStore = createReviewStoreAdapter(platform.reviewStorage);
const localDateMath = createLocalDateMath();
const candidateSelectionService = createCandidateSelectionService({
localDateMath,
random: platform.rng
});
const dailySuggestionService = createDailySuggestionService({
reviewStore,
localDateMath,
candidateSelectionService
});
const reviewMutationService = createReviewMutationService({
reviewStore,
candidateSelectionService
});
const popupStateLoader = createPopupStateLoader({
readWorkspace() {
return reviewStore.readWorkspace();
},
listDueCandidates(input) {
return candidateSelectionService.listDueCandidates(input);
}
});
function loadWorkspacePopupState(input) {
const result = popupStateLoader.load({
mode: "workspace",
source: input.source,
today: input.today,
reviewWorkspace: input.reviewWorkspace
});
if (!result.ok) {
throw new Error("workspace popup state loading must not fail");
}
return result.value;
}
function refreshPopupState(input) {
const result = dailySuggestionService.ensureTodaySuggestion({
today: input.today,
trigger: "menu"
});
if (!result.ok) {
recordDiagnostic({
code: result.error.kind,
component: "DailySuggestionService",
operation: "popup_refresh"
});
return null;
}
return loadWorkspacePopupState({
source: input.source,
today: input.today,
reviewWorkspace: result.value.reviewWorkspace
});
}
function loadReadonlyPopupState(input) {
const result = popupStateLoader.load({
mode: "readonly",
source: input.source,
today: input.today
});
if (!result.ok) {
recordDiagnostic({
code: result.error.kind,
component: "PopupStateLoader",
operation: "popup_readonly_load"
});
return null;
}
return result.value;
}
function runPopupPrimaryAction(input) {
const result = input.action === "complete" ? reviewMutationService.completeTodayProblem({
today: input.today,
expectedDailyState: input.expectedDailyState
}) : reviewMutationService.fetchNextTodayProblem({
today: input.today,
expectedDailyState: input.expectedDailyState
});
if (!result.ok) {
if (result.error.kind === "storage_unavailable") {
recordDiagnostic({
code: result.error.kind,
component: "ReviewMutationService",
operation: "popup_primary_action"
});
}
return refreshPopupState({
source: input.source,
today: input.today
});
}
return loadWorkspacePopupState({
source: input.source,
today: input.today,
reviewWorkspace: result.value.reviewWorkspace
});
}
const defaultPopupPresenter = dependencies.openPopup === void 0 ? createPopupShellPresenter(document, {
getToday,
loadReadonly: loadReadonlyPopupState,
refreshPopup: refreshPopupState,
runPrimaryAction: runPopupPrimaryAction
}) : null;
function presentPopup(input) {
if (defaultPopupPresenter === null) {
dependencies.openPopup?.({
source: input.source,
today: input.today,
dailyState: input.reviewWorkspace.dailyState
});
return;
}
defaultPopupPresenter(loadWorkspacePopupState(input));
}
const menuEntryAdapter = createMenuEntryAdapter({
pageAdapter,
getToday,
openPopup(input) {
const menuSuggestionResult = dailySuggestionService.ensureTodaySuggestion({
today: input.today,
trigger: "menu"
});
if (!menuSuggestionResult.ok) {
recordDiagnostic({
code: menuSuggestionResult.error.kind,
component: "DailySuggestionService",
operation: "menu_open_popup"
});
return;
}
presentPopup({
source: input.source,
today: input.today,
reviewWorkspace: menuSuggestionResult.value.reviewWorkspace
});
}
});
const mountResult = menuEntryAdapter.ensureEntryMounted();
if (!mountResult.ok) {
recordDiagnostic({
code: mountResult.error.kind,
component: "MenuEntryAdapter",
operation: "startup_menu_mount"
});
}
const toggleMountCoordinator = createToggleMountCoordinator({
pageAdapter,
getToday,
resolveIsRegistered(problemId) {
const workspace = reviewStore.readWorkspace();
if (!workspace.ok) {
recordDiagnostic({
code: workspace.error.kind,
component: "ReviewStoreAdapter",
operation: "startup_toggle_state_load"
});
}
return workspace.ok && workspace.value.reviewItems.some((item) => item.problemId === problemId);
},
onToggle(input) {
const result = input.isRegistered ? reviewMutationService.unregisterProblem({
problemId: input.problemId,
today: input.today
}) : reviewMutationService.registerProblem({
problemId: input.problemId,
problemTitle: input.problemTitle,
today: input.today
});
if (!result.ok) {
if (result.error.kind === "storage_unavailable") {
recordDiagnostic({
code: result.error.kind,
component: "ReviewMutationService",
operation: "toggle_click"
});
}
return input.isRegistered;
}
return result.value.reviewWorkspace.reviewItems.some(
(item) => item.problemId === input.problemId
);
}
});
const toggleMountResult = page.kind === "problem" || page.kind === "submission_detail" ? toggleMountCoordinator.mount() : null;
if (toggleMountResult !== null && !toggleMountResult.ok) {
recordDiagnostic({
code: toggleMountResult.error.kind,
component: "ToggleMountCoordinator",
operation: "startup_toggle_mount"
});
}
const today = getToday();
const dailySuggestionResult = dailySuggestionService.ensureTodaySuggestion({
today,
trigger: "bootstrap"
});
if (!dailySuggestionResult.ok) {
recordDiagnostic({
code: dailySuggestionResult.error.kind,
component: "DailySuggestionService",
operation: "startup_daily_suggestion"
});
}
if (dailySuggestionResult.ok && dailySuggestionResult.value.shouldAutoOpenPopup) {
presentPopup({
source: "bootstrap",
today,
reviewWorkspace: dailySuggestionResult.value.reviewWorkspace
});
}
return {
session: "authenticated",
menuEntryMounted: mountResult.ok && mountResult.value.mounted,
toggleMounted: toggleMountResult?.ok === true && toggleMountResult.value.mounted
};
}
function createDiagnosticRecorder(diagnosticSink) {
return (event) => {
diagnosticSink?.(event);
};
}
function resolvePlatformPorts(dependencies) {
const reviewStorage = dependencies.platform?.reviewStorage ?? dependencies.reviewStorage ?? createUserscriptPlatformPorts().reviewStorage;
const rng = dependencies.platform?.rng ?? dependencies.rng ?? Math.random;
const diagnosticSink = dependencies.platform?.diagnosticSink ?? dependencies.diagnosticSink ?? (() => void 0);
return {
rng,
reviewStorage,
diagnosticSink
};
}
// src/main.ts
if (false) {
bootstrapUserscript({
platform: createUserscriptPlatformPorts({
dev: true,
consoleRef: console
})
});
logDevWorkspaceSnapshot(console);
} else {
bootstrapUserscript({
platform: createUserscriptPlatformPorts()
});
}
})();