Files
com.gregkilmer.www/legacy/src/lib/archidekt.ts
T
Claude a2f723ca2d Start the rebuild: note schema, vault kit and data migration
Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 18:40:55 +00:00

180 lines
6.2 KiB
TypeScript

/**
* archidekt.ts
* ---------------------------------------------------------------------------
* Server-side helper for pulling Greg's public MTG decks from Archidekt into
* the TCGs page. The site is SSR (output: 'server'), so this runs on the
* server — no CORS or API key required (Archidekt's decks list endpoint is
* public JSON).
*
* Endpoint: GET https://archidekt.com/api/decks/v3/?ownerUsername=<username>
* -> { count, results: [{ id, name, featured, colors, size, deckFormat,
* updatedAt, private, unlisted, theorycrafted, ... }] }
*
* Decks are cached to a filesystem file for `CACHE_TTL_MS` (default 1 hour) so
* repeat page loads are instant and we're gentle on Archidekt. The cache is
* stale-while-revalidate: if a refresh fails, we serve the stale data rather
* than erroring. Every failure path returns an empty list — the page renders a
* fallback link and never breaks.
*/
/** Greg's Archidekt username. Change here to point at a different account. */
export const ARCHIDEKT_USERNAME = 'Patiun';
/** How long the cached deck list is considered fresh. Default: 1 hour. */
export const CACHE_TTL_MS = 60 * 60 * 1000;
const API_BASE = 'https://archidekt.com/api/decks/v3';
const USER_AGENT = 'GregKilmerProfileSite/1.0 (+https://gregkilmer.com)';
/** Known Archidekt deckFormat ids → labels. Add as needed. */
const FORMAT_LABELS: Record<number, string> = {
1: 'Casual',
2: 'Modern',
3: 'Commander',
5: 'Standard',
7: 'Legacy',
10: 'Pauper',
14: 'Historic',
16: 'Pioneer',
37: 'Oathbreaker',
45: 'Brawl',
};
export interface ArchidektDeck {
id: number;
name: string;
/** Featured card art URL (may be empty). */
featured: string;
/** Mana color counts present in the deck. */
colors: { W: number; U: number; B: number; R: number; G: number };
/** Total card count. */
size: number;
/** Archidekt format id. */
formatId: number;
/** Human-readable format label (e.g. "Commander"). */
formatLabel: string;
/** ISO timestamp of last update. */
updatedAt: string;
/** Direct link to the deck on Archidekt. */
href: string;
theorycrafted: boolean;
}
export const formatLabel = (id: number): string => FORMAT_LABELS[id] ?? 'Custom';
interface RawDeck {
id: number;
name: string;
featured?: string;
customFeatured?: string;
colors?: { W?: number; U?: number; B?: number; R?: number; G?: number };
size?: number;
deckFormat?: number;
updatedAt?: string;
private?: boolean;
unlisted?: boolean;
theorycrafted?: boolean;
}
const fileURL = (username: string): string =>
// node_modules/.cache is gitignored and survives across dev reloads.
new URL(
`../node_modules/.cache/archidekt-${username}.json`,
import.meta.url,
).href;
/**
* Reads the cached decks for `username` if still fresh, else null.
* Best-effort: any read/parse error returns null (treated as a miss).
*/
async function readCache(username: string): Promise<ArchidektDeck[] | null> {
try {
const { readFile } = await import('node:fs/promises');
const raw = await readFile(fileURL(username), 'utf8');
const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] };
if (!data?.fetchedAt || !Array.isArray(data.decks)) return null;
if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null;
return data.decks;
} catch {
return null;
}
}
/** Writes the cache. Failures are non-fatal (best-effort). */
async function writeCache(username: string, decks: ArchidektDeck[]): Promise<void> {
try {
const { mkdir, writeFile } = await import('node:fs/promises');
const { dirname } = await import('node:path');
const { fileURLToPath } = await import('node:url');
const path = fileURLToPath(fileURL(username));
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify({ fetchedAt: Date.now(), decks }, null, 2));
} catch {
/* cache write is best-effort */
}
}
/** Reads stale cache regardless of TTL (used as a fallback on fetch failure). */
async function readStale(username: string): Promise<ArchidektDeck[] | null> {
try {
const { readFile } = await import('node:fs/promises');
const raw = await readFile(fileURL(username), 'utf8');
const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] };
return Array.isArray(data?.decks) ? data.decks : null;
} catch {
return null;
}
}
/**
* Fetches Greg's public Archidekt decks, using a ~1h filesystem cache.
* Never throws — on any failure returns an empty list (page shows a fallback).
*/
export async function getDecks(username = ARCHIDEKT_USERNAME): Promise<ArchidektDeck[]> {
// 1. Fresh cache hit.
const cached = await readCache(username);
if (cached) return cached;
// 2. Live fetch.
const url = `${API_BASE}/?ownerUsername=${encodeURIComponent(username)}`;
try {
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
// Don't let a slow Archidekt hang the page render.
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`Archidekt responded ${res.status}`);
const data = (await res.json()) as { results?: RawDeck[] };
const decks: ArchidektDeck[] = (data.results ?? [])
.filter((d) => !d.private && !d.unlisted)
.map((d) => ({
id: d.id,
name: d.name,
featured: d.customFeatured || d.featured || '',
colors: {
W: d.colors?.W ?? 0,
U: d.colors?.U ?? 0,
B: d.colors?.B ?? 0,
R: d.colors?.R ?? 0,
G: d.colors?.G ?? 0,
},
size: d.size ?? 0,
formatId: d.deckFormat ?? 0,
formatLabel: formatLabel(d.deckFormat ?? 0),
updatedAt: d.updatedAt ?? '',
href: `https://archidekt.com/decks/${d.id}`,
theorycrafted: Boolean(d.theorycrafted),
}));
await writeCache(username, decks);
return decks;
} catch {
// 3. Stale-while-revalidate fallback, else empty.
return (await readStale(username)) ?? [];
}
}
/** The Archidekt search-URL for the full decks list (used in fallback links). */
export const archidektSearchUrl = (username = ARCHIDEKT_USERNAME): string =>
`https://archidekt.com/search/decks?ownerUsername=${encodeURIComponent(username)}`;