diff --git a/src/collate.ts b/src/collate.ts new file mode 100644 index 0000000..8498274 --- /dev/null +++ b/src/collate.ts @@ -0,0 +1,144 @@ +import type { Embed, Message } from "@/discord/schemas.ts"; + +export type FlattenedEmbed = { + channelId: string; + embed: Embed; + messageAuthor: { id: string; username: string; bot?: boolean }; + messageId: string; + messageTimestamp: string; +}; + +export type ExtractedFieldRow = { + channelId: string; + embedDescription: string | null; + embedTitle: string | null; + fields: Record; + messageId: string; + messageTimestamp: string; +}; + +export type CollatedData = { + byAuthor: Record; + byChannel: Record; + embeds: FlattenedEmbed[]; + embedsByAuthor: Record; + embedsByDomain: Record; + embedsByProvider: Record; + embedsByType: Record; + extractedFields: ExtractedFieldRow[]; + messages: Message[]; + totalEmbeds: number; + totalMessages: number; +}; + +const extractDomain = (url: string): string | null => { + try { + return new URL(url).hostname; + } catch { + return null; + } +}; + +const increment = (record: Record, key: string): void => { + record[key] = (record[key] ?? 0) + 1; +}; + +const processEmbed = (embed: Embed, data: CollatedData): void => { + if (embed.type) { + increment(data.embedsByType, embed.type); + } + if (embed.provider?.name) { + increment(data.embedsByProvider, embed.provider.name); + } + if (embed.author?.name) { + increment(data.embedsByAuthor, embed.author.name); + } + + if (embed.url) { + const domain = extractDomain(embed.url); + if (domain) { + increment(data.embedsByDomain, domain); + } + } +}; + +const extractFields = ( + embed: Embed, + msg: Message, + data: CollatedData +): void => { + if (!embed.fields || embed.fields.length === 0) { + return; + } + + const fields: Record = {}; + for (const field of embed.fields) { + fields[field.name] = field.value; + } + + data.extractedFields.push({ + messageId: msg.id, + channelId: msg.channel_id, + messageTimestamp: msg.timestamp, + embedTitle: embed.title ?? null, + embedDescription: embed.description ?? null, + fields, + }); +}; + +const processMessage = (msg: Message, data: CollatedData): void => { + increment(data.byChannel, msg.channel_id); + + const authorEntry = data.byAuthor[msg.author.id]; + if (authorEntry) { + authorEntry.count++; + } else { + data.byAuthor[msg.author.id] = { + count: 1, + username: msg.author.username, + bot: msg.author.bot, + }; + } + + const embeds = msg.embeds ?? []; + data.totalEmbeds += embeds.length; + + for (const embed of embeds) { + data.embeds.push({ + messageId: msg.id, + channelId: msg.channel_id, + messageTimestamp: msg.timestamp, + messageAuthor: { + id: msg.author.id, + username: msg.author.username, + bot: msg.author.bot, + }, + embed, + }); + + processEmbed(embed, data); + extractFields(embed, msg, data); + } +}; + +export const collateResults = (messages: Message[]): CollatedData => { + const data: CollatedData = { + totalMessages: messages.length, + byChannel: {}, + byAuthor: {}, + totalEmbeds: 0, + embedsByType: {}, + embedsByProvider: {}, + embedsByDomain: {}, + embedsByAuthor: {}, + messages, + embeds: [], + extractedFields: [], + }; + + for (const msg of messages) { + processMessage(msg, data); + } + + return data; +}; diff --git a/src/errors.ts b/src/errors.ts index 0ccbe49..377f3bb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -32,3 +32,8 @@ export class ExportError extends TaggedError("ExportError")<{ message: string; cause: unknown; }>() {} + +export class PresetError extends TaggedError("PresetError")<{ + message: string; + cause: unknown; +}>() {} diff --git a/src/export.ts b/src/export.ts new file mode 100644 index 0000000..455cdcc --- /dev/null +++ b/src/export.ts @@ -0,0 +1,195 @@ +import { Result } from "better-result"; +import type { CollatedData } from "@/collate.ts"; +import { ExportError } from "@/errors.ts"; + +const escapeCsvField = (value: string): string => { + if ( + value.includes(",") || + value.includes('"') || + value.includes("\n") || + value.includes("\r") + ) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +}; + +const toCsvRow = (fields: string[]): string => + fields.map(escapeCsvField).join(","); + +export const exportJson = async ( + data: CollatedData, + filePath: string +): Promise> => { + return await Result.tryPromise({ + try: async () => { + const { messages: _messages, ...summary } = data; + const output = { + ...summary, + messages: data.messages.map((msg) => ({ + id: msg.id, + channel_id: msg.channel_id, + author: msg.author, + content: msg.content, + timestamp: msg.timestamp, + embeds: msg.embeds, + attachments: msg.attachments, + })), + }; + await Bun.write(filePath, JSON.stringify(output, null, 2)); + }, + catch: (cause) => + new ExportError({ + message: `Failed to export JSON: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); +}; + +export const exportMessagesCsv = async ( + data: CollatedData, + filePath: string +): Promise> => { + return await Result.tryPromise({ + try: async () => { + const headers = [ + "message_id", + "channel_id", + "timestamp", + "author_id", + "author_username", + "author_is_bot", + "content", + "embed_count", + "attachment_count", + ]; + + const rows = [toCsvRow(headers)]; + + for (const msg of data.messages) { + rows.push( + toCsvRow([ + msg.id, + msg.channel_id, + msg.timestamp, + msg.author.id, + msg.author.username, + String(msg.author.bot ?? false), + msg.content, + String(msg.embeds?.length ?? 0), + String(msg.attachments?.length ?? 0), + ]) + ); + } + + await Bun.write(filePath, rows.join("\n")); + }, + catch: (cause) => + new ExportError({ + message: `Failed to export messages CSV: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); +}; + +export const exportEmbedsCsv = async ( + data: CollatedData, + filePath: string +): Promise> => { + return await Result.tryPromise({ + try: async () => { + const headers = [ + "message_id", + "channel_id", + "timestamp", + "author_id", + "author_username", + "embed_type", + "embed_title", + "embed_description", + "embed_url", + "embed_provider", + "embed_author", + "field_count", + ]; + + const rows = [toCsvRow(headers)]; + + for (const entry of data.embeds) { + rows.push( + toCsvRow([ + entry.messageId, + entry.channelId, + entry.messageTimestamp, + entry.messageAuthor.id, + entry.messageAuthor.username, + entry.embed.type ?? "", + entry.embed.title ?? "", + entry.embed.description ?? "", + entry.embed.url ?? "", + entry.embed.provider?.name ?? "", + entry.embed.author?.name ?? "", + String(entry.embed.fields?.length ?? 0), + ]) + ); + } + + await Bun.write(filePath, rows.join("\n")); + }, + catch: (cause) => + new ExportError({ + message: `Failed to export embeds CSV: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); +}; + +export const exportFieldsCsv = async ( + data: CollatedData, + filePath: string +): Promise> => { + return await Result.tryPromise({ + try: async () => { + // Collect all unique field names across all extracted fields + const fieldNames = new Set(); + for (const row of data.extractedFields) { + for (const name of Object.keys(row.fields)) { + fieldNames.add(name); + } + } + + const sortedFieldNames = [...fieldNames].sort(); + + const headers = [ + "message_id", + "channel_id", + "timestamp", + "embed_title", + "embed_description", + ...sortedFieldNames, + ]; + + const rows = [toCsvRow(headers)]; + + for (const row of data.extractedFields) { + rows.push( + toCsvRow([ + row.messageId, + row.channelId, + row.messageTimestamp, + row.embedTitle ?? "", + row.embedDescription ?? "", + ...sortedFieldNames.map((name) => row.fields[name] ?? ""), + ]) + ); + } + + await Bun.write(filePath, rows.join("\n")); + }, + catch: (cause) => + new ExportError({ + message: `Failed to export fields CSV: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); +}; diff --git a/src/paths.ts b/src/paths.ts index f06576d..831ec39 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; export const APP_DIR = join(homedir(), ".discord-search"); export const SETTINGS_FILE = join(APP_DIR, "settings.json"); +export const PRESETS_FILE = join(APP_DIR, ".discord-search-presets.json"); export const OUTPUT_DIR = join(APP_DIR, "output"); const chmodSafe = async (path: string, mode: number): Promise => { diff --git a/src/presets.ts b/src/presets.ts new file mode 100644 index 0000000..91dff80 --- /dev/null +++ b/src/presets.ts @@ -0,0 +1,84 @@ +import { Result } from "better-result"; +import { z } from "zod"; +import { SearchParamsSchema } from "@/discord/schemas.ts"; +import { PresetError } from "@/errors.ts"; +import { PRESETS_FILE } from "@/paths.ts"; + +const PresetSchema = z.object({ + name: z.string(), + params: SearchParamsSchema, +}); + +const PresetsArraySchema = z.array(PresetSchema); + +export type Preset = z.infer; + +export const loadPresets = async (): Promise> => { + return await Result.tryPromise({ + try: async () => { + const file = Bun.file(PRESETS_FILE); + const exists = await file.exists(); + if (!exists) { + return []; + } + const text = await file.text(); + const parsed = JSON.parse(text); + return PresetsArraySchema.parse(parsed); + }, + catch: (cause) => + new PresetError({ + message: `Failed to load presets: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); +}; + +export const savePreset = async ( + name: string, + params: z.infer +): Promise> => { + return await Result.tryPromise({ + try: async () => { + const presetsResult = await loadPresets(); + if (!presetsResult.isOk()) { + throw presetsResult.error; + } + const presets = presetsResult.value; + + const index = presets.findIndex((p) => p.name === name); + if (index >= 0) { + presets[index] = { name, params }; + } else { + presets.push({ name, params }); + } + + await Bun.write(PRESETS_FILE, JSON.stringify(presets, null, 2)); + }, + catch: (cause) => + new PresetError({ + message: `Failed to save preset: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); +}; + +export const deletePreset = async ( + name: string +): Promise> => { + return await Result.tryPromise({ + try: async () => { + const presetsResult = await loadPresets(); + if (!presetsResult.isOk()) { + throw presetsResult.error; + } + const presets = presetsResult.value; + const filtered = presets.filter((p) => p.name !== name); + await Bun.write(PRESETS_FILE, JSON.stringify(filtered, null, 2)); + }, + catch: (cause) => + new PresetError({ + message: `Failed to delete preset: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); +};