import { isKnown5chanDeveloper } from './author-display-utils'; interface DirectoryFeatures { postsPerPage?: number; pseudonymityMode?: string; nsfw?: boolean; noSpoilers?: boolean; noSpoilerReplies?: boolean; hasFlags?: boolean; requirePostLink?: boolean; requirePostLinkIsMedia?: boolean; [key: string]: unknown; } export interface DirectoryCommunity { title?: string; address: string; name?: string; publicKey?: string; nsfw?: boolean; directoryCode?: string; features?: DirectoryFeatures; } export interface DirectoriesData { title: string; description: string; createdAt: number; updatedAt: number; communities: DirectoryCommunity[]; } export interface DirectoryListBoard { address: string; publicKey?: string; owner?: string; score?: number; addedAt?: number; nsfw?: boolean; features?: DirectoryFeatures; } export interface DirectoryList { directoryCode: string; title?: string; description?: string; features?: DirectoryFeatures; rules?: string[]; createdAt?: number; updatedAt?: number; boards: DirectoryListBoard[]; } interface DirectoryDefaultsEntry { directoryCode?: string; title?: string; features?: DirectoryFeatures; rules?: string[]; } export interface DirectoryDefaultsData { title?: string; description?: string; createdAt?: number; updatedAt?: number; directories: Record; } const DIRECTORY_CODE_ORDER = [ 'a', 'f', 'co', 'ck', 'pol', 'biz', 'sci', 'g', 'v', 'vg', 'vr', 'fit', 'sp', 'tg', 'adv', 'wsg', 'diy', 'out', 'i', 'ic', 'mu', 'int', 'lit', 'his', 'tv', 't', 'x', 'vip', 'gif', 'bant', 'b', 'an', ] as const; const DIRECTORY_CODE_ORDER_INDEX: ReadonlyMap = new Map(DIRECTORY_CODE_ORDER.map((code, index) => [code, index])); export const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const toNumber = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined); export const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined); const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => { if (!isRecord(value)) { return undefined; } const normalizedFeatures = Object.entries(value).reduce((acc, [key, featureValue]) => { if (typeof featureValue === 'string' || typeof featureValue === 'boolean' || typeof featureValue === 'number') { acc[key] = featureValue; } return acc; }, {}); return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : undefined; }; const normalizeRules = (value: unknown): string[] | undefined => { if (!Array.isArray(value)) { return undefined; } const rules = value.filter((rule): rule is string => typeof rule === 'string' && rule.length > 0); return rules.length > 0 ? rules : undefined; }; const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryDefaultsEntry => { if (!isRecord(raw)) { return { directoryCode: code }; } const directoryCode = toString(raw.directoryCode) ?? code; const features = normalizeFeatures(raw.features); const rules = normalizeRules(raw.rules); return { directoryCode, ...(toString(raw.title) ? { title: toString(raw.title)! } : {}), ...(features ? { features } : {}), ...(rules ? { rules } : {}), }; }; export const normalizeDirectoryDefaultsData = (raw: unknown): DirectoryDefaultsData => { const directoriesRaw = isRecord(raw) && isRecord(raw.directories) ? raw.directories : {}; const directories = Object.fromEntries(Object.entries(directoriesRaw).map(([code, value]) => [code, normalizeDirectoryDefaultsEntry(code, value)])); return { ...(isRecord(raw) && toString(raw.title) ? { title: toString(raw.title)! } : {}), ...(isRecord(raw) && toString(raw.description) ? { description: toString(raw.description)! } : {}), ...(isRecord(raw) && toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}), ...(isRecord(raw) && toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}), directories, }; }; const deriveNsfw = (value: { nsfw?: unknown; features?: { nsfw?: boolean; safeForWork?: boolean } }): boolean | undefined => { const features = value.features; const safeForWork = typeof features?.safeForWork === 'boolean' ? features.safeForWork : undefined; if (safeForWork !== undefined) { return !safeForWork; } const featuresNsfw = typeof features?.nsfw === 'boolean' ? features.nsfw : undefined; const topLevelNsfw = typeof value.nsfw === 'boolean' ? value.nsfw : undefined; return topLevelNsfw ?? featuresNsfw; }; export const toCanonicalCommunity = (value: { address?: unknown; communityAddress?: unknown; name?: unknown; publicKey?: unknown; title?: unknown; nsfw?: unknown; directoryCode?: unknown; features?: unknown; }): DirectoryCommunity | null => { const name = toString(value.name) ?? toString(value.address) ?? toString(value.communityAddress); if (!name) { return null; } const features = normalizeFeatures(value.features); const nsfw = deriveNsfw({ nsfw: value.nsfw, features }); return { address: name, name, ...(typeof value.publicKey === 'string' ? { publicKey: value.publicKey } : {}), ...(typeof value.title === 'string' ? { title: value.title } : {}), ...(typeof value.directoryCode === 'string' ? { directoryCode: value.directoryCode } : {}), ...(features ? { features } : {}), ...(nsfw !== undefined ? { nsfw } : {}), }; }; const normalizeDirectoryListBoard = (raw: unknown): DirectoryListBoard | null => { if (!isRecord(raw)) return null; const address = toString(raw.address) ?? toString(raw.name); if (!address) return null; const features = normalizeFeatures(raw.features); const nsfw = deriveNsfw({ nsfw: raw.nsfw, features }); const score = toNumber(raw.score); return { address, ...(toString(raw.publicKey) ? { publicKey: toString(raw.publicKey)! } : {}), ...(toString(raw.owner) ? { owner: toString(raw.owner)! } : {}), ...(score !== undefined ? { score } : {}), ...(toNumber(raw.addedAt) !== undefined ? { addedAt: toNumber(raw.addedAt) } : {}), ...(features ? { features } : {}), ...(nsfw !== undefined ? { nsfw } : {}), }; }; export const normalizeDirectoryList = (raw: unknown, fallbackCode: string, defaults?: DirectoryDefaultsData): DirectoryList | null => { if (!isRecord(raw)) return null; const boardsRaw = Array.isArray(raw.boards) ? raw.boards : Array.isArray(raw.communities) ? raw.communities : null; if (!boardsRaw) return null; const boards = boardsRaw.map(normalizeDirectoryListBoard).filter((board): board is DirectoryListBoard => board !== null); if (boards.length === 0) return null; const rawCode = toString(raw.directoryCode); const defaultEntry = defaults?.directories[rawCode ?? fallbackCode] ?? defaults?.directories[fallbackCode]; const directoryCode = toString(defaultEntry?.directoryCode) ?? rawCode ?? fallbackCode; const features = normalizeFeatures(defaultEntry?.features) ?? normalizeFeatures(raw.features); const rules = normalizeRules(raw.rules); return { directoryCode, ...(toString(defaultEntry?.title) ? { title: toString(defaultEntry?.title)! } : toString(raw.title) ? { title: toString(raw.title)! } : {}), ...(toString(raw.description) ? { description: toString(raw.description)! } : {}), ...(features ? { features } : {}), ...(rules ? { rules } : {}), ...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}), ...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}), boards, }; }; export const sortDirectoryLists = (lists: DirectoryList[]): DirectoryList[] => [...lists].sort((a, b) => { const aIndex = DIRECTORY_CODE_ORDER_INDEX.get(a.directoryCode) ?? Number.MAX_SAFE_INTEGER; const bIndex = DIRECTORY_CODE_ORDER_INDEX.get(b.directoryCode) ?? Number.MAX_SAFE_INTEGER; if (aIndex !== bIndex) { return aIndex - bIndex; } return a.directoryCode.localeCompare(b.directoryCode); }); /** * Sort boards by future score data when present. Static list ties break in favor * of known 5chan developer owners, then `addedAt` asc. * Final tie-break is address for deterministic rendering. */ export const sortDirectoryBoardsByRank = (boards: DirectoryListBoard[]): DirectoryListBoard[] => [...boards].sort((a, b) => { if ((b.score ?? 0) !== (a.score ?? 0)) return (b.score ?? 0) - (a.score ?? 0); const aDeveloperOwned = isKnown5chanDeveloper(a.owner); const bDeveloperOwned = isKnown5chanDeveloper(b.owner); if (aDeveloperOwned !== bDeveloperOwned) return aDeveloperOwned ? -1 : 1; if ((a.addedAt ?? Number.MAX_SAFE_INTEGER) !== (b.addedAt ?? Number.MAX_SAFE_INTEGER)) { return (a.addedAt ?? Number.MAX_SAFE_INTEGER) - (b.addedAt ?? Number.MAX_SAFE_INTEGER); } return a.address.localeCompare(b.address); }); const getPrimaryDirectoryBoard = (boards: DirectoryListBoard[]): DirectoryListBoard | null => sortDirectoryBoardsByRank(boards)[0] ?? null; export const directoryListToCommunity = (list: DirectoryList): DirectoryCommunity | null => { const board = getPrimaryDirectoryBoard(list.boards); if (!board) return null; return toCanonicalCommunity({ address: board.address, name: board.address, publicKey: board.publicKey, title: list.title, nsfw: board.nsfw, directoryCode: list.directoryCode, features: list.features ?? board.features, }); };