Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions e2e/support/mock-supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,16 @@ async function handleFeedbackRpc(
sendJson(res, 200, [{ ...row }]);
}

function handleDmojProblem(res: http.ServerResponse, problemCode: string): void {
if (provider === 'fail') {
sendJson(res, 503, { error: 'mock dmoj upstream failure' });
return;
}
sendJson(res, 200, {
data: { object: { code: problemCode, name: 'Mock DMOJ Problem', types: ['Simulation'] } }
});
}

function handleKattisPage(res: http.ServerResponse): void {
if (provider === 'fail') {
res.writeHead(500, { 'Content-Type': 'text/plain' });
Expand Down Expand Up @@ -786,6 +796,12 @@ const server = http.createServer(async (req, res) => {
handleCodeforcesUserStatus(res);
return;
}
if (url.pathname.startsWith('/api/dmoj/problem/')) {
// DMOJ problem API (dmoj.ca/api/v2/problem/<code>) redirected here.
await readBody(req);
handleDmojProblem(res, url.pathname.slice('/api/dmoj/problem/'.length));
return;
}

// Current-actor interaction reads and isolated writes.
if (url.pathname === '/rest/v1/user_preferences') {
Expand Down
7 changes: 4 additions & 3 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,11 @@ const mockPreviewWebServer = {
// deterministic Supabase storage key the auth-seeding init script targets.
PUBLIC_SUPABASE_URL: MOCK_SUPABASE_URL,
PUBLIC_SUPABASE_PUBLISHABLE_KEY: 'mock-publishable-key',
// Redirect the server-side provider fetches (problemset / Kattis page) to
// the mock so no live provider is ever contacted during E2E.
// Redirect the server-side provider fetches (problemset / Kattis page /
// DMOJ API) to the mock so no live provider is ever contacted during E2E.
PUBLIC_CODEFORCES_API_BASE: `${MOCK_SUPABASE_URL}/api`,
PUBLIC_KATTIS_BASE: MOCK_SUPABASE_URL
PUBLIC_KATTIS_BASE: MOCK_SUPABASE_URL,
PUBLIC_DMOJ_API_BASE: `${MOCK_SUPABASE_URL}/api/dmoj`
}
};

Expand Down
3 changes: 2 additions & 1 deletion scripts/coverage-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ const publicEnv = Object.freeze({
PUBLIC_SUPABASE_URL: process.env.PUBLIC_SUPABASE_URL ?? 'http://localhost',
PUBLIC_SUPABASE_PUBLISHABLE_KEY: process.env.PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? 'placeholder',
PUBLIC_CODEFORCES_API_BASE: process.env.PUBLIC_CODEFORCES_API_BASE,
PUBLIC_KATTIS_BASE: process.env.PUBLIC_KATTIS_BASE
PUBLIC_KATTIS_BASE: process.env.PUBLIC_KATTIS_BASE,
PUBLIC_DMOJ_API_BASE: process.env.PUBLIC_DMOJ_API_BASE
});
const virtualModules = new Map([
['$app/environment', 'export const browser = false; export const dev = false;'],
Expand Down
2 changes: 1 addition & 1 deletion src/lib/collections/problemCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type SolvedFilter = 'all' | 'solved' | 'unsolved';
export type ProblemSourceFilter = 'all' | Problem['source'];

const SOLVED_FILTER_STATES = ['all', 'solved', 'unsolved'] as const;
const SOURCE_FILTER_STATES = ['all', 'codeforces', 'kattis'] as const;
const SOURCE_FILTER_STATES = ['all', 'codeforces', 'kattis', 'dmoj'] as const;

type ProblemCollectionState = {
sourceItems: readonly Problem[];
Expand Down
21 changes: 19 additions & 2 deletions src/lib/components/ProblemTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
import TableFeedbackButtons from './TableFeedbackButtons.svelte';
const codeforcesLogo = '/images/codeforces.png';
const kattisLogo = '/images/kattis.png';
const dmojLogo = '/images/dmoj.svg';
const SOURCE_LOGOS: Record<Problem['source'], string> = {
codeforces: codeforcesLogo,
kattis: kattisLogo,
dmoj: dmojLogo
};

export let problems: Problem[] = [];
export let bodyId: string | undefined = undefined;
Expand All @@ -22,7 +28,7 @@
export let difficultySortDirection: SortDirection = null;
export let solvedFilterState: 'all' | 'solved' | 'unsolved' = 'all';
export let authorFilterValue: string | null = null;
export let sourceFilterValue: 'all' | 'codeforces' | 'kattis' = 'all';
export let sourceFilterValue: 'all' | Problem['source'] = 'all';
export let onLike: (problemId: string, isLike: boolean) => Promise<void>;
export let onToggleSolved: (problemId: string, isSolved: boolean) => Promise<void>;
export let onDifficultySort: () => void;
Expand Down Expand Up @@ -74,6 +80,8 @@
function getDifficultyTooltip(problem: Problem): string {
if (problem.source === 'kattis') {
return `Kattis difficulty mapped from 1-10 scale to 800-3500 rating range`;
} else if (problem.source === 'dmoj') {
return `DMOJ scores problems in points, which are not comparable to the 800-3500 rating range`;
} else {
return `${getRatingTierName(problem.difficulty)}`;
}
Expand Down Expand Up @@ -180,6 +188,15 @@
></div>
</div>
</span>
{:else if sourceFilterValue === 'dmoj'}
<span class="text-sm font-bold text-[#2e7d32]" aria-hidden="true">
<div class="relative">
<img src={dmojLogo} alt="" class="h-5 w-5 object-contain" />
<div
class="absolute -right-1 -bottom-1 h-3 w-3 rounded border border-white bg-[#2e7d32]"
></div>
</div>
</span>
{:else}
<span class="text-sm font-bold text-[var(--color-text-muted)]" aria-hidden="true">
<svg
Expand Down Expand Up @@ -298,7 +315,7 @@
<td class="p-2 text-center md:p-3">
<span class="flex items-center justify-center">
<img
src={problem.source === 'codeforces' ? codeforcesLogo : kattisLogo}
src={SOURCE_LOGOS[problem.source]}
alt={problem.source}
class="h-6 w-6 object-contain"
/>
Expand Down
150 changes: 150 additions & 0 deletions src/lib/providers/dmoj/ingestion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type {
DuplicateCheckResult,
ExtractedEntry,
ProblemDraft,
ResolvedItem
} from '../../submit/types.ts';

export const DMOJ_HOST = 'dmoj.ca';
const ALLOWED_HOSTS = new Set([DMOJ_HOST, 'www.dmoj.ca']);

export type DmojProblemInfo = { problemCode: string; url: string };
export type DmojProblemMetadata = { name: string; types: string[] };

export type DmojIngestionDependencies = {
checkProblem: (canonicalUrl: string) => Promise<DuplicateCheckResult>;
fetchProblem: (url: string) => Promise<unknown>;
parseProblem?: (payload: unknown, problemCode: string) => DmojProblemMetadata;
now?: () => string;
logError?: (message: string, error: unknown) => void;
};

export function buildCanonicalDmojProblemUrl(problemCode: string): string {
return `https://${DMOJ_HOST}/problem/${problemCode}`;
}

/**
* Reduce input to a DMOJ problem code. Unlike Kattis this rejects bare codes:
* DMOJ codes are opaque short strings, so accepting them unqualified would let
* any stray word classify as a DMOJ problem.
*/
export function parseDmojProblemCode(input: string): string | null {
if (typeof input !== 'string') return null;
const trimmed = input.trim();
if (!trimmed) return null;

const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(trimmed);
const isSchemeRelative = trimmed.startsWith('//');
const candidate = hasScheme || isSchemeRelative ? trimmed : `https://${trimmed}`;

let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
if (parsed.protocol !== 'https:' || !ALLOWED_HOSTS.has(parsed.hostname)) return null;
if (parsed.username || parsed.password || parsed.port) return null;
return parsed.pathname.match(/^\/problem\/([a-z0-9_]+)$/)?.[1] ?? null;
}

export function parseDmojProblem(input: string): DmojProblemInfo | null {
const problemCode = parseDmojProblemCode(input);
return problemCode ? { problemCode, url: buildCanonicalDmojProblemUrl(problemCode) } : null;
}

export function extractDmojEntries(text: string): ExtractedEntry[] {
const seen = new Set<string>();
const entries: ExtractedEntry[] = [];
for (const input of text.split(/[\n\s]+/)) {
const info = parseDmojProblem(input.trim());
if (!info || seen.has(info.url)) continue;
seen.add(info.url);
entries.push({ kind: 'problem', url: info.url });
}
return entries;
}

export function formatDmojLabel(url: string, name?: string): string {
if (name) return name;
return url.replace(/^https?:\/\/(?:www\.)?dmoj\.ca\/problem\/([a-z0-9_]+).*$/, '$1');
}

/** Read the name and problem types out of a DMOJ API v2 single-problem payload. */
export function parseDmojProblemPayload(
payload: unknown,
problemCode: string
): DmojProblemMetadata {
const object = (payload as { data?: { object?: Record<string, unknown> } })?.data?.object;
const name = typeof object?.name === 'string' ? object.name.trim() : '';
const types = Array.isArray(object?.types)
? object.types.filter((type): type is string => typeof type === 'string')
: [];
return { name: name || problemCode, types };
}

export function createDmojIngestion(dependencies: DmojIngestionDependencies) {
const now = dependencies.now ?? (() => new Date().toISOString());
const parseProblem = dependencies.parseProblem ?? parseDmojProblemPayload;
const logError =
dependencies.logError ?? ((message: string, error: unknown) => console.error(message, error));

async function resolve(
entry: ExtractedEntry,
handle: string = 'anonymous'
): Promise<ResolvedItem> {
const info = parseDmojProblem(entry.url);
if (!info) {
return {
valid: false,
kind: 'problem',
label: entry.url,
url: entry.url,
reason: 'Invalid URL'
};
}

const duplicate = await dependencies.checkProblem(info.url);
if (duplicate.error || duplicate.duplicate) {
return {
valid: false,
kind: 'problem',
label: formatDmojLabel(info.url),
url: info.url,
reason: duplicate.message ?? duplicate.error ?? 'Problem already exists in database'
};
}

// DMOJ scores problems in points, which are not comparable to the stored
// Codeforces-style rating range, so difficulty is deliberately left unset.
let metadata: DmojProblemMetadata;
try {
metadata = parseProblem(await dependencies.fetchProblem(info.url), info.problemCode);
} catch (error) {
logError('Error fetching DMOJ problem metadata:', error);
metadata = { name: info.problemCode, types: [] };
}

const draft: ProblemDraft = {
name: metadata.name,
tags: metadata.types,
url: info.url,
solved: 0,
dateAdded: now(),
addedBy: handle,
addedByUrl: handle ? `https://${DMOJ_HOST}/user/${handle}` : `https://${DMOJ_HOST}`,
likes: 0,
dislikes: 0
};

return {
valid: true,
kind: 'problem',
label: formatDmojLabel(info.url, draft.name),
url: info.url,
payload: draft
};
}

return { extract: extractDmojEntries, resolve };
}
2 changes: 1 addition & 1 deletion src/lib/queries/problemQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type Problem = {
addedByUrl: string;
likes: number;
dislikes: number;
source: 'codeforces' | 'kattis';
source: 'codeforces' | 'kattis' | 'dmoj';
type?: string;
};

Expand Down
6 changes: 4 additions & 2 deletions src/lib/services/problemSource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { parseDmojProblemCode } from '../providers/dmoj/ingestion.ts';
import { parseKattisProblemId } from '../providers/kattis/ingestion.ts';

/** Determine the problem source using the exact-host Kattis URL validator. */
export function getProblemSource(url: string): 'codeforces' | 'kattis' {
/** Determine the problem source using the exact-host provider URL validators. */
export function getProblemSource(url: string): 'codeforces' | 'kattis' | 'dmoj' {
if (parseDmojProblemCode(url)) return 'dmoj';
return parseKattisProblemId(url) ? 'kattis' : 'codeforces';
}
33 changes: 33 additions & 0 deletions src/lib/submit/providers/dmoj.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createDmojIngestion } from '$lib/providers/dmoj/ingestion';
import type { ProviderAdapter, SubmissionPersistence } from '$lib/submit/types';

async function fetchDmojProblem(url: string, fetchProblem: typeof fetch = fetch): Promise<unknown> {
const response = await fetchProblem(`/api/dmoj?url=${encodeURIComponent(url)}`);
const data = (await response.json()) as { problem?: unknown; error?: string };
if (!response.ok) throw new Error(data.error || 'Failed to fetch problem');
return data.problem;
}

export function createDmojSubmitAdapter(
persistence: SubmissionPersistence,
fetchProblem: typeof fetch = fetch
): ProviderAdapter {
const ingestion = createDmojIngestion({
checkProblem: async (url) => persistence.checkEquivalentProblemUrls(url),
fetchProblem: (url) => fetchDmojProblem(url, fetchProblem)
});

return {
id: 'dmoj',
name: 'DMOJ',
icon: '/images/dmoj.svg',
placeholder: 'https://dmoj.ca/problem/ciw26p2\nhttps://dmoj.ca/problem/helloworld',
help: 'Paste DMOJ problem URLs. Separate entries with spaces or new lines.',
extract: ingestion.extract,
resolve: ingestion.resolve,
commit: (item) =>
item.kind === 'problem'
? persistence.insertProblem(item.payload)
: Promise.resolve({ success: false, message: 'Failed to add entry' })
};
}
6 changes: 4 additions & 2 deletions src/lib/submit/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import {
} from '$lib/submit/submissionPersistence';
import type { ProviderAdapters } from '$lib/submit/types';
import { createCodeforcesSubmitAdapter } from './codeforces';
import { createDmojSubmitAdapter } from './dmoj';
import { createKattisSubmitAdapter } from './kattis';

export function createProviderAdapters(): ProviderAdapters {
const persistence = createSubmissionPersistence(supabase as unknown as SubmissionClient);
return {
codeforces: createCodeforcesSubmitAdapter(persistence),
kattis: createKattisSubmitAdapter(persistence)
kattis: createKattisSubmitAdapter(persistence),
dmoj: createDmojSubmitAdapter(persistence)
};
}

export const providerOrder = ['codeforces', 'kattis'] as const;
export const providerOrder = ['codeforces', 'kattis', 'dmoj'] as const;
2 changes: 1 addition & 1 deletion src/lib/submit/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ProviderId = 'codeforces' | 'kattis';
export type ProviderId = 'codeforces' | 'kattis' | 'dmoj';
export type SubmitItemKind = 'problem' | 'contest';
export type WorkflowStage = 'source' | 'links' | 'review' | 'complete';

Expand Down
4 changes: 3 additions & 1 deletion src/lib/submit/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export type ResolveOutcome =

export function providerFromUrl(url: URL): ProviderId | undefined {
const provider = url.searchParams.get('provider');
return provider === 'codeforces' || provider === 'kattis' ? provider : undefined;
return provider === 'codeforces' || provider === 'kattis' || provider === 'dmoj'
? provider
: undefined;
}

function deriveState(
Expand Down
Loading
Loading