@penpair/core 0.4.1 → 0.5.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.
@@ -0,0 +1,11 @@
1
+ import { type Snippet } from "../snippet.js";
2
+ export interface NativeCopyPlacement {
3
+ id: string;
4
+ x: number;
5
+ y: number;
6
+ }
7
+ export declare function nativeCopyInspect(fileId: string, sourcePage: string, frameIds: string[], releaseVersion: string, ownerKey?: string): Snippet;
8
+ export declare function nativeCopyCreatePage(fileId: string, releaseVersion: string, ownerKey: string): Snippet;
9
+ export declare function nativeCopySelect(fileId: string, pageId: string, frameIds: string[]): Snippet;
10
+ export declare function nativeCopyObserve(fileId: string, pageId: string): Snippet;
11
+ export declare function nativeCopyArrange(fileId: string, pageId: string, placements: NativeCopyPlacement[], runId: string): Snippet;
@@ -0,0 +1,293 @@
1
+ import { snippet } from "../snippet.js";
2
+ const helpers = `
3
+ const assertFile = () => {
4
+ if (!penpot.currentFile || penpot.currentFile.id !== CFG.fileId) {
5
+ throw new Error("Pinned copy file changed");
6
+ }
7
+ };
8
+ const assertPage = () => {
9
+ assertFile();
10
+ if (!penpot.currentPage || penpot.currentPage.id !== CFG.pageId) {
11
+ throw new Error("Pinned copy page changed");
12
+ }
13
+ };
14
+ const scan = page => {
15
+ if (!page || !page.root || !Array.isArray(page.root.children)) {
16
+ throw new Error("Copy page hierarchy is unavailable");
17
+ }
18
+ const found = new Map();
19
+ const parents = new Map();
20
+ const stack = page.root.children.map(shape => [shape, null]).reverse();
21
+ if (stack.length > 20000) throw new Error("Copy page node budget exceeded");
22
+ while (stack.length) {
23
+ if (found.size >= 20000) throw new Error("Copy page node budget exceeded");
24
+ const [shape, parentId] = stack.pop();
25
+ if (!shape || typeof shape.id !== "string" || !shape.id || found.has(shape.id)) {
26
+ throw new Error("Copy page has malformed or duplicate shape identities");
27
+ }
28
+ found.set(shape.id, shape);
29
+ parents.set(shape.id, parentId);
30
+ const children = shape.children === undefined ? [] : shape.children;
31
+ if (!Array.isArray(children)) throw new Error("Copy page hierarchy is malformed");
32
+ if (found.size + stack.length + children.length > 20000) {
33
+ throw new Error("Copy page node budget exceeded");
34
+ }
35
+ for (let index = children.length - 1; index >= 0; index--) {
36
+ stack.push([children[index], shape.id]);
37
+ }
38
+ }
39
+ return { found, parents };
40
+ };
41
+ const count = shape => {
42
+ let total = 0;
43
+ const seen = new Set();
44
+ const stack = [shape];
45
+ while (stack.length) {
46
+ const current = stack.pop();
47
+ if (!current || seen.has(current.id) || ++total > 20000) {
48
+ throw new Error("Copy subtree is malformed or exceeds its node budget");
49
+ }
50
+ seen.add(current.id);
51
+ const children = current.children === undefined ? [] : current.children;
52
+ if (!Array.isArray(children) || total + stack.length + children.length > 20000) {
53
+ throw new Error("Copy subtree is malformed or exceeds its node budget");
54
+ }
55
+ stack.push(...children);
56
+ }
57
+ return total;
58
+ };
59
+ const geometry = shape => {
60
+ if (![shape.x, shape.y, shape.width, shape.height].every(Number.isFinite) || shape.width < 0 || shape.height < 0) {
61
+ throw new Error("Copy shape geometry is unavailable");
62
+ }
63
+ return { id: shape.id, name: shape.name, x: shape.x, y: shape.y, width: shape.width, height: shape.height, nodeCount: count(shape) };
64
+ };
65
+ const metadata = (shape, key) => {
66
+ if (typeof shape.getSharedPluginData !== "function") {
67
+ throw new Error("Copy ownership metadata API unavailable");
68
+ }
69
+ const raw = shape.getSharedPluginData("penpair", key);
70
+ if (!raw) return null;
71
+ let value;
72
+ try { value = JSON.parse(raw); } catch { throw new Error("Malformed copy ownership metadata"); }
73
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
74
+ throw new Error("Malformed copy ownership metadata");
75
+ }
76
+ return value;
77
+ };
78
+ const releaseMarkers = page => {
79
+ const release = metadata(page.root, "design-release");
80
+ if (release && (release.schemaVersion !== 1 || release.owner !== "penpair" || release.productVersion !== CFG.releaseVersion || typeof release.releaseId !== "string" || !release.releaseId || typeof release.ownerKey !== "string" || !release.ownerKey || release.status !== "draft")) {
81
+ throw new Error("Release destination is finalized or its ownership marker is invalid");
82
+ }
83
+ const copy = metadata(page.root, "native-release-copy");
84
+ if (copy && (copy.schemaVersion !== 1 || copy.owner !== "penpair" || copy.kind !== "native-release-copy" || copy.fileId !== CFG.fileId || copy.productVersion !== CFG.releaseVersion || typeof copy.ownerKey !== "string" || !copy.ownerKey || copy.created !== true)) {
85
+ throw new Error("Native copy page ownership marker is invalid");
86
+ }
87
+ return { release, copy };
88
+ };
89
+ const destination = () => {
90
+ assertFile();
91
+ if (!Array.isArray(penpot.currentFile.pages) || penpot.currentFile.pages.length > 1000) {
92
+ throw new Error("Copy page list is unavailable or exceeds its budget");
93
+ }
94
+ const matches = penpot.currentFile.pages.filter(page => page.name === "release/" + CFG.releaseVersion);
95
+ if (matches.length > 1) throw new Error("Release destination page name is ambiguous");
96
+ const owned = CFG.ownerKey ? penpot.currentFile.pages.filter(page => {
97
+ const marker = metadata(page.root, "native-release-copy");
98
+ if (!marker || marker.ownerKey !== CFG.ownerKey) return false;
99
+ releaseMarkers(page);
100
+ return true;
101
+ }) : [];
102
+ if (owned.length > 1) throw new Error("Native copy destination ownership is ambiguous");
103
+ if (owned.length && matches.some(page => page.id !== owned[0].id)) {
104
+ throw new Error("Owned copy destination conflicts with another release page");
105
+ }
106
+ const page = owned[0] || matches[0] || null;
107
+ if (page) releaseMarkers(page);
108
+ return page;
109
+ };
110
+ const frameSelection = (page, frameIds) => {
111
+ if (!Array.isArray(frameIds) || frameIds.length > 100 || new Set(frameIds).size !== frameIds.length || frameIds.some(id => typeof id !== "string" || !id)) {
112
+ throw new Error("Copy selection requires distinct board IDs within its budget");
113
+ }
114
+ const tree = scan(page);
115
+ const selected = frameIds.map(id => {
116
+ const shape = tree.found.get(id);
117
+ if (!shape || shape.type !== "board") throw new Error("Copy source board is missing or not a board");
118
+ let parentId = tree.parents.get(id);
119
+ while (parentId !== null && parentId !== undefined) {
120
+ if (frameIds.includes(parentId)) throw new Error("Copy source boards overlap");
121
+ parentId = tree.parents.get(parentId);
122
+ }
123
+ return shape;
124
+ });
125
+ if (selected.reduce((total, shape) => total + count(shape), 0) > 20000) {
126
+ throw new Error("Copy selection node budget exceeded");
127
+ }
128
+ return selected;
129
+ };
130
+ `;
131
+ function validateIdentity(fileId) {
132
+ if (typeof fileId !== "string" || !fileId.trim() || fileId.length > 1000) {
133
+ throw new Error("Copy requires a pinned file ID");
134
+ }
135
+ }
136
+ function validateVersion(releaseVersion) {
137
+ if (typeof releaseVersion !== "string" ||
138
+ !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/.test(releaseVersion) ||
139
+ releaseVersion.length > 100) {
140
+ throw new Error("Copy requires a bounded release version");
141
+ }
142
+ }
143
+ export function nativeCopyInspect(fileId, sourcePage, frameIds, releaseVersion, ownerKey) {
144
+ validateIdentity(fileId);
145
+ validateVersion(releaseVersion);
146
+ if (ownerKey !== undefined && (!ownerKey.trim() || ownerKey.length > 1000)) {
147
+ throw new Error("Copy inspection owner key must be bounded and nonempty");
148
+ }
149
+ if (!sourcePage.trim() || sourcePage.length > 1000 || !frameIds.length) {
150
+ throw new Error("Copy inspection requires a source page and board IDs");
151
+ }
152
+ return snippet("native-copy-inspect", `${helpers}
153
+ assertFile();
154
+ const target = destination();
155
+ const sources = penpot.currentFile.pages.filter(page => page.id === CFG.sourcePage || page.name === CFG.sourcePage);
156
+ if (sources.length !== 1) throw new Error("Copy source page is missing or ambiguous");
157
+ const source = sources[0];
158
+ if (target && source.id === target.id) throw new Error("Source and release destination must be different pages");
159
+ const selected = frameSelection(source, CFG.frameIds);
160
+ let destinationRootIds = [];
161
+ let bottom = 0;
162
+ let createdPageOwner = null;
163
+ if (target) {
164
+ scan(target);
165
+ destinationRootIds = target.root.children.map(shape => shape.id);
166
+ for (const shape of target.root.children) {
167
+ const bounds = geometry(shape);
168
+ bottom = Math.max(bottom, bounds.y + bounds.height);
169
+ }
170
+ createdPageOwner = releaseMarkers(target).copy?.ownerKey || null;
171
+ }
172
+ return { ok: true, fileId: penpot.currentFile.id, revision: penpot.currentFile.revn,
173
+ sourcePageId: source.id, sourcePageName: source.name,
174
+ destinationPageId: target ? target.id : null, destinationName: "release/" + CFG.releaseVersion,
175
+ destinationNamed: !!target && target.name === "release/" + CFG.releaseVersion,
176
+ destinationRootIds, destinationBounds: { bottom }, sourceFrames: selected.map(geometry),
177
+ createdPageOwner, destinationFinal: false };
178
+ `, {
179
+ fileId,
180
+ sourcePage,
181
+ frameIds,
182
+ releaseVersion,
183
+ ownerKey,
184
+ }, "read");
185
+ }
186
+ export function nativeCopyCreatePage(fileId, releaseVersion, ownerKey) {
187
+ validateIdentity(fileId);
188
+ validateVersion(releaseVersion);
189
+ if (!ownerKey.trim() || ownerKey.length > 1000) {
190
+ throw new Error("Copy page creation requires a bounded owner key");
191
+ }
192
+ return snippet("native-copy-create-page", `${helpers}
193
+ let target = destination();
194
+ const created = !target;
195
+ if (!target) {
196
+ if (typeof penpot.createPage !== "function" || typeof penpot.openPage !== "function") {
197
+ throw new Error("Native copy page creation API unavailable");
198
+ }
199
+ target = penpot.createPage();
200
+ await penpot.openPage(target);
201
+ assertFile();
202
+ if (penpot.currentPage.id !== target.id || typeof target.root.setSharedPluginData !== "function") {
203
+ throw new Error("Created copy page identity or metadata API unavailable");
204
+ }
205
+ target.root.setSharedPluginData("penpair", "native-release-copy", JSON.stringify({
206
+ schemaVersion: 1, owner: "penpair", kind: "native-release-copy", fileId: CFG.fileId,
207
+ productVersion: CFG.releaseVersion, ownerKey: CFG.ownerKey, created: true
208
+ }));
209
+ } else {
210
+ await penpot.openPage(target);
211
+ }
212
+ assertFile();
213
+ if (penpot.currentPage.id !== target.id) throw new Error("Copy destination did not become active");
214
+ if (target.name !== "release/" + CFG.releaseVersion) target.name = "release/" + CFG.releaseVersion;
215
+ return { ok: true, fileId: CFG.fileId, pageId: target.id, created };
216
+ `, {
217
+ fileId,
218
+ releaseVersion,
219
+ ownerKey,
220
+ }, "write");
221
+ }
222
+ export function nativeCopySelect(fileId, pageId, frameIds) {
223
+ validateIdentity(fileId);
224
+ return snippet("native-copy-select", `${helpers}
225
+ assertPage();
226
+ penpot.selection = frameSelection(penpot.currentPage, CFG.frameIds);
227
+ return { ok: true, fileId: CFG.fileId, pageId: CFG.pageId, selectionIds: penpot.selection.map(shape => shape.id) };
228
+ `, {
229
+ fileId,
230
+ pageId,
231
+ frameIds,
232
+ }, "write");
233
+ }
234
+ export function nativeCopyObserve(fileId, pageId) {
235
+ validateIdentity(fileId);
236
+ return snippet("native-copy-observe", `${helpers}
237
+ assertPage();
238
+ scan(penpot.currentPage);
239
+ const rootFrames = penpot.currentPage.root.children.map(shape => {
240
+ const marker = metadata(shape, "native-copy");
241
+ if (marker && (typeof marker.runId !== "string" || !marker.runId)) throw new Error("Malformed copied-board marker");
242
+ return { ...geometry(shape), type: shape.type, copyRunId: marker ? marker.runId : null };
243
+ });
244
+ if (!Array.isArray(penpot.selection) || penpot.selection.length > 100) throw new Error("Copy selection observation is unavailable");
245
+ return { ok: true, fileId: CFG.fileId, pageId: CFG.pageId, revision: penpot.currentFile.revn,
246
+ rootIds: rootFrames.map(shape => shape.id), selectionIds: penpot.selection.map(shape => shape.id), rootFrames };
247
+ `, {
248
+ fileId,
249
+ pageId,
250
+ }, "read");
251
+ }
252
+ export function nativeCopyArrange(fileId, pageId, placements, runId) {
253
+ validateIdentity(fileId);
254
+ if (!runId.trim() ||
255
+ runId.length > 1000 ||
256
+ !placements.length ||
257
+ placements.length > 100 ||
258
+ placements.some((placement) => !placement.id || !Number.isFinite(placement.x) || !Number.isFinite(placement.y)) ||
259
+ new Set(placements.map((placement) => placement.id)).size !== placements.length) {
260
+ throw new Error("Copy arrangement requires distinct boards and finite positions");
261
+ }
262
+ return snippet("native-copy-arrange", `${helpers}
263
+ assertPage();
264
+ const roots = penpot.currentPage.root.children;
265
+ if (!Array.isArray(roots) || roots.length > 20000) throw new Error("Copy destination roots are unavailable");
266
+ const selected = CFG.placements.map(placement => {
267
+ const matches = roots.filter(shape => shape.id === placement.id && shape.type === "board");
268
+ if (matches.length !== 1) throw new Error("Copied board is missing or no longer a destination root");
269
+ const shape = matches[0];
270
+ const marker = metadata(shape, "native-copy");
271
+ if (marker && (typeof marker.runId !== "string" || !marker.runId || marker.runId.length > 1000)) {
272
+ throw new Error("Copied-board transfer marker is malformed");
273
+ }
274
+ if (typeof shape.setSharedPluginData !== "function") throw new Error("Copied-board marker API unavailable");
275
+ return { shape, placement, marker };
276
+ });
277
+ for (const { shape, placement, marker } of selected) {
278
+ const provenance = marker && marker.runId === CFG.runId ? marker : {
279
+ runId: CFG.runId,
280
+ ...(marker ? { previousRunId: marker.runId } : {})
281
+ };
282
+ shape.setSharedPluginData("penpair", "native-copy", JSON.stringify(provenance));
283
+ if (shape.x !== placement.x) shape.x = placement.x;
284
+ if (shape.y !== placement.y) shape.y = placement.y;
285
+ }
286
+ return { ok: true, fileId: CFG.fileId, pageId: CFG.pageId, placements: selected.map(({ shape }) => ({ id: shape.id, x: shape.x, y: shape.y })) };
287
+ `, {
288
+ fileId,
289
+ pageId,
290
+ placements,
291
+ runId,
292
+ }, "write");
293
+ }
@@ -0,0 +1,28 @@
1
+ import type { PenpotToolClient } from "../client.js";
2
+ import type { NativeClipboard } from "../native-editor-clipboard.js";
3
+ import { type CopiedFramePair } from "./copy-evidence.js";
4
+ import { type CopyRequest, type CopyPreview } from "./copy-plan.js";
5
+ export type { CopyRequest } from "./copy-plan.js";
6
+ export interface CopyOptions {
7
+ apply?: boolean;
8
+ resume?: boolean;
9
+ runId?: string;
10
+ redact?: (value: string) => string;
11
+ onStep?: (message: string) => void;
12
+ clipboard?: () => Promise<NativeClipboard>;
13
+ sleep?: (milliseconds: number) => Promise<void>;
14
+ }
15
+ export interface CopyResult {
16
+ schemaVersion: 1;
17
+ kind: "native-release-copy";
18
+ status: "preview" | "copied" | "incomplete";
19
+ preview: CopyPreview;
20
+ runId?: string;
21
+ receiptPath?: string;
22
+ pairs?: CopiedFramePair[];
23
+ nativeVerified?: boolean;
24
+ pixelsMatch?: boolean;
25
+ problems: string[];
26
+ }
27
+ /** Copy native boards through an explicitly supplied editor transport; preview never touches clipboard or selection. */
28
+ export declare function copyReleaseBoards(client: PenpotToolClient, root: string, request: CopyRequest, options?: CopyOptions): Promise<CopyResult>;