diff --git a/backend/cmd/seed-daemon/main.go b/backend/cmd/seed-daemon/main.go index 0bd03add8..fb2e8fe9e 100644 --- a/backend/cmd/seed-daemon/main.go +++ b/backend/cmd/seed-daemon/main.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "os" + "path/filepath" "slices" "strings" "time" @@ -89,7 +90,12 @@ func main() { if keyStoreEnvironment == "" { keyStoreEnvironment = "main" } - ks := core.NewOSKeyStore(keyStoreEnvironment) + var ks core.KeyStore + if os.Getenv("SEED_FILE_KEYSTORE") == "1" { + ks = core.NewFileKeyStore(filepath.Join(cfg.Base.DataDir, "keys.json")) + } else { + ks = core.NewOSKeyStore(keyStoreEnvironment) + } dir, err := storage.Open(cfg.Base.DataDir, nil, ks, cfg.LogLevel) if err != nil { diff --git a/backend/core/file_keystore.go b/backend/core/file_keystore.go new file mode 100644 index 000000000..ec593dd58 --- /dev/null +++ b/backend/core/file_keystore.go @@ -0,0 +1,155 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sync" +) + +type fileKeyStore struct { + path string + mu sync.RWMutex +} + +type fileKeyData struct { + Keys map[string][]byte `json:"keys"` +} + +func NewFileKeyStore(path string) KeyStore { + return &fileKeyStore{path: path} +} + +func (fks *fileKeyStore) load() (*fileKeyData, error) { + data, err := os.ReadFile(fks.path) + if err != nil { + if os.IsNotExist(err) { + return &fileKeyData{Keys: make(map[string][]byte)}, nil + } + return nil, err + } + var fkd fileKeyData + if err := json.Unmarshal(data, &fkd); err != nil { + return nil, err + } + if fkd.Keys == nil { + fkd.Keys = make(map[string][]byte) + } + return &fkd, nil +} + +func (fks *fileKeyStore) save(fkd *fileKeyData) error { + data, err := json.MarshalIndent(fkd, "", " ") + if err != nil { + return err + } + return os.WriteFile(fks.path, data, 0600) +} + +func (fks *fileKeyStore) GetKey(ctx context.Context, name string) (*KeyPair, error) { + fks.mu.RLock() + defer fks.mu.RUnlock() + + fkd, err := fks.load() + if err != nil { + return nil, err + } + + privBytes, ok := fkd.Keys[name] + if !ok { + return nil, fmt.Errorf("%s: %w", name, errKeyNotFound) + } + + kp := new(KeyPair) + return kp, kp.UnmarshalBinary(privBytes) +} + +func (fks *fileKeyStore) StoreKey(ctx context.Context, name string, kp *KeyPair) error { + if !nameFormat.MatchString(name) { + return fmt.Errorf("invalid name format") + } + if kp == nil { + return fmt.Errorf("can't store empty key") + } + + fks.mu.Lock() + defer fks.mu.Unlock() + + fkd, err := fks.load() + if err != nil { + return err + } + + if _, ok := fkd.Keys[name]; ok { + return fmt.Errorf("Name already exists. Please delete it first") + } + + keyBytes, err := kp.MarshalBinary() + if err != nil { + return err + } + fkd.Keys[name] = keyBytes + return fks.save(fkd) +} + +func (fks *fileKeyStore) ListKeys(ctx context.Context) ([]NamedKey, error) { + fks.mu.RLock() + defer fks.mu.RUnlock() + + fkd, err := fks.load() + if err != nil { + return nil, err + } + + var ret []NamedKey + for name, privBytes := range fkd.Keys { + priv := new(KeyPair) + if err := priv.UnmarshalBinary(privBytes); err != nil { + return nil, err + } + ret = append(ret, NamedKey{Name: name, PublicKey: priv.Principal()}) + } + return ret, nil +} + +func (fks *fileKeyStore) DeleteKey(ctx context.Context, name string) error { + fks.mu.Lock() + defer fks.mu.Unlock() + + fkd, err := fks.load() + if err != nil { + return err + } + + if _, ok := fkd.Keys[name]; !ok { + return errKeyNotFound + } + delete(fkd.Keys, name) + return fks.save(fkd) +} + +func (fks *fileKeyStore) DeleteAllKeys(ctx context.Context) error { + fks.mu.Lock() + defer fks.mu.Unlock() + return fks.save(&fileKeyData{Keys: make(map[string][]byte)}) +} + +func (fks *fileKeyStore) ChangeKeyName(ctx context.Context, currentName, newName string) error { + fks.mu.Lock() + defer fks.mu.Unlock() + + fkd, err := fks.load() + if err != nil { + return err + } + + privBytes, ok := fkd.Keys[currentName] + if !ok { + return errKeyNotFound + } + + delete(fkd.Keys, currentName) + fkd.Keys[newName] = privBytes + return fks.save(fkd) +} diff --git a/frontend/apps/desktop/src/app-context-provider.tsx b/frontend/apps/desktop/src/app-context-provider.tsx index e246725c2..b875798f0 100644 --- a/frontend/apps/desktop/src/app-context-provider.tsx +++ b/frontend/apps/desktop/src/app-context-provider.tsx @@ -17,6 +17,8 @@ export function AppContextProvider({ openMarkdownDirectories, openLatexFiles, openLatexDirectories, + openPdfFiles, + openPdfDirectories, readMediaFile, exportDocument, exportDocuments, @@ -62,6 +64,22 @@ export function AppContextProvider({ }[] docMap: Map }> + openPdfFiles: (accountId: string) => Promise<{ + documents: { + pdfContent: ArrayBuffer + title: string + directoryPath: string + }[] + docMap: Map + }> + openPdfDirectories: (accountId: string) => Promise<{ + documents: { + pdfContent: ArrayBuffer + title: string + directoryPath: string + }[] + docMap: Map + }> readMediaFile: (filePath: string) => Promise<{ filePath: string content: string @@ -99,6 +117,8 @@ export function AppContextProvider({ openMarkdownDirectories, openLatexFiles, openLatexDirectories, + openPdfFiles, + openPdfDirectories, readMediaFile, exportDocument, exportDocuments, diff --git a/frontend/apps/desktop/src/app-context.tsx b/frontend/apps/desktop/src/app-context.tsx index b2570b208..38e3678c6 100644 --- a/frontend/apps/desktop/src/app-context.tsx +++ b/frontend/apps/desktop/src/app-context.tsx @@ -45,6 +45,22 @@ export type AppContext = { }[] docMap: Map }> + openPdfFiles: (accountId: string) => Promise<{ + documents: { + pdfContent: ArrayBuffer + title: string + directoryPath: string + }[] + docMap: Map + }> + openPdfDirectories: (accountId: string) => Promise<{ + documents: { + pdfContent: ArrayBuffer + title: string + directoryPath: string + }[] + docMap: Map + }> readMediaFile: (filePath: string) => Promise<{ filePath: string content: string diff --git a/frontend/apps/desktop/src/components/create-doc-button.tsx b/frontend/apps/desktop/src/components/create-doc-button.tsx index b391f7639..91b7efa27 100644 --- a/frontend/apps/desktop/src/components/create-doc-button.tsx +++ b/frontend/apps/desktop/src/components/create-doc-button.tsx @@ -41,6 +41,8 @@ function ImportMenuItem({ onImportDirectory: importing.importDirectory, onImportLatexFile: importing.importLatexFile, onImportLatexDirectory: importing.importLatexDirectory, + onImportPdfFile: importing.importPdfFile, + onImportPdfDirectory: importing.importPdfDirectory, }) } diff --git a/frontend/apps/desktop/src/components/import-doc-button.tsx b/frontend/apps/desktop/src/components/import-doc-button.tsx index 2cbd693cc..7cebc6d9b 100644 --- a/frontend/apps/desktop/src/components/import-doc-button.tsx +++ b/frontend/apps/desktop/src/components/import-doc-button.tsx @@ -17,6 +17,10 @@ import { processLinkMarkdown, processMediaMarkdown, } from '@shm/editor/blocknote/core/extensions/Markdown/MarkdownToBlocks' +import { + PdfToBlocks, + extractPdfTitle, +} from '@shm/editor/blocknote/core/extensions/Pdf/PdfToBlocks' import {createHypermediaDocLinkPlugin} from '@shm/editor/hypermedia-link-plugin' import {HMResourceFetchResult, UnpackedHypermediaId} from '@shm/shared/hm-types' import {invalidateQueries, queryClient} from '@shm/shared/models/query-client' @@ -65,6 +69,8 @@ export function ImportDialog({ onImportDirectory: () => void onImportLatexFile: () => void onImportLatexDirectory: () => void + onImportPdfFile: () => void + onImportPdfDirectory: () => void // onImportWebSite: () => void } onClose: () => void @@ -121,6 +127,28 @@ export function ImportDialog({ Import LaTeX Directory + + ) @@ -138,6 +166,8 @@ export function ImportDropdownButton({ importDirectory, importLatexFile, importLatexDirectory, + importPdfFile, + importPdfDirectory, content, } = useImporting(id) @@ -170,6 +200,18 @@ export function ImportDropdownButton({ onClick: () => importLatexDirectory(), icon: , }, + { + key: 'pdf-file', + label: 'Import PDF File', + onClick: () => importPdfFile(), + icon: , + }, + { + key: 'pdf-directory', + label: 'Import PDF Folder', + onClick: () => importPdfDirectory(), + icon: , + }, ]} /> @@ -184,6 +226,8 @@ export function useImporting(parentId: UnpackedHypermediaId) { openMarkdownFiles, openLatexDirectories, openLatexFiles, + openPdfFiles, + openPdfDirectories, } = useAppContext() const accts = useMyAccountsWithWriteAccess(parentId) const navigate = useNavigate() @@ -319,11 +363,43 @@ export function useImporting(parentId: UnpackedHypermediaId) { }) } + function startPdfImport( + importFunction: (id: string) => Promise<{ + documents: {pdfContent: ArrayBuffer; title: string; directoryPath: string}[] + docMap: Map + }>, + ) { + importFunction(parentId.id) + .then(async (result) => { + const docs: ImportedDocument[] = result.documents.map((doc) => ({ + pdfContent: doc.pdfContent, + title: doc.title, + directoryPath: doc.directoryPath, + })) + if (docs.length) { + importDialog.open({ + documents: docs, + documentCount: docs.length, + docMap: result.docMap, + onSuccess: handleConfirm, + }) + } else { + toast.error('No PDF documents found.') + } + }) + .catch((error) => { + console.error('Error importing PDF documents:', error) + toast.error(`Import error: ${error.message || error}`) + }) + } + return { importFile: () => startImport(openMarkdownFiles), importDirectory: () => startImport(openMarkdownDirectories), importLatexFile: () => startLatexImport(openLatexFiles), importLatexDirectory: () => startLatexImport(openLatexDirectories), + importPdfFile: () => startPdfImport(openPdfFiles), + importPdfDirectory: () => startPdfImport(openPdfDirectories), importWebSite: () => webImporting.open({destinationId: parentId}), content: ( <> @@ -581,6 +657,7 @@ const ImportDocumentsWithFeedback = ( for (const { markdownContent, latexContent, + pdfContent, title, directoryPath, } of documents) { @@ -589,7 +666,12 @@ const ImportDocumentsWithFeedback = ( let cover: string | undefined let blocks: any[] - if (latexContent) { + if (pdfContent) { + // Process PDF document + blocks = await PdfToBlocks(pdfContent) + const pdfTitle = await extractPdfTitle(pdfContent) + if (pdfTitle) documentTitle = pdfTitle + } else if (latexContent) { // Process LaTeX document const metadata = extractLatexMetadata(latexContent) documentTitle = metadata.title || title diff --git a/frontend/apps/desktop/src/components/import-doc-dialog.tsx b/frontend/apps/desktop/src/components/import-doc-dialog.tsx index 4643b2a2e..5f1e208cf 100644 --- a/frontend/apps/desktop/src/components/import-doc-dialog.tsx +++ b/frontend/apps/desktop/src/components/import-doc-dialog.tsx @@ -5,6 +5,7 @@ import {useAppDialog} from '@shm/ui/universal-dialog' export type ImportedDocument = { markdownContent?: string latexContent?: string + pdfContent?: ArrayBuffer title: string directoryPath: string } diff --git a/frontend/apps/desktop/src/main.ts b/frontend/apps/desktop/src/main.ts index a2b437f6d..0de56ffae 100644 --- a/frontend/apps/desktop/src/main.ts +++ b/frontend/apps/desktop/src/main.ts @@ -943,6 +943,144 @@ function initializeIpcHandlers() { } }) + ipcMain.on('open-pdf-file', async (event, accountId: string) => { + const focusedWindow = BrowserWindow.getFocusedWindow() + if (!focusedWindow) { + console.error('No focused window found.') + return + } + + const options: OpenDialogOptions = { + title: 'Select PDF files', + properties: ['openFile', 'multiSelections'], + filters: [{name: 'PDF Files', extensions: ['pdf']}], + } + + try { + const result = await dialog.showOpenDialog(focusedWindow, options) + if (!result.canceled && result.filePaths.length > 0) { + const files = result.filePaths + const validDocuments: {pdfContent: ArrayBuffer; title: string; directoryPath: string}[] = [] + const docMap = new Map< + string, + {relativePath?: string; name: string; path: string} + >() + + for (const filePath of files) { + const stats = fs.lstatSync(filePath) + if (stats.isFile() && filePath.endsWith('.pdf')) { + const pdfBuffer = fs.readFileSync(filePath) + const pdfContent = pdfBuffer.buffer.slice( + pdfBuffer.byteOffset, + pdfBuffer.byteOffset + pdfBuffer.byteLength, + ) + const dirName = path.basename(filePath) + const title = formatTitle(dirName.replace(/\.pdf$/, '')) + + docMap.set('./' + dirName, { + name: title, + path: path.join( + accountId, + title.toLowerCase().replace(/\s+/g, '-'), + ), + }) + + validDocuments.push({ + pdfContent, + title, + directoryPath: path.dirname(filePath), + }) + } + } + + event.sender.send('pdf-files-content-response', { + success: true, + result: {documents: validDocuments, docMap: docMap}, + }) + } else { + event.sender.send('pdf-files-content-response', { + success: false, + error: 'File selection was canceled', + }) + } + } catch (err: unknown) { + console.error('Error selecting PDF file:', err) + event.sender.send('pdf-files-content-response', { + success: false, + error: err instanceof Error ? err.message : 'Unknown error occurred', + }) + } + }) + + ipcMain.on('open-pdf-directory', async (event, accountId: string) => { + const focusedWindow = BrowserWindow.getFocusedWindow() + if (!focusedWindow) { + console.error('No focused window found.') + return + } + + const options: OpenDialogOptions = { + title: 'Select PDF Directory', + properties: ['openDirectory'], + } + + try { + const result = await dialog.showOpenDialog(focusedWindow, options) + if (!result.canceled && result.filePaths.length > 0) { + const directoryPath = result.filePaths[0]! + const files = fs.readdirSync(directoryPath) + const pdfFiles = files.filter((file) => file.endsWith('.pdf')) + + const validDocuments: {pdfContent: ArrayBuffer; title: string; directoryPath: string}[] = [] + const docMap = new Map< + string, + {relativePath?: string; name: string; path: string} + >() + + for (const pdfFile of pdfFiles) { + const filePath = path.join(directoryPath, pdfFile) + const fileName = path.basename(pdfFile, '.pdf') + const title = formatTitle(fileName) + const pdfBuffer = fs.readFileSync(filePath) + const pdfContent = pdfBuffer.buffer.slice( + pdfBuffer.byteOffset, + pdfBuffer.byteOffset + pdfBuffer.byteLength, + ) + + docMap.set('./' + pdfFile, { + name: title, + path: path.join( + accountId, + title.toLowerCase().replace(/\s+/g, '-'), + ), + }) + + validDocuments.push({ + pdfContent, + title, + directoryPath, + }) + } + + event.sender.send('pdf-directories-content-response', { + success: true, + result: {documents: validDocuments, docMap: docMap}, + }) + } else { + event.sender.send('pdf-directories-content-response', { + success: false, + error: 'Directory selection was canceled', + }) + } + } catch (err: unknown) { + console.error('Error selecting PDF directory:', err) + event.sender.send('pdf-directories-content-response', { + success: false, + error: err instanceof Error ? err.message : 'Unknown error occurred', + }) + } + }) + ipcMain.on('read-media-file', async (event, filePath) => { try { const absoluteFilePath = path.resolve(filePath) diff --git a/frontend/apps/desktop/src/preload.ts b/frontend/apps/desktop/src/preload.ts index c6e133f26..3844cde27 100644 --- a/frontend/apps/desktop/src/preload.ts +++ b/frontend/apps/desktop/src/preload.ts @@ -132,6 +132,37 @@ contextBridge.exposeInMainWorld('docImport', { ipcRenderer.send('open-latex-file', accountId) }) }, + + openPdfFiles: (accountId: string) => { + return new Promise((resolve, reject) => { + ipcRenderer.once('pdf-files-content-response', (event, response) => { + if (response.success) { + resolve(response.result) + } else { + reject(response.error) + } + }) + + ipcRenderer.send('open-pdf-file', accountId) + }) + }, + + openPdfDirectories: (accountId: string) => { + return new Promise((resolve, reject) => { + ipcRenderer.once( + 'pdf-directories-content-response', + (event, response) => { + if (response.success) { + resolve(response.result) + } else { + reject(response.error) + } + }, + ) + + ipcRenderer.send('open-pdf-directory', accountId) + }) + }, }) contextBridge.exposeInMainWorld('docExport', { diff --git a/frontend/apps/desktop/src/root.tsx b/frontend/apps/desktop/src/root.tsx index cf86062dc..70b361798 100644 --- a/frontend/apps/desktop/src/root.tsx +++ b/frontend/apps/desktop/src/root.tsx @@ -428,6 +428,14 @@ function MainApp({}: {}) { // @ts-ignore return window.docImport.openLatexDirectories(accountId) }} + openPdfFiles={(accountId: string) => { + // @ts-ignore + return window.docImport.openPdfFiles(accountId) + }} + openPdfDirectories={(accountId: string) => { + // @ts-ignore + return window.docImport.openPdfDirectories(accountId) + }} readMediaFile={(filePath: string) => { // @ts-ignore return window.docImport.readMediaFile(filePath) diff --git a/frontend/apps/web/app/entry.server.tsx b/frontend/apps/web/app/entry.server.tsx index 225b8ca89..343cac6f9 100644 --- a/frontend/apps/web/app/entry.server.tsx +++ b/frontend/apps/web/app/entry.server.tsx @@ -29,6 +29,7 @@ import { } from './instrumentation.server' import {resolveResource} from './loaders' import {logDebug} from './logger' +import {documentToMarkdown} from './markdown.server' import {ParsedRequest, parseRequest} from './request' import { applyConfigSubscriptions, @@ -263,6 +264,115 @@ function uriEncodedAuthors(authors: string[]) { return authors.map((author) => encodeURIComponent(`hm://${author}`)).join(',') } +/** + * Handle requests with .md extension - return raw markdown + * This enables bots and agents to easily consume SHM content without + * installing CLI tools or parsing HTML/React. + * + * Usage: GET https://hyper.media/hm/z6Mk.../path.md + * Returns: text/markdown with the document content + */ +async function handleMarkdownRequest( + parsedRequest: ParsedRequest, + hostname: string +): Promise { + const {url, pathParts} = parsedRequest + + try { + // Strip .md extension from the last path part + const lastPart = pathParts[pathParts.length - 1] + const strippedPath = [...pathParts.slice(0, -1)] + if (lastPart && lastPart.endsWith('.md')) { + strippedPath.push(lastPart.slice(0, -3)) + } + + // Get service config to resolve account + const serviceConfig = await getConfig(hostname) + const originAccountId = serviceConfig?.registeredAccountUid + + // Build the resource ID + let resourceId: ReturnType | null = null + const version = url.searchParams.get('v') + const latest = url.searchParams.get('l') === '' + + if (strippedPath.length === 0) { + if (originAccountId) { + resourceId = hmId(originAccountId, {path: [], version, latest}) + } + } else if (strippedPath[0] === 'hm') { + resourceId = hmId(strippedPath[1], { + path: strippedPath.slice(2), + version, + latest, + }) + } else if (originAccountId) { + resourceId = hmId(originAccountId, {path: strippedPath, version, latest}) + } + + if (!resourceId) { + return new Response('# Not Found\n\nCould not resolve resource ID.', { + status: 404, + headers: {'Content-Type': 'text/markdown; charset=utf-8'}, + }) + } + + // Fetch the resource + const resource = await resolveResource(resourceId) + + if (resource.type === 'document') { + const md = await documentToMarkdown(resource.document, { + includeMetadata: true, + includeFrontmatter: url.searchParams.has('frontmatter'), + }) + + return new Response(md, { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'X-Hypermedia-Id': encodeURIComponent(resourceId.id), + 'X-Hypermedia-Version': resource.document.version, + 'X-Hypermedia-Type': 'Document', + 'Cache-Control': 'public, max-age=60', + }, + }) + } else if (resource.type === 'comment') { + // For comments, create a simple markdown response + const content = resource.comment.content || [] + const fakeDoc = { + content, + metadata: {}, + version: resource.comment.version, + authors: [resource.comment.author], + } as any + + const md = await documentToMarkdown(fakeDoc, {includeMetadata: false}) + + return new Response(md, { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'X-Hypermedia-Id': encodeURIComponent(resourceId.id), + 'X-Hypermedia-Type': 'Comment', + }, + }) + } + + return new Response('# Not Found\n\nResource type not supported.', { + status: 404, + headers: {'Content-Type': 'text/markdown; charset=utf-8'}, + }) + } catch (e) { + console.error('Error handling markdown request:', e) + return new Response( + `# Error\n\nFailed to load resource: ${(e as Error).message}`, + { + status: 500, + headers: {'Content-Type': 'text/markdown; charset=utf-8'}, + } + ) + } +} + async function handleOptionsRequest(request: Request) { const parsedRequest = parseRequest(request) const {hostname} = parsedRequest @@ -353,6 +463,12 @@ export default async function handleRequest( status: 404, }) } + + // Handle .md extension requests - return raw markdown for bots/agents + if (url.pathname.endsWith('.md')) { + return await handleMarkdownRequest(parsedRequest, hostname) + } + if (url.pathname.startsWith('/hm/embed/')) { // allowed to embed anywhere } else { diff --git a/frontend/apps/web/app/markdown.server.ts b/frontend/apps/web/app/markdown.server.ts new file mode 100644 index 000000000..da8cf592c --- /dev/null +++ b/frontend/apps/web/app/markdown.server.ts @@ -0,0 +1,521 @@ +/** + * Server-side markdown converter for Seed Hypermedia documents + * Enables HTTP GET with .md extension to return raw markdown + */ + +import type {BlockNode, Block, Annotation, HMDocument, UnpackedHypermediaId} from '@shm/shared/hm-types' +import {hmId, parseHMUrl, packHmId} from '@shm/shared' +import {grpcClient} from './client.server' +import {resolveResource} from './loaders' +import {serverUniversalClient} from './server-universal-client' + +export type MarkdownOptions = { + includeMetadata?: boolean + includeFrontmatter?: boolean +} + +/** + * Cache for resolved account names to avoid repeated lookups + */ +const accountNameCache = new Map() + +/** + * Cache for resolved embed content to avoid repeated fetches + */ +const embedContentCache = new Map() + +/** + * Resolve account display name from account ID + */ +async function resolveAccountName(accountId: string): Promise { + if (accountNameCache.has(accountId)) { + return accountNameCache.get(accountId)! + } + + try { + const account = await grpcClient.documents.getAccount({ + id: accountId, + }) + + const name = account.metadata?.name || accountId.slice(0, 8) + '...' + accountNameCache.set(accountId, name) + return name + } catch (e) { + console.error('Failed to resolve account name for', accountId, e) + // Fallback to shortened account ID + const fallbackName = accountId.slice(0, 8) + '...' + accountNameCache.set(accountId, fallbackName) + return fallbackName + } +} + +/** + * Convert a document to markdown + */ +export async function documentToMarkdown( + doc: HMDocument, + options?: MarkdownOptions +): Promise { + const lines: string[] = [] + + // Optional frontmatter + if (options?.includeFrontmatter && doc.metadata) { + lines.push('---') + if (doc.metadata.name) lines.push(`title: "${escapeYaml(doc.metadata.name)}"`) + if (doc.metadata.summary) lines.push(`summary: "${escapeYaml(doc.metadata.summary)}"`) + if (doc.authors?.length) lines.push(`authors: [${doc.authors.join(', ')}]`) + lines.push(`version: ${doc.version}`) + lines.push('---') + lines.push('') + } + + // Title from metadata + if (options?.includeMetadata && doc.metadata?.name) { + lines.push(`# ${doc.metadata.name}`) + lines.push('') + } + + // Pre-warm caches: collect all embed/query URLs and resolve in parallel + await prewarmEmbedCache(doc.content || []) + + // Content blocks + for (const node of doc.content || []) { + const blockMd = await blockNodeToMarkdown(node, 0) + if (blockMd) { + lines.push(blockMd) + } + } + + return lines.join('\n') +} + +/** + * Pre-warm the embed content cache by resolving all embeds in parallel. + * This avoids sequential fetches during markdown generation. + */ +async function prewarmEmbedCache(content: BlockNode[]): Promise { + const embedUrls = new Set() + const accountIds = new Set() + + function collectUrls(nodes: BlockNode[]) { + for (const node of nodes) { + const block = node.block + if (block.type === 'Embed' && block.link && !embedContentCache.has(block.link)) { + embedUrls.add(block.link) + } + // Collect mention account IDs from annotations + if (block.annotations) { + for (const ann of block.annotations) { + if (ann.type === 'Link' && ann.link) { + const parsed = parseHMUrl(ann.link) + if (parsed?.uid && (!parsed.path || parsed.path.length === 0) && !accountNameCache.has(parsed.uid)) { + accountIds.add(parsed.uid) + } + } + } + } + if (node.children) collectUrls(node.children) + } + } + + collectUrls(content) + + // Resolve all embeds and account names in parallel + const embedPromises = [...embedUrls].map(async (url) => { + try { + const parsed = parseHMUrl(url) + if (!parsed) return + const resourceId = hmId(parsed.uid, { + path: parsed.path, + version: parsed.version, + latest: parsed.latest, + blockRef: parsed.blockRef, + }) + const resource = await resolveResource(resourceId) + if (resource.type === 'document' && resource.document) { + let content = '' + if (parsed.blockRef) { + const targetBlock = findBlockById(resource.document.content || [], parsed.blockRef) + if (targetBlock) content = targetBlock.text || '' + } else { + content = resource.document.metadata?.name || resource.document.content?.[0]?.block?.text || '' + } + const result = content + ? `> ${content.split('\n').join('\n> ')}` + : `> [Embed: ${url}](${url})` + embedContentCache.set(url, result) + } + } catch (e) { + embedContentCache.set(url, `> [Embed: ${url}](${url})`) + } + }) + + const accountPromises = [...accountIds].map(async (uid) => { + try { + const account = await grpcClient.documents.getAccount({ id: uid }) + accountNameCache.set(uid, account.metadata?.name || uid.slice(0, 8) + '...') + } catch { + accountNameCache.set(uid, uid.slice(0, 8) + '...') + } + }) + + await Promise.all([...embedPromises, ...accountPromises]) +} + +/** + * Convert a block node (with children) to markdown + */ +async function blockNodeToMarkdown( + node: BlockNode, + depth: number +): Promise { + const block = node.block + const children = node.children || [] + + let result = await blockToMarkdown(block, depth) + + // Handle children based on childrenType + const childrenType = block.attributes?.childrenType as string | undefined + + for (const child of children) { + const childMd = await blockNodeToMarkdown(child, depth + 1) + if (childMd) { + if (childrenType === 'Ordered') { + result += '\n' + indent(depth + 1) + '1. ' + childMd.trim() + } else if (childrenType === 'Unordered') { + result += '\n' + indent(depth + 1) + '- ' + childMd.trim() + } else if (childrenType === 'Blockquote') { + result += '\n' + indent(depth + 1) + '> ' + childMd.trim() + } else { + result += '\n' + childMd + } + } + } + + return result +} + +/** + * Convert a single block to markdown + */ +async function blockToMarkdown( + block: Block, + depth: number +): Promise { + const ind = indent(depth) + + switch (block.type) { + case 'Paragraph': + return ind + await applyAnnotations(block.text || '', block.annotations) + + case 'Heading': + // Use depth to determine heading level (max h6) + const level = Math.min(depth + 1, 6) + const hashes = '#'.repeat(level) + return `${hashes} ${await applyAnnotations(block.text || '', block.annotations)}` + + case 'Code': + const lang = (block.attributes?.language as string) || '' + return ind + '```' + lang + '\n' + ind + (block.text || '') + '\n' + ind + '```' + + case 'Math': + return ind + '$$\n' + ind + (block.text || '') + '\n' + ind + '$$' + + case 'Image': + const altText = block.text || 'image' + const imgUrl = formatMediaUrl(block.link || '') + return ind + `![${altText}](${imgUrl})` + + case 'Video': + const videoUrl = formatMediaUrl(block.link || '') + return ind + `[Video](${videoUrl})` + + case 'File': + const fileName = (block.attributes?.name as string) || 'file' + const fileUrl = formatMediaUrl(block.link || '') + return ind + `[${fileName}](${fileUrl})` + + case 'Embed': + return await resolveEmbedBlock(block, ind) + + case 'WebEmbed': + return ind + `[Web Embed](${block.link})` + + case 'Button': + const buttonText = block.text || 'Button' + return ind + `[${buttonText}](${block.link})` + + case 'Query': + return await resolveQueryBlock(block, ind) + + case 'Nostr': + return ind + `[Nostr: ${block.link}](${block.link})` + + default: + if (block.text) { + return ind + block.text + } + return '' + } +} + +/** + * Resolve an embed block by loading the target document and inlining content + */ +async function resolveEmbedBlock(block: Block, indent: string): Promise { + if (!block.link) { + return indent + `> [Embed: No URL]` + } + + // Check cache first for performance + if (embedContentCache.has(block.link)) { + return indent + embedContentCache.get(block.link)! + } + + try { + // Parse the embed URL to get the resource ID + const parsed = parseHMUrl(block.link) + if (!parsed) { + const result = `> [Embed: ${block.link}](${block.link})` + embedContentCache.set(block.link, result) + return indent + result + } + + // Use the existing resolveResource function for consistency + const resourceId = hmId(parsed.uid, { + path: parsed.path, + version: parsed.version, + latest: parsed.latest, + blockRef: parsed.blockRef, + }) + + const resource = await resolveResource(resourceId) + + if (resource.type !== 'document' || !resource.document) { + const result = `> [Embed: ${block.link}](${block.link})` + embedContentCache.set(block.link, result) + return indent + result + } + + // Extract relevant content based on blockRef if present + let content = '' + if (parsed.blockRef) { + // Find the specific block referenced + const targetBlock = findBlockById(resource.document.content || [], parsed.blockRef) + if (targetBlock) { + content = targetBlock.text || '' + } + } else { + // Use the document title or first block + const title = resource.document.metadata?.name + if (title) { + content = title + } else if (resource.document.content?.[0]?.block?.text) { + content = resource.document.content[0].block.text + } + } + + let result: string + if (content) { + // Format as blockquote with proper indentation + result = `> ${content.split('\n').join('\n> ')}` + } else { + result = `> [Embed: ${block.link}](${block.link})` + } + + embedContentCache.set(block.link, result) + return indent + result + + } catch (e) { + console.error('Failed to resolve embed:', block.link, e) + const result = `> [Embed: ${block.link}](${block.link})` + embedContentCache.set(block.link, result) + return indent + result + } +} + +/** + * Resolve a query block by executing the query and generating a list of links + */ +async function resolveQueryBlock(block: Block, ind: string): Promise { + try { + const queryData = (block.attributes as any)?.query + if (!queryData?.includes?.length) { + return ind + `` + } + + // Execute the query using the server universal client + const result = await serverUniversalClient.request('Query', { + includes: queryData.includes, + sort: queryData.sort, + limit: queryData.limit, + }) + + if (!result || !('results' in result) || !result.results?.length) { + return ind + `` + } + + // Format results as a markdown list of links + const lines: string[] = [] + for (const doc of result.results) { + const title = doc.metadata?.name || doc.path?.[doc.path.length - 1] || 'Untitled' + const hmUrl = doc.id ? packHmId(doc.id) : '' + if (hmUrl) { + lines.push(`${ind}- [${title}](${hmUrl})`) + } else { + lines.push(`${ind}- ${title}`) + } + } + return lines.join('\n') + } catch (e) { + console.error('Failed to resolve query:', block, e) + return ind + `` + } +} + +/** + * Helper function to find a block by ID in a document's content + */ +function findBlockById(content: any[], blockId: string): any | null { + for (const node of content) { + if (node.block?.id === blockId) { + return node.block + } + // Recursively search children + if (node.children) { + const found = findBlockById(node.children, blockId) + if (found) return found + } + } + return null +} + +/** + * Apply text annotations (bold, italic, links, etc.) + */ +async function applyAnnotations( + text: string, + annotations: Annotation[] | undefined +): Promise { + if (!annotations || annotations.length === 0) { + return text + } + + // Build a list of markers with positions + type Marker = {pos: number; type: 'open' | 'close'; annotation: Annotation} + const markers: Marker[] = [] + + for (const ann of annotations) { + const starts = ann.starts || [] + const ends = ann.ends || [] + + for (let i = 0; i < starts.length; i++) { + markers.push({pos: starts[i], type: 'open', annotation: ann}) + if (ends[i] !== undefined) { + markers.push({pos: ends[i], type: 'close', annotation: ann}) + } + } + } + + // Sort by position (opens before closes at same position) + markers.sort((a, b) => { + if (a.pos !== b.pos) return a.pos - b.pos + return a.type === 'open' ? -1 : 1 + }) + + // Build result string + let result = '' + let lastPos = 0 + + for (const marker of markers) { + result += text.slice(lastPos, marker.pos) + lastPos = marker.pos + result += await getAnnotationMarker(marker.annotation, marker.type) + } + + result += text.slice(lastPos) + + // Remove object replacement characters (used for inline embeds) + result = result.replace(/\uFFFC/g, '') + + return result +} + +/** + * Get markdown marker for annotation + */ +async function getAnnotationMarker( + ann: Annotation, + type: 'open' | 'close' +): Promise { + switch (ann.type) { + case 'Bold': + return '**' + case 'Italic': + return '_' + case 'Strike': + return '~~' + case 'Code': + return '`' + case 'Underline': + return type === 'open' ? '' : '' + case 'Link': + if (type === 'open') { + // Check if this is a mention (hm:// link to an account with no path) + if (ann.link) { + const parsed = parseHMUrl(ann.link) + if (parsed?.uid && (!parsed.path || parsed.path.length === 0)) { + try { + const name = await resolveAccountName(parsed.uid) + return `[@${name}](${ann.link})` + } catch (e) { + // fallback to link syntax + } + } + } + return '[' + } else { + // If we already emitted the full mention markdown in 'open', skip close + if (ann.link) { + const parsed = parseHMUrl(ann.link) + if (parsed?.uid && (!parsed.path || parsed.path.length === 0)) { + return '' + } + } + return `](${ann.link || ''})` + } + case 'Embed': + if (type === 'open') { + return '[' + } else { + return `](${ann.link || ''})` + } + default: + return '' + } +} + +/** + * Format media URL (handle ipfs:// URLs) + */ +function formatMediaUrl(url: string): string { + if (url.startsWith('ipfs://')) { + const cid = url.slice(7) + return `https://ipfs.io/ipfs/${cid}` + } + return url +} + +/** + * Create indentation string + */ +function indent(depth: number): string { + return ' '.repeat(depth) +} + +/** + * Escape string for YAML frontmatter + */ +function escapeYaml(str: string): string { + return str.replace(/"/g, '\\"').replace(/\n/g, '\\n') +} diff --git a/frontend/packages/editor/package.json b/frontend/packages/editor/package.json index 93906ab3c..49af40e97 100644 --- a/frontend/packages/editor/package.json +++ b/frontend/packages/editor/package.json @@ -50,6 +50,7 @@ "mermaid": "^11.4.0", "multiformats": "^13.3.2", "nanoid": "4.0.2", + "pdfjs-dist": "^3.11.174", "prosemirror-state": "1.4.4", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -77,9 +78,12 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "jsdom": "22.1.0", + "pdf-lib": "^1.17.1", "prettier": "3.0.2", "typescript": "5.8.3", "typescript-eslint": "^8.18.2", - "vite": "6.3.5" + "vite": "6.3.5", + "vitest": "^4.0.18" } } diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Pdf/PdfToBlocks.ts b/frontend/packages/editor/src/blocknote/core/extensions/Pdf/PdfToBlocks.ts new file mode 100644 index 000000000..6023d81be --- /dev/null +++ b/frontend/packages/editor/src/blocknote/core/extensions/Pdf/PdfToBlocks.ts @@ -0,0 +1,713 @@ +import {nanoid} from 'nanoid' +import type {Block, BlockSchema, StyledText, Styles} from '../..' + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface TextItem { + str: string + dir: string + transform: number[] // [scaleX, skewX, skewY, scaleY, translateX, translateY] + width: number + height: number + fontName: string + hasEOL: boolean +} + +interface TextMarkedContent { + type: 'beginMarkedContent' | 'endMarkedContent' + tag?: string +} + +type PageItem = TextItem | TextMarkedContent + +interface TextStyle { + fontFamily?: string + ascent?: number + descent?: number + vertical?: boolean +} + +interface FontObject { + name?: string + type?: string + bold?: boolean + italic?: boolean + black?: boolean + isMonospace?: boolean +} + +interface PdfPage { + getTextContent: (params?: { + includeMarkedContent?: boolean + }) => Promise<{items: PageItem[]; styles: Record}> + getOperatorList: () => Promise + getViewport: (params: {scale: number}) => {width: number; height: number} + commonObjs: { + get: (name: string) => FontObject | undefined + has: (name: string) => boolean + } +} + +interface PdfDocument { + numPages: number + getPage: (pageNumber: number) => Promise +} + +// ── Internal representation ────────────────────────────────────────────────── + +interface TextRun { + text: string + fontSize: number + fontName: string + x: number + y: number + width: number + bold: boolean + italic: boolean + monospace: boolean +} + +interface TextLine { + runs: TextRun[] + y: number + x: number + fontSize: number + fontName: string + bold: boolean + italic: boolean + monospace: boolean + pageIndex: number +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function isTextItem(item: PageItem): item is TextItem { + return 'str' in item +} + +function isBoldFont(fontName: string): boolean { + const lower = fontName.toLowerCase() + return ( + lower.includes('bold') || + lower.includes('heavy') || + lower.includes('black') + ) +} + +function isItalicFont(fontName: string): boolean { + const lower = fontName.toLowerCase() + return ( + lower.includes('italic') || + lower.includes('oblique') || + lower.includes('slant') + ) +} + +function isMonospaceFont(fontName: string): boolean { + const lower = fontName.toLowerCase() + return ( + lower.includes('mono') || + lower.includes('courier') || + lower.includes('consolas') || + lower.includes('menlo') || + lower.includes('firacode') || + lower.includes('inconsolata') || + lower.includes('source code') || + lower.includes('dejavu sans mono') || + lower.includes('lucida console') + ) +} + +function createBlock( + type: string, + props: Record = {}, + content: StyledText[] = [], + children: Block[] = [], +): Block { + return { + id: nanoid(10), + type, + props: { + textAlignment: 'left', + diff: 'null', + childrenType: 'Group', + listLevel: '1', + ...props, + }, + content, + children, + } as Block +} + +// ── Font size clustering for heading detection ─────────────────────────────── + +interface FontSizeCluster { + size: number + count: number +} + +function clusterFontSizes(lines: TextLine[]): { + bodySize: number + headingLevels: Map // fontSize -> heading level (1,2,3) +} { + const sizeCount = new Map() + + for (const line of lines) { + if (line.runs.length === 0) continue + // Use rounded font size + const size = Math.round(line.fontSize * 10) / 10 + sizeCount.set(size, (sizeCount.get(size) || 0) + 1) + } + + if (sizeCount.size === 0) { + return {bodySize: 12, headingLevels: new Map()} + } + + // Sort clusters by count descending — most common is body text + const clusters: FontSizeCluster[] = Array.from(sizeCount.entries()) + .map(([size, count]) => ({size, count})) + .sort((a, b) => b.count - a.count) + + const bodySize = clusters[0]!.size + + // Sizes larger than body text are potential headings + const headingSizes = clusters + .filter((c) => c.size > bodySize + 0.5) + .map((c) => c.size) + .sort((a, b) => b - a) // largest first + + const headingLevels = new Map() + for (let i = 0; i < headingSizes.length && i < 3; i++) { + headingLevels.set(headingSizes[i]!, i + 1) + } + + return {bodySize, headingLevels} +} + +// ── List detection ─────────────────────────────────────────────────────────── + +const BULLET_PATTERNS = /^[\u2022\u2023\u25E6\u2043\u2219•·‣⁃○◦▪▸►–—-]\s+/ +const NUMBERED_PATTERN = /^(\d+)[.)]\s+/ +const LETTER_PATTERN = /^[a-zA-Z][.)]\s+/ + +function detectListType( + text: string, +): {type: 'bullet' | 'numbered'; content: string} | null { + const bulletMatch = text.match(BULLET_PATTERNS) + if (bulletMatch) { + return {type: 'bullet', content: text.slice(bulletMatch[0].length)} + } + + const numberedMatch = text.match(NUMBERED_PATTERN) + if (numberedMatch) { + return {type: 'numbered', content: text.slice(numberedMatch[0].length)} + } + + const letterMatch = text.match(LETTER_PATTERN) + if (letterMatch) { + return {type: 'numbered', content: text.slice(letterMatch[0].length)} + } + + return null +} + +// ── Extract text runs from pages ───────────────────────────────────────────── + +async function extractTextRuns(doc: PdfDocument): Promise { + const allLines: TextLine[] = [] + + for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { + const page = await doc.getPage(pageNum) + // Trigger font loading by getting operator list first + await page.getOperatorList() + const textContent = await page.getTextContent({includeMarkedContent: false}) + const items = textContent.items.filter(isTextItem) + const styles = textContent.styles + + if (items.length === 0) continue + + // Build a font info cache from commonObjs and styles + const fontInfoCache = new Map() + const getFontInfo = (fontName: string): {bold: boolean; italic: boolean; monospace: boolean} => { + const cached = fontInfoCache.get(fontName) + if (cached) return cached + + let bold = false + let italic = false + let monospace = false + + // Try commonObjs first (most reliable) + try { + if (page.commonObjs.has(fontName)) { + const fontObj = page.commonObjs.get(fontName) + if (fontObj) { + bold = fontObj.bold === true || fontObj.black === true + italic = fontObj.italic === true + monospace = fontObj.isMonospace === true + // Also check font name for monospace + if (fontObj.name) { + if (!monospace) monospace = isMonospaceFont(fontObj.name) + if (!bold) bold = isBoldFont(fontObj.name) + if (!italic) italic = isItalicFont(fontObj.name) + } + } + } + } catch { + // commonObjs may not be available + } + + // Fallback to styles fontFamily + if (!bold && !italic && !monospace) { + const style = styles[fontName] + if (style?.fontFamily) { + monospace = style.fontFamily === 'monospace' || isMonospaceFont(style.fontFamily) + } + } + + // Fallback to font name heuristics + if (!bold) bold = isBoldFont(fontName) + if (!italic) italic = isItalicFont(fontName) + if (!monospace) monospace = isMonospaceFont(fontName) + + const info = {bold, italic, monospace} + fontInfoCache.set(fontName, info) + return info + } + + // Group items into lines by Y position (within tolerance) + const lineGroups: TextItem[][] = [] + let currentLineItems: TextItem[] = [] + let currentY: number | null = null + const Y_TOLERANCE = 2 // pixels + + // Sort by Y (descending, since PDF Y goes bottom-up) then X + const sorted = [...items].sort((a, b) => { + const yDiff = b.transform[5]! - a.transform[5]! + if (Math.abs(yDiff) > Y_TOLERANCE) return yDiff + return a.transform[4]! - b.transform[4]! + }) + + for (const item of sorted) { + const y = item.transform[5]! + if (currentY === null || Math.abs(y - currentY) > Y_TOLERANCE) { + if (currentLineItems.length > 0) { + lineGroups.push(currentLineItems) + } + currentLineItems = [item] + currentY = y + } else { + currentLineItems.push(item) + } + } + if (currentLineItems.length > 0) { + lineGroups.push(currentLineItems) + } + + // Convert line groups to TextLine objects + for (const group of lineGroups) { + // Sort by X within line + group.sort((a, b) => a.transform[4]! - b.transform[4]!) + + const runs: TextRun[] = group + .filter((item) => item.str.length > 0) + .map((item) => { + const fontInfo = getFontInfo(item.fontName) + return { + text: item.str, + fontSize: Math.abs(item.transform[3]!), + fontName: item.fontName, + x: item.transform[4]!, + y: item.transform[5]!, + width: item.width, + bold: fontInfo.bold, + italic: fontInfo.italic, + monospace: fontInfo.monospace, + } + }) + + if (runs.length === 0) continue + + const firstRun = runs[0]! + allLines.push({ + runs, + y: firstRun.y, + x: firstRun.x, + fontSize: firstRun.fontSize, + fontName: firstRun.fontName, + bold: runs.every((r) => r.bold), + italic: runs.every((r) => r.italic), + monospace: runs.every((r) => r.monospace), + pageIndex: pageNum - 1, + }) + } + } + + return allLines +} + +// ── Merge lines into paragraphs ────────────────────────────────────────────── + +interface ParagraphGroup { + lines: TextLine[] + type: 'paragraph' | 'heading' | 'code' | 'list-bullet' | 'list-numbered' + headingLevel?: number +} + +function groupLinesIntoParagraphs( + lines: TextLine[], + bodySize: number, + headingLevels: Map, +): ParagraphGroup[] { + const groups: ParagraphGroup[] = [] + let currentGroup: ParagraphGroup | null = null + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const roundedSize = Math.round(line.fontSize * 10) / 10 + const headingLevel = headingLevels.get(roundedSize) + const lineText = line.runs.map((r) => r.text).join('') + + // Detect list items + const listInfo = detectListType(lineText) + + // Detect code blocks (monospace lines) + const isCode = line.monospace && !headingLevel + + // Determine line type + let lineType: ParagraphGroup['type'] + if (headingLevel) { + lineType = 'heading' + } else if (isCode) { + lineType = 'code' + } else if (listInfo) { + lineType = listInfo.type === 'bullet' ? 'list-bullet' : 'list-numbered' + } else { + lineType = 'paragraph' + } + + // Check if we should continue the current group or start a new one + // Page break always starts new group + const isPageBreak = + i > 0 && lines[i - 1]!.pageIndex !== line.pageIndex + + const shouldStartNew = + !currentGroup || + isPageBreak || + // Headings always start new groups + lineType === 'heading' || + currentGroup.type === 'heading' || + // List items are individual + lineType === 'list-bullet' || + lineType === 'list-numbered' || + currentGroup.type === 'list-bullet' || + currentGroup.type === 'list-numbered' || + // Code blocks group together but not with non-code + (lineType === 'code') !== (currentGroup.type === 'code') || + // Large Y gap means new paragraph (more than 1.5x line height) + (i > 0 && + !isPageBreak && + Math.abs(lines[i - 1]!.y - line.y) > line.fontSize * 1.8 && + lineType !== 'code') + + if (shouldStartNew) { + if (currentGroup && currentGroup.lines.length > 0) { + groups.push(currentGroup) + } + currentGroup = { + lines: [line], + type: lineType, + headingLevel: headingLevel, + } + } else { + currentGroup!.lines.push(line) + } + } + + if (currentGroup && currentGroup.lines.length > 0) { + groups.push(currentGroup) + } + + return groups +} + +// ── Convert runs to styled text ────────────────────────────────────────────── + +function runsToStyledText(runs: TextRun[], allBold: boolean, allItalic: boolean): StyledText[] { + const result: StyledText[] = [] + + for (const run of runs) { + if (run.text.length === 0) continue + + const styles: Styles = {} + + // Only mark bold/italic if not all runs share the same style + // (avoids marking heading text as "bold" when the whole heading is bold) + if (run.bold && !allBold) styles.bold = true + if (run.italic && !allItalic) styles.italic = true + if (run.monospace) styles.code = true + + // Merge with previous run if same styles + const prev = result[result.length - 1] + if ( + prev && + prev.type === 'text' && + JSON.stringify(prev.styles) === JSON.stringify(styles) + ) { + prev.text += run.text + } else { + result.push({ + type: 'text', + text: run.text, + styles, + }) + } + } + + return result +} + +// ── Main converter ─────────────────────────────────────────────────────────── + +/** + * Convert PDF binary data to BlockNote blocks. + * Uses pdfjs-dist for text extraction. + */ +export async function PdfToBlocks( + pdfData: ArrayBuffer, +): Promise[]> { + // Dynamic import of pdfjs-dist to handle environments where it may not be available + const pdfjsLib = await import('pdfjs-dist') + + // Load the PDF document + const loadingTask = pdfjsLib.getDocument({data: new Uint8Array(pdfData) as Uint8Array}) + const doc = await loadingTask.promise as unknown as PdfDocument + + if (doc.numPages === 0) { + return [] + } + + // Extract all text lines + const lines = await extractTextRuns(doc) + + if (lines.length === 0) { + return [] + } + + // Cluster font sizes to determine body text and heading levels + const {bodySize, headingLevels} = clusterFontSizes(lines) + + // Group lines into logical paragraphs + const paragraphGroups = groupLinesIntoParagraphs( + lines, + bodySize, + headingLevels, + ) + + // Convert paragraph groups to blocks + const blocks: Block[] = [] + + for (const group of paragraphGroups) { + switch (group.type) { + case 'heading': { + const level = String(group.headingLevel || 1) + const allRuns = group.lines.flatMap((l) => l.runs) + const content = runsToStyledText(allRuns, true, false) + if (content.length > 0) { + blocks.push(createBlock('heading', {level}, content)) + } + break + } + + case 'code': { + const codeText = group.lines + .map((l) => l.runs.map((r) => r.text).join('')) + .join('\n') + blocks.push( + createBlock('code-block', {language: ''}, [ + {type: 'text', text: codeText, styles: {}}, + ]), + ) + break + } + + case 'list-bullet': + case 'list-numbered': { + const childrenType = + group.type === 'list-bullet' ? 'Unordered' : 'Ordered' + const listChildren: Block[] = [] + + for (const line of group.lines) { + const lineText = line.runs.map((r) => r.text).join('') + const listInfo = detectListType(lineText) + const contentText = listInfo ? listInfo.content : lineText + const allBold = line.runs.every((r) => r.bold) + const allItalic = line.runs.every((r) => r.italic) + + // Build styled text from the content (after stripping the bullet/number) + const styledContent: StyledText[] = [{ + type: 'text', + text: contentText, + styles: { + ...(allBold ? {bold: true} : {}), + ...(allItalic ? {italic: true} : {}), + } as Styles, + }] + + listChildren.push( + createBlock('paragraph', {type: 'p'}, styledContent), + ) + } + + blocks.push( + createBlock( + 'paragraph', + {type: 'p', childrenType}, + [], + listChildren, + ), + ) + break + } + + case 'paragraph': + default: { + // Merge all runs from all lines with spaces between lines + const allRuns: TextRun[] = [] + const allBold = group.lines.every((l) => l.bold) + const allItalic = group.lines.every((l) => l.italic) + + for (let i = 0; i < group.lines.length; i++) { + const line = group.lines[i]! + if (i > 0 && allRuns.length > 0) { + // Add space between lines if the previous run doesn't end with space/hyphen + const lastRun = allRuns[allRuns.length - 1]! + if (lastRun.text.endsWith('-')) { + // Hyphenated word — remove hyphen and join + lastRun.text = lastRun.text.slice(0, -1) + } else if (!lastRun.text.endsWith(' ')) { + allRuns.push({ + ...line.runs[0]!, + text: ' ', + width: 0, + }) + } + } + allRuns.push(...line.runs) + } + + const content = runsToStyledText(allRuns, allBold, allItalic) + if (content.length > 0) { + blocks.push(createBlock('paragraph', {type: 'p'}, content)) + } + break + } + } + } + + // Organize blocks by heading hierarchy + const organizedBlocks: Block[] = [] + const stack: {level: number; block: Block}[] = [] + + for (const block of blocks) { + const headingLevel = + block.type === 'heading' + ? parseInt(String((block.props as Record).level), 10) + : 0 + + if (headingLevel > 0) { + while (stack.length && stack[stack.length - 1]!.level >= headingLevel) { + stack.pop() + } + + const parent = stack[stack.length - 1] + if (parent) { + parent.block.children.push(block) + } else { + organizedBlocks.push(block) + } + + stack.push({level: headingLevel, block}) + } else { + const parent = stack[stack.length - 1] + if (parent) { + parent.block.children.push(block) + } else { + organizedBlocks.push(block) + } + } + } + + return organizedBlocks +} + +/** + * Extract a title from PDF content (first heading or first line of text). + */ +export async function extractPdfTitle( + pdfData: ArrayBuffer, +): Promise { + try { + const pdfjsLib = await import('pdfjs-dist') + const loadingTask = pdfjsLib.getDocument({data: new Uint8Array(pdfData) as Uint8Array}) + const doc = await loadingTask.promise + + if (doc.numPages === 0) return undefined + + const page = await doc.getPage(1) + const textContent = await page.getTextContent({includeMarkedContent: false}) + const items = (textContent.items as PageItem[]).filter(isTextItem) + + if (items.length === 0) return undefined + + // Find the largest font size on the first page — likely the title + let maxFontSize = 0 + let titleItems: TextItem[] = [] + + for (const item of items) { + const fontSize = Math.abs(item.transform[3]!) + if (fontSize > maxFontSize + 0.5) { + maxFontSize = fontSize + titleItems = [item] + } else if (Math.abs(fontSize - maxFontSize) <= 0.5) { + titleItems.push(item) + } + } + + // Only treat as title if the largest font is bigger than the most common font + // Count font sizes to find body size + const sizeCount = new Map() + for (const item of items) { + const size = Math.round(Math.abs(item.transform[3]!) * 10) / 10 + sizeCount.set(size, (sizeCount.get(size) || 0) + 1) + } + const bodySize = Array.from(sizeCount.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || 12 + + // Only use as title if it's larger than body text + if (maxFontSize <= bodySize + 0.5) { + // All text is same size, use first line + return items + .slice(0, 10) + .map((item) => item.str) + .join('') + .trim() + .slice(0, 100) || undefined + } + + // Sort title items by position (left to right) + titleItems.sort((a, b) => a.transform[4]! - b.transform[4]!) + + if (titleItems.length > 0) { + return titleItems.map((item) => item.str).join('').trim() || undefined + } + + // Fallback: first line of text + return items + .slice(0, 10) + .map((item) => item.str) + .join('') + .trim() + .slice(0, 100) || undefined + } catch { + return undefined + } +} diff --git a/frontend/packages/editor/src/blocknote/core/extensions/Pdf/__tests__/PdfToBlocks.test.ts b/frontend/packages/editor/src/blocknote/core/extensions/Pdf/__tests__/PdfToBlocks.test.ts new file mode 100644 index 000000000..07cc088bf --- /dev/null +++ b/frontend/packages/editor/src/blocknote/core/extensions/Pdf/__tests__/PdfToBlocks.test.ts @@ -0,0 +1,289 @@ +import {describe, it, expect} from 'vitest' +import {PDFDocument, StandardFonts, rgb} from 'pdf-lib' +import {PdfToBlocks, extractPdfTitle} from '../PdfToBlocks' + +// Helper to create a simple PDF with text +async function createSimplePdf( + pages: Array<{ + texts: Array<{ + text: string + x: number + y: number + size: number + font?: 'helvetica' | 'helveticaBold' | 'helveticaOblique' | 'courier' + }> + }>, +): Promise { + const doc = await PDFDocument.create() + const helvetica = await doc.embedFont(StandardFonts.Helvetica) + const helveticaBold = await doc.embedFont(StandardFonts.HelveticaBold) + const helveticaOblique = await doc.embedFont(StandardFonts.HelveticaOblique) + const courier = await doc.embedFont(StandardFonts.Courier) + + const fontMap = { + helvetica, + helveticaBold, + helveticaOblique, + courier, + } + + for (const pageSpec of pages) { + const page = doc.addPage([612, 792]) // Letter size + for (const textSpec of pageSpec.texts) { + const font = fontMap[textSpec.font || 'helvetica'] + page.drawText(textSpec.text, { + x: textSpec.x, + y: textSpec.y, + size: textSpec.size, + font, + }) + } + } + + const bytes = await doc.save() + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer +} + +describe('PdfToBlocks', () => { + it('should handle an empty PDF', async () => { + const doc = await PDFDocument.create() + doc.addPage([612, 792]) + const bytes = await doc.save() + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer + + const blocks = await PdfToBlocks(buffer) + expect(blocks).toEqual([]) + }) + + it('should extract simple paragraphs', async () => { + const pdf = await createSimplePdf([ + { + texts: [ + {text: 'Hello World', x: 72, y: 700, size: 12}, + {text: 'Second paragraph', x: 72, y: 660, size: 12}, + ], + }, + ]) + + const blocks = await PdfToBlocks(pdf) + expect(blocks.length).toBeGreaterThanOrEqual(1) + + // Should have paragraph blocks + const paragraphs = blocks.filter((b) => b.type === 'paragraph') + expect(paragraphs.length).toBeGreaterThanOrEqual(1) + }) + + it('should detect headings from larger font sizes', async () => { + const pdf = await createSimplePdf([ + { + texts: [ + {text: 'Main Title', x: 72, y: 750, size: 24}, + {text: 'Body text here.', x: 72, y: 700, size: 12}, + {text: 'Body text continues.', x: 72, y: 680, size: 12}, + {text: 'Subtitle', x: 72, y: 640, size: 18}, + {text: 'More body text.', x: 72, y: 610, size: 12}, + ], + }, + ]) + + const blocks = await PdfToBlocks(pdf) + + // Should have heading blocks + const headings = flattenBlocks(blocks).filter((b) => b.type === 'heading') + expect(headings.length).toBeGreaterThanOrEqual(1) + }) + + it('should preserve bold styling', async () => { + const pdf = await createSimplePdf([ + { + texts: [ + {text: 'Normal text ', x: 72, y: 700, size: 12, font: 'helvetica'}, + {text: 'bold text', x: 160, y: 700, size: 12, font: 'helveticaBold'}, + ], + }, + ]) + + const blocks = await PdfToBlocks(pdf) + expect(blocks.length).toBeGreaterThanOrEqual(1) + + // Find paragraph with mixed content + const paragraphs = flattenBlocks(blocks).filter( + (b) => b.type === 'paragraph', + ) + expect(paragraphs.length).toBeGreaterThanOrEqual(1) + + // Check that there's at least one content item with bold + const allContent = paragraphs.flatMap((p) => p.content || []) + const boldItems = allContent.filter( + (c: Record) => + c.type === 'text' && (c.styles as Record)?.bold === true, + ) + expect(boldItems.length).toBeGreaterThanOrEqual(1) + }) + + it('should preserve italic styling', async () => { + const pdf = await createSimplePdf([ + { + texts: [ + {text: 'Normal text ', x: 72, y: 700, size: 12, font: 'helvetica'}, + { + text: 'italic text', + x: 160, + y: 700, + size: 12, + font: 'helveticaOblique', + }, + ], + }, + ]) + + const blocks = await PdfToBlocks(pdf) + const paragraphs = flattenBlocks(blocks).filter( + (b) => b.type === 'paragraph', + ) + const allContent = paragraphs.flatMap((p) => p.content || []) + const italicItems = allContent.filter( + (c: Record) => + c.type === 'text' && + (c.styles as Record)?.italic === true, + ) + expect(italicItems.length).toBeGreaterThanOrEqual(1) + }) + + it('should detect monospace as code blocks', async () => { + const pdf = await createSimplePdf([ + { + texts: [ + {text: 'Normal paragraph.', x: 72, y: 750, size: 12}, + {text: 'const x = 42;', x: 72, y: 700, size: 12, font: 'courier'}, + {text: 'return x;', x: 72, y: 685, size: 12, font: 'courier'}, + {text: 'Another paragraph.', x: 72, y: 640, size: 12}, + ], + }, + ]) + + const blocks = await PdfToBlocks(pdf) + const codeBlocks = flattenBlocks(blocks).filter( + (b) => b.type === 'code-block', + ) + expect(codeBlocks.length).toBeGreaterThanOrEqual(1) + }) + + it('should detect bullet lists', async () => { + const pdf = await createSimplePdf([ + { + texts: [ + {text: '• First item', x: 72, y: 700, size: 12}, + {text: '• Second item', x: 72, y: 680, size: 12}, + {text: '• Third item', x: 72, y: 660, size: 12}, + ], + }, + ]) + + const blocks = await PdfToBlocks(pdf) + // Should have a list parent with Unordered childrenType + const listParents = flattenBlocks(blocks).filter( + (b) => + (b.props as Record)?.childrenType === 'Unordered', + ) + expect(listParents.length).toBeGreaterThanOrEqual(1) + }) + + it('should detect numbered lists', async () => { + const pdf = await createSimplePdf([ + { + texts: [ + {text: '1. First item', x: 72, y: 700, size: 12}, + {text: '2. Second item', x: 72, y: 680, size: 12}, + {text: '3. Third item', x: 72, y: 660, size: 12}, + ], + }, + ]) + + const blocks = await PdfToBlocks(pdf) + const listParents = flattenBlocks(blocks).filter( + (b) => (b.props as Record)?.childrenType === 'Ordered', + ) + expect(listParents.length).toBeGreaterThanOrEqual(1) + }) + + it('should handle multi-page PDFs', async () => { + const pdf = await createSimplePdf([ + { + texts: [{text: 'Page one content', x: 72, y: 700, size: 12}], + }, + { + texts: [{text: 'Page two content', x: 72, y: 700, size: 12}], + }, + { + texts: [{text: 'Page three content', x: 72, y: 700, size: 12}], + }, + ]) + + const blocks = await PdfToBlocks(pdf) + expect(blocks.length).toBeGreaterThanOrEqual(3) + }) + + it('should organize headings into hierarchy', async () => { + const pdf = await createSimplePdf([ + { + texts: [ + {text: 'Chapter Title', x: 72, y: 750, size: 24}, + {text: 'Some intro text.', x: 72, y: 710, size: 12}, + {text: 'Section Title', x: 72, y: 670, size: 18}, + {text: 'Section body.', x: 72, y: 640, size: 12}, + ], + }, + ]) + + const blocks = await PdfToBlocks(pdf) + // The top-level should have the chapter heading + const topHeadings = blocks.filter((b) => b.type === 'heading') + expect(topHeadings.length).toBeGreaterThanOrEqual(1) + + // The chapter heading should have children (the section) + if (topHeadings.length > 0 && topHeadings[0]!.children.length > 0) { + // Hierarchy is working + expect(topHeadings[0]!.children.length).toBeGreaterThanOrEqual(1) + } + }) + + it('should extract title from PDF', async () => { + const pdf = await createSimplePdf([ + { + texts: [ + {text: 'My Document Title', x: 72, y: 750, size: 24}, + {text: 'By Author Name', x: 72, y: 720, size: 12}, + {text: 'Regular content here.', x: 72, y: 680, size: 12}, + ], + }, + ]) + + const title = await extractPdfTitle(pdf) + expect(title).toBe('My Document Title') + }) + + it('should handle PDF with no text gracefully (scanned PDF simulation)', async () => { + const doc = await PDFDocument.create() + const page = doc.addPage([612, 792]) + // Draw a rectangle instead of text to simulate a scanned page + page.drawRectangle({x: 50, y: 50, width: 500, height: 700, color: rgb(0.9, 0.9, 0.9)}) + const bytes = await doc.save() + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer + + const blocks = await PdfToBlocks(buffer) + expect(blocks).toEqual([]) + }) +}) + +// Helper to flatten blocks and their children for easier testing +function flattenBlocks(blocks: Array<{type: string; children: unknown[]; content?: unknown[]; props?: unknown}>): Array<{type: string; children: unknown[]; content?: unknown[]; props?: unknown}> { + const result: Array<{type: string; children: unknown[]; content?: unknown[]; props?: unknown}> = [] + for (const block of blocks) { + result.push(block) + if (block.children && Array.isArray(block.children)) { + result.push(...flattenBlocks(block.children as typeof blocks)) + } + } + return result +} diff --git a/frontend/packages/editor/vitest.config.ts b/frontend/packages/editor/vitest.config.ts new file mode 100644 index 000000000..e3576112d --- /dev/null +++ b/frontend/packages/editor/vitest.config.ts @@ -0,0 +1,10 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/__tests__/**/*.test.ts'], + testTimeout: 30000, + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + }, +}) diff --git a/frontend/packages/editor/vitest.setup.ts b/frontend/packages/editor/vitest.setup.ts new file mode 100644 index 000000000..835d2981a --- /dev/null +++ b/frontend/packages/editor/vitest.setup.ts @@ -0,0 +1,50 @@ +// Polyfill DOMMatrix for pdfjs-dist in jsdom +if (typeof globalThis.DOMMatrix === 'undefined') { + class DOMMatrixPolyfill { + a = 1; b = 0; c = 0; d = 1; e = 0; f = 0 + m11 = 1; m12 = 0; m13 = 0; m14 = 0 + m21 = 0; m22 = 1; m23 = 0; m24 = 0 + m31 = 0; m32 = 0; m33 = 1; m34 = 0 + m41 = 0; m42 = 0; m43 = 0; m44 = 1 + is2D = true + isIdentity = true + + constructor(init?: string | number[]) { + if (Array.isArray(init) && init.length >= 6) { + this.a = this.m11 = init[0]! + this.b = this.m12 = init[1]! + this.c = this.m21 = init[2]! + this.d = this.m22 = init[3]! + this.e = this.m41 = init[4]! + this.f = this.m42 = init[5]! + } + } + + inverse() { return new DOMMatrixPolyfill() } + multiply() { return new DOMMatrixPolyfill() } + translate() { return new DOMMatrixPolyfill() } + scale() { return new DOMMatrixPolyfill() } + rotate() { return new DOMMatrixPolyfill() } + transformPoint(point?: {x?: number; y?: number}) { + return {x: point?.x || 0, y: point?.y || 0, z: 0, w: 1} + } + } + + // @ts-expect-error polyfill + globalThis.DOMMatrix = DOMMatrixPolyfill +} + +// Polyfill Path2D +if (typeof globalThis.Path2D === 'undefined') { + class Path2DPolyfill { + moveTo() {} + lineTo() {} + closePath() {} + rect() {} + arc() {} + bezierCurveTo() {} + quadraticCurveTo() {} + } + // @ts-expect-error polyfill + globalThis.Path2D = Path2DPolyfill +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b9e2cbe2..520a832a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,7 +85,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.8 + version: 1.3.9 '@types/glob': specifier: ^8.1.0 version: 8.1.0 @@ -118,7 +118,7 @@ importers: version: 10.9.1(@types/node@22.19.7)(typescript@5.8.3) vitest: specifier: ^3.0.9 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jiti@2.6.1)(jsdom@22.1.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jiti@2.6.1)(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) frontend/apps/desktop: dependencies: @@ -184,7 +184,7 @@ importers: version: 7.49.0 '@sentry/vite-plugin': specifier: latest - version: 4.9.0(encoding@0.1.13) + version: 4.9.1(encoding@0.1.13) '@shm/shared': specifier: workspace:* version: link:../../packages/shared @@ -494,7 +494,7 @@ importers: version: 7.8.1(encoding@0.1.13) jsdom: specifier: 22.1.0 - version: 22.1.0 + version: 22.1.0(canvas@2.11.2(encoding@0.1.13)) loglevel: specifier: 1.8.1 version: 1.8.1 @@ -521,7 +521,7 @@ importers: version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: 0.34.2 - version: 0.34.2(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0)(lightningcss@1.30.2)(playwright@1.57.0)(terser@5.46.0) + version: 0.34.2(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(playwright@1.57.0)(terser@5.46.0) xvfb-maybe: specifier: 0.2.1 version: 0.2.1 @@ -589,7 +589,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.8.3) '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.7.0(vite@6.3.5(@types/node@22.19.7)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) autoprefixer: specifier: ^10.4.17 version: 10.4.23(postcss@8.5.6) @@ -613,7 +613,7 @@ importers: version: 5.8.3 vite: specifier: 6.3.5 - version: 6.3.5(@types/node@22.19.7)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) frontend/apps/landing: dependencies: @@ -638,19 +638,19 @@ importers: version: 18.2.21 '@vitejs/plugin-react': specifier: ^4.4.1 - version: 4.7.0(vite@6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.3.5(@types/node@22.19.7)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) autoprefixer: specifier: ^10.4.21 version: 10.4.23(postcss@8.5.6) eslint: specifier: ^9.25.0 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(jiti@1.21.7) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.39.2(jiti@2.6.1)) + version: 5.2.0(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-react-refresh: specifier: ^0.4.19 - version: 0.4.26(eslint@9.39.2(jiti@2.6.1)) + version: 0.4.26(eslint@9.39.2(jiti@1.21.7)) globals: specifier: ^16.0.0 version: 16.5.0 @@ -665,10 +665,10 @@ importers: version: 5.8.3 typescript-eslint: specifier: ^8.30.1 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + version: 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) vite: specifier: 6.3.5 - version: 6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.3.5(@types/node@22.19.7)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) frontend/apps/notify: dependencies: @@ -743,7 +743,7 @@ importers: version: 7.49.0 '@sentry/vite-plugin': specifier: latest - version: 4.9.0(encoding@0.1.13) + version: 4.9.1(encoding@0.1.13) '@shm/emails': specifier: workspace:* version: link:../emails @@ -858,7 +858,7 @@ importers: version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^1.3.1 - version: 1.6.1(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0)(lightningcss@1.30.2)(terser@5.46.0) + version: 1.6.1(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.46.0) frontend/apps/perf-web: dependencies: @@ -1085,7 +1085,7 @@ importers: version: 7.49.0 '@sentry/vite-plugin': specifier: latest - version: 4.9.0(encoding@0.1.13) + version: 4.9.1(encoding@0.1.13) '@shm/editor': specifier: workspace:* version: link:../../packages/editor @@ -1217,7 +1217,7 @@ importers: version: 2.1.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) remix-utils: specifier: ^9.0.0 - version: 9.0.1(react-router@7.13.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0) + version: 9.0.1(@standard-schema/spec@1.1.0)(react-router@7.13.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0) sharp: specifier: ^0.33.5 version: 0.33.5 @@ -1305,7 +1305,7 @@ importers: version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^1.3.1 - version: 1.6.1(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0)(lightningcss@1.30.2)(terser@5.46.0) + version: 1.6.1(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.46.0) frontend/packages/editor: dependencies: @@ -1408,6 +1408,9 @@ importers: nanoid: specifier: 4.0.2 version: 4.0.2 + pdfjs-dist: + specifier: ^3.11.174 + version: 3.11.174(encoding@0.1.13) prosemirror-state: specifier: 1.4.4 version: 1.4.4 @@ -1484,6 +1487,12 @@ importers: globals: specifier: ^15.14.0 version: 15.15.0 + jsdom: + specifier: 22.1.0 + version: 22.1.0(canvas@2.11.2(encoding@0.1.13)) + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 prettier: specifier: 3.0.2 version: 3.0.2 @@ -1496,6 +1505,9 @@ importers: vite: specifier: 6.3.5 version: 6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jiti@2.6.1)(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) frontend/packages/shared: dependencies: @@ -1538,7 +1550,7 @@ importers: version: 5.8.3 vitest: specifier: 0.34.2 - version: 0.34.2(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0)(lightningcss@1.30.2)(playwright@1.57.0)(terser@5.46.0) + version: 0.34.2(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(playwright@1.57.0)(terser@5.46.0) frontend/packages/ui: dependencies: @@ -1668,7 +1680,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.8 + version: 1.3.9 tests: dependencies: @@ -1681,7 +1693,7 @@ importers: version: 1.57.0 vitest: specifier: ^3.0.9 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jiti@2.6.1)(jsdom@22.1.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jiti@2.6.1)(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -4415,6 +4427,10 @@ packages: '@manuscripts/prosemirror-recreate-steps@0.1.4': resolution: {integrity: sha512-z3/PGcWB3SbOzhUkiZKAAMeWVz3jR1uacUXuYfJfSE+XgTUCtxB/xnbmx7SpTvtBEtw9EFXomTqjoG6DGlC2nQ==} + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + '@mdx-js/mdx@2.3.0': resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} @@ -4883,6 +4899,12 @@ packages: '@paulirish/trace_engine@0.0.59': resolution: {integrity: sha512-439NUzQGmH+9Y017/xCchBP9571J4bzhpcNhrxorf7r37wcyJZkgUfrUsRL3xl+JDcZ6ORhoFCzCw98c6S3YHw==} + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@peculiar/asn1-android@2.6.0': resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} @@ -6231,8 +6253,8 @@ packages: resolution: {integrity: sha512-tpViyDd8AhQGYYhI94xi2aaDopXOPfL2Apwrtb3qirWkomIQ2K86W1mPmkce+B0cFOnW2Dxv/ZTFKz6ghjK75A==} engines: {node: '>=8'} - '@sentry/babel-plugin-component-annotate@4.9.0': - resolution: {integrity: sha512-TJ7sVoa2Bf36lpJjBAzpNDC5Hg+evjsQnqUPeDx9Nz/YFw0u9rK1cwvi95gVWpx7PJSDCkljIv3aw0m4RatHpQ==} + '@sentry/babel-plugin-component-annotate@4.9.1': + resolution: {integrity: sha512-0gEoi2Lb54MFYPOmdTfxlNKxI7kCOvNV7gP8lxMXJ7nCazF5OqOOZIVshfWjDLrc0QrSV6XdVvwPV9GDn4wBMg==} engines: {node: '>= 14'} '@sentry/browser@7.57.0': @@ -6243,8 +6265,8 @@ packages: resolution: {integrity: sha512-M+tKqawH9S3CqlAIcqdZcHbcsNQkEa9MrPqPCYvXco3C4LRpNizJP2XwBiGQY2yK+fOSvbaWpPtlI938/wuRZQ==} engines: {node: '>=14.18'} - '@sentry/bundler-plugin-core@4.9.0': - resolution: {integrity: sha512-gOVgHG5BrxCFmZow1XovlDr1FH/gO/LfD8OKci1rryeqHVBLr3+S4yS4ACl+E5lfQPym8Ve1BKh793d1rZ0dyA==} + '@sentry/bundler-plugin-core@4.9.1': + resolution: {integrity: sha512-moii+w7N8k8WdvkX7qCDY9iRBlhgHlhTHTUQwF2FNMhBHuqlNpVcSJJqJMjFUQcjYMBDrZgxhfKV18bt5ixwlQ==} engines: {node: '>= 14'} '@sentry/cli-darwin@2.58.4': @@ -6433,8 +6455,8 @@ packages: resolution: {integrity: sha512-wZxU2HWlzsnu8214Xy7S7cRIuD6h8Z5DnnkojJfX0i0NLooepZQk2824el1Q13AakLb7/S8CHSHXOMnCtoSduw==} engines: {node: '>=14.18'} - '@sentry/vite-plugin@4.9.0': - resolution: {integrity: sha512-1olEMpZnwZS4UdboB25w5sZYJSlwbxJaieoL0c7FDNMyyns5GhXSl6mK8Lpx9w3rIc88gw9JOdsehdd6YrlLOA==} + '@sentry/vite-plugin@4.9.1': + resolution: {integrity: sha512-Tlyg2cyFYp/icX58GWvfpvZr9NLdLs2/xyFVyS8pQ0faZWmoXic3FMzoXYHV1gsdMbL1Yy5WQvGJy8j1rS8LGA==} engines: {node: '>= 14'} '@shuding/opentype.js@1.4.0-beta.0': @@ -6694,6 +6716,9 @@ packages: '@spacingbat3/lss@1.2.0': resolution: {integrity: sha512-aywhxHNb6l7COooF3m439eT/6QN8E/RSl5IVboSKthMHcp0GlZYMSoS7546rqDLmFRxTD8f1tu/NIS9vtDwYAg==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} @@ -6985,8 +7010,8 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/bun@1.3.8': - resolution: {integrity: sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA==} + '@types/bun@1.3.9': + resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -7548,6 +7573,9 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -7559,9 +7587,23 @@ packages: vite: optional: true + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/runner@0.34.2': resolution: {integrity: sha512-8ydGPACVX5tK3Dl0SUwxfdg02h+togDNeQX3iXVFYgzF5odxvaou7HnquALFZkyVuYskoaHUOqOyOLpOEj5XTA==} @@ -7571,6 +7613,9 @@ packages: '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/snapshot@0.34.2': resolution: {integrity: sha512-qhQ+xy3u4mwwLxltS4Pd4SR+XHv4EajiTPNY3jkIBLUApE6/ce72neJPSUQZ7bL3EBuKI+NhvzhGj3n5baRQUQ==} @@ -7580,6 +7625,9 @@ packages: '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/spy@0.34.2': resolution: {integrity: sha512-yd4L9OhfH6l0Av7iK3sPb3MykhtcRN5c5K5vm1nTbuN7gYn+yvUVVsyvzpHrjqS7EWqn9WsPJb7+0c3iuY60tA==} @@ -7589,6 +7637,9 @@ packages: '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/utils@0.34.2': resolution: {integrity: sha512-Lzw+kAsTPubhoQDp1uVAOP6DhNia1GMDsI9jgB0yMn+/nDaPieYQ88lKqz/gGjSHL4zwOItvpehec9OY+rS73w==} @@ -7598,6 +7649,9 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vscode/sudo-prompt@9.3.2': resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==} @@ -7759,6 +7813,14 @@ packages: os: [darwin] hasBin: true + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -8119,8 +8181,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.3.8: - resolution: {integrity: sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q==} + bun-types@1.3.9: + resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==} bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -8202,6 +8264,10 @@ packages: canvas-renderer@2.2.1: resolution: {integrity: sha512-RrBgVL5qCEDIXpJ6NrzyRNoTnXxYarqm/cS/W6ERhUJts5UQtt/XPEosGN3rqUkZ4fjBArlnCbsISJ+KCFnIAg==} + canvas@2.11.2: + resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==} + engines: {node: '>=6'} + capture-stack-trace@1.0.2: resolution: {integrity: sha512-X/WM2UQs6VMHUtjUDnZTRI+i1crWteJySFzr9UpGoQa4WQffXVTTXuekjl7TjZRlcF2XfjgITT0HxZ9RnxeT0w==} engines: {node: '>=0.10.0'} @@ -8224,6 +8290,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -8412,6 +8482,10 @@ packages: color-string@1.9.1: resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + color@3.2.1: resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} @@ -8530,6 +8604,9 @@ packages: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + console-log-colors@0.4.0: resolution: {integrity: sha512-XX0qO0MUzbREpPrutavmOLML8h8IokzKTzJqMwvykBcL9D7bMbj5P17+driOjy4RaA99aIAkMDo8holIxYbbpQ==} engines: {node: '>= 4.1.0'} @@ -8932,6 +9009,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@4.2.1: + resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==} + engines: {node: '>=8'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -9004,6 +9085,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + denodeify@1.2.1: resolution: {integrity: sha512-KNTihKNmQENUZeKu5fzfpzRqR5S2VMp4gl9RFHiWzj9DfvYQPMJ6XHKNaQxaGCXwPk6y9yme3aUoaiAe+KX+vg==} @@ -9900,6 +9984,11 @@ packages: resolution: {integrity: sha512-w4n9cPWyP7aHxKxYHFQMegj7WIAsL/YX/C4Bs5Rr8s1H9M1rNtRWRsw+ovYMkXDQ5S4ZbYHsHAPmevPjPgw44w==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} @@ -10127,6 +10216,9 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + hashlru@2.3.0: resolution: {integrity: sha512-0cMsjjIC8I+D3M44pOQdsy0OHXGLVz6Z0beRuufhKa0KfaD2wGwAev6jILzXsd3/vpnNQJmWyZtIILqM1N+n5A==} @@ -11862,6 +11954,10 @@ packages: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} + mimic-response@2.1.0: + resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==} + engines: {node: '>=8'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -12223,6 +12319,11 @@ packages: resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} engines: {node: '>=6.0.0'} + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + nopt@6.0.0: resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -12284,6 +12385,10 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -12317,6 +12422,9 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + omggif@1.0.10: resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} @@ -12628,6 +12736,10 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + path2d-polyfill@2.0.1: + resolution: {integrity: sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==} + engines: {node: '>=8'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -12641,6 +12753,13 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + + pdfjs-dist@3.11.174: + resolution: {integrity: sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==} + engines: {node: '>=18'} + pe-library@1.0.1: resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==} engines: {node: '>=14', npm: '>=7'} @@ -13937,6 +14056,9 @@ packages: simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + simple-get@3.1.1: + resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} + simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} @@ -14388,6 +14510,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + tinyspy@2.2.1: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} @@ -15235,6 +15361,40 @@ packages: jsdom: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -15376,6 +15536,9 @@ packages: engines: {node: '>=8'} hasBin: true + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + widest-line@2.0.1: resolution: {integrity: sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==} engines: {node: '>=4'} @@ -18103,6 +18266,11 @@ snapshots: eslint: 8.57.1 eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': + dependencies: + eslint: 9.39.2(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -19317,6 +19485,22 @@ snapshots: prosemirror-view: 1.41.3 rfc6902: 3.1.1 + '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)': + dependencies: + detect-libc: 2.1.2 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0(encoding@0.1.13) + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.7.3 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + '@mdx-js/mdx@2.3.0': dependencies: '@types/estree-jsx': 1.0.5 @@ -19973,6 +20157,14 @@ snapshots: legacy-javascript: 0.0.1 third-party-web: 0.29.0 + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@peculiar/asn1-android@2.6.0': dependencies: '@peculiar/asn1-schema': 2.6.0 @@ -21674,7 +21866,7 @@ snapshots: '@sentry/utils': 7.57.0 tslib: 2.8.1 - '@sentry/babel-plugin-component-annotate@4.9.0': {} + '@sentry/babel-plugin-component-annotate@4.9.1': {} '@sentry/browser@7.57.0': dependencies: @@ -21695,10 +21887,10 @@ snapshots: '@sentry/types': 8.30.0 '@sentry/utils': 8.30.0 - '@sentry/bundler-plugin-core@4.9.0(encoding@0.1.13)': + '@sentry/bundler-plugin-core@4.9.1(encoding@0.1.13)': dependencies: '@babel/core': 7.28.6 - '@sentry/babel-plugin-component-annotate': 4.9.0 + '@sentry/babel-plugin-component-annotate': 4.9.1 '@sentry/cli': 2.58.4(encoding@0.1.13) dotenv: 16.6.1 find-up: 5.0.0 @@ -22049,9 +22241,9 @@ snapshots: dependencies: '@sentry/types': 8.30.0 - '@sentry/vite-plugin@4.9.0(encoding@0.1.13)': + '@sentry/vite-plugin@4.9.1(encoding@0.1.13)': dependencies: - '@sentry/bundler-plugin-core': 4.9.0(encoding@0.1.13) + '@sentry/bundler-plugin-core': 4.9.1(encoding@0.1.13) unplugin: 1.0.1 transitivePeerDependencies: - encoding @@ -22440,6 +22632,8 @@ snapshots: '@spacingbat3/lss@1.2.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.18': dependencies: tslib: 2.8.1 @@ -22718,9 +22912,9 @@ snapshots: dependencies: '@types/node': 22.19.7 - '@types/bun@1.3.8': + '@types/bun@1.3.9': dependencies: - bun-types: 1.3.8 + bun-types: 1.3.9 '@types/cacheable-request@6.0.3': dependencies: @@ -23137,6 +23331,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.54.0 + eslint: 9.39.2(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -23166,6 +23376,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.54.0 + debug: 4.4.1 + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.54.0 @@ -23213,6 +23435,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + debug: 4.4.1 + eslint: 9.39.2(jiti@1.21.7) + ts-api-utils: 2.4.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.54.0 @@ -23270,6 +23504,17 @@ snapshots: - supports-color - typescript + '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.8.3) + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) @@ -23535,6 +23780,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(vite@6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -23544,10 +23798,23 @@ snapshots: msw: 2.12.7(@types/node@22.19.7)(typescript@5.8.3) vite: 6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(vite@6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.7(@types/node@22.19.7)(typescript@5.8.3) + vite: 6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + '@vitest/runner@0.34.2': dependencies: '@vitest/utils': 0.34.2 @@ -23566,6 +23833,11 @@ snapshots: pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + '@vitest/snapshot@0.34.2': dependencies: magic-string: 0.30.21 @@ -23584,6 +23856,12 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@0.34.2': dependencies: tinyspy: 2.2.1 @@ -23596,6 +23874,8 @@ snapshots: dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.0.18': {} + '@vitest/utils@0.34.2': dependencies: diff-sequences: 29.6.3 @@ -23615,6 +23895,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + '@vscode/sudo-prompt@9.3.2': {} '@web3-storage/multipart-parser@1.0.0': {} @@ -23774,6 +24059,15 @@ snapshots: repeat-string: 1.6.1 optional: true + aproba@2.1.0: + optional: true + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + optional: true + arg@4.1.3: {} arg@5.0.2: {} @@ -24208,7 +24502,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.3.8: + bun-types@1.3.9: dependencies: '@types/node': 22.19.7 @@ -24316,6 +24610,16 @@ snapshots: dependencies: '@types/node': 22.19.7 + canvas@2.11.2(encoding@0.1.13): + dependencies: + '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) + nan: 2.25.0 + simple-get: 3.1.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + capture-stack-trace@1.0.2: {} cborg@4.5.8: {} @@ -24346,6 +24650,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -24563,6 +24869,9 @@ snapshots: color-name: 1.1.4 simple-swizzle: 0.2.2 + color-support@1.1.3: + optional: true + color@3.2.1: dependencies: color-convert: 2.0.1 @@ -24704,6 +25013,9 @@ snapshots: transitivePeerDependencies: - supports-color + console-control-strings@1.1.0: + optional: true + console-log-colors@0.4.0: {} content-disposition@0.5.4: @@ -25127,6 +25439,11 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@4.2.1: + dependencies: + mimic-response: 2.1.0 + optional: true + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -25186,6 +25503,9 @@ snapshots: delayed-stream@1.0.0: {} + delegates@1.0.0: + optional: true + denodeify@1.2.1: {} depd@2.0.0: {} @@ -25733,6 +26053,10 @@ snapshots: dependencies: eslint: 8.57.1 + eslint-plugin-react-hooks@5.2.0(eslint@9.39.2(jiti@1.21.7)): + dependencies: + eslint: 9.39.2(jiti@1.21.7) + eslint-plugin-react-hooks@5.2.0(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -25741,6 +26065,10 @@ snapshots: dependencies: eslint: 8.57.1 + eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)): + dependencies: + eslint: 9.39.2(jiti@1.21.7) + eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -25802,6 +26130,47 @@ snapshots: transitivePeerDependencies: - supports-color + eslint@9.39.2(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + eslint@9.39.2(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) @@ -26406,6 +26775,19 @@ snapshots: gar@1.0.4: optional: true + gauge@3.0.2: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + optional: true + generate-function@2.3.1: dependencies: is-property: 1.0.2 @@ -26695,6 +27077,9 @@ snapshots: dependencies: has-symbols: 1.1.0 + has-unicode@2.0.1: + optional: true + hashlru@2.3.0: {} hasown@2.0.2: @@ -27930,7 +28315,7 @@ snapshots: transitivePeerDependencies: - supports-color - jsdom@22.1.0: + jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)): dependencies: abab: 2.0.6 cssstyle: 3.0.0 @@ -27955,6 +28340,8 @@ snapshots: whatwg-url: 12.0.1 ws: 8.19.0 xml-name-validator: 4.0.0 + optionalDependencies: + canvas: 2.11.2(encoding@0.1.13) transitivePeerDependencies: - bufferutil - supports-color @@ -29217,6 +29604,9 @@ snapshots: mimic-response@1.0.1: {} + mimic-response@2.1.0: + optional: true + mimic-response@3.1.0: {} min-document@2.19.2: @@ -29750,6 +30140,11 @@ snapshots: nodemailer@6.10.1: {} + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + optional: true + nopt@6.0.0: dependencies: abbrev: 1.1.1 @@ -29819,6 +30214,14 @@ snapshots: dependencies: path-key: 4.0.0 + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + optional: true + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -29842,6 +30245,8 @@ snapshots: object-keys@1.1.1: optional: true + obug@2.1.1: {} + omggif@1.0.10: {} on-finished@2.3.0: @@ -30159,6 +30564,9 @@ snapshots: path-type@4.0.0: {} + path2d-polyfill@2.0.1: + optional: true + pathe@1.1.2: {} pathe@2.0.3: {} @@ -30167,6 +30575,21 @@ snapshots: pathval@2.0.1: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + + pdfjs-dist@3.11.174(encoding@0.1.13): + optionalDependencies: + canvas: 2.11.2(encoding@0.1.13) + path2d-polyfill: 2.0.1 + transitivePeerDependencies: + - encoding + - supports-color + pe-library@1.0.1: {} peek-readable@4.1.0: {} @@ -31271,10 +31694,11 @@ snapshots: mdast-util-to-markdown: 1.5.0 unified: 10.1.2 - remix-utils@9.0.1(react-router@7.13.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0): + remix-utils@9.0.1(@standard-schema/spec@1.1.0)(react-router@7.13.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0): dependencies: type-fest: 4.41.0 optionalDependencies: + '@standard-schema/spec': 1.1.0 react: 18.2.0 react-router: 7.13.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -31705,6 +32129,13 @@ snapshots: simple-concat@1.0.1: {} + simple-get@3.1.1: + dependencies: + decompress-response: 4.2.1 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + simple-get@4.0.1: dependencies: decompress-response: 6.0.0 @@ -32188,6 +32619,8 @@ snapshots: tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@2.2.1: {} tinyspy@4.0.4: {} @@ -32469,6 +32902,17 @@ snapshots: typedarray@0.0.6: {} + typescript-eslint@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.8.3) + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3): dependencies: '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) @@ -32994,7 +33438,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@0.34.2(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0)(lightningcss@1.30.2)(playwright@1.57.0)(terser@5.46.0): + vitest@0.34.2(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(playwright@1.57.0)(terser@5.46.0): dependencies: '@types/chai': 4.3.20 '@types/chai-subset': 1.3.6(@types/chai@4.3.20) @@ -33022,7 +33466,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: happy-dom: 7.8.1(encoding@0.1.13) - jsdom: 22.1.0 + jsdom: 22.1.0(canvas@2.11.2(encoding@0.1.13)) playwright: 1.57.0 transitivePeerDependencies: - less @@ -33033,7 +33477,7 @@ snapshots: - supports-color - terser - vitest@1.6.1(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0)(lightningcss@1.30.2)(terser@5.46.0): + vitest@1.6.1(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.46.0): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 @@ -33058,7 +33502,7 @@ snapshots: optionalDependencies: '@types/node': 22.19.7 happy-dom: 7.8.1(encoding@0.1.13) - jsdom: 22.1.0 + jsdom: 22.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - less - lightningcss @@ -33069,7 +33513,7 @@ snapshots: - supports-color - terser - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jiti@2.6.1)(jsdom@22.1.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jiti@2.6.1)(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -33098,7 +33542,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 22.19.7 happy-dom: 7.8.1(encoding@0.1.13) - jsdom: 22.1.0 + jsdom: 22.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti - less @@ -33113,6 +33557,46 @@ snapshots: - tsx - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(happy-dom@7.8.1(encoding@0.1.13))(jiti@2.6.1)(jsdom@22.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@22.19.7)(typescript@5.8.3))(vite@6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.3.5(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.7 + happy-dom: 7.8.1(encoding@0.1.13) + jsdom: 22.1.0(canvas@2.11.2(encoding@0.1.13)) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vlq@1.0.1: {} vscode-jsonrpc@8.2.0: {} @@ -33255,6 +33739,11 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + optional: true + widest-line@2.0.1: dependencies: string-width: 4.2.3 diff --git a/seed-daemon b/seed-daemon new file mode 100755 index 000000000..0024ca070 Binary files /dev/null and b/seed-daemon differ