matrix-search

A small nodejs script which indexes a whole matrix account into meilisearch and has a pdf rendering UI
git clone git://archive.git.mtrnord.blog/MTRNord/matrix-search.git
Log | Files | Refs | README | LICENSE

commit ad4e0f8842f7eb551e035af9d28b24905ac603bb
parent b54096e3dc1da4aa4175982dd17ac3300e56efd8
Author: MTRNord <mtrnord1@gmail.com>
Date:   Sun,  9 Jun 2024 17:25:28 +0200

Migrate to meilisearch

Diffstat:
M.gitignore | 8++++++--
Aconfig.example.yaml | 5+++++
Mpackage.json | 7++++---
Mpnpm-lock.yaml | 62++++++++++++++++++++++++++++++--------------------------------
Msrc/bot.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Asrc/config.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/indexer.ts | 63++++++++++++++++++++-------------------------------------------
Asrc/matrix.ts | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/renderer.tsx | 31+++++++++++++++++++------------
9 files changed, 413 insertions(+), 111 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -2,4 +2,8 @@ dist node_modules output.pdf storage -db.msp -\ No newline at end of file +db.msp +config.yaml +meilisearch* +dumps +data.ms +\ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml @@ -0,0 +1,4 @@ +homeserverUrl: "https://matrix.midnightthoughts.space" +accessToken: "" +indexer: + filePath: "./db.msp" +\ No newline at end of file diff --git a/package.json b/package.json @@ -15,10 +15,11 @@ "typescript": "^5.4.5" }, "dependencies": { - "@orama/orama": "^2.0.19", - "@orama/plugin-data-persistence": "^2.0.19", "@react-pdf/renderer": "^3.4.4", + "@sinclair/typebox": "^0.32.31", "matrix-bot-sdk": "^0.7.1", - "react": "^18.3.1" + "meilisearch": "^0.40.0", + "react": "^18.3.1", + "yaml": "^2.4.5" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -8,21 +8,24 @@ importers: .: dependencies: - '@orama/orama': - specifier: ^2.0.19 - version: 2.0.19 - '@orama/plugin-data-persistence': - specifier: ^2.0.19 - version: 2.0.19 '@react-pdf/renderer': specifier: ^3.4.4 version: 3.4.4(react@18.3.1) + '@sinclair/typebox': + specifier: ^0.32.31 + version: 0.32.31 matrix-bot-sdk: specifier: ^0.7.1 version: 0.7.1 + meilisearch: + specifier: ^0.40.0 + version: 0.40.0 react: specifier: ^18.3.1 version: 18.3.1 + yaml: + specifier: ^2.4.5 + version: 2.4.5 devDependencies: '@types/react': specifier: ^18.3.3 @@ -41,17 +44,6 @@ packages: resolution: {integrity: sha512-JXyrHuCVMydUGgSetWsfqbbvHj3aUMOX5TUghlMtLFromyEu7wIsNgYt7PjJ+k3WdF4GVABRy4P6GNjaEMy2uA==} engines: {node: '>= 16'} - '@msgpack/msgpack@2.8.0': - resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} - engines: {node: '>= 10'} - - '@orama/orama@2.0.19': - resolution: {integrity: sha512-9nlF5iYUghewRt2TDmAJO5TN4hYFPhql4d2ZAdJYJtGSIi2BZjMNQIqQ874w0q/rur67wp0AWLb020EakEN0aA==} - engines: {node: '>= 16.0.0'} - - '@orama/plugin-data-persistence@2.0.19': - resolution: {integrity: sha512-u/ligext2UZuvfM3Nm1JckL7Y6MbT85rXODKtJzZuPdpc2+hilGon9Fmkp00ENBx+dAtzcK+3Q00AY1V5T3Mfg==} - '@react-pdf/fns@2.2.1': resolution: {integrity: sha512-s78aDg0vDYaijU5lLOCsUD+qinQbfOvcNeaoX9AiE7+kZzzCo6B/nX+l48cmt9OosJmvZvE9DWR9cLhrhOi2pA==} @@ -93,6 +85,9 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@sinclair/typebox@0.32.31': + resolution: {integrity: sha512-rYB0tgGHawpom3ZwwsGidvI0NI+W/rRHu1dyyO1KlIoH8iMdg3esSnYQxQtyJ8eflhqxmzEV7Nu8zT4JY7CHKw==} + '@swc/helpers@0.4.14': resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==} @@ -324,9 +319,6 @@ packages: domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} - dpack@0.6.22: - resolution: {integrity: sha512-WGPNlW2OAE7Bj0eODMpAHUcEqxrlg01e9OFZDxQodminIgC194/cRHT7K04Z1j7AUEWTeeplYGrIv/xRdwU9Hg==} - ecc-jsbn@0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} @@ -568,6 +560,9 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + meilisearch@0.40.0: + resolution: {integrity: sha512-BoRhQMr2mBFLEeCfsvPluksGb01IaOiWvV3Deu3iEY+yYJ4jdGTu+IQi5FCjKlNQ7/TMWSN2XUToSgvH1tj0BQ==} + merge-descriptors@1.0.1: resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} @@ -919,6 +914,11 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + yaml@2.4.5: + resolution: {integrity: sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==} + engines: {node: '>= 14'} + hasBin: true + yoga-layout@2.0.1: resolution: {integrity: sha512-tT/oChyDXelLo2A+UVnlW9GU7CsvFMaEnd9kVFsaiCQonFAXd3xrHhkLYu+suwwosrAEQ746xBU+HvYtm1Zs2Q==} @@ -935,16 +935,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@msgpack/msgpack@2.8.0': {} - - '@orama/orama@2.0.19': {} - - '@orama/plugin-data-persistence@2.0.19': - dependencies: - '@msgpack/msgpack': 2.8.0 - '@orama/orama': 2.0.19 - dpack: 0.6.22 - '@react-pdf/fns@2.2.1': dependencies: '@babel/runtime': 7.24.7 @@ -1057,6 +1047,8 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 + '@sinclair/typebox@0.32.31': {} + '@swc/helpers@0.4.14': dependencies: tslib: 2.6.3 @@ -1304,8 +1296,6 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - dpack@0.6.22: {} - ecc-jsbn@0.1.2: dependencies: jsbn: 0.1.1 @@ -1593,6 +1583,12 @@ snapshots: media-typer@0.3.0: {} + meilisearch@0.40.0: + dependencies: + cross-fetch: 3.1.8 + transitivePeerDependencies: + - encoding + merge-descriptors@1.0.1: {} methods@1.1.2: {} @@ -1948,4 +1944,6 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + yaml@2.4.5: {} + yoga-layout@2.0.1: {} diff --git a/src/bot.ts b/src/bot.ts @@ -1,26 +1,81 @@ -import { MatrixClient, RustSdkCryptoStorageProvider, SimpleFsStorageProvider } from "matrix-bot-sdk"; +import { MatrixClient, RustSdkCryptoStorageProvider, SimpleFsStorageProvider, RustSdkCryptoStoreType } from "matrix-bot-sdk"; import Indexer from "./indexer.js"; +import Config from "./config.js"; +import { GetRoomMessagesRequest, GetRoomMessagesResponse, Received, RoomEventFilter, Event } from "./matrix.js"; -const storageProvider = new SimpleFsStorageProvider("./storage/bot.json"); -const cryptoProvider = new RustSdkCryptoStorageProvider("./storage/crypto"); +const directions = { forward: "f", reverse: "b" } as const; +// Pending https://github.com/turt2live/matrix-bot-sdk/issues/250 +async function* getRoomEvents( + client: MatrixClient, + room: string, + direction: "forward" | "reverse", + filter?: RoomEventFilter +): AsyncGenerator<Received<Event>[], void, void> { + const path = `/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages`; + const base: GetRoomMessagesRequest = { + ...(direction && { dir: directions[direction] }), + ...(filter && { filter: JSON.stringify(filter) }), + }; -//TODO: get these from a config or env vars -const homeserverUrl = "https://matrix.org"; -const accessToken = ""; -const indexer = new Indexer(); -await indexer.init(); + let from: string | undefined; + do { + const query: GetRoomMessagesRequest = { ...base, ...(from && { from }) }; + const response: GetRoomMessagesResponse = await client.doRequest("GET", path, query); + from = response.end; + yield response.chunk; + } while (from); +} -const client = new MatrixClient(homeserverUrl, accessToken, storageProvider, cryptoProvider); +// Ensures no matrix IDs are in the content.body +function cleanContent(content: any) { + if (typeof content !== "object") return content; + if (content.body) { + content.body = content.body.replace(/@[a-zA-Z0-9_\-\.=]+:[a-zA-Z0-9\-.]+/g, "<mxid>"); + } + return content; +} -client.on("room.message", async (roomId, event) => { - if (event["content"]["msgtype"] === "m.text") { - const body = event["content"]["body"]; - console.info(`Received message in room ${roomId}: ${body}`); +async function run() { - await indexer.insert(event["content"]); - } -}); + const storageProvider = new SimpleFsStorageProvider("./storage/bot.json"); + const cryptoProvider = new RustSdkCryptoStorageProvider("./storage/crypto", RustSdkCryptoStoreType.Sqlite); + + const config = new Config(); + const homeserverUrl = config.homeserverUrl; + const accessToken = config.accessToken; + const indexer = new Indexer(); + + const client = new MatrixClient(homeserverUrl, accessToken, storageProvider, cryptoProvider); + + client.on("room.message", async (roomId, event) => { + if (event["content"]["msgtype"] === "m.text") { + console.info(`Received message in room ${roomId}`); -client.start().then(() => { + const res = await indexer.insert({ id: event["event_id"].replace("$", "").replace(":", "_").replace(".", "_"), sender: event["sender"], content: cleanContent(event["content"]), room_id: roomId }); + console.info(`Indexed message:`, res); + } + }); + + await client.start(); console.info("Bot started!"); -}); -\ No newline at end of file + + // TODO: Only do this on the first run + console.info("Indexing existing messages") + const joined_rooms = await client.getJoinedRooms(); + for (const room of joined_rooms) { + const pages = getRoomEvents(client, room, "reverse", { types: ["m.room.message"] }); + for await (const page of pages) { + for (const event of page) { + if (await client.crypto.isRoomEncrypted(room)) { + await client.crypto.onRoomEvent(room, event); + } else { + const res = await indexer.insert({ id: event["event_id"].replace("$", "").replace(":", "_").replace(".", "_"), sender: event["sender"], content: cleanContent(event["content"]), room_id: room }); + console.info(`Indexed message:`, res); + } + } + } + } + +} + +await run(); +\ No newline at end of file diff --git a/src/config.ts b/src/config.ts @@ -0,0 +1,62 @@ +import { Type, type Static } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; +import fs from 'node:fs'; +import YAML from 'yaml'; + +// The config schema +const ConfigSchema = Type.Object({ + homeserverUrl: Type.String(), + accessToken: Type.String(), +}); + +export type ConfigSchema = Static<typeof ConfigSchema>; + +// A config loader with strict schema types using typebox +export default class Config { + private config: ConfigSchema; + + constructor() { + // Load the config from the env and use the config for the rest. + // Then verify the config against the schema + const config = { + homeserverUrl: process.env.HOMESERVER_URL, + accessToken: process.env.ACCESS_TOKEN, + }; + + // load the config file async and merge it with the env + const configFile = fs.readFileSync('config.yaml', 'utf-8'); + const configFromFile = YAML.parse(configFile); + const mergedConfig = { + ...config, + ...configFromFile + }; + + // Verify the config against the schema + if (!Value.Check(ConfigSchema, mergedConfig)) { + throw new Error('Invalid config'); + } + this.config = mergedConfig; + } + + public get homeserverUrl(): string { + return this.config.homeserverUrl; + } + + public get accessToken(): string { + return this.config.accessToken; + } + + public set homeserverUrl(value: string) { + this.config.homeserverUrl = value; + this.save(); + } + + public set accessToken(value: string) { + this.config.accessToken = value; + this.save(); + } + + public save() { + fs.writeFileSync('config.yaml', YAML.stringify(this.config)); + } +} +\ No newline at end of file diff --git a/src/indexer.ts b/src/indexer.ts @@ -1,5 +1,4 @@ -import { AnyOrama, create, insert, search } from '@orama/orama'; -import { persistToFile, restoreFromFile } from '@orama/plugin-data-persistence/server'; +import { Hits, MeiliSearch } from 'meilisearch'; import fs from 'node:fs'; /* This is the indexer. It is a wrapper around orama. @@ -10,54 +9,32 @@ import fs from 'node:fs'; * */ export default class Indexer { - private db?: AnyOrama; + private client = new MeiliSearch({ + host: 'http://localhost:7700', + apiKey: 'aSampleMasterKey' + }); - public async init() { - const filePath = 'db.msp'; - // Check if database exists - if (!fs.existsSync(filePath)) { - // Create database - this.db = await create({ - schema: { - room_id: 'string', - text: { - body: 'string', - formatted_body: 'string', - "m.mentions": { - user_ids: 'string[]' - }, - "m.relates_to": { - "m.in_reply_to": { - event_id: 'string' - } - }, - } - }, - components: { - tokenizer: { - stemmerSkipProperties: ['room_id'] - } - } - }); - await persistToFile(this.db, 'binary', filePath); - } else { - // Load database - this.db = await restoreFromFile('binary', filePath); - } - } + constructor() { } // TODO: Properly type the data public async insert(data: any) { - if (!this.db) { - throw new Error('Database not initialized'); - } - return await insert(this.db, data); + const index = this.client.index('text'); + return await index.addDocuments([data], { primaryKey: 'id' }); } public async search(query: string) { - if (!this.db) { - throw new Error('Database not initialized'); + const index = this.client.index('text'); + let results: Hits<Record<string, any>> = []; + let page = 1; + // Keep searching until we got everything + let response = await index.search(query, { page: page }); + results = results.concat(response.hits); + while (page < response.totalPages) { + response = await index.search(query, { page: page }); + results = results.concat(response.hits); + page++; } - return await search(this.db, { term: query }); + + return { hits: results, estimatedTotalHits: results.length }; } } \ No newline at end of file diff --git a/src/matrix.ts b/src/matrix.ts @@ -0,0 +1,192 @@ +import type { + MatrixError, PowerLevelsEventContent as PowerLevels, + RoomCreateOptions as RoomCreateFullOptions, +} from "matrix-bot-sdk"; + +export interface RoomCreateOptions extends RoomCreateFullOptions { + preset?: Exclude<NonNullable<RoomCreateFullOptions["preset"]>, "trusted_private_chat">; +} + +// +// Events +// + +interface IEvent<T extends string, C> { + type: T; + event_id: string; + content: C; +} + +export interface IStateEvent<T extends string, C> extends IEvent<T, C> { + state_key: string; +} + +export type Received<E extends Event> = E & { + content: E["content"] | {} /* redacted */; + origin_server_ts: number; + room_id: string; + sender: string; +}; + +export type MessageEvent<T = unknown> = ( + | IEvent< + "m.reaction", + { "m.relates_to": { rel_type: "m.annotation"; event_id: string; key: string } } + > + | IEvent< + "m.room.message", + { + body: string; + msgtype: "m.notice" | "m.text"; + "m.relates_to"?: { rel_type: "m.replace"; event_id: string }; + } & ({} | { format: "org.matrix.custom.html"; formatted_body: string }) + > + | (IEvent<"m.room.redaction", { reason?: string }> & { redacts: string }) +) & { type: T }; + +type WidgetContent = { + creatorUserId: string; + name: string; + avatar_url?: string; +} & ( + | { type: "customwidget"; url: string } + | { + type: "jitsi"; + url: string; + data: { domain: string; conferenceId: string; roomName: string }; + } + ); + +export type StateEvent<T = unknown> = ( + | IStateEvent<"im.vector.modular.widgets", {} | WidgetContent> + | IStateEvent< + "io.element.widgets.layout", + { + widgets: Record< + string, + { index: number; container: "top"; height: number; width: number } + >; + } + > + | IStateEvent<"m.room.avatar", { url: string }> + | IStateEvent<"m.room.canonical_alias", { alias: string; alt_aliases?: string[] }> + | IStateEvent<"m.room.guest_access", { guest_access: "can_join" | "forbidden" }> + | IStateEvent< + "m.room.history_visibility", + { history_visibility: "invited" | "joined" | "shared" | "world_readable" } + > + | IStateEvent< + "m.room.join_rules", + | { join_rule: "invite" | "knock" | "private" | "public" } + | { + join_rule: "knock_restricted" | "restricted"; + allow: { type: "m.room_membership"; room_id: string }[]; + } + > + | IStateEvent< + "m.room.member", + { membership: "ban" | "invite" | "join" | "knock" | "leave" } + > + | IStateEvent<"m.room.name", { name: string }> + | IStateEvent<"m.room.power_levels", PowerLevels> + | IStateEvent<"m.room.topic", { topic: string }> +) & { type: T }; + +export type Event = MessageEvent | StateEvent; + +export type StateEventInput = Omit<StateEvent, "event_id" | "sender" | "state_key"> & + Partial<Pick<StateEvent, "state_key">>; + +// +// Client API +// + +export interface GetRoomMessagesRequest { + dir: "b" | "f"; + filter?: string; // RoomEventFilter + from?: string; +} + +export interface GetRoomMessagesResponse { + chunk: Received<Event>[]; + end?: string; +} + +export interface RoomEventFilter { + not_senders?: string[]; + not_types?: string[]; + senders?: string[]; + types?: string[]; +} + +export interface Sync { + rooms?: { + join?: { + [id: string]: { + state: { events: Received<StateEvent>[] }; + timeline: { events: Received<Event>[] }; + }; + }; + }; +} + +// Workaround for turt2live/matrix-bot-sdk#197 +export const orNone = (error: MatrixError) => { + if (error.errcode === "M_NOT_FOUND") return undefined; + + throw error; +}; + +// +// Server constants +// + +const defaultState: Record<string, StateEvent["content"]> = { + "m.room.guest_access/": { guest_access: "forbidden" }, +}; + +export const moderatorLevel = 50; + +// As at https://github.com/matrix-org/synapse/blob/v1.67.0/synapse/handlers/room.py#L123 +export const resolvePreset = ( + preset: RoomCreateOptions["preset"] +): Pick<RoomCreateOptions, "initial_state"> => { + switch (preset) { + case undefined: + return {}; + case "public_chat": + return { + initial_state: [ + { type: "m.room.guest_access", content: { guest_access: "forbidden" } }, + { + type: "m.room.history_visibility", + content: { history_visibility: "shared" }, + }, + { type: "m.room.join_rules", content: { join_rule: "public" } }, + ], + }; + case "private_chat": + return { + initial_state: [ + { type: "m.room.guest_access", content: { guest_access: "can_join" } }, + { + type: "m.room.history_visibility", + content: { history_visibility: "shared" }, + }, + { type: "m.room.join_rules", content: { join_rule: "invite" } }, + ], + }; + default: + throw new Error(`Not implemented for preset ${preset}`); + } +}; + +// +// Helpers +// + +export const isStateEvent = (event: Event): event is StateEvent => "state_key" in event; + +export const isUserId = (text: string): boolean => /^@[-.\w]+:[-.\w]+$/.test(text); + +export const permalinkPattern = /https:\/\/matrix\.to\/#\/([!#@][-.\w]+:[-.\w]+)/; diff --git a/src/renderer.tsx b/src/renderer.tsx @@ -1,7 +1,8 @@ import React, { PropsWithChildren } from 'react'; import ReactPDF, { Font, Page, Text, View, Document, StyleSheet } from '@react-pdf/renderer'; import Indexer from './indexer.js'; -import { Results, AnyDocument } from '@orama/orama'; +import Config from './config.js'; +import { Hits, SearchParams, SearchResponse } from 'meilisearch'; Font.registerEmojiSource({ format: 'png', @@ -20,11 +21,11 @@ const styles = StyleSheet.create({ }) const indexer = new Indexer(); -await indexer.init(); -await indexer.insert({ text: { body: "Hello, World with test!" } }); + +const borderRadius = 5; // A pdf renderer -const MessageDocument = ({ query, queryResults }: PropsWithChildren<{ query: string, queryResults: Results<AnyDocument> }>) => { +const MessageDocument = ({ query, queryResults }: PropsWithChildren<{ query: string, queryResults: { hits: Hits<Record<string, any>>; estimatedTotalHits: number; } }>) => { return ( <Document title={`Search results for: "${query}"`} @@ -34,19 +35,23 @@ const MessageDocument = ({ query, queryResults }: PropsWithChildren<{ query: str <Page size="A4" style={styles.page}> <View> <Text>Search results for: {query}</Text> - <Text>Results: {queryResults.count}</Text> + <Text>Results: {queryResults.estimatedTotalHits}</Text> {queryResults.hits.map((result, index) => ( - <View key={index}> - <Text>{JSON.stringify(result.document, null, 2)}</Text> + <View key={index} style={{ flexDirection: 'column', margin: 10, padding: 5, backgroundColor: '#e6e6e6', gap: 25, borderTopLeftRadius: borderRadius, borderTopRightRadius: borderRadius, borderBottomLeftRadius: borderRadius, borderBottomRightRadius: borderRadius }}> + <View style={{ flexDirection: 'row', justifyContent: 'space-between' }} > + <Text>{result.room_id}</Text> + <Text>{result.sender}</Text> + </View> + <Text>{result.content.body}</Text> </View> ))} </View> - <View style={{ flexDirection: 'row', justifyContent: 'space-between' }} > + <View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 10 }} fixed> <Text render={({ pageNumber, totalPages }) => ( `${pageNumber} / ${totalPages}` - )} fixed /> - <Text fixed> + )} /> + <Text> Matrix Search Engine </Text> </View> @@ -57,7 +62,9 @@ const MessageDocument = ({ query, queryResults }: PropsWithChildren<{ query: str export async function renderPDFToDisk(query: string) { const queryResults = await indexer.search(query); + //console.log(`Search results:`, queryResults); ReactPDF.renderToFile(<MessageDocument query={query} queryResults={queryResults} />, `output.pdf`); } -await renderPDFToDisk("test"); -\ No newline at end of file +await renderPDFToDisk("test"); +console.log("PDF rendered to disk"); +\ No newline at end of file