Add pond ZIP + per-page docx/odt export (#65)
All checks were successful
CD / Build and push images (push) Successful in 3m57s
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Auth e2e pack (push) Successful in 3m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
All checks were successful
CD / Build and push images (push) Successful in 3m57s
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Auth e2e pack (push) Successful in 3m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
Two export paths, both permission-aware (permissions.md):
- `GET /ponds/:id/export/markdown` streams a ZIP of the pond's readable
pages as Markdown (one `<slug>.md` per page, a `media/` directory,
wikilinks rewritten to relative `[text](slug.md)` links, image sources to
`media/<id>.<ext>`). The `reader` guard is "may see the pond"; the service
filters to the pages the requester may actually read, so a label-restricted
reader gets only their slice. Media is appended as read streams and pages as
small strings, so memory stays bounded for a large pond (500-page test).
- `POST /pages/:id/export {format: docx|odt}` enqueues a `markdown → pandoc →
file` conversion job (the #62 queue): embedded images are inlined as data
URIs so the sidecar embeds them, wikilinks flatten to text. The client polls
`GET /jobs/:id` and downloads `GET /jobs/:id/result`.
Frontend: office-export buttons in the page menu (`.docx`/`.odt` run the job
and download the result; PDF is a disabled placeholder for Gotenberg, #67) and
a "Download pond as ZIP" link in pond settings. New `export` i18n namespace
(de+en). Markdown copy/download stay as-is (#30).
Robustness: the pond ZIP skips an attachment whose bytes are missing on disk
(data drift) rather than letting an unhandled read-stream error crash the api;
`FileStorageService.exists` gates inclusion, with a defensive stream error
handler. The per-page export drops an unreadable image the same way.
- shared: EXPORT_FORMATS + pageExportInputSchema; export-markdown transform
helpers (image/wikilink rewrites, MIME→extension).
- deps: archiver (streaming ZIP; v7 for CommonJS compat), fflate (dev, reads
ZIPs in tests).
- tests: export-markdown unit + export.service.db (ZIP contents & relative
links, label-restricted omission, docx job with inlined images, 500-page
streaming, missing-media skip); e2e export pack (ZIP download; `.docx`
self-skips without a pandoc sidecar, as in the import pack, #64).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
e2f942c0ff
commit
699c003d04
@ -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%';" | \
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<boolean> {
|
||||
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<void> {
|
||||
await rm(this.pathFor(pondId, fileId), { force: true });
|
||||
|
||||
48
apps/api/src/import-export/export-markdown.test.ts
Normal file
48
apps/api/src/import-export/export-markdown.test.ts
Normal file
@ -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  b ';
|
||||
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 ', new Set(['intro']), media);
|
||||
expect(out).toBe('See [intro](intro.md) and ');
|
||||
});
|
||||
|
||||
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('', new Set(), media);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
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]] ', uris);
|
||||
expect(out).toBe('Intro ');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
69
apps/api/src/import-export/export-markdown.ts
Normal file
69
apps/api/src/import-export/export-markdown.ts
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Markdown rewrites for export (issue #65, ADR 0009). A page's cached Markdown
|
||||
* stores images as `` 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 — ``. 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<string>,
|
||||
mediaNameById: Map<string, string>,
|
||||
): 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 ? `` : 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, string>): 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 ? `` : whole;
|
||||
});
|
||||
}
|
||||
|
||||
const EXTENSION_BY_MIME: Readonly<Record<string, string>> = {
|
||||
'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';
|
||||
}
|
||||
44
apps/api/src/import-export/export.controller.ts
Normal file
44
apps/api/src/import-export/export.controller.ts
Normal file
@ -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<void> {
|
||||
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<ConversionJobView> {
|
||||
return this.exports.enqueuePageExport(request.user!, pageId, input.format);
|
||||
}
|
||||
}
|
||||
361
apps/api/src/import-export/export.service.db.test.ts
Normal file
361
apps/api/src/import-export/export.service.db.test.ts
Normal file
@ -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<ConversionResult> {
|
||||
this.lastInput = request.input.toString('utf8');
|
||||
return Promise.resolve({ output: Buffer.from('OFFICE-BYTES'), mimeType: 'application/x-test' });
|
||||
}
|
||||
reachable(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Filenames of every entry in a ZIP buffer. */
|
||||
function zipEntries(buffer: Buffer): Record<string, Uint8Array> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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: `);
|
||||
|
||||
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(``);
|
||||
});
|
||||
|
||||
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`);
|
||||
|
||||
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 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<string> {
|
||||
return seedPage(pondId, title, markdown);
|
||||
}
|
||||
});
|
||||
200
apps/api/src/import-export/export.service.ts
Normal file
200
apps/api/src/import-export/export.service.ts
Normal file
@ -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<void> {
|
||||
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<string>();
|
||||
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 `<slug>.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<ConversionJobView> {
|
||||
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<Map<string, string>> {
|
||||
const dataUriById = new Map<string, string>();
|
||||
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<Buffer> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
|
||||
60
apps/web/e2e/export.spec.ts
Normal file
60
apps/web/e2e/export.spec.ts
Normal file
@ -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<ReturnType<typeof contextForUser>>,
|
||||
): 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();
|
||||
});
|
||||
45
apps/web/src/export/DocumentExportMenu.tsx
Normal file
45
apps/web/src/export/DocumentExportMenu.tsx
Normal file
@ -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 (
|
||||
<span className="editor-page__export">
|
||||
{EXPORT_FORMATS.map((format) => (
|
||||
<button
|
||||
key={format}
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={status[format] === 'busy'}
|
||||
onClick={() => exportPage(pageId, slug, format)}
|
||||
>
|
||||
{label(format)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="button" disabled title={t('pdfSoon')}>
|
||||
{t('pdf')}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
75
apps/web/src/export/use-document-export.ts
Normal file
75
apps/web/src/export/use-document-export.ts
Normal file
@ -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<void> => 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<void> {
|
||||
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<Record<ExportFormat, ExportStatus>>;
|
||||
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<Partial<Record<ExportFormat, ExportStatus>>>({});
|
||||
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<ConversionJobView>(`/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<ConversionJobView>(`/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 };
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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')}
|
||||
</a>
|
||||
<DocumentExportMenu pageId={pageId} slug={slug} />
|
||||
<button type="button" className="button editor-page__labels-toggle" onClick={onToggleLabels}>
|
||||
{t('labels:picker.open')}
|
||||
</button>
|
||||
|
||||
@ -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 {
|
||||
<PondFileManager pondId={pond.data.id} />
|
||||
</section>
|
||||
)}
|
||||
<section className="pond-export">
|
||||
<h2>{tExport('pond.heading')}</h2>
|
||||
<p className="pond-export__hint">{tExport('pond.hint')}</p>
|
||||
<a
|
||||
className="button"
|
||||
href={`/api/v1/ponds/${pond.data.id}/export/markdown`}
|
||||
download={`${pond.data.slug}.zip`}
|
||||
>
|
||||
{tExport('pond.zip')}
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
14
packages/shared/i18n/de/export.json
Normal file
14
packages/shared/i18n/de/export.json
Normal file
@ -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."
|
||||
}
|
||||
}
|
||||
14
packages/shared/i18n/en/export.json
Normal file
14
packages/shared/i18n/en/export.json
Normal file
@ -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."
|
||||
}
|
||||
}
|
||||
@ -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<typeof pageExportInputSchema>;
|
||||
|
||||
460
pnpm-lock.yaml
generated
460
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user