@web-to-figma/desktop 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +93 -0
  2. package/assets/chrome-version.json +11 -0
  3. package/assets/extension/README.md +18 -0
  4. package/assets/extension/chrome-mv3-headed.crx +0 -0
  5. package/assets/extension/chrome-mv3-headless.crx +0 -0
  6. package/bin/web-to-figma.js +5 -0
  7. package/db/migrations/000001_create_artifact_tables.up.sql +69 -0
  8. package/db/migrations/000002_create_tag_tables.up.sql +39 -0
  9. package/db/migrations/000003_seed_tag_values.up.sql +97 -0
  10. package/dist/auth/captureSession.d.ts +14 -0
  11. package/dist/auth/captureSession.js +1 -0
  12. package/dist/auth/jwtSecret.d.ts +2 -0
  13. package/dist/auth/jwtSecret.js +1 -0
  14. package/dist/capture/browserPool.d.ts +24 -0
  15. package/dist/capture/browserPool.js +1 -0
  16. package/dist/capture/captureService.d.ts +25 -0
  17. package/dist/capture/captureService.js +1 -0
  18. package/dist/capture/captureStatus.d.ts +7 -0
  19. package/dist/capture/captureStatus.js +1 -0
  20. package/dist/capture/chromeDownloader.d.ts +21 -0
  21. package/dist/capture/chromeDownloader.js +1 -0
  22. package/dist/capture/crxUtils.d.ts +4 -0
  23. package/dist/capture/crxUtils.js +1 -0
  24. package/dist/capture/extensionPinPolicy.d.ts +10 -0
  25. package/dist/capture/extensionPinPolicy.js +1 -0
  26. package/dist/capture/headedBrowserState.d.ts +22 -0
  27. package/dist/capture/headedBrowserState.js +1 -0
  28. package/dist/capture/headedExtension.d.ts +7 -0
  29. package/dist/capture/headedExtension.js +1 -0
  30. package/dist/capture/manualLaunch.d.ts +2 -0
  31. package/dist/capture/manualLaunch.js +1 -0
  32. package/dist/cli.d.ts +1 -0
  33. package/dist/cli.js +85 -0
  34. package/dist/config.d.ts +49 -0
  35. package/dist/config.js +109 -0
  36. package/dist/constants.d.ts +1 -0
  37. package/dist/constants.js +2 -0
  38. package/dist/index.d.ts +17 -0
  39. package/dist/index.js +241 -0
  40. package/dist/loadEnv.d.ts +1 -0
  41. package/dist/loadEnv.js +14 -0
  42. package/dist/logger.d.ts +4 -0
  43. package/dist/logger.js +32 -0
  44. package/dist/port.d.ts +4 -0
  45. package/dist/port.js +19 -0
  46. package/dist/security/redact.d.ts +3 -0
  47. package/dist/security/redact.js +34 -0
  48. package/dist/sentry.d.ts +3 -0
  49. package/dist/sentry.js +66 -0
  50. package/dist/server/app.d.ts +18 -0
  51. package/dist/server/app.js +82 -0
  52. package/dist/server/checkServer.d.ts +2 -0
  53. package/dist/server/checkServer.js +24 -0
  54. package/dist/server/context.d.ts +20 -0
  55. package/dist/server/context.js +20 -0
  56. package/dist/server/middleware/desktopJwt.d.ts +10 -0
  57. package/dist/server/middleware/desktopJwt.js +1 -0
  58. package/dist/server/routes/capture.d.ts +4 -0
  59. package/dist/server/routes/capture.js +39 -0
  60. package/dist/server/routes/captures.d.ts +3 -0
  61. package/dist/server/routes/captures.js +75 -0
  62. package/dist/server/routes/collections.d.ts +3 -0
  63. package/dist/server/routes/collections.js +41 -0
  64. package/dist/server/routes/files.d.ts +3 -0
  65. package/dist/server/routes/files.js +18 -0
  66. package/dist/server/routes/health.d.ts +2 -0
  67. package/dist/server/routes/health.js +8 -0
  68. package/dist/server/routes/image.d.ts +2 -0
  69. package/dist/server/routes/image.js +58 -0
  70. package/dist/server/routes/proxy.d.ts +2 -0
  71. package/dist/server/routes/proxy.js +24 -0
  72. package/dist/server/routes/upload.d.ts +3 -0
  73. package/dist/server/routes/upload.js +102 -0
  74. package/dist/server/uploadBody.d.ts +1 -0
  75. package/dist/server/uploadBody.js +5 -0
  76. package/dist/storage/captureRepo.d.ts +21 -0
  77. package/dist/storage/captureRepo.js +172 -0
  78. package/dist/storage/collectionRepo.d.ts +14 -0
  79. package/dist/storage/collectionRepo.js +104 -0
  80. package/dist/storage/db.d.ts +10 -0
  81. package/dist/storage/db.js +30 -0
  82. package/dist/storage/fileStore.d.ts +26 -0
  83. package/dist/storage/fileStore.js +93 -0
  84. package/dist/storage/migrate.d.ts +8 -0
  85. package/dist/storage/migrate.js +61 -0
  86. package/dist/storage/seeds.d.ts +7 -0
  87. package/dist/storage/seeds.js +30 -0
  88. package/dist/storage/tagRepo.d.ts +7 -0
  89. package/dist/storage/tagRepo.js +31 -0
  90. package/dist/terminal/banner.d.ts +10 -0
  91. package/dist/terminal/banner.js +115 -0
  92. package/dist/terminal/chromeInstall.d.ts +3 -0
  93. package/dist/terminal/chromeInstall.js +45 -0
  94. package/dist/types.d.ts +102 -0
  95. package/dist/types.js +1 -0
  96. package/dist/updater/installer.d.ts +2 -0
  97. package/dist/updater/installer.js +61 -0
  98. package/dist/updater/runtime.d.ts +25 -0
  99. package/dist/updater/runtime.js +153 -0
  100. package/dist/updater/types.d.ts +29 -0
  101. package/dist/updater/types.js +1 -0
  102. package/dist/updater/updateLog.d.ts +3 -0
  103. package/dist/updater/updateLog.js +7 -0
  104. package/dist/updater/versionCheck.d.ts +5 -0
  105. package/dist/updater/versionCheck.js +59 -0
  106. package/dist/utils.d.ts +9 -0
  107. package/dist/utils.js +55 -0
  108. package/package.json +72 -0
@@ -0,0 +1,39 @@
1
+ export function registerCaptureRoutes(app, ctx, captureService) {
2
+ app.post('/api/artifact/capture', async (request, reply) => {
3
+ const body = request.body;
4
+ if (captureService.isBusy()) {
5
+ return reply.status(409).send({ message: 'Capture already in progress' });
6
+ }
7
+ try {
8
+ const { requestId } = await captureService.startCapture(body.url, body.sizes, body.themes);
9
+ return reply.status(202).send({ requestId });
10
+ }
11
+ catch (err) {
12
+ const message = err instanceof Error ? err.message : 'Capture failed to start';
13
+ if (message.includes('Chrome')) {
14
+ return reply.status(503).send({ message: 'Chrome not available' });
15
+ }
16
+ if (message.includes('disk')) {
17
+ return reply.status(507).send({ message: 'Insufficient disk space' });
18
+ }
19
+ return reply.status(500).send({ message });
20
+ }
21
+ });
22
+ app.get('/api/artifact/capture-status', async (request, reply) => {
23
+ const query = request.query;
24
+ if (!query.requestId) {
25
+ return reply.status(400).send({ message: 'Request id is required' });
26
+ }
27
+ const status = ctx.captures.getCaptureRequestStatus(query.requestId);
28
+ if (!status) {
29
+ return reply.status(404).send({ message: 'Capture request not found' });
30
+ }
31
+ return reply.status(200).type('application/json').send(JSON.parse(status));
32
+ });
33
+ app.post('/api/artifact/cancel-capture', async (request, reply) => {
34
+ const body = request.body;
35
+ ctx.captures.cancelCaptureRequest(body.requestId);
36
+ captureService.cancel(body.requestId);
37
+ return reply.status(200).send();
38
+ });
39
+ }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AppContext } from '../context.js';
3
+ export declare function registerCaptureListRoutes(app: FastifyInstance, ctx: AppContext): void;
@@ -0,0 +1,75 @@
1
+ import { LOCAL_USER_ID } from '../../utils.js';
2
+ export function registerCaptureListRoutes(app, ctx) {
3
+ app.get('/api/artifact/captures', async (request, reply) => {
4
+ const query = request.query;
5
+ if (query.collectionExternalId) {
6
+ const owner = ctx.collections.getCollectionOwner(query.collectionExternalId);
7
+ if (owner === null) {
8
+ return reply.status(404).send();
9
+ }
10
+ if (owner !== LOCAL_USER_ID) {
11
+ return reply.status(401).send();
12
+ }
13
+ }
14
+ const limit = parseInt(query.limit ?? '50', 10) || 50;
15
+ const offset = parseInt(query.offset ?? '0', 10) || 0;
16
+ return ctx.captures.listCaptures(query.collectionExternalId, limit, offset);
17
+ });
18
+ app.get('/api/artifact/capture/:externalId', async (request, reply) => {
19
+ const params = request.params;
20
+ const capture = ctx.captures.getCaptureWithExports(params.externalId);
21
+ if (!capture) {
22
+ return reply.status(404).send();
23
+ }
24
+ return capture;
25
+ });
26
+ app.post('/api/artifact/captures', async (request, reply) => {
27
+ const capture = request.body;
28
+ capture.userId = LOCAL_USER_ID;
29
+ if (capture.collectionExternalId) {
30
+ const owner = ctx.collections.getCollectionOwner(capture.collectionExternalId);
31
+ if (owner === null) {
32
+ return reply.status(404).send();
33
+ }
34
+ if (owner !== LOCAL_USER_ID) {
35
+ return reply.status(401).send();
36
+ }
37
+ }
38
+ ctx.captures.saveCapture(capture);
39
+ return reply.status(200).send();
40
+ });
41
+ app.post('/api/artifact/capture-update-collection', async (request, reply) => {
42
+ const body = request.body;
43
+ const capture = ctx.captures.getCaptureWithExports(body.captureExternalId);
44
+ if (!capture) {
45
+ return reply.status(404).send();
46
+ }
47
+ if (capture.userId !== LOCAL_USER_ID) {
48
+ return reply.status(401).send();
49
+ }
50
+ ctx.captures.updateCollectionForCapture(body.captureExternalId, body.collectionExternalId);
51
+ if (body.collectionIconUrl) {
52
+ ctx.collections.updateIcon(body.collectionExternalId, body.collectionIconUrl);
53
+ }
54
+ const tagIds = body.tagValueIds ?? body.tags ?? [];
55
+ if (capture.id && tagIds.length) {
56
+ ctx.captures.assignTags(capture.id, tagIds);
57
+ }
58
+ return reply.status(200).send();
59
+ });
60
+ app.post('/api/artifact/capture-remove', async (request, reply) => {
61
+ const body = request.body;
62
+ const capture = ctx.captures.getCaptureWithExports(body.captureExternalId);
63
+ if (!capture) {
64
+ return reply.status(404).send();
65
+ }
66
+ if (capture.userId !== LOCAL_USER_ID) {
67
+ return reply.status(401).send();
68
+ }
69
+ ctx.captures.removeCaptureFromCollection(body.captureExternalId);
70
+ return reply.status(200).send();
71
+ });
72
+ app.get('/api/artifact/tags', async () => {
73
+ return ctx.tags.getAllTags();
74
+ });
75
+ }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AppContext } from '../context.js';
3
+ export declare function registerCollectionRoutes(app: FastifyInstance, ctx: AppContext): void;
@@ -0,0 +1,41 @@
1
+ import { LOCAL_USER_ID } from '../../utils.js';
2
+ export function registerCollectionRoutes(app, ctx) {
3
+ app.put('/api/artifact/collections', async (request, reply) => {
4
+ const collection = request.body;
5
+ collection.userId = LOCAL_USER_ID;
6
+ ctx.collections.saveCollection(collection);
7
+ return reply.status(200).send();
8
+ });
9
+ app.post('/api/artifact/collections', async (request, reply) => {
10
+ const collections = request.body;
11
+ for (const collection of collections) {
12
+ collection.userId = LOCAL_USER_ID;
13
+ }
14
+ ctx.collections.saveCollections(collections);
15
+ return reply.status(200).send();
16
+ });
17
+ app.get('/api/artifact/collections', async (request, reply) => {
18
+ const query = request.query;
19
+ if (query.withMetadata === 'true') {
20
+ return ctx.collections.listCollectionsWithMetadata();
21
+ }
22
+ return ctx.collections.listCollections();
23
+ });
24
+ app.delete('/api/artifact/collections', async (request, reply) => {
25
+ const query = request.query;
26
+ const collectionExternalId = query.collectionExternalId;
27
+ if (!collectionExternalId) {
28
+ return reply.status(400).send({ message: 'Collection id is required' });
29
+ }
30
+ const owner = ctx.collections.getCollectionOwner(collectionExternalId);
31
+ if (owner === null) {
32
+ return reply.status(404).send();
33
+ }
34
+ if (owner !== LOCAL_USER_ID) {
35
+ return reply.status(401).send();
36
+ }
37
+ const replacement = ctx.collections.getDefaultCollectionExternalId();
38
+ ctx.collections.deleteCollection(collectionExternalId, replacement);
39
+ return reply.status(200).send();
40
+ });
41
+ }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AppContext } from '../context.js';
3
+ export declare function registerFileRoutes(app: FastifyInstance, ctx: AppContext): void;
@@ -0,0 +1,18 @@
1
+ import { contentTypeForPath } from '../../utils.js';
2
+ export function registerFileRoutes(app, ctx) {
3
+ app.get('/files/*', async (request, reply) => {
4
+ const params = request.params;
5
+ const relativePath = params['*'];
6
+ if (!relativePath) {
7
+ return reply.status(404).send({ message: 'File not found' });
8
+ }
9
+ try {
10
+ const { data, filePath } = ctx.files.readFile(relativePath);
11
+ reply.header('Cache-Control', 'private, max-age=3600');
12
+ return reply.type(contentTypeForPath(filePath)).send(data);
13
+ }
14
+ catch {
15
+ return reply.status(404).send({ message: 'File not found' });
16
+ }
17
+ });
18
+ }
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export declare function registerHealthRoutes(app: FastifyInstance, port: number): void;
@@ -0,0 +1,8 @@
1
+ import { PACKAGE_VERSION } from '../../config.js';
2
+ export function registerHealthRoutes(app, port) {
3
+ app.get('/health', async () => ({
4
+ status: 'UP',
5
+ version: PACKAGE_VERSION,
6
+ port,
7
+ }));
8
+ }
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export declare function registerImageRoutes(app: FastifyInstance): void;
@@ -0,0 +1,58 @@
1
+ import sharp from 'sharp';
2
+ const MAX_SIZE = 4096;
3
+ export function registerImageRoutes(app) {
4
+ app.post('/resizeImg', async (request, reply) => {
5
+ const body = request.body;
6
+ try {
7
+ const input = Buffer.from(body.image, 'base64');
8
+ let pipeline = sharp(input);
9
+ if (body.width > body.height) {
10
+ pipeline = pipeline.resize({ width: MAX_SIZE });
11
+ }
12
+ else {
13
+ pipeline = pipeline.resize({ height: MAX_SIZE });
14
+ }
15
+ let output;
16
+ switch (body.type) {
17
+ case 'jpeg':
18
+ case 'jpg':
19
+ output = await pipeline.jpeg({ quality: 100 }).toBuffer();
20
+ break;
21
+ case 'png':
22
+ case 'webp':
23
+ output = await pipeline.png().toBuffer();
24
+ break;
25
+ default:
26
+ return reply.status(400).send({ message: 'Unknown image format' });
27
+ }
28
+ return reply.type('text/plain').send(output.toString('base64'));
29
+ }
30
+ catch (err) {
31
+ return reply.status(400).send({ message: err instanceof Error ? err.message : 'Invalid image' });
32
+ }
33
+ });
34
+ app.post('/convertImg', async (request, reply) => {
35
+ const body = request.body;
36
+ try {
37
+ const input = Buffer.from(body.image, 'base64');
38
+ let pipeline = sharp(input);
39
+ let output;
40
+ switch (body.to_type) {
41
+ case 'jpeg':
42
+ case 'jpg':
43
+ output = await pipeline.jpeg({ quality: 100 }).toBuffer();
44
+ break;
45
+ case 'png':
46
+ case 'webp':
47
+ output = await pipeline.png().toBuffer();
48
+ break;
49
+ default:
50
+ return reply.status(400).send({ message: 'Unknown image format' });
51
+ }
52
+ return reply.type('text/plain').send(output.toString('base64'));
53
+ }
54
+ catch (err) {
55
+ return reply.status(400).send({ message: err instanceof Error ? err.message : 'Invalid image' });
56
+ }
57
+ });
58
+ }
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export declare function registerProxyRoutes(app: FastifyInstance): void;
@@ -0,0 +1,24 @@
1
+ export function registerProxyRoutes(app) {
2
+ app.get('/proxy', async (request, reply) => {
3
+ const query = request.query;
4
+ const remoteUrl = query.url;
5
+ if (!remoteUrl) {
6
+ return reply.status(400).send({ message: 'url is required' });
7
+ }
8
+ let url = remoteUrl;
9
+ let first = true;
10
+ for (const [key, value] of Object.entries(query)) {
11
+ if (key === 'url' || value === undefined) {
12
+ continue;
13
+ }
14
+ url += `${first ? '?' : '&'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
15
+ first = false;
16
+ }
17
+ const response = await fetch(url);
18
+ for (const [key, value] of response.headers.entries()) {
19
+ reply.header(key, value);
20
+ }
21
+ const body = Buffer.from(await response.arrayBuffer());
22
+ return reply.status(response.status).send(body);
23
+ });
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AppContext } from '../context.js';
3
+ export declare function registerUploadRoutes(app: FastifyInstance, ctx: AppContext): void;
@@ -0,0 +1,102 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { getMultipartUpload, registerMultipartUpload, removeMultipartUpload, cleanupStaleMultipartUploads, } from '../../storage/fileStore.js';
3
+ export function registerUploadRoutes(app, ctx) {
4
+ const cleanupInterval = setInterval(() => {
5
+ const removed = cleanupStaleMultipartUploads(ctx.paths.tmpDir);
6
+ if (removed > 0) {
7
+ ctx.logger.info({ removed }, 'multipart_upload_cleanup');
8
+ }
9
+ }, 15 * 60 * 1000);
10
+ cleanupInterval.unref();
11
+ app.addHook('onClose', async () => {
12
+ clearInterval(cleanupInterval);
13
+ });
14
+ app.post('/api/artifact/upload-url', async (request, reply) => {
15
+ const body = request.body;
16
+ const uploadUrl = `${ctx.baseUrl}/api/artifact/local-upload?key=${encodeURIComponent(body.key)}`;
17
+ const accessUrl = ctx.files.accessUrl(ctx.baseUrl, body.key);
18
+ return { uploadUrl, accessUrl };
19
+ });
20
+ app.post('/api/artifact/multipart-upload-url', async (request, reply) => {
21
+ const body = request.body;
22
+ const uploadId = randomUUID();
23
+ registerMultipartUpload({
24
+ uploadId,
25
+ key: body.key,
26
+ numParts: body.numParts,
27
+ createdAt: Date.now(),
28
+ });
29
+ const uploadUrls = Array.from({ length: body.numParts }, (_, i) => `${ctx.baseUrl}/api/artifact/local-upload-part?uploadId=${encodeURIComponent(uploadId)}` +
30
+ `&partNumber=${i + 1}`);
31
+ return {
32
+ uploadId,
33
+ uploadUrls,
34
+ accessUrl: ctx.files.accessUrl(ctx.baseUrl, body.key),
35
+ };
36
+ });
37
+ app.post('/api/artifact/complete-multipart-upload', async (request, reply) => {
38
+ const body = request.body;
39
+ const upload = getMultipartUpload(body.uploadId);
40
+ if (!upload) {
41
+ return reply.status(404).send({ message: 'Upload not found' });
42
+ }
43
+ if (body.parts.length !== upload.numParts) {
44
+ return reply.status(400).send({ message: 'Part count mismatch' });
45
+ }
46
+ try {
47
+ ctx.files.completeMultipart(body.key, body.uploadId, upload.numParts);
48
+ }
49
+ catch (err) {
50
+ const message = err instanceof Error ? err.message : 'Multipart upload failed';
51
+ return reply.status(400).send({ message });
52
+ }
53
+ removeMultipartUpload(body.uploadId);
54
+ return reply.status(200).send();
55
+ });
56
+ app.put('/api/artifact/local-upload', async (request, reply) => {
57
+ const query = request.query;
58
+ if (!query.key) {
59
+ return reply.status(400).send({ message: 'key is required' });
60
+ }
61
+ const data = request.body;
62
+ if (!Buffer.isBuffer(data)) {
63
+ return reply.status(400).send({ message: 'Upload body must be raw bytes' });
64
+ }
65
+ const contentType = request.headers['content-type'];
66
+ const contentEncoding = request.headers['content-encoding'];
67
+ try {
68
+ ctx.files.writeFile(query.key, data, contentType, contentEncoding ?? null);
69
+ }
70
+ catch (err) {
71
+ const message = err instanceof Error ? err.message : 'Upload failed';
72
+ return reply.status(400).send({ message });
73
+ }
74
+ return reply.status(200).send();
75
+ });
76
+ app.put('/api/artifact/local-upload-part', async (request, reply) => {
77
+ const query = request.query;
78
+ if (!query.uploadId || !query.partNumber) {
79
+ return reply.status(400).send({ message: 'uploadId and partNumber are required' });
80
+ }
81
+ const upload = getMultipartUpload(query.uploadId);
82
+ if (!upload) {
83
+ return reply.status(404).send({ message: 'Upload not found' });
84
+ }
85
+ const partNumber = parseInt(query.partNumber, 10);
86
+ if (!Number.isFinite(partNumber) || partNumber < 1 || partNumber > upload.numParts) {
87
+ return reply.status(400).send({ message: 'Invalid part number' });
88
+ }
89
+ const data = request.body;
90
+ if (!Buffer.isBuffer(data)) {
91
+ return reply.status(400).send({ message: 'Upload body must be raw bytes' });
92
+ }
93
+ try {
94
+ ctx.files.writePart(query.uploadId, partNumber, data);
95
+ }
96
+ catch (err) {
97
+ const message = err instanceof Error ? err.message : 'Upload failed';
98
+ return reply.status(400).send({ message });
99
+ }
100
+ return reply.status(200).send();
101
+ });
102
+ }
@@ -0,0 +1 @@
1
+ export declare function isRawArtifactUploadRequest(method: string, url: string): boolean;
@@ -0,0 +1,5 @@
1
+ export function isRawArtifactUploadRequest(method, url) {
2
+ const path = url.split('?')[0] ?? url;
3
+ return (method === 'PUT' &&
4
+ (path === '/api/artifact/local-upload' || path === '/api/artifact/local-upload-part'));
5
+ }
@@ -0,0 +1,21 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { Capture, CaptureUpdate } from '../types.js';
3
+ import type { CollectionRepo } from './collectionRepo.js';
4
+ export declare class CaptureRepo {
5
+ private readonly db;
6
+ private readonly collections;
7
+ constructor(db: Database.Database, collections: CollectionRepo);
8
+ saveCapture(capture: Capture): number;
9
+ getCaptureWithExports(externalId: string): Capture | null;
10
+ listCaptures(collectionExternalId: string | undefined, limit: number, offset: number): Capture[];
11
+ updateCollectionForCapture(captureExternalId: string, collectionExternalId: string): void;
12
+ removeCaptureFromCollection(captureExternalId: string): void;
13
+ assignTags(captureId: number, tagValueIds: number[]): void;
14
+ getCaptureId(externalId: string): number | null;
15
+ getCaptureOwner(externalId: string): number | null;
16
+ saveCaptureRequest(requestId: string, url: string, update: CaptureUpdate): void;
17
+ getCaptureRequestStatus(requestId: string): string | null;
18
+ cancelCaptureRequest(requestId: string): void;
19
+ isCaptureRequestCancelled(requestId: string): boolean;
20
+ private listExports;
21
+ }
@@ -0,0 +1,172 @@
1
+ import { LOCAL_USER_ID, marshalString, nowIso } from '../utils.js';
2
+ export class CaptureRepo {
3
+ db;
4
+ collections;
5
+ constructor(db, collections) {
6
+ this.db = db;
7
+ this.collections = collections;
8
+ }
9
+ saveCapture(capture) {
10
+ if (!capture.collectionExternalId) {
11
+ capture.collectionExternalId = this.collections.getDefaultCollectionExternalId();
12
+ }
13
+ const now = nowIso();
14
+ const tx = this.db.transaction(() => {
15
+ const result = this.db
16
+ .prepare(`
17
+ INSERT INTO capture (
18
+ created_at, updated_at, external_id, user_id, url,
19
+ is_full_page, collection_external_id
20
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
21
+ `)
22
+ .run(now, now, capture.externalId, capture.userId ?? LOCAL_USER_ID, capture.url, capture.isFullPage ? 1 : 0, capture.collectionExternalId);
23
+ const captureId = Number(result.lastInsertRowid);
24
+ const insertExport = this.db.prepare(`
25
+ INSERT INTO export (
26
+ created_at, updated_at, capture_id, theme, size_name,
27
+ width, height, url, screenshot_url, thumbnail_url
28
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
29
+ `);
30
+ for (const exp of capture.exports) {
31
+ insertExport.run(now, now, captureId, exp.theme, exp.sizeName, exp.width, exp.height, exp.url, exp.screenshotUrl ?? '', exp.thumbnailUrl ?? '');
32
+ }
33
+ if (capture.exports[0]?.thumbnailUrl) {
34
+ this.collections.updateIcon(capture.collectionExternalId, capture.exports[0].thumbnailUrl);
35
+ }
36
+ return captureId;
37
+ });
38
+ return tx();
39
+ }
40
+ getCaptureWithExports(externalId) {
41
+ const row = this.db
42
+ .prepare(`
43
+ SELECT id, external_id, user_id, url, is_full_page, collection_external_id
44
+ FROM capture
45
+ WHERE external_id = ? AND deleted_at IS NULL
46
+ `)
47
+ .get(externalId);
48
+ if (!row) {
49
+ return null;
50
+ }
51
+ const capture = rowToCapture(row);
52
+ capture.exports = this.listExports(Number(row.id));
53
+ return capture;
54
+ }
55
+ listCaptures(collectionExternalId, limit, offset) {
56
+ let query = `
57
+ SELECT id, external_id, user_id, url, is_full_page, collection_external_id
58
+ FROM capture
59
+ WHERE deleted_at IS NULL
60
+ `;
61
+ const params = [];
62
+ if (collectionExternalId) {
63
+ query += ' AND collection_external_id = ?';
64
+ params.push(collectionExternalId);
65
+ }
66
+ else {
67
+ query += ' AND user_id = ?';
68
+ params.push(LOCAL_USER_ID);
69
+ }
70
+ query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
71
+ params.push(limit, offset);
72
+ const rows = this.db.prepare(query).all(...params);
73
+ const captures = rows.map(rowToCapture);
74
+ for (const capture of captures) {
75
+ capture.exports = this.listExports(capture.id);
76
+ }
77
+ return captures;
78
+ }
79
+ updateCollectionForCapture(captureExternalId, collectionExternalId) {
80
+ this.db
81
+ .prepare('UPDATE capture SET collection_external_id = ?, updated_at = ? WHERE external_id = ?')
82
+ .run(collectionExternalId, nowIso(), captureExternalId);
83
+ }
84
+ removeCaptureFromCollection(captureExternalId) {
85
+ const defaultId = this.collections.getDefaultCollectionExternalId();
86
+ this.updateCollectionForCapture(captureExternalId, defaultId);
87
+ }
88
+ assignTags(captureId, tagValueIds) {
89
+ if (tagValueIds.length === 0) {
90
+ return;
91
+ }
92
+ const now = nowIso();
93
+ const insert = this.db.prepare(`
94
+ INSERT OR IGNORE INTO capture_tag_mapping (created_at, updated_at, capture_id, tag_value_id)
95
+ VALUES (?, ?, ?, ?)
96
+ `);
97
+ const tx = this.db.transaction(() => {
98
+ for (const tagValueId of tagValueIds) {
99
+ insert.run(now, now, captureId, tagValueId);
100
+ }
101
+ });
102
+ tx();
103
+ }
104
+ getCaptureId(externalId) {
105
+ const row = this.db
106
+ .prepare('SELECT id FROM capture WHERE external_id = ? AND deleted_at IS NULL')
107
+ .get(externalId);
108
+ return row?.id ?? null;
109
+ }
110
+ getCaptureOwner(externalId) {
111
+ const row = this.db
112
+ .prepare('SELECT user_id FROM capture WHERE external_id = ? AND deleted_at IS NULL')
113
+ .get(externalId);
114
+ return row?.user_id ?? null;
115
+ }
116
+ saveCaptureRequest(requestId, url, update) {
117
+ const now = nowIso();
118
+ this.db
119
+ .prepare(`
120
+ INSERT INTO capture_request (created_at, updated_at, external_id, user_id, url, status)
121
+ VALUES (?, ?, ?, ?, ?, ?)
122
+ ON CONFLICT(user_id, external_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at
123
+ `)
124
+ .run(now, now, requestId, LOCAL_USER_ID, url, marshalString(update));
125
+ }
126
+ getCaptureRequestStatus(requestId) {
127
+ const row = this.db
128
+ .prepare('SELECT status FROM capture_request WHERE external_id = ? AND user_id = ?')
129
+ .get(requestId, LOCAL_USER_ID);
130
+ return row?.status ?? null;
131
+ }
132
+ cancelCaptureRequest(requestId) {
133
+ this.db
134
+ .prepare('UPDATE capture_request SET is_cancelled = 1, updated_at = ? WHERE external_id = ? AND user_id = ?')
135
+ .run(nowIso(), requestId, LOCAL_USER_ID);
136
+ }
137
+ isCaptureRequestCancelled(requestId) {
138
+ const row = this.db
139
+ .prepare('SELECT is_cancelled FROM capture_request WHERE external_id = ? AND user_id = ?')
140
+ .get(requestId, LOCAL_USER_ID);
141
+ return Boolean(row?.is_cancelled);
142
+ }
143
+ listExports(captureId) {
144
+ const rows = this.db
145
+ .prepare(`
146
+ SELECT theme, size_name, width, height, url, screenshot_url, thumbnail_url
147
+ FROM export
148
+ WHERE capture_id = ? AND deleted_at IS NULL
149
+ `)
150
+ .all(captureId);
151
+ return rows.map((row) => ({
152
+ theme: row.theme,
153
+ sizeName: row.size_name,
154
+ width: row.width,
155
+ height: row.height,
156
+ url: row.url,
157
+ screenshotUrl: row.screenshot_url,
158
+ thumbnailUrl: row.thumbnail_url,
159
+ }));
160
+ }
161
+ }
162
+ function rowToCapture(row) {
163
+ return {
164
+ id: row.id,
165
+ externalId: row.external_id,
166
+ userId: row.user_id,
167
+ url: row.url,
168
+ isFullPage: Boolean(row.is_full_page),
169
+ collectionExternalId: row.collection_external_id,
170
+ exports: [],
171
+ };
172
+ }
@@ -0,0 +1,14 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { Collection, CollectionWithMetadata } from '../types.js';
3
+ export declare class CollectionRepo {
4
+ private readonly db;
5
+ constructor(db: Database.Database);
6
+ saveCollection(collection: Collection): void;
7
+ saveCollections(collections: Collection[]): void;
8
+ listCollections(): Collection[];
9
+ listCollectionsWithMetadata(): CollectionWithMetadata[];
10
+ getCollectionOwner(externalId: string): number | null;
11
+ getDefaultCollectionExternalId(): string;
12
+ deleteCollection(collectionExternalId: string, replacementCollectionId: string): void;
13
+ updateIcon(externalId: string, iconUrl: string): void;
14
+ }