diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1bf1377..50a8016 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -250,6 +250,17 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/import.spec.ts + - name: Reset login rate limit before export pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + # The ZIP case runs; the .docx case self-skips without a pandoc sidecar (#65). + - name: Run export pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/export.spec.ts + - name: Reset login rate limit before offline pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/package.json b/apps/api/package.json index cb8d2d7..4db6c2e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,6 +21,7 @@ "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@prisma/client": "^6.3.0", + "archiver": "^7.0.1", "argon2": "^0.44.0", "cookie-parser": "^1.4.7", "dompurify": "^3.4.11", @@ -47,12 +48,14 @@ "@nestjs/cli": "^11.0.0", "@nestjs/testing": "^11.0.0", "@swc/core": "^1.10.0", + "@types/archiver": "^6.0.4", "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jsdom": "^28.0.3", "@types/multer": "^2.0.0", "@types/nodemailer": "^8.0.1", "@types/supertest": "^6.0.0", + "fflate": "^0.8.3", "pino-pretty": "^13.0.0", "supertest": "^7.0.0", "tsx": "^4.19.0", diff --git a/apps/api/src/files/file-storage.service.ts b/apps/api/src/files/file-storage.service.ts index 7b6dedb..3a42655 100644 --- a/apps/api/src/files/file-storage.service.ts +++ b/apps/api/src/files/file-storage.service.ts @@ -1,5 +1,5 @@ import { createReadStream } from 'node:fs'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { access, mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { Readable } from 'node:stream'; @@ -30,6 +30,18 @@ export class FileStorageService { return createReadStream(this.pathFor(pondId, fileId)); } + /** Whether the file's bytes are actually on disk. Used by the pond export to + * skip an attachment whose bytes are missing (data drift) rather than crash + * the archive stream (issue #65). */ + async exists(pondId: string, fileId: string): Promise { + try { + await access(this.pathFor(pondId, fileId)); + return true; + } catch { + return false; + } + } + /** Idempotent — removing an already-absent file is not an error. */ async delete(pondId: string, fileId: string): Promise { await rm(this.pathFor(pondId, fileId), { force: true }); diff --git a/apps/api/src/import-export/export-markdown.test.ts b/apps/api/src/import-export/export-markdown.test.ts new file mode 100644 index 0000000..86b8ada --- /dev/null +++ b/apps/api/src/import-export/export-markdown.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { + imageExtension, + imageFileIds, + markdownForDocument, + markdownForZip, +} from './export-markdown'; + +describe('export markdown rewrites (issue #65)', () => { + it('extracts image attachment ids in document order', () => { + const md = 'a ![one](file-1) b ![two](file-2)'; + expect(imageFileIds(md)).toEqual(['file-1', 'file-2']); + }); + + describe('markdownForZip', () => { + const media = new Map([['file-1', 'file-1.png']]); + + it('links a wikilink to a readable target .md and keeps an image relative', () => { + const out = markdownForZip('See [[intro]] and ![dot](file-1)', new Set(['intro']), media); + expect(out).toBe('See [intro](intro.md) and ![dot](media/file-1.png)'); + }); + + it('uses the display text and drops the link when the target is not readable', () => { + const out = markdownForZip('[[secret|Secret Page]] here', new Set(), media); + expect(out).toBe('Secret Page here'); + }); + + it('leaves an image whose attachment is missing untouched', () => { + const out = markdownForZip('![x](file-gone)', new Set(), media); + expect(out).toBe('![x](file-gone)'); + }); + }); + + describe('markdownForDocument', () => { + it('inlines images as data URIs and flattens wikilinks to text', () => { + const uris = new Map([['file-1', 'data:image/png;base64,AAAA']]); + const out = markdownForDocument('[[intro|Intro]] ![dot](file-1)', uris); + expect(out).toBe('Intro ![dot](data:image/png;base64,AAAA)'); + }); + }); + + it('maps image MIME types to extensions', () => { + expect(imageExtension('image/png')).toBe('png'); + expect(imageExtension('image/jpeg')).toBe('jpg'); + expect(imageExtension('application/octet-stream')).toBe('bin'); + }); +}); diff --git a/apps/api/src/import-export/export-markdown.ts b/apps/api/src/import-export/export-markdown.ts new file mode 100644 index 0000000..1a762d9 --- /dev/null +++ b/apps/api/src/import-export/export-markdown.ts @@ -0,0 +1,69 @@ +/** + * Markdown rewrites for export (issue #65, ADR 0009). A page's cached Markdown + * stores images as `![alt]()` and wikilinks as `[[slug]]` / + * `[[slug|text]]`; neither is portable as-is, so each export target rewrites + * them: the pond ZIP to relative files, an office document to inline data. + */ + +// A wikilink token — `[[slug]]` or `[[slug|display text]]`. Only real wikilink +// nodes serialize this way (literal brackets in text are escaped), so matching +// the raw token is safe. +const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; +// A Markdown image — `![alt](src)`. In cached page Markdown `src` is always a +// bare attachment id (no parentheses), so the group is unambiguous. +const IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; + +/** The image attachment ids referenced by a page's Markdown, in order. */ +export function imageFileIds(markdown: string): string[] { + const ids: string[] = []; + for (const match of markdown.matchAll(IMAGE)) ids.push(match[2]!); + return ids; +} + +/** + * Rewrite for the pond ZIP: a wikilink becomes a relative link to the target + * page's `.md` file when that page is in the export (readable), else its plain + * display text; an image source becomes a relative path into `media/`. + */ +export function markdownForZip( + markdown: string, + readableSlugs: Set, + mediaNameById: Map, +): string { + return markdown + .replace(WIKILINK, (_whole, slug: string, text?: string) => { + const label = (text ?? slug).trim(); + return readableSlugs.has(slug) ? `[${label}](${encodeURIComponent(slug)}.md)` : label; + }) + .replace(IMAGE, (whole, alt: string, src: string) => { + const name = mediaNameById.get(src); + return name ? `![${alt}](media/${name})` : whole; + }); +} + +/** + * Rewrite for a standalone office document: a wikilink becomes plain display + * text (there is no target document to link to), and an image source becomes an + * inline `data:` URI so pandoc embeds the bytes. + */ +export function markdownForDocument(markdown: string, dataUriById: Map): string { + return markdown + .replace(WIKILINK, (_whole, slug: string, text?: string) => (text ?? slug).trim()) + .replace(IMAGE, (whole, alt: string, src: string) => { + const uri = dataUriById.get(src); + return uri ? `![${alt}](${uri})` : whole; + }); +} + +const EXTENSION_BY_MIME: Readonly> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', +}; + +/** File extension for an attachment's stored bytes, from its MIME type. */ +export function imageExtension(mimeType: string): string { + return EXTENSION_BY_MIME[mimeType] ?? 'bin'; +} diff --git a/apps/api/src/import-export/export.controller.ts b/apps/api/src/import-export/export.controller.ts new file mode 100644 index 0000000..c5d4047 --- /dev/null +++ b/apps/api/src/import-export/export.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common'; +import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared'; +import type { Response } from 'express'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators'; + +import { ExportService } from './export.service'; + +/** + * Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and + * a single page to `.docx`/`.odt`. Per-page Markdown download stays on the pages + * controller (`GET /pages/:id/export/markdown`, #30). + */ +@Controller() +export class ExportController { + constructor(private readonly exports: ExportService) {} + + /** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The + * `reader` role is "may see the pond"; the service filters to readable pages, + * so a label-restricted reader gets only their slice. */ + @Get('ponds/:pondId/export/markdown') + @RequiresPondRole('reader', { idParam: 'pondId' }) + async pondZip( + @Param('pondId') pondId: string, + @Req() request: AuthedRequest, + @Res() response: Response, + ): Promise { + await this.exports.streamPondMarkdownZip(request.user!, pondId, response); + } + + /** Enqueue a `.docx`/`.odt` export of one page; poll `GET /jobs/:id` and + * download `GET /jobs/:id/result`. */ + @Post('pages/:pageId/export') + @RequiresPagePermission('read', { idParam: 'pageId' }) + pageExport( + @Param('pageId') pageId: string, + @Body(new ZodValidationPipe(pageExportInputSchema)) input: PageExportInput, + @Req() request: AuthedRequest, + ): Promise { + return this.exports.enqueuePageExport(request.user!, pageId, input.format); + } +} diff --git a/apps/api/src/import-export/export.service.db.test.ts b/apps/api/src/import-export/export.service.db.test.ts new file mode 100644 index 0000000..fac324c --- /dev/null +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -0,0 +1,361 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { unzipSync } from 'fflate'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { AuthTokensService } from '../auth/auth-tokens.service'; +import { FileStorageService } from '../files/file-storage.service'; +import { FilesService } from '../files/files.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +import { ConversionWorker } from './conversion-worker.service'; +import { ConversionRequest, ConversionResult, PandocConverter } from './pandoc.converter'; + +/** Export (issue #65): pond ZIP of Markdown + per-page docx/odt job. The ZIP + * path needs no converter; the docx path uses an injected fake that records the + * Markdown it is handed, so the image-inlining / wikilink-flattening is checked + * without a live pandoc. */ + +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +class RecordingConverter extends PandocConverter { + lastInput = ''; + convert(request: ConversionRequest): Promise { + this.lastInput = request.input.toString('utf8'); + return Promise.resolve({ output: Buffer.from('OFFICE-BYTES'), mimeType: 'application/x-test' }); + } + reachable(): Promise { + return Promise.resolve(true); + } +} + +/** Filenames of every entry in a ZIP buffer. */ +function zipEntries(buffer: Buffer): Record { + return unzipSync(new Uint8Array(buffer)); +} + +describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let worker: ConversionWorker; + let files: FilesService; + let fake: RecordingConverter; + const suffix = uniqueSuffix(); + const password = 'exportiere meine sachen 1'; + + const owner = { username: `ella-export-${suffix}`, displayName: `Ella Export ${suffix}` }; + let ownerId: string; + let ownerCookie: string; + let personalPondId: string; + + const api = () => request(app.getHttpServer()); + + async function login(username: string): Promise { + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200); + return sessionCookieOf(res); + } + + /** Create a page with a crafted content-cache Markdown (the exporter reads the + * cache, not the Yjs state), returning its slug. */ + async function seedPage(pondId: string, title: string, markdown: string): Promise { + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + await prisma.page.create({ + data: { + pondId, + title, + slug, + ydocState: new Uint8Array(), + sortKey: title, + createdBy: ownerId, + contentCache: { create: { plainText: markdown, markdown, html: '', outline: [] } }, + }, + }); + return slug; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + fake = new RecordingConverter(); + app = await createTestApp((builder) => + builder.overrideProvider(PandocConverter).useValue(fake), + ); + worker = app.get(ConversionWorker); + files = app.get(FilesService); + + const users = app.get(UsersService); + const tokens = app.get(AuthTokensService); + const ownerUser = await users.createUser({ + username: owner.username, + email: `${owner.username}@example.org`, + displayName: owner.displayName, + password, + locale: 'en', + }); + ownerId = ownerUser.id; + const verify = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600); + await api().post('/api/v1/auth/verify-email').send({ token: verify }).expect(204); + ownerCookie = await login(owner.username); + personalPondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId, type: 'PERSONAL' } })) + .id; + }); + + afterAll(async () => { + await prisma.conversionJob.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + const where = { pond: { owner: { username: { contains: suffix } } } }; + await prisma.attachment.deleteMany({ where }); + await prisma.pageLabel.deleteMany({ where: { page: where } }); + await prisma.label.deleteMany({ where }); + await prisma.page.deleteMany({ where }); + await prisma.roleGrant.deleteMany({ where }); + await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('exports a pond ZIP with one .md per page, a media dir, and relative links', async () => { + // A real stored image so the media stream has bytes. + const image = await files.upload({ id: ownerId } as never, personalPondId, { + buffer: Buffer.from(PNG_BASE64, 'base64'), + size: 70, + originalname: 'dot.png', + }); + await seedPage(personalPondId, 'Zip Intro', 'Welcome. See [[zip-target]].'); + await seedPage(personalPondId, 'Zip Target', `# Zip Target\n\nAn image: ![dot](${image.id})`); + + const res = await api() + .get(`/api/v1/ponds/${personalPondId}/export/markdown`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse((r, cb) => { + const chunks: Buffer[] = []; + r.on('data', (c: Buffer) => chunks.push(c)); + r.on('end', () => cb(null, Buffer.concat(chunks))); + }) + .expect(200); + + expect(res.headers['content-type']).toContain('application/zip'); + expect(res.headers['content-disposition']).toContain('attachment'); + const entries = zipEntries(res.body as Buffer); + const names = Object.keys(entries); + expect(names).toContain('zip-intro.md'); + expect(names).toContain('zip-target.md'); + expect(names).toContain(`media/${image.id}.png`); + + const intro = Buffer.from(entries['zip-intro.md']!).toString('utf8'); + // The wikilink became a relative link to the readable target's .md. + expect(intro).toContain('[zip-target](zip-target.md)'); + const target = Buffer.from(entries['zip-target.md']!).toString('utf8'); + // The image src became a working relative path into media/. + expect(target).toContain(`![dot](media/${image.id}.png)`); + }); + + it('skips an attachment whose bytes are missing on disk instead of crashing', async () => { + // An attachment row with no file (data drift): upload then remove the bytes. + const image = await files.upload({ id: ownerId } as never, personalPondId, { + buffer: Buffer.from(PNG_BASE64, 'base64'), + size: 70, + originalname: 'ghost.png', + }); + await app.get(FileStorageService).delete(personalPondId, image.id); + await seedPage(personalPondId, 'Ghost Image', `# Ghost\n\n![gone](${image.id})`); + + const res = await api() + .get(`/api/v1/ponds/${personalPondId}/export/markdown`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse((r, cb) => { + const chunks: Buffer[] = []; + r.on('data', (c: Buffer) => chunks.push(c)); + r.on('end', () => cb(null, Buffer.concat(chunks))); + }) + .expect(200); + + const names = Object.keys(zipEntries(res.body as Buffer)); + // The page is still exported; the missing media file is simply not included. + expect(names).toContain('ghost-image.md'); + expect(names).not.toContain(`media/${image.id}.png`); + }); + + it('omits pages a label-restricted reader cannot read from their pond ZIP', async () => { + // A shared pond with a public page and a `secret`-labelled page; a reader is + // allowed pond-wide but denied on the secret label. + const shared = await prisma.pond.create({ + data: { + slug: `shared-export-${suffix}`, + name: 'Shared Export', + type: 'SHARED', + ownerId, + usage: { create: {} }, + }, + }); + const reader = await app.get(UsersService).createUser({ + username: `rudy-reader-${suffix}`, + email: `rudy-reader-${suffix}@example.org`, + displayName: `Rudy Reader ${suffix}`, + password, + locale: 'en', + }); + await app.get(UsersService).markEmailVerified(reader.id); + const secret = await prisma.label.create({ + data: { pondId: shared.id, name: 'secret', color: '#334455' }, + }); + const publicSlug = await seedPageIn(shared.id, 'Public Page', 'Everyone may read this.'); + const secretSlug = await seedPageIn(shared.id, 'Secret Page', 'Classified.'); + const secretPage = await prisma.page.findFirstOrThrow({ + where: { pondId: shared.id, slug: secretSlug }, + }); + await prisma.pageLabel.create({ data: { pageId: secretPage.id, labelId: secret.id } }); + // Reader: pond-wide reader ALLOW + secret-label DENY. + await prisma.roleGrant.createMany({ + data: [ + { + pondId: shared.id, + subjectType: 'USER', + subjectId: reader.id, + role: 'READER', + scopeType: 'POND', + effect: 'ALLOW', + createdBy: ownerId, + }, + { + pondId: shared.id, + subjectType: 'USER', + subjectId: reader.id, + role: 'READER', + scopeType: 'LABEL', + scopeId: secret.id, + effect: 'DENY', + createdBy: ownerId, + }, + ], + }); + + const readerCookie = await login(`rudy-reader-${suffix}`); + const res = await api() + .get(`/api/v1/ponds/${shared.id}/export/markdown`) + .set('Cookie', readerCookie) + .buffer(true) + .parse((r, cb) => { + const chunks: Buffer[] = []; + r.on('data', (c: Buffer) => chunks.push(c)); + r.on('end', () => cb(null, Buffer.concat(chunks))); + }) + .expect(200); + + const names = Object.keys(zipEntries(res.body as Buffer)); + expect(names).toContain(`${publicSlug}.md`); + expect(names).not.toContain(`${secretSlug}.md`); + }); + + it('exports a page to .docx: images inlined as data URIs, wikilinks flattened', async () => { + const image = await files.upload({ id: ownerId } as never, personalPondId, { + buffer: Buffer.from(PNG_BASE64, 'base64'), + size: 70, + originalname: 'inl.png', + }); + const slug = await seedPage( + personalPondId, + 'Docx Source', + `# Docx Source\n\n![pic](${image.id}) and [[other|Other Page]].`, + ); + const page = await prisma.page.findFirstOrThrow({ + where: { pondId: personalPondId, slug }, + }); + + const enqueued = await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'docx' }) + .expect(201); + expect(enqueued.body.status).toBe('pending'); + expect(enqueued.body.kind).toBe('export_docx'); + + await worker.drain(); + + // The Markdown handed to pandoc inlined the image and flattened the wikilink. + expect(fake.lastInput).toContain('data:image/png;base64,'); + expect(fake.lastInput).toContain('Other Page'); + expect(fake.lastInput).not.toContain('[['); + + const done = await api() + .get(`/api/v1/jobs/${enqueued.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(done.body.status).toBe('succeeded'); + const result = await api() + .get(`/api/v1/jobs/${enqueued.body.id}/result`) + .set('Cookie', ownerCookie) + .expect(200); + expect(result.text).toBe('OFFICE-BYTES'); + }); + + it('streams a large pond export (500 pages) without buffering it all', async () => { + const big = await prisma.pond.create({ + data: { + slug: `big-export-${suffix}`, + name: 'Big Export', + type: 'SHARED', + ownerId, + usage: { create: {} }, + }, + }); + await prisma.roleGrant.create({ + data: { + pondId: big.id, + subjectType: 'USER', + subjectId: ownerId, + role: 'POND_ADMIN', + scopeType: 'POND', + effect: 'ALLOW', + createdBy: ownerId, + }, + }); + const rows = Array.from({ length: 500 }, (_, i) => ({ + pondId: big.id, + title: `Page ${i}`, + slug: `page-${i}`, + ydocState: new Uint8Array(), + sortKey: String(i).padStart(4, '0'), + createdBy: ownerId, + })); + await prisma.page.createMany({ data: rows }); + const created = await prisma.page.findMany({ where: { pondId: big.id }, select: { id: true } }); + await prisma.pageContentCache.createMany({ + data: created.map((p, i) => ({ + pageId: p.id, + plainText: `Body ${i}`, + markdown: `# Page ${i}\n\nBody ${i}.`, + html: '', + outline: [], + })), + }); + + const res = await api() + .get(`/api/v1/ponds/${big.id}/export/markdown`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse((r, cb) => { + const chunks: Buffer[] = []; + r.on('data', (c: Buffer) => chunks.push(c)); + r.on('end', () => cb(null, Buffer.concat(chunks))); + }) + .expect(200); + + const mdEntries = Object.keys(zipEntries(res.body as Buffer)).filter((n) => n.endsWith('.md')); + expect(mdEntries).toHaveLength(500); + }); + + /** Same as {@link seedPage} but for an arbitrary pond. */ + async function seedPageIn(pondId: string, title: string, markdown: string): Promise { + return seedPage(pondId, title, markdown); + } +}); diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts new file mode 100644 index 0000000..a203618 --- /dev/null +++ b/apps/api/src/import-export/export.service.ts @@ -0,0 +1,200 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConversionJobView, ExportFormat } from '@dorfteich/shared'; +import { User } from '@prisma/client'; +import archiver from 'archiver'; +import type { Response } from 'express'; +import { PinoLogger } from 'nestjs-pino'; + +import { FileStorageService } from '../files/file-storage.service'; +import { PermissionService } from '../permissions/permission.service'; +import { PrismaService } from '../prisma/prisma.service'; + +import { ConversionJobService } from './conversion-job.service'; +import { + imageExtension, + imageFileIds, + markdownForDocument, + markdownForZip, +} from './export-markdown'; + +/** + * Export (ADR 0009, issue #65): a whole pond as a ZIP of Markdown (one `.md` + * per readable page, a `media/` directory, wikilinks as relative links), and a + * single page to `.docx`/`.odt` via a conversion job. Both respect the + * requester's read permissions — a pond export contains only the pages the + * requester may read (permissions.md). + */ +@Injectable() +export class ExportService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionService, + private readonly storage: FileStorageService, + private readonly jobs: ConversionJobService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(ExportService.name); + } + + /** + * Stream a ZIP of the pond's readable pages as Markdown to `res`. Page + * Markdown is appended as small strings; media is appended as read streams, so + * memory stays bounded regardless of pond size (the 500-page AC). The guard + * already checked the requester may see the pond; here we filter to the pages + * they may actually read. + */ + async streamPondMarkdownZip(user: User, pondId: string, res: Response): Promise { + const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); + if (!pond) throw new NotFoundException(); + + const pages = await this.prisma.page.findMany({ + where: { pondId, deletedAt: null }, + orderBy: { title: 'asc' }, + include: { + labels: { select: { labelId: true } }, + contentCache: { select: { markdown: true } }, + }, + }); + const readableIds = await this.permissions.filterPages( + user, + pondId, + pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })), + 'read', + ); + const readablePages = pages.filter((page) => readableIds.has(page.id)); + const readableSlugs = new Set(readablePages.map((page) => page.slug)); + + // Every image referenced by a readable page — resolved to attachments that + // still exist in this pond, so the media directory matches the rewrites. + const referenced = new Set(); + for (const page of readablePages) { + for (const id of imageFileIds(page.contentCache?.markdown ?? '')) referenced.add(id); + } + const attachmentRows = + referenced.size > 0 + ? await this.prisma.attachment.findMany({ + where: { id: { in: [...referenced] }, pondId, deletedAt: null }, + select: { id: true, mimeType: true }, + }) + : []; + // Only include media whose bytes are actually on disk — a row whose file is + // missing (data drift) must not error the archive stream and crash the api. + const present = await Promise.all( + attachmentRows.map((a) => this.storage.exists(pond.id, a.id)), + ); + const attachments = attachmentRows.filter((_, i) => present[i]); + const mediaNameById = new Map( + attachments.map((a) => [a.id, `${a.id}.${imageExtension(a.mimeType)}`]), + ); + + const archive = archiver('zip', { zlib: { level: 9 } }); + res.set('Content-Type', 'application/zip'); + res.set('Content-Disposition', `attachment; filename="${pond.slug}.zip"`); + res.set('X-Content-Type-Options', 'nosniff'); + archive.on('error', (error) => { + this.logger.error({ pondId, err: error.message }, 'pond export archive failed'); + res.destroy(error); + }); + archive.pipe(res); + + for (const page of readablePages) { + const markdown = markdownForZip( + page.contentCache?.markdown ?? '', + readableSlugs, + mediaNameById, + ); + // Page slugs are unique within a pond, so `.md` never collides. + archive.append(markdown, { name: `${page.slug}.md` }); + } + for (const attachment of attachments) { + const stream = this.storage.createReadStream(pond.id, attachment.id); + // Defence in depth: a file removed between the existence check and the + // read must not throw an unhandled stream error — let the archive skip it. + stream.on('error', (error) => + this.logger.warn( + { pondId, fileId: attachment.id, err: error.message }, + 'export: media read failed', + ), + ); + archive.append(stream, { name: `media/${mediaNameById.get(attachment.id)!}` }); + } + this.logger.info( + { pondId, pages: readablePages.length, media: attachments.length, userId: user.id }, + 'audit: pond exported as markdown zip', + ); + await archive.finalize(); + } + + /** + * Enqueue a `markdown → pandoc → .docx/.odt` conversion for one page (issue + * #65). Embedded images are inlined as `data:` URIs so the sidecar embeds + * them; wikilinks become plain text (a standalone document has no targets). + * The client polls `GET /jobs/:id` and downloads `GET /jobs/:id/result`. + */ + async enqueuePageExport( + user: User, + pageId: string, + format: ExportFormat, + ): Promise { + const page = await this.prisma.page.findFirst({ + where: { id: pageId, deletedAt: null }, + include: { contentCache: { select: { markdown: true } } }, + }); + if (!page) throw new NotFoundException(); + + const markdown = page.contentCache?.markdown ?? ''; + const dataUriById = await this.inlineImages(page.pondId, imageFileIds(markdown)); + const document = markdownForDocument(markdown, dataUriById); + + const job = await this.jobs.enqueue({ + ownerId: user.id, + kind: `export_${format}`, + from: 'gfm', + to: format, + input: Buffer.from(document, 'utf8'), + standalone: true, + }); + this.logger.info( + { jobId: job.id, pageId, format, userId: user.id }, + 'audit: page export enqueued', + ); + return this.jobs.viewOf(job); + } + + /** Read each referenced attachment's bytes into a `data:` URI (a page has few + * images, so holding them briefly is fine — unlike the streamed pond ZIP). */ + private async inlineImages(pondId: string, ids: string[]): Promise> { + const dataUriById = new Map(); + if (ids.length === 0) return dataUriById; + const attachments = await this.prisma.attachment.findMany({ + where: { id: { in: ids }, pondId, deletedAt: null }, + select: { id: true, mimeType: true }, + }); + for (const attachment of attachments) { + try { + const bytes = await readStream(this.storage.createReadStream(pondId, attachment.id)); + dataUriById.set( + attachment.id, + `data:${attachment.mimeType};base64,${bytes.toString('base64')}`, + ); + } catch (error) { + // A missing file (data drift) drops the image rather than failing the + // whole export — the rest of the page still converts. + this.logger.warn( + { pondId, fileId: attachment.id, err: (error as Error).message }, + 'export: inline image read failed', + ); + } + } + return dataUriById; + } +} + +function readStream(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); +} diff --git a/apps/api/src/import-export/import-export.module.ts b/apps/api/src/import-export/import-export.module.ts index 28d0557..6299cdc 100644 --- a/apps/api/src/import-export/import-export.module.ts +++ b/apps/api/src/import-export/import-export.module.ts @@ -5,6 +5,8 @@ import { PagesModule } from '../pages/pages.module'; import { ConversionJobService } from './conversion-job.service'; import { ConversionWorker } from './conversion-worker.service'; +import { ExportController } from './export.controller'; +import { ExportService } from './export.service'; import { IMPORT_PROCESSOR } from './import.constants'; import { ImportController } from './import.controller'; import { ImportService } from './import.service'; @@ -19,11 +21,12 @@ import { PandocConverter, PandocServerConverter } from './pandoc.converter'; */ @Module({ imports: [FilesModule, PagesModule], - controllers: [JobsController, ImportController], + controllers: [JobsController, ImportController, ExportController], providers: [ ConversionJobService, ConversionWorker, ImportService, + ExportService, // The worker resolves the import pipeline through this token (never the // class), so its file does not import the import service's (avoids a cycle). { provide: IMPORT_PROCESSOR, useExisting: ImportService }, diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 1bb6905..c6b4253 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -98,6 +98,15 @@ E2E_PANDOC=1 E2E_BASE_URL=http://localhost:5990 \ pnpm --filter @dorfteich/web exec playwright test e2e/import.spec.ts ``` +## Export (`export.spec.ts`, issue #65) + +The pond-settings "Download pond as ZIP" link (a Markdown ZIP of the readable +pages) and the page-menu office export. The ZIP download needs no sidecar; the +`.docx` export runs a conversion job and, like the import `.docx` case, +**self-skips unless `E2E_PANDOC` is set**. The permission-filtered ZIP contents, +the docx pandoc output, and the 500-page streaming path are covered at the api +level in `export.service.db.test.ts` (+ `export-markdown.test.ts`). + ## Content fixtures `db:seed` also creates a **shared** pond `content-fixtures` (owned by diff --git a/apps/web/e2e/export.spec.ts b/apps/web/e2e/export.spec.ts new file mode 100644 index 0000000..ae53c94 --- /dev/null +++ b/apps/web/e2e/export.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Export pack (issue #65): the pond-settings ZIP download and the page-menu + * office-format export. The ZIP needs no conversion sidecar; `.docx` runs a + * conversion job and self-skips without `E2E_PANDOC` (CI's e2e stack has no + * reachable pandoc — same as the import pack, #64). Selectors are + * language-neutral (CSS classes, not button text). + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +async function personalPond( + context: Awaited>, +): Promise<{ id: string; slug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + return { id: pond.id, slug: pond.slug }; +} + +test('downloads a pond as a Markdown ZIP from pond settings', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + // Make sure the pond has at least one page to export. + await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `Export Me ${Date.now()}` }, + }); + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/settings`); + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.locator('.pond-export a').click(), + ]); + expect(download.suggestedFilename()).toBe(`${pond.slug}.zip`); + + await context.close(); +}); + +test('exports a page to .docx from the page menu', async ({ browser }) => { + test.skip(!process.env.E2E_PANDOC, 'needs a reachable pandoc sidecar'); + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `Docx Export ${Date.now()}` }, + }); + const created_page = await created.json(); + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/${created_page.slug}`); + + // The first export button is `.docx`; clicking runs the job and downloads it. + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 30000 }), + page.locator('.editor-page__export button').first().click(), + ]); + expect(download.suggestedFilename()).toBe(`${created_page.slug}.docx`); + + await context.close(); +}); diff --git a/apps/web/src/export/DocumentExportMenu.tsx b/apps/web/src/export/DocumentExportMenu.tsx new file mode 100644 index 0000000..04f9a77 --- /dev/null +++ b/apps/web/src/export/DocumentExportMenu.tsx @@ -0,0 +1,45 @@ +import { EXPORT_FORMATS, ExportFormat } from '@dorfteich/shared'; +import { useTranslation } from 'react-i18next'; + +import { useDocumentExport } from './use-document-export'; + +interface DocumentExportMenuProps { + pageId: string; + slug: string; +} + +/** + * Office-format export buttons for the page menu (issue #65): `.docx`/`.odt` + * run a conversion job and download the result; PDF is a disabled placeholder + * until Gotenberg (#67). Markdown copy/download live in the page menu already + * (#30). + */ +export function DocumentExportMenu({ pageId, slug }: DocumentExportMenuProps): React.JSX.Element { + const { t } = useTranslation('export'); + const { status, exportPage } = useDocumentExport(); + + const label = (format: ExportFormat): string => { + if (status[format] === 'busy') return t('exporting'); + if (status[format] === 'error') return t('failed'); + return t(format); + }; + + return ( + + {EXPORT_FORMATS.map((format) => ( + + ))} + + + ); +} diff --git a/apps/web/src/export/use-document-export.ts b/apps/web/src/export/use-document-export.ts new file mode 100644 index 0000000..3a4b121 --- /dev/null +++ b/apps/web/src/export/use-document-export.ts @@ -0,0 +1,75 @@ +import type { ConversionJobView, ExportFormat } from '@dorfteich/shared'; +import { useCallback, useState } from 'react'; + +import { ApiError, apiGet, apiPost } from '../lib/api'; + +export type ExportStatus = 'idle' | 'busy' | 'error'; + +const POLL_INTERVAL_MS = 1000; +const MAX_POLLS = 180; + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Fetch a finished job's result and save it under `fileName`. The result + * endpoint is session-authenticated, so a plain `fetch` carries the cookie. */ +async function downloadResult(jobId: string, fileName: string): Promise { + const response = await fetch(`/api/v1/jobs/${jobId}/result`); + if (!response.ok) throw new ApiError(response.status, { code: 'conversion_failed', message: '' }); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +export interface UseDocumentExport { + /** Per-format status so each button shows its own progress/error. */ + status: Partial>; + exportPage: (pageId: string, slug: string, format: ExportFormat) => void; +} + +/** + * Export a page to `.docx`/`.odt` (issue #65): enqueue the conversion job, poll + * it, and download the result when it succeeds. Markdown export stays a direct + * link (#30); PDF waits for Gotenberg (#67). + */ +export function useDocumentExport(): UseDocumentExport { + const [status, setStatus] = useState>>({}); + const set = useCallback((format: ExportFormat, value: ExportStatus): void => { + setStatus((prev) => ({ ...prev, [format]: value })); + }, []); + + const exportPage = useCallback( + (pageId: string, slug: string, format: ExportFormat): void => { + void (async () => { + set(format, 'busy'); + try { + let job = await apiPost(`/pages/${pageId}/export`, { format }); + for ( + let poll = 0; + job.status !== 'succeeded' && job.status !== 'failed' && poll < MAX_POLLS; + poll += 1 + ) { + await delay(POLL_INTERVAL_MS); + job = await apiGet(`/jobs/${job.id}`); + } + if (job.status === 'succeeded') { + await downloadResult(job.id, `${slug}.${format}`); + set(format, 'idle'); + } else { + set(format, 'error'); + } + } catch { + set(format, 'error'); + } + })(); + }, + [set], + ); + + return { status, exportPage }; +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index d7229e3..97a4a78 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -3,6 +3,7 @@ import deAuth from '@dorfteich/shared/i18n/de/auth.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deEditor from '@dorfteich/shared/i18n/de/editor.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json'; +import deExport from '@dorfteich/shared/i18n/de/export.json'; import deFiles from '@dorfteich/shared/i18n/de/files.json'; import deImport from '@dorfteich/shared/i18n/de/import.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json'; @@ -18,6 +19,7 @@ import enAuth from '@dorfteich/shared/i18n/en/auth.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json'; +import enExport from '@dorfteich/shared/i18n/en/export.json'; import enFiles from '@dorfteich/shared/i18n/en/files.json'; import enImport from '@dorfteich/shared/i18n/en/import.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json'; @@ -50,6 +52,7 @@ void i18n auth: enAuth, settings: enSettings, editor: enEditor, + export: enExport, files: enFiles, import: enImport, labels: enLabels, @@ -67,6 +70,7 @@ void i18n auth: deAuth, settings: deSettings, editor: deEditor, + export: deExport, files: deFiles, import: deImport, labels: deLabels, diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index c75f318..8a96aa5 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -16,6 +16,7 @@ import { LabelPicker } from '../labels/LabelPicker'; import { BacklinksPanel } from '../links/BacklinksPanel'; import { collaborationCaretFor } from '../editor/collaboration-caret'; import { documentExtensions } from '../editor/document-extensions'; +import { DocumentExportMenu } from '../export/DocumentExportMenu'; import { ImageUpload } from '../editor/image-upload'; import { PresenceStrip } from '../editor/PresenceStrip'; import { Toolbar } from '../editor/Toolbar'; @@ -222,6 +223,7 @@ function PageMenu({ > {t('page.downloadMarkdown')} + diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index 48017d5..dcd95f3 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -27,6 +27,7 @@ export function PondSettingsPage(): React.JSX.Element { const { t: tMembers } = useTranslation('members'); const { t: tErrors } = useTranslation('errors'); const { t: tFiles } = useTranslation('files'); + const { t: tExport } = useTranslation('export'); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { user } = useAuth(); @@ -71,6 +72,17 @@ export function PondSettingsPage(): React.JSX.Element { )} +
+

{tExport('pond.heading')}

+

{tExport('pond.hint')}

+ + {tExport('pond.zip')} + +
); } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index cfd38c9..f97a6ae 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -491,6 +491,16 @@ button { gap: var(--space-2); } +/* The export buttons flow inline with the other page actions (same gap). */ +.editor-page__export { + display: contents; +} + +.pond-export__hint { + color: var(--color-text-muted); + margin-bottom: var(--space-2); +} + .editor-shell { border: 1px solid var(--color-border); border-radius: var(--radius); diff --git a/packages/shared/i18n/de/export.json b/packages/shared/i18n/de/export.json new file mode 100644 index 0000000..6aa4533 --- /dev/null +++ b/packages/shared/i18n/de/export.json @@ -0,0 +1,14 @@ +{ + "label": "Exportieren", + "docx": "Word (.docx)", + "odt": "OpenDocument (.odt)", + "pdf": "PDF", + "pdfSoon": "PDF-Export folgt in Kürze", + "exporting": "Wird exportiert…", + "failed": "Export fehlgeschlagen", + "pond": { + "heading": "Export", + "zip": "Teich als ZIP herunterladen", + "hint": "Eine Markdown-Datei je lesbarer Seite, mit einem Bilder-Ordner." + } +} diff --git a/packages/shared/i18n/en/export.json b/packages/shared/i18n/en/export.json new file mode 100644 index 0000000..a235fb0 --- /dev/null +++ b/packages/shared/i18n/en/export.json @@ -0,0 +1,14 @@ +{ + "label": "Export", + "docx": "Word (.docx)", + "odt": "OpenDocument (.odt)", + "pdf": "PDF", + "pdfSoon": "PDF export is coming soon", + "exporting": "Exporting…", + "failed": "Export failed", + "pond": { + "heading": "Export", + "zip": "Download pond as ZIP", + "hint": "One Markdown file per page you can read, with an images folder." + } +} diff --git a/packages/shared/src/conversion.ts b/packages/shared/src/conversion.ts index 3d2abf2..eb63bff 100644 --- a/packages/shared/src/conversion.ts +++ b/packages/shared/src/conversion.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + /** * Import/export conversion job types shared between api and web (ADR 0009, * issue #62). A conversion runs asynchronously against the pandoc sidecar; @@ -28,3 +30,15 @@ export interface ConversionJobView { * in-process and come back already `succeeded`. */ export const IMPORT_EXTENSIONS = ['docx', 'odt', 'md', 'markdown'] as const; export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number]; + +/** Office formats a single page exports to (issue #65) — each runs a + * `markdown → pandoc → file` conversion job whose result is downloaded from + * `GET /jobs/:id/result`. Markdown export is a separate direct download (#30); + * PDF is a placeholder until Gotenberg (#67). */ +export const EXPORT_FORMATS = ['docx', 'odt'] as const; +export type ExportFormat = (typeof EXPORT_FORMATS)[number]; + +export const pageExportInputSchema = z.object({ + format: z.enum(EXPORT_FORMATS), +}); +export type PageExportInput = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70583af..55d78c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@prisma/client': specifier: ^6.3.0 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + archiver: + specifier: ^7.0.1 + version: 7.0.1 argon2: specifier: ^0.44.0 version: 0.44.0 @@ -117,6 +120,9 @@ importers: '@swc/core': specifier: ^1.10.0 version: 1.15.43 + '@types/archiver': + specifier: ^6.0.4 + version: 6.0.4 '@types/cookie-parser': specifier: ^1.4.10 version: 1.4.10(@types/express@5.0.6) @@ -135,6 +141,9 @@ importers: '@types/supertest': specifier: ^6.0.0 version: 6.0.3 + fflate: + specifier: ^0.8.3 + version: 0.8.3 pino-pretty: specifier: ^13.0.0 version: 13.1.3 @@ -1620,6 +1629,10 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1736,6 +1749,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.61.1': resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} engines: {node: '>=18'} @@ -2135,6 +2152,9 @@ packages: resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==} engines: {node: '>=12'} + '@types/archiver@6.0.4': + resolution: {integrity: sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2229,6 +2249,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/readdir-glob@1.1.5': + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -2398,6 +2421,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2465,10 +2492,18 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -2479,6 +2514,14 @@ packages: append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + argon2@0.44.0: resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} engines: {node: '>=16.17.0'} @@ -2529,6 +2572,14 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-plugin-polyfill-corejs2@0.4.17: resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} peerDependencies: @@ -2551,6 +2602,43 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2581,12 +2669,19 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2713,6 +2808,10 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2770,6 +2869,9 @@ packages: core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -2783,6 +2885,15 @@ packages: typescript: optional: true + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -2896,6 +3007,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2913,6 +3027,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -3082,6 +3199,13 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -3111,6 +3235,9 @@ packages: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3132,6 +3259,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3265,6 +3395,11 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -3527,6 +3662,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3540,6 +3678,9 @@ packages: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -3622,6 +3763,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -3766,6 +3911,10 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3839,6 +3988,10 @@ packages: resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} engines: {node: '>=6.0.0'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} @@ -3936,6 +4089,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -4115,9 +4272,16 @@ packages: typescript: optional: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + prosemirror-changeset@2.4.1: resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} @@ -4248,10 +4412,20 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -4335,6 +4509,9 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4497,10 +4674,17 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -4517,6 +4701,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -4528,6 +4715,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4587,6 +4778,12 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -4643,6 +4840,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -5156,6 +5356,14 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5223,6 +5431,10 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -6475,6 +6687,15 @@ snapshots: optionalDependencies: '@types/node': 26.1.0 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} '@jridgewell/gen-mapping@0.3.13': @@ -6611,6 +6832,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.61.1': dependencies: playwright: 1.61.1 @@ -6930,6 +7154,10 @@ snapshots: magic-string: 0.30.21 string.prototype.matchall: 4.0.12 + '@types/archiver@6.0.4': + dependencies: + '@types/readdir-glob': 1.1.5 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -7050,6 +7278,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 26.1.0 + '@types/resolve@1.20.2': {} '@types/send@1.2.1': @@ -7304,6 +7536,10 @@ snapshots: '@xtuc/long@4.2.2': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -7363,16 +7599,44 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + ansis@4.2.0: {} any-promise@1.3.0: {} append-field@1.0.0: {} + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.0 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + argon2@0.44.0: dependencies: '@phc/format': 1.0.0 @@ -7421,6 +7685,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + b4a@1.8.1: {} + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): dependencies: '@babel/compat-data': 7.29.7 @@ -7449,6 +7715,35 @@ snapshots: balanced-match@4.0.4: {} + bare-events@2.9.1: {} + + bare-fs@4.7.4: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.5: + dependencies: + bare-path: 3.1.1 + base64-js@1.5.1: {} baseline-browser-mapping@2.10.41: {} @@ -7494,6 +7789,8 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) + buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -7501,6 +7798,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -7620,6 +7922,14 @@ snapshots: component-emitter@1.3.1: {} + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -7662,6 +7972,8 @@ snapshots: dependencies: browserslist: 4.28.4 + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -7676,6 +7988,13 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -7778,6 +8097,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} effect@3.21.0: @@ -7793,6 +8114,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + empathic@2.0.0: {} encodeurl@2.0.0: {} @@ -8094,6 +8417,14 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} expect-type@1.4.0: {} @@ -8143,6 +8474,8 @@ snapshots: fast-equals@5.4.0: {} + fast-fifo@1.3.2: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -8155,6 +8488,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -8331,6 +8666,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -8582,6 +8926,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -8590,6 +8936,12 @@ snapshots: iterare@1.2.1: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -8675,6 +9027,10 @@ snapshots: kleur@4.1.5: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + leven@3.1.0: {} levn@0.4.1: @@ -8790,6 +9146,10 @@ snapshots: dependencies: brace-expansion: 2.1.1 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -8849,6 +9209,8 @@ snapshots: nodemailer@9.0.3: {} + normalize-path@3.0.0: {} + nwsapi@2.2.24: {} nypm@0.6.8: @@ -8954,6 +9316,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.5.1 @@ -9124,8 +9491,12 @@ snapshots: transitivePeerDependencies: - magicast + process-nextick-args@2.0.1: {} + process-warning@5.0.0: {} + process@0.11.10: {} + prosemirror-changeset@2.4.1: dependencies: prosemirror-transform: 1.12.0 @@ -9281,12 +9652,34 @@ snapshots: react@19.2.7: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + readdirp@4.1.2: {} real-require@0.2.0: {} @@ -9413,6 +9806,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -9586,12 +9981,27 @@ snapshots: streamsearch@1.1.0: {} + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.9 @@ -9632,6 +10042,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -9646,6 +10060,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-comments@2.0.1: {} @@ -9710,6 +10128,24 @@ snapshots: tapable@2.3.3: {} + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.4 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + temp-dir@2.0.0: {} tempy@0.6.0: @@ -9736,6 +10172,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -10348,6 +10790,18 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.21.0: {} @@ -10389,6 +10843,12 @@ snapshots: yoctocolors-cjs@2.1.3: {} + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod@3.25.76: {} zod@4.4.3: {}