import type { JsonRecord, ListingSort, SearchSort, ShapedComment, ShapedListing, ShapedPost, ShapedSubreddit, ShapedUser, SubmitKind, TimeFilter, UserListing, VoteDirection, } from './types.ts' import { asRecord, booleanValue, numberValue, stringValue } from './types.ts' const SELFTEXT_LIMIT = 2000 const COMMENT_BODY_LIMIT = 2000 export function normalizeSubreddit(value: unknown, label = 'subreddit'): string { const raw = requireString(value, label + ' is required.') const trimmed = raw.replace(/^\/?(r\/)?/i, '').replace(/\/+$/, '') if (!trimmed) { throw new Error(label + ' is required.') } if (!/^[A-Za-z0-9_]+$/.test(trimmed)) { throw new Error(label + ' must be a Reddit community name.') } return trimmed } export function normalizeUsername(value: unknown): string { const raw = requireString(value, 'username is required.') const trimmed = raw.replace(/^\/?(u\/|user\/)/i, '').replace(/\/+$/, '') if (!trimmed) { throw new Error('username is required.') } if (!/^[A-Za-z0-9_-]+$/.test(trimmed)) { throw new Error('username must be a Reddit username.') } return trimmed } export function normalizeThingId(value: unknown, prefix: 't1' | 't3', label: string): string { const raw = requireString(value, label + ' is required.') if (raw.startsWith(prefix + '_')) return raw if (/^[A-Za-z0-9]+$/.test(raw)) return prefix + '_' + raw throw new Error(label + ' must be a Reddit ' + prefix + ' fullname or id.') } export function optionalLimit(value: unknown, fallback = 25): number { if (value === undefined || value === null || value === '') return fallback if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > 100) { throw new Error('limit must be an integer from 1 through 100.') } return value } export function listingSort(value: unknown, fallback: ListingSort = 'hot'): ListingSort { const sort = value === undefined || value === null || value === '' ? fallback : value switch (sort) { case 'best': case 'hot': case 'new': case 'top': case 'rising': case 'controversial': return sort default: throw new Error('sort must be best, hot, new, top, rising, or controversial.') } } export function searchSort(value: unknown, fallback: SearchSort = 'relevance'): SearchSort { const sort = value === undefined || value === null || value === '' ? fallback : value switch (sort) { case 'relevance': case 'hot': case 'top': case 'new': case 'comments': return sort default: throw new Error('sort must be relevance, hot, top, new, or comments.') } } export function timeFilter(value: unknown): TimeFilter | undefined { if (value === undefined || value === null || value === '') return undefined switch (value) { case 'hour': case 'day': case 'week': case 'month': case 'year': case 'all': return value default: throw new Error('t must be hour, day, week, month, year, or all.') } } export function userListing(value: unknown, fallback: UserListing = 'about'): UserListing { const listing = value === undefined || value === null || value === '' ? fallback : value switch (listing) { case 'about': case 'submitted': case 'comments': case 'overview': return listing default: throw new Error('listing must be about, submitted, comments, or overview.') } } export function submitKind(value: unknown, fallback: SubmitKind = 'self'): SubmitKind { const kind = value === undefined || value === null || value === '' ? fallback : value switch (kind) { case 'self': case 'link': return kind default: throw new Error('kind must be self or link.') } } export function voteDirection(value: unknown): VoteDirection { switch (value) { case 1: case '1': case 'up': return 1 case 0: case '0': case 'clear': return 0 case -1: case '-1': case 'down': return -1 default: throw new Error("direction must be 1, 0, -1, 'up', 'down', or 'clear'.") } } export function requireString(value: unknown, message: string): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new Error(message) } return value.trim() } export function requireConfirm(confirm: unknown, message: string): void { if (confirm !== true) { throw new Error(message) } } export function truncateText(value: string | null, limit: number): { text: string | null; truncated: boolean } { if (value === null) return { text: null, truncated: false } if (value.length <= limit) return { text: value, truncated: false } return { text: value.slice(0, limit), truncated: true } } export function listingPath(subreddit: string | undefined, sort: ListingSort): string { if (subreddit) return '/r/' + encodeURIComponent(subreddit) + '/' + sort return '/' + sort } export function shapeListing(body: unknown): ShapedListing { const listing = unwrapListing(body) return { kind: 'listing', after: stringValue(listing.after) ?? null, before: stringValue(listing.before) ?? null, posts: childrenOf(listing).flatMap((child) => { if (child.kind !== 't3') return [] const post = shapePost(child.data) return post ? [post] : [] }), } } export function shapeCommentListing(body: unknown): ShapedComment[] { const listing = unwrapListing(body) return flattenComments(childrenOf(listing)) } export function shapePost(value: unknown): ShapedPost | null { const data = asRecord(value) if (!data) return null const selftext = truncateText(stringValue(data.selftext) ?? null, SELFTEXT_LIMIT) return { id: stringValue(data.id) ?? '', name: stringValue(data.name) ?? '', title: stringValue(data.title) ?? '', author: stringValue(data.author) ?? '', subreddit: stringValue(data.subreddit) ?? '', score: numberValue(data.score), upvoteRatio: numberValue(data.upvote_ratio), numComments: numberValue(data.num_comments), url: stringValue(data.url) ?? null, permalink: permalinkOf(data.permalink), createdUtc: numberValue(data.created_utc), over18: booleanValue(data.over_18), stickied: booleanValue(data.stickied), isSelf: booleanValue(data.is_self), selftext: selftext.text, selftextTruncated: selftext.truncated, linkFlairText: stringValue(data.link_flair_text) ?? null, } } export function shapeComment(value: unknown): ShapedComment | null { const data = asRecord(value) if (!data) return null const body = truncateText(stringValue(data.body) ?? null, COMMENT_BODY_LIMIT) return { id: stringValue(data.id) ?? '', name: stringValue(data.name) ?? '', author: stringValue(data.author) ?? '', body: body.text, bodyTruncated: body.truncated, score: numberValue(data.score), permalink: permalinkOf(data.permalink), createdUtc: numberValue(data.created_utc), parentId: stringValue(data.parent_id) ?? null, } } export function shapeSubreddit(body: unknown): ShapedSubreddit { const data = unwrapThingData(body) return { id: stringValue(data.id) ?? null, name: stringValue(data.name) ?? null, displayName: stringValue(data.display_name) ?? null, title: stringValue(data.title) ?? null, publicDescription: stringValue(data.public_description) ?? null, subscribers: numberValue(data.subscribers), accountsActive: numberValue(data.accounts_active) ?? numberValue(data.active_user_count), over18: booleanValue(data.over18) || booleanValue(data.over_18), url: stringValue(data.url) ?? null, createdUtc: numberValue(data.created_utc), submissionType: stringValue(data.submission_type) ?? null, } } export function shapeUser(body: unknown): ShapedUser { const data = unwrapThingData(body) return { id: stringValue(data.id) ?? null, name: stringValue(data.name) ?? null, createdUtc: numberValue(data.created_utc) ?? numberValue(data.created), commentKarma: numberValue(data.comment_karma), linkKarma: numberValue(data.link_karma), totalKarma: numberValue(data.total_karma), isGold: booleanValue(data.is_gold), isMod: booleanValue(data.is_mod), verified: booleanValue(data.verified), hasVerifiedEmail: typeof data.has_verified_email === 'boolean' ? data.has_verified_email : null, } } export function shapeMe(body: unknown): ShapedUser & { hasMail: boolean | null } { const data = asRecord(body) ?? unwrapThingData(body) return { ...shapeUser({ kind: 't2', data }), hasMail: typeof data.has_mail === 'boolean' ? data.has_mail : null, } } function unwrapThingData(body: unknown): JsonRecord { const record = asRecord(body) if (!record) { throw new Error('Reddit response was not an object.') } const nested = asRecord(record.data) return nested ?? record } function unwrapListing(body: unknown): JsonRecord { const record = asRecord(body) if (!record) { throw new Error('Reddit listing response was not an object.') } if (record.kind === 'Listing') { return asRecord(record.data) ?? {} } return asRecord(record.data) ?? record } function childrenOf(listing: JsonRecord): Array { if (!Array.isArray(listing.children)) return [] return listing.children.filter((child): child is JsonRecord & { kind?: string; data?: unknown } => { return Boolean(child && typeof child === 'object' && !Array.isArray(child)) }) } function flattenComments(children: Array): ShapedComment[] { const comments: ShapedComment[] = [] for (const child of children) { if (child.kind === 'more') continue if (child.kind === 't1') { const comment = shapeComment(child.data) if (comment) comments.push(comment) const replies = asRecord(child.data)?.replies if (replies && typeof replies === 'object') { comments.push(...shapeCommentListing(replies)) } } } return comments } function permalinkOf(value: unknown): string | null { const permalink = stringValue(value) if (!permalink) return null if (permalink.startsWith('http')) return permalink return 'https://www.reddit.com' + (permalink.startsWith('/') ? permalink : '/' + permalink) }