Compare commits

...
49 Commits
Author SHA1 Message Date
andrés gonzález 88d715938f 📚 Add migration guide page (#11454)
Point First Steps at the enterprise migration PDF with a short
summary, without duplicating the Community post.
2026-09-02 14:06:06 +02:00
andrés gonzález b6b1a47a7b 📚 Update MCP Quick demo video (#11453)
Replace the outdated Quick demo embed on the MCP docs with the
new recording.
2026-09-02 13:54:01 +02:00
David Barragán Merino 6f35348c7c 🐳 Pin docker images to 2.17
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-08-27 10:51:08 +02:00
Andrey Antukh 1d2c37e52c 📎 Update changelog 2026-08-27 10:00:13 +02:00
Andrey Antukh f7fd3e1cd5 📚 Update the update-changelog skill 2026-08-27 10:00:00 +02:00
Alejandro Alonso f7bdc9786c 🐛 Compare text numeric attrs with float tolerance (#11366)
Editor/WASM round-trips can truncate line-height strings
(e.g. 1.3333333333333333 → 1.33333). Exact string compare
treated that as a style change and detached typography tokens.
2026-08-27 09:41:50 +02:00
Andrey Antukh 88a52d1098 📎 Update changelog 2026-08-27 09:24:53 +02:00
Alejandro AlonsoandAndrés Moya 2318866f8d 🎉 Add repair functions for variant validation errors (#10768) (#11309)
* 🎉 Add repair functions for variant validation errors

* 📚 Fix copyright notice

Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
2026-08-21 11:17:25 +02:00
Andrey AntukhandSumit Ridhal 4da6499197 🐛 Fix linear gradients in SVG text exports (#11272)
* 🐛 Use gradient type instead of export type in SVG renderer

data->gradient-def was comparing the render `type` parameter (:svg,
:png, :pdf) against "linear" to decide between linearGradient and
radialGradient elements. Since the export type is never "linear",
the comparison always fell through to radialGradient, causing all
linear gradients to be exported as radial in SVG output.

Read the gradient type from the data map instead:
(get-in data ["gradient" "type"])

Closes #5972

* 🐛 Add SVG gradient export regression test

Extract SVG gradient definition generation from the renderer so it can
be tested directly. Add exporter test build wiring and cover both
linear and radial gradient output.

AI-assisted-by: gpt-5.6-luna

*  Standardize exporter testing workflow

Align exporter scripts with the frontend testing pattern. Add a
dedicated GitHub Actions workflow and document the canonical exporter
commands in Serena memories.

AI-assisted-by: gpt-5.6-luna

*  Add focused exporter test execution

Mirror frontend test-runner behavior for focused namespaces and test
vars. Support --focus, --log-level, and --help, and document the
commands.

AI-assisted-by: gpt-5.6-luna

* 🐛 Replace shell exec with execFile in exporter

Replace child_process.exec with execFile to eliminate shell
interpretation. Add hex color validation in exporter and frontend
to reject malformed input before command construction.

This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated
OS command injection vulnerability where malicious fill-color values
could execute arbitrary commands in the exporter container.

Defense in depth:
- Layer 1: execFile passes arguments directly without shell parsing
- Layer 2: Exporter validates colors with strict hex regex
- Layer 3: Frontend filters invalid colors before DOM emission

All three independent reporters' attack vectors are addressed:
- Quote breakout (lyhtheori)
- Command substitution (B1gN0Se)
- Path traversal (KimiSecurityTeam)

AI-assisted-by: qwen3.7-plus

* 🐛 Use existing hex-color-string? and fix test path mismatch

Address code review feedback:

- Replace duplicated hex-color-rx and valid-hex-color? with existing
  hex-color-string? from app.common.types.color
- Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned

AI-assisted-by: qwen3.7-plus

---------

Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
2026-08-19 13:57:14 +02:00
Sebastien MALOT 7ac61e0597 🐛 Fix typo in auto-file-snapshot timeout setting (#10909)
Corrected a typo in the configuration documentation regarding the auto-file-snapshot timeout setting.

Signed-off-by: Sebastien MALOT <sebastien.malot@pm.gouv.fr>
2026-08-17 22:14:01 +02:00
Andrey Antukh 509f5395cb 📎 Update changelog 2026-08-17 12:08:38 +02:00
Andrey Antukh d835baefec Merge remote-tracking branch 'origin/staging' 2026-08-03 09:15:03 +02:00
Andrey Antukh d04cbf175e 🐛 Fix nil dereference crash during flex layout drag operations (#10845)
Production crash where @(get bounds id) threw
"No protocol method IDeref.-deref defined for type null"
when a shape ID had no corresponding entry in the bounds map
during layout calculations.

Added defensive nil guards (when-let / when) to all unprotected
bounds dereference sites:

- flex_layout/bounds.cljc: layout-content-points (parent + child)
  and layout-content-bounds
- grid_layout/bounds.cljc: layout-content-points and
  layout-content-bounds
- min_size_layout.cljc: child-min-width grid branch (3 sites) and
  child-min-height grid branch

Added 7 new tests in geom_bounds_layout_nil_test.cljc covering all
nil-bounds edge cases for flex, grid, and min-size layout paths.
Registered in runner.cljc.

Closes #10843

AI-assisted-by: qwen3.7-plus
2026-07-31 12:51:35 +02:00
Andrey Antukh 764b62906b 🐛 Handle unrecognized JSON escape sequences as malformed-json (#10808)
* 📎 Update serena documentation about creating-prs workflow

* 🐛 Handle unrecognized JSON escape sequences as malformed-json

When clojure.data.json's read-escaped-char encounters an unrecognized
escape sequence (e.g. a backslash followed by '}', or other case
fall-throughs in the parser) in a JSON request body, it throws a bare
IllegalArgumentException. Previously this fell through to the generic
RuntimeException branch in wrap-parse-request's handle-error, which
unwrapped and recurred without matching, eventually reaching the
internal-error handler and producing HTTP 500 + an error report — even
though the root cause was malformed client input, not a server bug.

The fix converts any IllegalArgumentException raised in the JSON parse
path into a `:validation`/`:malformed-json` error by raising a new
ex-info (which is caught by the top-level error handler in
`app.http/router-handler`). The result is an HTTP 400 response with a
descriptive hint, and no error report is generated. This addresses
~10% of all error reports received.

The new IAE branch is placed before the RuntimeException branch in
the cond (since IllegalArgumentException IS-A RuntimeException) and
uses the throw-style (ex/raise) to match the existing
RequestTooBigException / EOFException branches. A comment above the
handle-error cond documents why raising is intentional and is caught
by the top-level app.http error handler, not by the per-route
wrap-errors middleware.

Test suite changes:

- Extend the existing `DummyRequest` defrecord in
  `http_middleware_test.clj` from 2 fields to 12 fields, implementing
  every IRequest method, and add a private `make-dummy-request`
  constructor that accepts an options map with every key optional and
  sensible `:or` defaults. Future fields added to DummyRequest won't
  break existing call sites as long as the `:or` defaults are kept in
  sync.

- Remove the now-redundant `JsonRequest` defrecord and migrate all 11
  `->DummyRequest` call sites to `make-dummy-request`.

- Add 6 new deftest cases:
  - parse-request-illegal-argument-exception: malformed JSON body
    (containing `\}`) is converted to `:malformed-json`.
  - parse-request-request-too-big-exception: RequestTooBigException
    is converted to `:request-body-too-large`.
  - parse-request-eof-exception: java.io.EOFException is converted
    to `:malformed-json`.
  - parse-request-runtime-exception-with-cause: a wrapped
    RuntimeException recurses on ex-cause and dispatches to the
    matching specific branch.
  - parse-request-runtime-exception-without-cause: a bare
    RuntimeException falls through to errors/handle, returning 500
    with :type :server-error :code :unexpected.
  - parse-request-non-runtime-throwable: java.io.IOException (a
    non-RuntimeException Throwable) is handled by the dedicated
    handle-exception method, returning 500 with :code :io-exception.

Together, the new tests cover all 6 branches of wrap-parse-request's
handle-error cond.

Refs #10804.

AI-assisted-by: minimax-m3
2026-07-31 12:06:19 +02:00
Dominik Jain 30943f1074 Make MCP tool call timeout configurable, raising default
The timeout for tool calls (which is trictly relevant for plugin tasks only)
is now configurable via env. var PENPOT_MCP_TOOL_TIMEOUT_S.

The default was raised from 30 to 120, because 30 seconds was not enough for
some calls, especially in larger Penpot files. #10953
2026-07-30 14:21:42 +02:00
Dominik Jain b4659df5b2 🐛 Preserve established plugin connection when rejecting a duplicate #10961
In multi-user mode, rejecting a second plugin WebSocket connection for an
already-registered user token performed the full removeConnection cleanup
for the newcomer. Since the token-keyed cleanup is keyed by token rather
than by socket, this deleted the clientsByToken entry and the Redis
request-channel subscription of the established, healthy connection. That
connection then remained open and heartbeating but was unroutable, so every
subsequent MCP tool call for the user failed although a valid plugin
connection existed.

removeConnection now performs the token-keyed cleanup only if the removed
connection actually owns the token registration, so rejecting a duplicate
releases only the resources the newcomer itself registered.

AI-assisted-by: claude-fable-5
2026-07-30 14:21:42 +02:00
Dominik Jain 1ae9334064 🐛 Fail fast on Redis task dispatch when no instance is connected #10958
In multi-user mode, plugin task requests are published to a Redis channel
keyed by user token. When no MCP server instance held a plugin connection
for that token (e.g. after the user navigated away from the workspace),
the publish reached zero subscribers and the request was silently dropped,
so every tool call stalled until the 30-second task timeout instead of
failing with a meaningful error.

RedisBridge.sendTaskRequest now returns the PUBLISH receiver count and
releases its response-channel subscription when the request reached no
receiver (or publishing failed), since no response can arrive. PluginBridge
uses the count to reject the pending task immediately with the multi-user
connection error message; publish failures likewise reject the task instead
of surfacing as an unhandled rejection followed by a timeout. The pending-
task settlement logic shared with the timeout handler is extracted into a
rejectPendingTask helper.

AI-assisted-by: claude-fable-5
2026-07-30 14:21:42 +02:00
Andrey Antukh ef593514f2 🐛 Add nil guards on viewport-node in pixel overlay component (#10812)
* 🐛 Add nil guards on viewport-node in pixel overlay component

Add nil checks for viewport-node in process-pointer-move, viewport->canvas-coords, process-pointer-move-wasm, pick-color-at-wasm, and handle-draw-picker-canvas.

Fixes a crash ("can't access property 'getBoundingClientRect', ... is null")
when the viewport DOM node is unmounted while the color picker eyedropper
is active and pointer move events are still firing.

Fixes #10811

AI-assisted-by: mimo-v2.5

* 🐛 Remove unused app.common.pprint require from errors.cljs

Fixes clj-kondo warning: namespace app.common.pprint is required but never used.

AI-assisted-by: mimo-v2.5
2026-07-30 12:50:46 +02:00
Andrey Antukh 040080749b 🐛 Fix shape export failures when export name is nil or empty (#10852)
Use cuerdas blank-name handling directly when normalizing frontend export
payloads. Replace nil or blank export names with the object-id string in
request-simple-export, request-multiple-export, clipboard export, and plugin
direct export payloads so that the backend always receives a valid name. Add
focused frontend tests for nil/blank name normalization and normalized request
params.

AI-assisted-by: nex-n2-pro
2026-07-30 12:49:19 +02:00
Andrey Antukh fadb3124a0 🐛 Clamp gradient stop offsets to valid range (#10881)
* 🐛 Clamp gradient stop offsets to valid [0, 1] range

Fixed a bug where gradient stop offsets outside the valid [0, 1] range were being sent to the server, causing schema validation errors ('invalid shape found').

Changes:
- Viewport gradient handler: clamp offset in points-on-pointer-down before creating new stops
- Colorpicker gradient preview: clamp offset in handle-preview-down before adding stops
- Data layer: clamp offset parameter in update-colorpicker-add-stop and all stop offsets in update-colorpicker-stops; added app.common.math require

All clamping follows the existing pattern used in handle-marker-pointer-move.

AI-assisted-by: deepseek-v4-flash

* 💄 Fix formatting in gradient handlers

Fix cljfmt formatting issues in gradient handler functions.

AI-assisted-by: qwen3.7-plus
2026-07-30 12:49:00 +02:00
Andrey Antukh 25618febcd Merge remote-tracking branch 'origin/main' into staging 2026-07-30 08:46:52 +02:00
Andrey Antukh 5e5465a0fe 🐛 Fix audit event validation for error reports with string profile-id (#10898)
The audit event validation was failing when processing error reports that
contain string profile-id values. The error report storage converts
profile-id to string format, but the audit schema expects a UUID.

Changes:
- Modified prepare-rpc-event to convert string profile-id to UUID using
  uuid/parse* (exception-safe parsing)
- Updated access token middleware to set ::id and ::type on request so
  audit context includes token identification
- Added tests for profile-id conversion and token context population

Closes #10897

AI-assisted-by: qwen3.7-plus
2026-07-30 07:32:02 +02:00
Andrey Antukh adda7e6645 📚 Update testing serena memories 2026-07-29 19:55:12 +02:00
Alejandro AlonsoandCursor c8d1f5b397 🐛 Guard finalize-view-interaction! against spurious pointerup events (#10917)
Every pointerup unconditionally fires finish-panning and finish-zooming,
which call finalize-view-interaction!. This triggered internal-render
(and reset_canvas) on plain clicks — causing a visible white flash on
large viewports or weak GPUs.

Add a guard so finalize-view-interaction! only runs when a view
interaction (pan/zoom) is actually active.

Fixes #10915

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:43:29 +02:00
Andrey Antukh bbc7e9bee9 Add a script for run ci-like tasks 2026-07-28 12:47:33 +02:00
Andrey Antukh 4b994d20aa 🐛 Fix leaked focus timers in dashboard sidebar navigation (#10715)
* 🐛 Fix leaked deferred DOM ops on dashboard navigation and template clone

The React reconciliation "removeChild" error surfaced during rapid
dashboard navigation because several effects scheduled deferred DOM
operations (focus, CSS positioning) without returning a cleanup that
cancelled them. When the component unmounted before the callback
fired, it ran against stale DOM and desynchronized React fiber tree
from the actual DOM.

- context_menu_a11y.cljs: replace tm/schedule-on-idle (30s idle
  window) with tm/schedule (setTimeout 0) and return a rx/dispose!
  cleanup.
- dropdown.cljs: capture the tm/schedule handle and dispose it in
  the effect cleanup.
- tooltip.cljs: capture the ts/raf handle and cancel it on cleanup.

AI-assisted-by: opencode-go/mimo-v2.5-pro

* 🐛 Fix leaked focus timers in dashboard sidebar navigation

Six sidebar navigation handlers scheduled setTimeout callbacks to mutate
tabindex/focus on React-managed title elements without cancelling prior
pending callbacks. During rapid keyboard navigation (Projects→Fonts→Libs→Drafts)
the stale callbacks fired against unmounted DOM, desyncing React fiber tree
and triggering "removeChild" NotFoundError.

- sidebar-project*: cancel prior timer in on-key-down
- sidebar-search*: cancel prior timer in on-key-press
- sidebar-content*: cancel prior timer in go-projects-with-key,
  go-fonts-with-key, go-drafts-with-key, go-libs-with-key

Each handler now stores the timer handle in a component-level ref and
disposes any pending handle before scheduling a new one.

AI-assisted-by: opencode-go/mimo-v2.5-pro

* ♻️ Refactor sidebar focus timer handling into helpers

Extract the repeated dispose-before-schedule focus idiom into
schedule-focus-by-id! (sidebar.cljs) and focus-and-untabbable!
(app.util.dom). Replaces the six duplicated blocks and adds
mf/use-effect unmount cleanup to dispose any pending timer in the
three sidebar components, closing the remaining leak noted in the
original fix.

AI-assisted-by: opencode/hy3-free

* ♻️ Extract use-focus-timer-ref hook for sidebar components

Replace the duplicated mf/use-ref + mf/use-effect cleanup pairs in
sidebar-project*, sidebar-search*, and sidebar-content* with a shared
use-focus-timer-ref hook (app.main.ui.hooks). The hook creates the ref
and disposes any pending timer on unmount via mf/with-effect, reading the
ref with mf/ref-val instead of deref. mf/use-effect is now a body-level
hook call rather than a let binding.

AI-assisted-by: opencode/hy3-free

* 📎 Add pr feedback fix
2026-07-28 11:06:00 +02:00
Andrey Antukh 6c2b61e1ad 🐛 Fix several issues in RPC command handlers (#10670)
* 🐛 Fix several issues in RPC command handlers

- Reject circular library references in link-file-to-library
- Add explicit team permission check in search-files
- Constrain search-term max length to 250 chars
- Include :deleted-at in file ETag for COND caching
- Move storage I/O outside DB transaction in create-file-thumbnail

AI-assisted-by: deepseek-v4-pro

* 📎 Check perms before circular link checks

* 🐛 Handle circular library reference error

Catch :circular-library-reference error from backend when linking
files to libraries. Show user-friendly toast notification instead of
propagating unhandled error. Add English and Spanish translations.

AI-assisted-by: qwen3.7-plus
2026-07-28 10:59:16 +02:00
Andrey Antukh 458fa41036 Merge remote-tracking branch 'origin/main' into staging 2026-07-28 10:42:03 +02:00
Alejandro Alonso 6063a45c3c 🐛 Fix area selection aborted by select-shapes interrupt (#10870)
* 🐛 Fix area selection aborted by select-shapes interrupt

Only emit :interrupt from select-shapes when edition mode is active.
Unconditional :interrupt (from #10798) made drag-stopper cancel the
marquee mid-drag.

* 🔧 Fix text editor v2 fill e2e test on develop
2026-07-28 10:26:31 +02:00
Andrey Antukh 63e0c536f0 Align event names column in format-last-events output
The third column (event name) in error report "last events" now starts at
a consistent position regardless of the delta value, by right-padding the
delta string to 10 characters. The first event always shows (+0ms).

Adds tests for empty, single, multi-event, and column alignment cases.

AI-assisted-by: deepseek-v4-flash
2026-07-27 13:24:11 +00:00
Andrey Antukh af120feb1f 🐛 Fix workspace crash and cleanup viewport_ref event/resize handling (#10721)
Replace `globals/document` and `globals/window` with
`js/document` and `js/window` in workspace.cljs, removing
the unused `app.util.globals` import. This avoids "can't
access dead object" errors in Firefox when navigating between
pages/files, matching the existing pattern used in
viewport/hooks.cljs.

Fix a leaked MOUSELEAVE listener in viewport_ref.cljs — the
ref callback added a new listener on every mount but never
unregistered the previous one. Now uses standard
.addEventListener/.removeEventListener with a React ref to
track the handler for proper cleanup.

Fix ResizeObserver cleanup in viewport_ref.cljs —
`init-observer` is now a private function that only creates
an observer when a node is provided, and cleanup is handled
via the ref callback on unmount.

AI-assisted-by: mimo-v2.5-pro
2026-07-27 12:11:12 +02:00
Andrey Antukh 5a0cee44b1 🐛 Harden frontend .getData call sites against undefined receivers (#10718)
* 🐛 Fix nil getData crash dropping ZIP without manifest.json

Add nil-guard in read-as-text to raise typed :invalid-entry error instead of calling (.getData nil writer) which produced a raw TypeError.

Made read-zip-manifest public (was defn-) with explicit detection of missing manifest.json, raising typed :invalid-penpot-file validation error. The existing catch path surfaces this hint as a friendly user error instead of the raw TypeError text.

Add regression tests for both paths. 374 users were affected, 704 occurrences across 2.17.0-RC2/RC3/RC4.

Fixes #10709.

AI-assisted-by: minimax-m3

* 🐛 Harden dnd/get-data against missing dataTransfer

When the sortable hook or any caller passes a synthetic event
without a dataTransfer property (e.g. a dragend fired after a drop
that has already cleared the transfer), the previous implementation
called .getData directly on the nil/undefined result and threw
"Cannot read properties of undefined (reading 'getData')".

Wrap the body in when-let so get-data returns nil cleanly when
dataTransfer is missing. All three current callers
(hooks.cljs:164, viewport/actions.cljs:531 and :562) already treat
the return value as optional via when-let / when, so no caller
breaks.

Add a regression test covering both the missing-dataTransfer case
and a real dataTransfer roundtrip.

AI-assisted-by: minimax-m3

* 🐛 Harden paste handler against missing clipboardData in forms

When a paste event arrives without a clipboardData property (e.g.
a programmatically dispatched ClipboardEvent in some browsers, or
edge cases like dragging a file with no text content), the previous
implementation called .getData directly on the nil/undefined
clipboardData and threw "Cannot read properties of undefined
(reading 'getData')".

Wrap the body in when-let so the paste logic is skipped entirely
when clipboardData is missing. The existing (string? paste-data)
guard in the inner when already tolerates nil; no other caller
behavior changes.

AI-assisted-by: minimax-m3

* 🐛 Harden paste handler against missing clipboardData in components/forms

Same defensive pattern as the main/ui/forms.cljs paste handler: wrap
the body in when-let so the .getData call is skipped when the
clipboardData property is missing on the paste event. Prevents the
raw "Cannot read properties of undefined (reading 'getData')"
TypeError for programmatic / edge-case paste events.

AI-assisted-by: minimax-m3

* 🐛 Harden v3 text editor paste and styles-fn against undefined receivers

Two related fixes for the "Cannot read properties of undefined
(reading 'getData')" family of bugs in the workspace text editor:

- v3_editor.cljs: wrap the paste body in when-let on clipboardData
  so .getData("text/plain") is never called on a nil receiver. The
  existing (when (and text (seq text))) guard already tolerates nil
  text; only the outer .getData call was unprotected.

- editor.cljs: add (and content ...) to the if branch in styles-fn
  so .getText and .getData are never called on a nil content. The
  else branch (legacy.txt/styles-to-attrs) is already the correct
  fallback for missing content.

Add a regression test that mirrors the fixed patterns and verifies
they no longer throw on synthetic events with no clipboardData or
nil content.

AI-assisted-by: minimax-m3

* 🐛 Harden get-editor-block-data and get-editor-block-type against nil block

getCurrentBlock from Draft.js can return undefined for an empty
selection (e.g. before any block is created). The previous
implementations called .getData / .getType directly on the result
and threw "Cannot read properties of undefined (reading
'getData')" / "...reading 'getType')".

Wrap both functions in (when (some? block) ...) so they return nil
cleanly. Callers in editor.cljs and text_editor.cljs already handle
nil results (render-block short-circuits via the case on type; the
text-data caller in text_editor.cljs lets nil flow up), so no
upstream change is required.

Add a regression test covering both functions with nil and
js/undefined input.

AI-assisted-by: minimax-m3

* 🐛 Harden draft-js block-data helpers against nil block

Three related fixes in the vendored draft-js package:

- mergeBlockData: early-return undefined when block is falsy.
  Without this, the first line (block.getData()) throws for callers
  that pass a nil block.

- splitBlockPreservingData: guard the blockMap.get(...) lookup. If
  the start key is stale (e.g. after a Modifier.splitBlock that
  doesn't actually produce the expected key), .get() returns
  undefined and the subsequent .getData() throws. Fall back to an
  empty Immutable Map for the block data.

- updateBlockData: short-circuit (return state unchanged) when
  mergeBlockData returns undefined. Without this, the chain
  newBlock.getData() would throw on the same nil-block case that
  mergeBlockData now guards.

These match the defensive nil-handling pattern used elsewhere in
the frontend (.getData callers) and protect against stale
selection keys in the Draft.js content state.

AI-assisted-by: minimax-m3
2026-07-27 10:56:27 +02:00
Andrey Antukh aad9bc8f65 📎 Fix playwright version 2026-07-27 10:45:59 +02:00
Andrey Antukh 0b072b22b0 📚 Update changelog 2026-07-27 09:01:43 +02:00
Andrey Antukh 170d129a5a Merge remote-tracking branch 'origin/staging' 2026-07-27 08:52:50 +02:00
Andrey Antukh eda5aa76f0 Merge remote-tracking branch 'origin/main' into staging 2026-07-27 08:52:31 +02:00
Andrey Antukh b54c1f316a Add minor improvements for error report script 2026-07-25 09:56:38 +02:00
Eva Marco d94b139071 🐛 Fix text edition state when relesecting (#10798)
* 🐛 Fix text edition state when relesecting

* 🐛 Fix CI
2026-07-24 12:15:16 +02:00
Andrey Antukh d5d6c61ba4 💄 Remove unused app.common.pprint import in errors.cljs
AI-assisted-by: deepseek-v4-flash
2026-07-23 15:05:18 +00:00
Andrey Antukh f7c312021b Improve error-reports CLI with streaming, time-range, and stats
Server changes:
- Switch list ordering from DESC to ASC (oldest first)
- Flip cursor direction to > for forward pagination
- Add 'until' param for server-side upper-bound filtering

CLI changes:
- Add --from/--to flags mapping to server's since/until
- Streaming output for --all and --format ndjson
- Add --format ndjson option (one JSON object per line)
- Add --normalize-hints flag to strip dynamic values
- Add --output flag to write list results to file
- Add 'stats' subcommand with aggregations (signature, host,
  tenant, version, source, kind, hour) reading from API, file, stdin
- stats input supports JSON, JSON array, and NDJSON formats

Test changes:
- Fix pagination assertions for ASC ordering

AI-assisted-by: mimo-v2.5-pro
2026-07-23 13:35:09 +00:00
Andrey Antukh 45405a018b 📎 Add minor changes on error reports 2026-07-23 13:06:46 +02:00
Alejandro Alonso 8bf411c347 🐛 Fix viewer wasm position data init (#10805) 2026-07-23 12:58:20 +02:00
Andrey Antukh 9b88d35664 Merge remote-tracking branch 'origin/main' into staging 2026-07-23 11:02:56 +02:00
Andrey Antukh e4d88b3ab4 🐛 Show proper version on error report api 2026-07-23 11:02:27 +02:00
Andrey Antukh 66b978c01e Add wall-clock timestamps to last-events buffer
Each event entry in `last-events` is now wrapped as
`{:name <event-type> :t (app.common.time/now)}` so every event carries a
wall-clock timestamp. A new helper `format-last-events` renders the
buffer as a multi-line string with ISO time and delta-since-previous-
event in ms, replacing the previous pprint dump in error reports.

This lets support/devs tell whether the events leading up to a crash
were spaced out (user action) or jammed together (runaway loop).

AI-assisted-by: minimax-m3
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-07-23 10:50:06 +02:00
Andrey Antukh 52e5e0bec6 🐛 Add more fields on table format on errors report cli client 2026-07-23 10:46:17 +02:00
Andrey Antukh 7430ffe718 ⬆️ Update opencode on devenv 2026-07-23 10:46:05 +02:00
Andrey Antukh b3091399cd Add timestamp to the frontend error report (#10772) 2026-07-23 10:26:41 +02:00
Andrey Antukh b6629c0034 🐛 Mark non-Penpot zip files as unknown in import worker (#10782)
Non-export zip files were tagged as :legacy-zip with the body attached,
causing downstream parsing to crash on unrecognized zip content. Now
they are marked :unknown, matching how other unrecognized formats are
handled, so the import fails gracefully.

AI-assisted-by: deepseek-v4-flash
2026-07-23 10:26:22 +02:00
106 changed files with 3862 additions and 901 deletions

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
name: "CI: Exporter"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'exporter/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'exporter/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-exporter:
if: ${{ !github.event.pull_request.draft }}
name: "Exporter Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./exporter
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run lint:clj
- name: Tests
working-directory: ./exporter
run: |
./scripts/test
+1
View File
@@ -101,5 +101,6 @@ opencode.json
/.opencode/plans
/.opencode/reports
/.opencode/prompts
/.ci-logs
/.codex/
/tools/__pycache__
@@ -212,6 +212,37 @@ superseded it:
Replace the reference in the changelog entry with the correct merged PR number.
### 5b. Security advisory (GHSA) entries
Security advisories fixed in a release are documented in the changelog even
though they are **neither milestone issues nor PRs**. The GHSA ID and its
description are supplied by the user or the release notes — they never come
from the milestone fetch in step 2.
**Format** (matches the existing precedent in `CHANGES.md`, e.g. the
`create-font-variant` arbitrary file read advisory):
```markdown
- Fix <user-facing description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
```
Rules:
- Place the entry under `### :bug: Bugs fixed`, with **no issue or PR link**
only the advisory URL.
- The advisory may be **draft/unpublished** at changelog time (the URL 404s
publicly). Do **not** web-fetch or verify the URL, and do **not** drop the
entry because of that. Rely on the GHSA ID provided by the user.
- Derive the description from the supplied advisory title, imperative mood and
user-facing (e.g. `Fix command injection in SVG exporter via legacy fill-color`).
- These entries are **invisible to the automation**: they are not returned by
`gh.py issues`, not matched by `--compare` (step 3), not part of the PR
cross-reference (step 10), and not scanned by the anomaly-report regexes
(step 11, which only match `issues/` and `pull/` links). Add them manually.
- During pre-flight checks (step 6a) apply only the **backport/duplicate**
check: if the same GHSA already appears in an earlier version section, remove
it from the current section. Their absence from milestone cross-references
is expected, not an anomaly.
### 6. Read the current CHANGES.md
Read the top of `CHANGES.md` to understand the existing format and find the
@@ -400,6 +431,8 @@ if closed:
- ✅ Every merged milestone PR is either in the changelog or excluded by label
- ✅ PR and issue counts are internally consistent
- ✅ No false-positive PR-to-issue associations
- ✅ Advisory (GHSA) entries are not milestone PRs — their absence from the
cross-reference is intentional (see step 5b)
## Version section template
@@ -410,8 +443,12 @@ if closed:
- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- <fix description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
```
Advisory (GHSA) entries have no issue or PR link — just the advisory URL. See
step 5b.
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
After all edits and cross-referencing are complete, generate a structured
@@ -732,6 +769,14 @@ self-contained and clickable in any Markdown viewer.
Taiga description text or by searching GitHub PRs that reference the Taiga
URL. Replace the Taiga reference with the GitHub issue link and add the PR
reference if applicable.
- **Security advisory (GHSA) entries.** Advisories fixed in the release are
listed under `### :bug: Bugs fixed` with the advisory URL and **no issue or
PR link**, even though they are not in the milestone. The GHSA ID and
description come from the user — do **not** fetch or verify the URL, and do
not drop a draft (unpublished) advisory. Precedent:
`- Fix arbitrary file read security issue on create-font-variant rpc method
(https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)`.
See step 5b.
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
before making edits, don't rely on cached data.
- **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for
+3 -2
View File
@@ -5,9 +5,10 @@
## Layout and commands
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`.
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Exporter test conventions and CI: `mem:exporter/testing`.
## HTTP and browser pool
@@ -31,4 +32,4 @@
- WebP is produced by taking a PNG screenshot and converting it with ImageMagick.
- SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths.
- PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers.
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
+16
View File
@@ -0,0 +1,16 @@
# Exporter Testing
- READ `mem:testing` first.
- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`.
- Register every test namespace in `exporter-tests.runner`.
- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests.
- From `exporter/`: `pnpm run test` builds and runs tests with full output.
- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output.
- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`.
- For iterative focused runs, build once and reuse the compiled bundle.
- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`.
- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`.
- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`).
- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs.
- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting.
- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting.
+106 -45
View File
@@ -6,7 +6,8 @@
- Querying error reports from the database for debugging or analysis
- Filtering errors by source, kind, tenant, or backend version
- Exporting error data in JSON or table format
- Exporting error data in JSON, NDJSON, or table format
- Computing error statistics (top signatures, version, source, audit-log kind, hourly distribution, bursts, heatmap)
- Investigating specific error reports by ID
## Prerequisites
@@ -52,19 +53,25 @@ WHERE id = '<token-uuid>';
| Flag | Description | Default |
|------|-------------|---------|
| `-l, --limit <n>` | Max items per page (max: 200) | `50` |
| `--since <date>` | ISO timestamp (fetch errors before this date) | — |
| `--since-id <uuid>` | Fetch errors before this ID (cursor pagination) | — |
| `--from <date>` | ISO timestamp — oldest boundary (items after this) | — |
| `--to <date>` | ISO timestamp — newest boundary (items before this) | — |
| `--since <date>` | ISO timestamp — explicit cursor for manual pagination | — |
| `--since-id <uuid>` | Fetch errors after this ID (cursor pagination) | — |
| `-s, --source <name>` | Filter by source (see source names below) | — |
| `-p, --profile-id <uuid>` | Filter by profile ID | — |
| `-k, --kind <kind>` | Filter by kind (string) | — |
| `-t, --tenant <tenant>` | Filter by tenant (string) | — |
| `--version <version>` | Filter by version | — |
| `--hint <text>` | Filter by hint (ILIKE match) | — |
| `-a, --all` | Fetch all pages automatically | `false` |
| `-f, --format <type>` | Output format: `json` or `table` | `json` |
| `-a, --all` | Fetch all pages automatically (streams output) | `false` |
| `-f, --format <type>` | Output format: `json`, `table`, or `ndjson` | `table` |
| `--normalize-hints` | Normalize hints by stripping dynamic values | `false` |
| `-o, --output <file>` | Write output to file instead of stdout | — |
| `--env <path>` | Custom .env file path | `.env` |
| `-h, --help` | Show help message | — |
**Streaming behavior:** With `--all`, output must be `ndjson` or `table`; `--all --format json` is rejected because `--all` streams output. `--all --format table` prints rows immediately. `--format ndjson` always streams one JSON object per line.
#### `get` - Get a single error report by ID
```bash
@@ -77,10 +84,31 @@ WHERE id = '<token-uuid>';
|------|-------------|----------|
| `--id <uuid>` | Error report ID | Yes (or --error-id) |
| `--error-id <id>` | Error report error-id | Yes (or --id) |
| `-f, --format <type>` | Output format: `json` or `table` | No (default: `json`) |
| `-f, --format <type>` | Output format: `json` or `table` | No (default: `table`) |
| `--env <path>` | Custom .env file path | No (default: `.env`) |
| `-h, --help` | Show help message | No |
#### `stats` - Compute error report statistics
```bash
./scripts/error-reports.mjs stats [options]
```
Reads from `--input <file>`, stdin (piped), or fetches from API. Computes aggregations by signature, version, source, audit-log kind, hour, optional 5-minute bursts, and optional day-of-week × hour heatmap.
**Options:**
| Flag | Description | Default |
|------|-------------|---------|
| `--from <date>` | Start of interval (ISO timestamp) | — |
| `--to <date>` | End of interval (ISO timestamp) | — |
| `--limit <n>` | Items per page when fetching from API | `200` |
| `--input <file>` | Read from local JSON/NDJSON file instead of API | — |
| `--burst` | Detect 5-minute windows above 3× the average rate | `false` |
| `--heatmap` | Show day-of-week × hour-of-day heatmap | `false` |
| `-f, --format <type>` | Output format: `json` or `table` | `table` |
| `--env <path>` | Custom .env file path | `.env` |
## Source Names
The `--source` filter accepts these values:
@@ -89,6 +117,17 @@ The `--source` filter accepts these values:
- `audit-log`
- `rlimit`
## Hint Normalization
With `--normalize-hints` (or always in `stats`), hints are normalized by stripping dynamic values:
1. File IDs in file-id context → `<file-id>`
2. UUIDs (8-4-4-4-12 hex) → `<uuid>`
3. Numeric IDs in parentheses `(12345)``(<id>)`
4. Elapsed times (`7.5s`, `2m3.027s`) → `<elapsed>`
5. URIs (`https://...`) → `<uri>`
6. Unicode quotes and whitespace normalized
## Examples
### List recent errors
@@ -96,6 +135,22 @@ The `--source` filter accepts these values:
./scripts/error-reports.mjs list --limit 10
```
### Time-range query (today)
```bash
./scripts/error-reports.mjs list --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --all
```
### Stream all errors as NDJSON
```bash
./scripts/error-reports.mjs list --all --format ndjson > errors.ndjson
```
### Save to file with --output
```bash
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
./scripts/error-reports.mjs list --format json -o errors.json
```
### Filter by source
```bash
./scripts/error-reports.mjs list --source audit-log --limit 20
@@ -123,7 +178,7 @@ The `--source` filter accepts these values:
### Fetch all errors with pagination
```bash
./scripts/error-reports.mjs list --all --format json
./scripts/error-reports.mjs list --all
```
### Get specific error by ID
@@ -141,46 +196,36 @@ The `--source` filter accepts these values:
./scripts/error-reports.mjs list --source audit-log --kind exception-page --tenant production --limit 50
```
### Stats with burst and heatmap analysis
```bash
./scripts/error-reports.mjs stats --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --burst --heatmap
```
### Stats from file
```bash
./scripts/error-reports.mjs stats --input errors.json
```
### Stats from pipe
```bash
./scripts/error-reports.mjs list --all --format json | ./scripts/error-reports.mjs stats
```
## Output Formats
### Table (default)
Human-readable table format for terminal display:
```
Found 15 error reports
ID | Created At | Source | Profile ID | Kind | Hint
-------------------------------------+---------------------+-----------+--------------------------------------+----------------+------------------
550e8400-e29b-41d4-a716-446655440000 | 2026-01-20 10:30:00 | audit-log | e98bb95f-573d-8137-8008-252580aa456d | exception-page | Error description
abc12345-e29b-41d4-a716-446655440001 | 2026-01-20 10:29:00 | logging | - | error | Another error that is very long and ne...
More results: use --since 2026-01-20T10:28:00Z --since-id def45678-e29b-41d4-a716-446655440002
```
Human-readable table format for terminal display. With `--all`, rows stream as they arrive.
### JSON
Returns structured JSON with error details and pagination metadata:
Single page: `{items: [...], nextSince, nextId}`. `--all` cannot be combined with `--format json`; use `--format ndjson` for streaming.
```json
{
"items": [
{
"id": "uuid",
"createdAt": "2026-01-20T10:30:00Z",
"source": "audit-log",
"profileId": "e98bb95f-573d-8137-8008-252580aa456d",
"kind": "exception-page",
"tenant": "production",
"version": "2.1.0",
"hint": "Error description"
}
],
"nextSince": "2026-01-20T10:29:00Z",
"nextId": "next-uuid"
}
```
### NDJSON
One JSON object per line, always streaming. Pipe-friendly: `| jq -c '.hint'`, `| wc -l`.
## Pagination
The server returns items in **ascending** order (oldest first). Cursor pagination uses `--since` / `--since-id` to fetch the next page of newer items.
### Manual pagination
Use `--since` and `--since-id` with values from `nextSince` and `nextId` in the response:
@@ -191,20 +236,28 @@ Use `--since` and `--since-id` with values from `nextSince` and `nextId` in the
```
### Automatic pagination
Use `--all` to fetch all pages automatically:
Use `--all` to fetch all pages automatically (streams output):
```bash
./scripts/error-reports.mjs list --all
```
### Time-range queries
Use `--from` and `--to` to bound the query. These map to the server's `--since` and `--until` parameters:
```bash
./scripts/error-reports.mjs list --from 2026-07-20T00:00:00Z --to 2026-07-23T23:59:59Z --all
```
## Key principles
- **Authentication required** - Uses access token with `error-reports:read` permission
- **API endpoint configurable** - Set via `PENPOT_API_URI` in `.env` file
- **Table is default format** - Use `--format json` for structured JSON output
- **Pagination is automatic with --all** - Fetches all pages without manual cursor management
- **Table is default format** - Use `--format json` for structured JSON, `--format ndjson` for streaming
- **Streaming with --all** - Items print as they arrive, no buffering. Use `--format ndjson` or `--format table`; `--all --format json` is rejected.
- **Filters are combinable** - All filter options can be used together
- **Both flag formats supported** - `--option=value` and `--option value` both work
- **Ascending order** - Server returns oldest items first (changed from DESC)
## Error handling
@@ -217,12 +270,20 @@ The tool provides helpful error messages for common issues:
## Integration with other scripts
- **jq**: Pipe JSON output to `jq` for further processing
- **jq**: Pipe NDJSON output to `jq` for further processing
```bash
./scripts/error-reports.mjs list --all --format json | jq '.items[] | {id, kind, hint}'
./scripts/error-reports.mjs list --all --format ndjson | jq -c '{id, hint}'
```
- **stats from pipe**: Fetch data once, compute stats
```bash
./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats
```
- **stats from NDJSON pipe**: Works with NDJSON format too
```bash
./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats
```
- **grep/search**: Filter output by specific patterns
- **Redirect**: Save output to files for analysis
- **--output**: Save to file without shell redirection
```bash
./scripts/error-reports.mjs list --all --format json > errors.json
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
```
+19 -4
View File
@@ -137,17 +137,32 @@ E2E tests should not be added unless explicitly requested.
## Execution discipline
When running CLJS/JS tests (frontend, common):
**CRITICAL: Test output handling rules**
When running ANY test command (CLJS/JS or JVM):
1. **NEVER pipe test output directly to `| head`, `| tail`, `| grep`, or similar filters** — this can hide failures and cause you to miss critical errors.
2. **ALWAYS pipe to a file first, then read the file:**
```bash
# CORRECT:
pnpm run test 2>&1 > /tmp/test-output.txt
grep -A 5 "failures" /tmp/test-output.txt
# WRONG:
pnpm run test 2>&1 | tail -20
pnpm run test 2>&1 | grep "failures"
```
3. **Use `--focus` to narrow test scope** instead of filtering output.
4. **Read the full output file** to understand test results completely.
When running CLJS/JS tests (frontend, common):
- **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output.
- **Never pipe test output through `tail`, `head`, or similar filters** — doing so can silently hide test failures. Use `--focus` to narrow scope instead.
- **If you need to filter output, tee to a temp file first:** `pnpm run test:quiet 2>&1 | tee /tmp/penpot-test-output.txt`. The full output is preserved on disk so you can `grep`/`tail`/`head` the file without re-running.
- Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs).
- After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
When running JVM tests (backend, common):
- Use `clojure -M:dev:test` directly (no pnpm wrapper).
- The same no-piping rule applies: use `--focus` to narrow scope.
- Same file-piping rule applies.
## Verification Checklist
+1 -1
View File
@@ -30,7 +30,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
Include concise sections covering:
- what changed and why;
- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- screenshots or recordings for UI-visible changes;
- testing performed and residual risk;
- breaking changes or migration notes, if any.
+1
View File
@@ -109,4 +109,5 @@ precision while maintaining a strong focus on maintainability and performance.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
+36
View File
@@ -1,5 +1,39 @@
# CHANGELOG
## 2.17.2
### :bug: Bugs fixed
- Fix linear gradients in SVG text exports being emitted as radial gradients [#5972](https://github.com/penpot/penpot/issues/5972) (PR: [#11272](https://github.com/penpot/penpot/pull/11272))
- Fix typography token becoming detached when editing text content [#11362](https://github.com/penpot/penpot/issues/11362) (PR: [#11366](https://github.com/penpot/penpot/pull/11366))
- Fix command injection in SVG exporter via legacy fill-color (https://github.com/penpot/penpot/security/advisories/GHSA-4f36-m4hj-cv86)
## 2.17.1
### :bug: Bugs fixed
- Fix overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619))
- Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645))
- Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655))
- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778))
- Fix frontend throwing raw TypeError on undefined .getData receivers across import, paste, drag, and text editor paths [#10709](https://github.com/penpot/penpot/issues/10709) (PR: [#10718](https://github.com/penpot/penpot/pull/10718))
- Fix workspace crash with 'can't access dead object' in Firefox when navigating between pages [#10719](https://github.com/penpot/penpot/issues/10719) (PR: [#10721](https://github.com/penpot/penpot/pull/10721))
- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736))
- Fix dashboard sidebar throwing removeChild NotFoundError during rapid keyboard navigation [#10714](https://github.com/penpot/penpot/issues/10714) (PR: [#10715](https://github.com/penpot/penpot/pull/10715))
- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777))
- Fix import worker crashing when importing non-Penpot zip files [#10781](https://github.com/penpot/penpot/issues/10781) (PR: [#10782](https://github.com/penpot/penpot/pull/10782))
- Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805))
- Fix backend returning 500 when JSON request body has unrecognized escape sequence [#10804](https://github.com/penpot/penpot/issues/10804) (PR: [#10808](https://github.com/penpot/penpot/pull/10808))
- Fix color picker eyedropper crashing when viewport is unmounted during pointer move [#10811](https://github.com/penpot/penpot/issues/10811) (PR: [#10812](https://github.com/penpot/penpot/pull/10812))
- Fix flex layout crash when dragging shapes with missing bounds [#10843](https://github.com/penpot/penpot/issues/10843) (PR: [#10845](https://github.com/penpot/penpot/pull/10845))
- Fix export failing when shape has blank layer name [#10849](https://github.com/penpot/penpot/issues/10849) (PR: [#10852](https://github.com/penpot/penpot/pull/10852))
- Fix area selection (marquee) being aborted by select-shapes interrupt [#10872](https://github.com/penpot/penpot/issues/10872) (PR: [#10870](https://github.com/penpot/penpot/pull/10870))
- Fix gradient editor sending invalid stop offset when clicking outside gradient line [#10879](https://github.com/penpot/penpot/issues/10879) (PR: [#10881](https://github.com/penpot/penpot/pull/10881))
- Fix audit event validation failing when error reports contain string profile-id and missing token context [#10897](https://github.com/penpot/penpot/issues/10897) (PR: [#10898](https://github.com/penpot/penpot/pull/10898))
- Fix MCP tool call timeout being too low for some operations [#10953](https://github.com/penpot/penpot/issues/10953) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
- Fix MCP requests running into timeouts after leaving a file in Penpot [#10958](https://github.com/penpot/penpot/issues/10958) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
- Fix duplicate WebSocket MCP connection attempts deregistering the original connection's routing entries [#10961](https://github.com/penpot/penpot/issues/10961) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
## 2.17.0
### :rocket: Epics and highlights
@@ -43,6 +77,8 @@
### :bug: Bugs fixed
- Fix Plugin API variant creation failing due to undocumented multi-step workflow [#10075](https://github.com/penpot/penpot/issues/10075) (PR: [#10149](https://github.com/penpot/penpot/pull/10149))
- Fix workspace crash when editing text shapes with degenerate selrect [#10617](https://github.com/penpot/penpot/issues/10617) (PR: [#10618](https://github.com/penpot/penpot/pull/10618))
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @davidv399) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
+8 -3
View File
@@ -24,7 +24,7 @@
:cause cause))))
(def sql:get-token-data
"SELECT perms, profile_id, expires_at
"SELECT perms, profile_id, expires_at, type
FROM access_token
WHERE id = ?
AND (expires_at IS NULL
@@ -42,14 +42,19 @@
(fn [request]
(let [{:keys [type claims]} (get request ::http/auth-data)]
(if (= :token type)
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
(let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool))
token-id (get claims :tid)]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at))))
(assoc ::expires-at expires-at)
(some? token-id)
(assoc ::id token-id)
(some? type)
(assoc ::type type))))
(handler request)))))
+1 -1
View File
@@ -31,7 +31,7 @@
(assoc :request/user-agent (yreq/get-header request "user-agent"))
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data auth)
(assoc :request/auth-data (dissoc auth :token))
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error
+22 -4
View File
@@ -65,12 +65,25 @@
:else
request)))
;; The specific-exception branches below (IAE,
;; RequestTooBigException, EOFException) raise with
;; `ex/raise` rather than calling `errors/handle` directly.
;; This is intentional: the throw is caught by the
;; top-level error handler in `app.http/router-handler`
;; (`backend/src/app/http.clj`), which routes every
;; uncaught exception through `errors/handle`. The
;; per-route `wrap-errors` middleware in the route list
;; is a defensive layer; correctness does not depend on
;; it. Raising here keeps the cond uniform with the
;; existing RequestTooBigException / EOFException
;; branches.
(handle-error [cause request]
(cond
(instance? RuntimeException cause)
(if-let [cause (ex-cause cause)]
(handle-error cause request)
(errors/handle cause request))
(instance? IllegalArgumentException cause)
(ex/raise :type :validation
:code :malformed-json
:hint (ex-message cause)
:cause cause)
(instance? RequestTooBigException cause)
(ex/raise :type :validation
@@ -83,6 +96,11 @@
:hint (ex-message cause)
:cause cause)
(instance? RuntimeException cause)
(if-let [cause (ex-cause cause)]
(handle-error cause request)
(errors/handle cause request))
:else
(errors/handle cause request)))]
+3 -1
View File
@@ -336,7 +336,9 @@
(let [resultm (meta result)
request (-> params meta ::http/request)
profile-id (or (::profile-id resultm)
(:profile-id result)
(some-> (:profile-id result)
(cond-> (string? (:profile-id result))
uuid/parse*))
(::rpc/profile-id params)
uuid/zero)
@@ -68,7 +68,8 @@
[:kind {:optional true} ::sm/text]
[:tenant {:optional true} ::sm/text]
[:version {:optional true} ::sm/text]
[:hint {:optional true} ::sm/text]])
[:hint {:optional true} ::sm/text]
[:until {:optional true} ct/schema:inst]])
(def ^:private schema:get-error-reports-result
[:map
@@ -104,7 +105,7 @@
"FROM server_error_report"))
(defn- build-list-query
[{:keys [since since-id source profile-id kind tenant version hint limit]
[{:keys [since since-id source profile-id kind tenant version hint until limit]
:or {limit default-limit}}]
(let [source-id (when source (name->source source))
clauses (keep identity
@@ -126,14 +127,17 @@
{:where "content->>'~:hint' ILIKE ?"
:params [(str "%" hint "%")]})
(when since
{:where "(created_at, id) > (?::timestamptz, ?::uuid)"
:params [since (or since-id uuid/zero)]})
(when until
{:where "(created_at, id) < (?::timestamptz, ?::uuid)"
:params [since (or since-id uuid/zero)]})])
:params [until uuid/zero]})])
sql-parts (map :where clauses)
sql-params (mapcat :params clauses)
sql (str base-list-sql
(when (seq sql-parts)
(str " WHERE " (str/join " AND " sql-parts)))
" ORDER BY created_at DESC, id DESC"
" ORDER BY created_at ASC, id ASC"
" LIMIT ?")]
(into [sql] (concat sql-params [limit]))))
@@ -174,6 +178,7 @@
(merge content)
(update :source source->name)
(assoc :kind (or (:kind content) (:origin content)))
(assoc :version (:version content))
(d/without-nils)))
(ex/raise :type :not-found
:code :report-not-found
+11 -2
View File
@@ -156,11 +156,13 @@
(assoc mfile :permissions perms)))
(defn get-file-etag
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}]
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}]
(str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
(ct/format-inst modified-at :iso)
"/"
(uri/map->query-string permissions)))
(uri/map->query-string permissions)
"/"
(some-> deleted-at (ct/format-inst :iso))))
(sv/defmethod ::get-file
"Retrieve a file by its ID. Only authenticated users."
@@ -1102,6 +1104,13 @@
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
(when (contains? transitive-deps file-id)
(ex/raise :type :validation
:code :circular-library-reference
:hint "linking this library would create a circular dependency")))
(link-file-to-library conn params)
(bfc/get-libraries cfg [library-id]))
@@ -374,61 +374,6 @@
;; --- MUTATION COMMAND: create-file-thumbnail
(defn- create-file-thumbnail
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id revn props media] :as params}]
(media/validate-media-type! media)
(media/validate-media-size! media)
(let [file (bfc/get-file cfg file-id
:include-deleted? true
:load-data? false)
props (db/tjson (or props {}))
path (:path media)
mtype (:mtype media)
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))
tnow (ct/now)
media (sto/put-object! storage
{::sto/content data
::sto/deduplicate? true
::sto/touched-at tnow
:content-type mtype
:bucket "file-thumbnail"})
thumb (db/get* conn :file-thumbnail
{:file-id file-id
:revn revn}
{::db/remove-deleted false
::sql/for-update true})]
(if (some? thumb)
(do
;; We mark the old media id as touched if it does not match
(when (not= (:id media) (:media-id thumb))
(sto/touch-object! storage (:media-id thumb)))
(db/update! conn :file-thumbnail
{:media-id (:id media)
:deleted-at (:deleted-at file)
:updated-at tnow
:props props}
{:file-id file-id
:revn revn}))
(db/insert! conn :file-thumbnail
{:file-id file-id
:revn revn
:created-at tnow
:updated-at tnow
:deleted-at (:deleted-at file)
:props props
:media-id (:id media)}))
media))
(def ^:private
schema:create-file-thumbnail
[:map {:title "create-file-thumbnail"}
@@ -448,12 +393,57 @@
::rtry/when rtry/conflict-exception?
::sm/params schema:create-file-thumbnail}
;; FIXME: do not run the thumbnail upload inside a transaction
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(let [media (create-file-thumbnail cfg params)]
{:uri (files/resolve-public-uri (:id media))
:id (:id media)})))))
(media/validate-media-type! (:media params))
(media/validate-media-size! (:media params))
(db/run! cfg files/check-edition-permissions! profile-id file-id)
(when-not (db/read-only? (::db/pool cfg))
(let [storage (::sto/storage cfg)
file (bfc/get-file cfg file-id :include-deleted? true :load-data? false)
props (db/tjson (or (:props params) {}))
{:keys [path mtype]} (:media params)
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))
tnow (ct/now)
media (sto/put-object! storage
{::sto/content data
::sto/deduplicate? true
::sto/touched-at tnow
:content-type mtype
:bucket "file-thumbnail"})
revn (:revn params)
result (db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(let [thumb (db/get* conn :file-thumbnail
{:file-id file-id :revn revn}
{::db/remove-deleted false
::sql/for-update true})]
(if (some? thumb)
(do
(when (not= (:id media) (:media-id thumb))
(sto/touch-object! storage (:media-id thumb)))
(db/update! conn :file-thumbnail
{:media-id (:id media)
:deleted-at (:deleted-at file)
:updated-at tnow
:props props}
{:file-id file-id :revn revn}))
(db/insert! conn :file-thumbnail
{:file-id file-id
:revn revn
:created-at tnow
:updated-at tnow
:deleted-at (:deleted-at file)
:props props
:media-id (:id media)}))
media)))]
(when result
{:uri (files/resolve-public-uri (:id result))
:id (:id result)}))))
+6 -2
View File
@@ -6,9 +6,11 @@
(ns app.rpc.commands.search
(:require
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
@@ -66,11 +68,13 @@
(def ^:private schema:search-files
[:map {:title "search-files"}
[:team-id ::sm/uuid]
[:search-term {:optional true} :string]])
[:search-term {:optional true} [:string {:max 250}]]])
(sv/defmethod ::search-files
{::doc/added "1.17"
::doc/module :files
::sm/params schema:search-files}
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}]
(some->> search-term (search-files pool profile-id team-id)))
(dm/with-open [conn (db/open pool)]
(teams/check-read-permissions! conn profile-id team-id)
(some->> search-term (search-files conn profile-id team-id))))
@@ -21,19 +21,73 @@
[clojure.test :as t]
[mockery.core :refer [with-mocks]]
[yetti.request :as yreq]
[yetti.response :as yres]))
[yetti.response :as yres])
(:import
io.undertow.server.RequestTooBigException))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(defrecord DummyRequest [headers cookies]
(defrecord DummyRequest [headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert]
yreq/IRequestCookies
(get-cookie [_ name]
{:value (get cookies name)})
yreq/IRequest
(get-header [_ name]
(get headers name)))
(get headers name))
(method [_] method)
(body [_] body-stream)
(path [_] path)
(query [_] query)
(server-port [_] server-port)
(server-name [_] server-name)
(remote-addr [_] remote-addr)
(ssl-client-cert [_] ssl-client-cert)
(scheme [_] scheme)
(protocol [_] protocol))
(defn- make-dummy-request
"Constructs a DummyRequest from an options map. Every key is
optional; missing values fall back to sensible defaults. New
fields added to DummyRequest won't break existing call sites
as long as this constructor keeps its `:or` defaults in sync.
Recognized keys:
:headers — map of header name → value
:cookies — map of cookie name → value
:method — HTTP method keyword (default :get)
:body-stream — InputStream for the body (used directly)
:body-bytes — bytes or string for the body; wrapped in a
ByteArrayInputStream if :body-stream is not
given
:remote-addr — string (default \"127.0.0.1\")
:server-name — string (default \"test\")
:server-port — long (default 0)
:scheme — keyword (default :http)
:protocol — string (default \"HTTP/1.1\")
:path — string (default \"/test\")
:query — string or nil (default nil)
:ssl-client-cert — X509Certificate or nil (default nil)"
[{:keys [headers cookies method body-stream body-bytes
remote-addr server-name server-port scheme protocol
path query ssl-client-cert]
:or {headers {} cookies {} method :get
body-stream nil
remote-addr "127.0.0.1" server-name "test" server-port 0
scheme :http protocol "HTTP/1.1" path "/test" query nil
ssl-client-cert nil}}]
(let [body-stream (or body-stream
(when body-bytes
(java.io.ByteArrayInputStream.
(if (string? body-bytes)
(.getBytes ^String body-bytes "UTF-8")
body-bytes))))]
(->DummyRequest headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert)))
(t/deftest auth-middleware-1
(let [request (volatile! nil)
@@ -41,11 +95,11 @@
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(handler (make-dummy-request {}))
(t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {"authorization" "Token aaaa"} {}))
(handler (make-dummy-request {:headers {"authorization" "Token aaaa"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :token token-type))
@@ -58,10 +112,10 @@
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(handler (make-dummy-request {}))
(t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {}))
(handler (make-dummy-request {:headers {"authorization" "Bearer aaaa"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :bearer token-type))
@@ -74,10 +128,10 @@
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(handler (make-dummy-request {}))
(t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"}))
(handler (make-dummy-request {:cookies {"auth-token" "foobar"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :cookie token-type))
@@ -89,16 +143,16 @@
(fn [req] {::yres/status 200})
{:test1 "secret-key"})]
(let [response (handler (->DummyRequest {} {}))]
(let [response (handler (make-dummy-request {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key2"} {}))]
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key2"}}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key"}}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "test1 secret-key"} {}))]
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "test1 secret-key"}}))]
(t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz
@@ -113,6 +167,21 @@
(t/is (= #{} (:app.http.access-token/perms response)))
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
(t/deftest access-token-authz-sets-token-id-and-type
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token
(:id profile) "test" nil "mcp")
handler (#'app.http.access-token/wrap-authz identity th/*system*)
request {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}}
response (handler request)]
;; Must set ::actoken/id from claims :tid
(t/is (= (:id token) (:app.http.access-token/id response)))
;; Must set ::actoken/type from database
(t/is (= "mcp" (:app.http.access-token/type response)))
;; Existing assertions still pass
(t/is (= #{} (:app.http.access-token/perms response)))
(t/is (= (:id profile) (:app.http.access-token/profile-id response)))))
(defrecord MethodAwareDummyRequest [req-method headers]
yreq/IRequest
(method [_] req-method)
@@ -194,7 +263,7 @@
:user-agent "user agent"})
(#'session/assign-token cfg))
response (handler (->DummyRequest {} {"auth-token" (:token session)}))
response (handler (make-dummy-request {:cookies {"auth-token" (:token session)}}))
{:keys [token claims] token-type :type}
(get response ::http/auth-data)]
@@ -205,3 +274,127 @@
(t/is (= "penpot" (:aud claims)))
(t/is (= (:id session) (:sid claims)))
(t/is (= (:id profile) (:uid claims)))))
(t/deftest parse-request-illegal-argument-exception
;; clojure.data.json raises IllegalArgumentException (case
;; fall-through) on several kinds of malformed input. The
;; parse-request middleware should convert any such IAE into a
;; 400 :malformed-json validation error rather than letting it
;; surface as a 500 internal error. Because the conversion is
;; done by raising an ex-info (caught by the top-level error
;; handler in app.http/router-handler), this test asserts on
;; the ex-info thrown by wrap-parse-request directly.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] {::yres/status 200 ::yres/body :ok}))
;; Body contains the bytes for: {"x": "\}"} -- a string
;; value with a backslash followed by '}', which
;; clojure.data.json v0.5.x cannot handle.
body (.getBytes "{\"x\": \"\\}\"}" "UTF-8")
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes body})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :malformed-json (-> ex ex-data :code)))
(t/is (string? (-> ex ex-data :hint)))))
(t/deftest parse-request-request-too-big-exception
;; When RequestTooBigException is raised (e.g. the request body
;; exceeded the configured size limit), the middleware should
;; convert it to a 413 :request-body-too-large validation
;; error.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (RequestTooBigException. "too large"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :request-body-too-large (-> ex ex-data :code)))
(t/is (string? (-> ex ex-data :hint)))))
(t/deftest parse-request-eof-exception
;; When java.io.EOFException is raised (e.g. the body stream
;; was closed before the parser could read it), the middleware
;; should convert it to a 400 :malformed-json validation error.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (java.io.EOFException. "stream closed"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :malformed-json (-> ex ex-data :code)))
(t/is (string? (-> ex ex-data :hint)))))
(t/deftest parse-request-runtime-exception-with-cause
;; When a RuntimeException with a non-nil ex-cause is raised,
;; the middleware should recurse on the cause and dispatch
;; through the specific-exception branches. Here we wrap an
;; IllegalArgumentException in a RuntimeException and verify
;; it surfaces as :malformed-json.
(let [iae (IllegalArgumentException. "No matching clause: 99")
wrapped (doto (RuntimeException. "wrapped")
(.initCause iae))
handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw wrapped)))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :malformed-json (-> ex ex-data :code)))))
(t/deftest parse-request-runtime-exception-without-cause
;; When a bare RuntimeException (no ex-cause) is raised, the
;; middleware should fall through to errors/handle's :default
;; path and return a 500 with :type :server-error :code
;; :unexpected. This is the "true internal error" path.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (RuntimeException. "boom"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
response (handler request)
body (::yres/body response)]
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :unexpected (:code body)))
(t/is (= "boom" (:hint body)))))
(t/deftest parse-request-non-runtime-throwable
;; When a non-RuntimeException Throwable is raised (e.g. an
;; Error subclass or a non-RuntimeException checked-style
;; exception), the middleware should fall through to the
;; :else branch and call errors/handle. java.io.IOException
;; has a dedicated handle-exception method that returns 500
;; with :code :io-exception.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (java.io.IOException. "network gone"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
response (handler request)
body (::yres/body response)]
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :io-exception (:code body)))
(t/is (= "network gone" (:hint body)))))
@@ -13,6 +13,7 @@
[app.db :as db]
[app.loggers.audit :as audit]
[app.rpc :as-alias rpc]
[app.util.services :as sv]
[backend-tests.helpers :as th]
[clojure.test :as t]
[yetti.request]))
@@ -498,3 +499,47 @@
(t/is (some? (:tracked-at row)))
(t/is (= {} (:props row)))
(t/is (= {} (:context row))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREPARE-RPC-EVENT PROFILE-ID CONVERSION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest prepare-rpc-event-converts-string-profile-id-to-uuid
;; When result contains a string :profile-id (e.g. from error reports),
;; prepare-rpc-event must convert it to a UUID for audit schema compliance.
(let [prof (th/create-profile* 1 {:is-active true})
string-pid "33601240-a00b-11ea-ba1b-c554cc60e361"
expected #uuid "33601240-a00b-11ea-ba1b-c554cc60e361"
mdata {::sv/name "test-cmd"}
params {::rpc/profile-id (:id prof)
::rpc/request-id (uuid/next)
::rpc/request-at (ct/now)}
mock-req (reify
yetti.request/IRequest
(get-header [_ _] nil)
(remote-addr [_] "127.0.0.1"))
params (with-meta params {:app.http/request mock-req})
result {:profile-id string-pid :some-data "value"}
event (audit/prepare-rpc-event th/*system* mdata params result)]
;; profile-id must be a UUID, not a string
(t/is (uuid? (:profile-id event)))
(t/is (= expected (:profile-id event)))))
(t/deftest prepare-rpc-event-handles-invalid-string-profile-id
;; When result contains an invalid string :profile-id, it should fall back
;; to the RPC params profile-id (which is always a valid UUID).
(let [prof (th/create-profile* 1 {:is-active true})
mdata {::sv/name "test-cmd"}
params {::rpc/profile-id (:id prof)
::rpc/request-id (uuid/next)
::rpc/request-at (ct/now)}
mock-req (reify
yetti.request/IRequest
(get-header [_ _] nil)
(remote-addr [_] "127.0.0.1"))
params (with-meta params {:app.http/request mock-req})
result {:profile-id "not-a-valid-uuid"}
event (audit/prepare-rpc-event th/*system* mdata params result)]
;; profile-id must fall back to the RPC params profile-id
(t/is (uuid? (:profile-id event)))
(t/is (= (:id prof) (:profile-id event)))))
@@ -8,11 +8,15 @@
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
[app.rpc :as-alias rpc]
[app.util.services :as sv]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]))
[cuerdas.core :as str]
[yetti.request]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@@ -170,23 +174,23 @@
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "2"} :created-at t2})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "3"} :created-at t3})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "4"} :created-at t4})
;; Page 1: newest 2
;; Page 1: oldest 2 (ASC order)
(let [out (token-cmd profile {::th/type :get-error-reports :limit 2})]
(t/is (th/success? out))
(let [{:keys [items next-since next-id]} (:result out)]
(t/is (= 2 (count items)))
(t/is (some? next-since))
(t/is (some? next-id))
(t/is (= "4" (:hint (first items))))
(t/is (= "3" (:hint (second items))))
(t/is (= "1" (:hint (first items))))
(t/is (= "2" (:hint (second items))))
;; Page 2: next 2, using since and since-id from page 1
(let [out2 (token-cmd profile {::th/type :get-error-reports :limit 2 :since next-since :since-id next-id})]
(t/is (th/success? out2))
(let [{:keys [items next-since]} (:result out2)]
(t/is (= 2 (count items)))
(t/is (nil? next-since))
(t/is (= "2" (:hint (first items))))
(t/is (= "1" (:hint (second items))))))))))
(t/is (= "3" (:hint (first items))))
(t/is (= "4" (:hint (second items))))))))))
(t/deftest get-error-reports-pagination-same-timestamp
(let [profile (th/create-profile* 1 {:is-active true})
@@ -249,3 +253,36 @@
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :report-not-found (th/ex-code (:error out))))))
;; --- Audit event tests
(t/deftest get-error-report-audit-event-has-uuid-profile-id
;; When get-error-report returns a report with string profile-id in content,
;; the audit event must have a proper UUID profile-id (not a string).
;; This tests the prepare-rpc-event function directly since the test RPC
;; flow doesn't include the audit middleware wrapper.
(let [profile (th/create-profile* 1 {:is-active true})
id (uuid/next)
orig-pid "33601240-a00b-11ea-ba1b-c554cc60e361"
;; Simulate the result from get-error-report with string profile-id
result {:id id
:source "logging"
:hint "test error"
:profile-id orig-pid}
mdata {::sv/name "get-error-report"}
params {::rpc/profile-id (:id profile)
::rpc/request-id (uuid/next)
::rpc/request-at (ct/now)}
mock-req (reify yetti.request/IRequest
(get-header [_ _] nil)
(remote-addr [_] "127.0.0.1"))
params (with-meta params {:app.http/request mock-req})
event (audit/prepare-rpc-event th/*system* mdata params result)]
;; profile-id must be a UUID, not a string
(t/is (uuid? (:profile-id event)))
(t/is (= #uuid "33601240-a00b-11ea-ba1b-c554cc60e361" (:profile-id event)))))
;; Note: The integration of access token middleware with audit context is tested
;; via unit tests in rpc_audit_test.clj and http_middleware_test.clj.
;; The middleware sets ::id and ::type on the request, and prepare-context-from-request
;; reads these values to populate :access-token-id and :access-token-type in the context.
@@ -2319,3 +2319,75 @@
(t/is (not (nil? (:error out))))
(let [edata (-> out :error ex-data)]
(t/is (= :not-found (:type edata))))))
;; --- Security Fix Tests ---
(t/deftest link-file-to-library-circular-reference
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared true})
file2 (th/create-file* 2 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared true})
file3 (th/create-file* 3 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})]
(th/link-file-to-library* {:file-id (:id file3) :library-id (:id file2)})
(th/link-file-to-library* {:file-id (:id file2) :library-id (:id file1)})
(let [data {::th/type :link-file-to-library
::rpc/profile-id (:id profile)
:file-id (:id file1)
:library-id (:id file3)}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(let [edata (-> out :error ex-data)]
(t/is (= :circular-library-reference (:code edata)))))))
(t/deftest get-file-etag-includes-deleted-at
(let [profile-id (uuid/random)
file1 {:modified-at (ct/now)
:revn 1
:vern 0
:deleted-at nil
:permissions {:can-edit true}}
file2 (assoc file1 :deleted-at (ct/now))]
(t/is (not= (files/get-file-etag {::rpc/profile-id profile-id} file1)
(files/get-file-etag {::rpc/profile-id profile-id} file2)))))
(t/deftest search-files-with-permission
(let [profile (th/create-profile* 1)
_ (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
data {::th/type :search-files
::rpc/profile-id (:id profile)
:team-id (:default-team-id profile)
:search-term "test"}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (vector? (:result out)))))
(t/deftest search-files-forbidden
(let [profile (th/create-profile* 1)
other (th/create-profile* 2)
data {::th/type :search-files
::rpc/profile-id (:id other)
:team-id (:default-team-id profile)
:search-term "test"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(let [edata (-> out :error ex-data)]
(t/is (= :not-found (:type edata))))))
(t/deftest search-files-term-too-long
(let [profile (th/create-profile* 1)
data {::th/type :search-files
::rpc/profile-id (:id profile)
:team-id (:default-team-id profile)
:search-term (apply str (repeat 300 "x"))}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata))))))
+158 -69
View File
@@ -10,12 +10,14 @@
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.path-names :as cpn]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.pages-list :as ctpl]
[app.common.types.shape :as cts]
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]))
(log/set-level! :debug)
@@ -35,7 +37,7 @@
(assoc :width 0.01)
(assoc :height 0.01)
(cts/setup-rect)))]
(log/dbg :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -48,7 +50,7 @@
(log/debug :hint " -> set to " :parent-id uuid/zero)
(assoc shape :parent-id uuid/zero))]
(log/dbg :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -61,7 +63,7 @@
(log/debug :hint " -> add children to" :parent-id (:id parent-shape))
(update parent-shape :shapes conj (:id shape)))]
(log/dbg :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:parent-id shape)] repair-shape))))
@@ -74,7 +76,7 @@
(log/debug :hint " -> remove duplicated children")
(update shape :shapes distinct))]
(log/dbg :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -86,14 +88,14 @@
(log/debug :hint " -> remove child" :child-id (:child-id args))
(update parent-shape :shapes (fn [shapes]
(d/removev #(= (:child-id args) %) shapes))))]
(log/dbg :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :invalid-parent
[_ {:keys [shape page-id args] :as error} file-data _]
(log/dbg :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/change-parent (:parent-id args) [shape] nil {:allow-altering-copies true})))
@@ -109,7 +111,7 @@
(log/debug :hint " -> set to " :frame-id frame-id)
(assoc shape :frame-id frame-id)))]
(log/dbg :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -125,7 +127,7 @@
(log/debug :hint " -> set to " :frame-id frame-id)
(assoc shape :frame-id frame-id)))]
(log/dbg :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -138,7 +140,7 @@
(log/debug :hint " -> set :main-instance")
(assoc shape :main-instance true))]
(log/dbg :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -155,7 +157,7 @@
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)]
(log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -174,7 +176,7 @@
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
;; shape)]
(log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes shape-ids repair-shape))))
@@ -194,7 +196,7 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))]
(log/dbg :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
(if (and (some? component) (not (:deleted component)))
(-> (pcb/empty-changes nil page-id)
(pcb/with-library-data file-data)
@@ -211,7 +213,7 @@
;; Assign main instance in the component to current shape
(log/debug :hint " -> assign main-instance-page" :component-id (:id component))
(assoc component :main-instance-page page-id))]
(log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-library-data file-data)
(pcb/update-component (:component-id shape) repair-component))))
@@ -224,7 +226,7 @@
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)]
(log/dbg :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -237,7 +239,7 @@
(log/debug :hint " -> unset :main-instance")
(dissoc shape :main-instance))]
(log/dbg :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -250,7 +252,7 @@
(log/debug :hint " -> set :component-root")
(assoc shape :component-root true))]
(log/dbg :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -263,7 +265,7 @@
(log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))]
(log/dbg :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -310,7 +312,7 @@
;; If the shape still refers to the remote component, try to find the corresponding near one
;; and link to it. If not, detach the shape.
(log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
(if (some? matching-shape)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
@@ -329,7 +331,7 @@
(log/debug :hint " -> unhead shape")
(ctk/unhead-shape shape))]
(log/dbg :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -354,7 +356,7 @@
(nil? (:component-file args))
(dissoc :component-file)))]
(log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -367,7 +369,7 @@
(log/debug :hint " -> reroot shape")
(ctk/rehead-shape shape (:component-file args) (:component-id args)))]
(log/dbg :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -396,7 +398,7 @@
(assoc acc k v)))
{}
objects)))))]
(log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
(log/debug :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
(-> (pcb/empty-changes nil nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id shape) repair-component))))
@@ -409,7 +411,7 @@
(log/debug :hint " -> unset :shape-ref")
(dissoc shape :shape-ref))]
(log/dbg :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -422,7 +424,7 @@
(log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))]
(log/dbg :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -435,7 +437,7 @@
(log/debug :hint " -> set :component-root")
(assoc shape :component-root true))]
(log/dbg :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape)
@@ -449,7 +451,7 @@
(log/debug :hint " -> unset :component-root")
(dissoc shape :component-root))]
(log/dbg :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -462,7 +464,7 @@
(log/debug :hint " -> set :component-root")
(assoc shape :component-root true))]
(log/dbg :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -475,7 +477,7 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))]
(log/dbg :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -488,7 +490,7 @@
(log/debug :hint " -> detach shape" :shape-id (:id shape))
(ctk/detach-shape shape))]
(log/dbg :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -501,7 +503,7 @@
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
shape)]
(log/dbg :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -520,7 +522,7 @@
:r3 0
:r4 0))]
(log/dbg :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -538,7 +540,7 @@
(log/debug :hint " -> remove :objects")
(dissoc component :objects))))]
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
(log/debug :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id component) repair-component))))
@@ -554,7 +556,7 @@
(dissoc component :objects))
component))]
(log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
(log/debug :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id component) repair-component))))
@@ -567,7 +569,7 @@
(log/debug :hint " -> add :content-group to :touched-groups")
(update shape :touched ctk/set-touched-group :content-group))]
(log/dbg :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -580,7 +582,7 @@
(log/debug :hint " -> remove swap-slot")
(ctk/remove-swap-slot shape))]
(log/dbg :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
@@ -603,13 +605,11 @@
(log/debug :hint " -> remove swap-slot" :child-id (:id shape))
(ctk/remove-swap-slot shape))]
(log/dbg :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes (map :id child-with-duplicate) repair-shape))))
(defmethod repair-error :component-duplicate-slot
[_ {:keys [shape] :as error} file-data _]
(let [main-shape (get-in shape [:objects (:main-instance-id shape)])
@@ -633,7 +633,7 @@
(:objects component))]
(assoc component :objects objects)))]
(log/dbg :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
(log/debug :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id shape) repair-component))))
@@ -649,50 +649,139 @@
(ctk/set-swap-slot shape slot))
shape)))]
(log/dbg :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :not-a-variant
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :main-instance-not-a-variant
[_ {:keys [shape page-id args]} file-data _]
(let [repair-shape
(fn [shape]
(let [variant-id (:variant-id args)]
;; Set the desired variant-id
(log/debug :hint (str " -> set variant-id to " variant-id))
(assoc shape :variant-id variant-id)))]
(defmethod repair-error :invalid-variant-id
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(log/debug :hint "repairing shape :main-instance-not-a-variant" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :main-instance-invalid-variant-id
[_ {:keys [shape page-id args]} file-data _]
(let [repair-shape
(fn [shape]
(let [variant-id (:variant-id args)]
;; Set the desired variant-id
(log/debug :hint (str " -> set variant-id to " variant-id))
(assoc shape
:variant-id variant-id)))]
(log/debug :hint "repairing shape :main-instance-invalid-variant-id" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :invalid-variant-properties
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
[_ {:keys [shape page-id args]} file-data _]
(let [prop-names (:prop-names args)
component (get-in file-data [:components (:component-id shape)])
prop-values (into {} (map (juxt :name :value)) (:variant-properties component))
properties' (mapv (fn [name] {:name name :value (get prop-values name "")}) prop-names)
variant-name (ctv/properties-to-name properties')
repair-component
(fn [component]
;; Rebuild component properties, removing any extra ones and adding missing ones with empty value
(log/debug :hint " -> rebuild properties" :component-id (:id component) :prop-names (str prop-names))
(assoc component :variant-properties properties'))
repair-shape
(fn [shape]
(log/debug :hint " -> set variant-name" :variant-name variant-name)
(assoc shape :variant-name variant-name))]
(log/debug :hint "repairing shape :invalid-variant-properties" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/with-library-data file-data)
(pcb/update-component (:component-id shape) repair-component)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :variant-not-main
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
[_ {:keys [shape page-id]} file-data _]
(let [page (ctpl/get-page file-data page-id)
shape-ids (cfh/get-children-ids-with-self (:objects page) (:id shape))]
(log/debug :hint "repairing shape :variant-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
(log/debug :hint " -> delete shapes" :shape-ids shape-ids)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/remove-objects shape-ids))))
(defmethod repair-error :parent-not-variant
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
[_ {:keys [shape page-id]} file-data _]
(let [parent-id (:parent-id shape)
repair-fn
(fn [parent]
(log/debug :hint " -> set :is-variant-container true")
(assoc parent :is-variant-container true))]
(log/debug :hint "repairing shape :parent-not-variant" :id (:id shape) :name (:name shape) :parent-id parent-id :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [parent-id] repair-fn))))
(defmethod repair-error :variant-bad-name
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :variant-main-bad-name
[_ {:keys [shape page-id args]} file-data _]
(let [repair-fn
(fn [shape]
(log/debug :hint " -> set :name" :name (:variant-name args))
(assoc shape :name (:variant-name args)))]
(log/debug :hint "repairing shape :variant-main-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-fn))))
(defmethod repair-error :variant-bad-variant-name
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
(defmethod repair-error :variant-main-bad-variant-name
[_ {:keys [shape page-id]} file-data _]
(let [component (get-in file-data [:components (:component-id shape)])
variant-name (ctv/properties-to-name (:variant-properties component))
repair-fn
(fn [shape]
(log/debug :hint " -> set :variant-name" :variant-name variant-name)
(assoc shape :variant-name variant-name))]
(log/dbg :hint "repairing shape :variant-main-bad-variant-name" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-fn))))
(defmethod repair-error :variant-component-bad-name
[_ error file _]
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
file)
[_ {:keys [shape page-id args]} file-data _]
(let [[path name] (cpn/split-group-name (:variant-container-name args))
repair-fn
(fn [component]
(log/debug :hint " -> set :path and :name" :path path :name name)
(assoc component :path path :name name))]
(log/dbg :hint "repairing shape :variant-component-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-library-data file-data)
(pcb/update-component (:component-id shape) repair-fn))))
(defmethod repair-error :variant-component-bad-id
[_ {:keys [shape page-id args]} file-data _]
(let [repair-shape
(fn [shape]
(let [variant-id (:variant-id args)]
;; Set the desired variant-id
(log/debug :hint (str " -> set variant-id to " variant-id))
(assoc shape
:variant-id variant-id)))]
(log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :default
[_ error file _]
@@ -701,7 +790,7 @@
(defn repair-file
[{:keys [data id] :as file} libraries errors]
(log/dbg :hint "repairing file" :id (str id) :errors (count errors))
(log/debug :hint "repairing file" :id (str id) :errors (count errors))
(let [{:keys [redo-changes]}
(reduce (fn [changes error]
(pcb/concat-changes changes
+37 -29
View File
@@ -65,13 +65,13 @@
:misplaced-slot
:missing-slot
:shape-ref-cycle
:not-a-variant
:invalid-variant-id
:main-instance-not-a-variant
:main-instance-invalid-variant-id
:invalid-variant-properties
:variant-not-main
:parent-not-variant
:variant-bad-name
:variant-bad-variant-name
:variant-main-bad-name
:variant-main-bad-variant-name
:variant-component-bad-name
:variant-component-bad-id})
@@ -573,19 +573,23 @@
(run! (fn [child-id]
(when-let [child (get objects child-id)]
(if (not (ctk/is-variant? child))
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id child))
child file page)
(report-error :main-instance-not-a-variant
(str/ffmt "Main instance shape % should be a variant" (:id child))
child file page
:variant-id shape-id)
(do
(when (not= (:variant-id child) shape-id)
(report-error :invalid-variant-id
(str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child))
child file page))
(report-error :main-instance-invalid-variant-id
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
child file page
:variant-id shape-id))
(when (not= prop-names (cfv/extract-properties-names child file-data))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page))))))
child file page
:prop-names prop-names))))))
shapes)))
(defn- check-variant
"Shape is a variant, so
-it should be a main component
@@ -594,9 +598,9 @@
-its name should be the same as its parent's
"
[shape file page]
(let [parent (ctst/get-shape page (:parent-id shape))
component (ctkl/get-component (:data file) (:component-id shape) true)
name (ctv/properties-to-name (:variant-properties component))]
(let [parent (ctst/get-shape page (:parent-id shape))
component (ctkl/get-component (:data file) (:component-id shape) true)
variant-name (ctv/properties-to-name (:variant-properties component))]
(when-not (ctk/main-instance? shape)
(report-error :variant-not-main
(str/ffmt "Variant % is not a main instance" (:id shape))
@@ -605,23 +609,26 @@
(report-error :parent-not-variant
(str/ffmt "Variant % has an invalid parent" (:id shape))
shape file page))
(when-not (= name (:variant-name shape))
(report-error :variant-bad-variant-name
(when-not (= variant-name (:variant-name shape))
(report-error :variant-main-bad-variant-name
(str/ffmt "Variant % has an invalid variant-name" (:id shape))
shape file page))
shape file page
:variant-name variant-name))
(when-not (= (:name parent) (:name shape))
(report-error :variant-bad-name
(str/ffmt "Variant % has an invalid name" (:id shape))
shape file page))
(report-error :variant-main-bad-name
(str/ffmt "Main instance inside variant % has an invalid name" (:id shape))
shape file page
:variant-name (:name parent)))
(when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component)))
(report-error :variant-component-bad-name
(str/ffmt "Component % has an invalid name" (:id shape))
shape file page))
shape file page
:variant-container-name (:name parent)))
(when-not (= (:variant-id component) (:variant-id shape))
(report-error :variant-component-bad-id
(str/ffmt "Variant % has adifferent variant-id than its component" (:id shape))
shape file page))))
shape file page
:variant-id (:variant-id component)))))
(defn- check-shape
"Validate referential integrity and semantic coherence of
@@ -740,14 +747,15 @@
-It should have at least one variant property"
[component file]
(let [component-page (ctf/get-component-page (:data file) component)
main-component (if (:deleted component)
main-instance (if (:deleted component)
(dm/get-in component [:objects (:main-instance-id component)])
(ctst/get-shape component-page (:main-instance-id component)))]
(when (and main-component
(not (ctk/is-variant? main-component)))
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id main-component))
main-component file component-page))))
(when (and main-instance
(not (ctk/is-variant? main-instance)))
(report-error :main-instance-not-a-variant
(str/ffmt "Main instance shape % should be a variant" (:id main-instance))
main-instance file component-page
:variant-id (:variant-id component)))))
(defn- check-main-inside-main
[component file]
@@ -121,74 +121,79 @@
(defn layout-content-points
[bounds parent children objects]
(let [parent-id (dm/get-prop parent :id)
parent-bounds @(get bounds parent-id)
reverse? (ctl/reverse? parent)
children (cond->> children (not reverse?) reverse)]
(let [parent-id (dm/get-prop parent :id)
parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [reverse? (ctl/reverse? parent)
children (cond->> children (not reverse?) reverse)]
(loop [children (seq children)
result (transient [])
correct-v (gpt/point 0)]
(loop [children (seq children)
result (transient [])
correct-v (gpt/point 0)]
(if (not children)
(persistent! result)
(if (not children)
(persistent! result)
(let [child (first children)
child-id (dm/get-prop child :id)
child-bounds @(get bounds child-id)
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child)
(let [child (first children)
child-id (dm/get-prop child :id)
child-bounds-ref (get bounds child-id)
child-bounds (some-> child-bounds-ref deref)
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child)
[child-bounds correct-v]
(if (or (ctl/fill-width? child) (ctl/fill-height? child))
(child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects)
[(->> child-bounds (map #(gpt/add % correct-v))) correct-v])
[child-bounds correct-v]
(if (and child-bounds
(or (ctl/fill-width? child) (ctl/fill-height? child)))
(child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects)
[(when child-bounds
(->> child-bounds (map #(gpt/add % correct-v))))
correct-v])
child-bounds
(when (d/not-empty? child-bounds)
(-> (gpo/parent-coords-bounds child-bounds parent-bounds)
(gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))]
child-bounds
(when (d/not-empty? child-bounds)
(-> (gpo/parent-coords-bounds child-bounds parent-bounds)
(gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))]
(recur (next children)
(cond-> result (some? child-bounds) (conj! child-bounds))
correct-v))))))
(recur (next children)
(cond-> result (some? child-bounds) (conj! child-bounds))
correct-v))))))))
(defn layout-content-bounds
[bounds {:keys [layout-padding] :as parent} children objects]
(let [parent-id (:id parent)
parent-bounds @(get bounds parent-id)
(let [parent-id (:id parent)
parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [row? (ctl/row? parent)
col? (ctl/col? parent)
space-around? (ctl/space-around? parent)
space-evenly? (ctl/space-evenly? parent)
content-evenly? (ctl/content-evenly? parent)
[layout-gap-row layout-gap-col] (ctl/gaps parent)
row? (ctl/row? parent)
col? (ctl/col? parent)
space-around? (ctl/space-around? parent)
space-evenly? (ctl/space-evenly? parent)
content-evenly? (ctl/content-evenly? parent)
[layout-gap-row layout-gap-col] (ctl/gaps parent)
row-pad (if (or (and col? space-evenly?)
(and col? space-around?)
(and row? content-evenly?))
layout-gap-row
0)
row-pad (if (or (and col? space-evenly?)
(and col? space-around?)
(and row? content-evenly?))
layout-gap-row
0)
col-pad (if (or (and row? space-evenly?)
(and row? space-around?)
(and col? content-evenly?))
layout-gap-col
0)
col-pad (if (or (and row? space-evenly?)
(and row? space-around?)
(and col? content-evenly?))
layout-gap-col
0)
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
pad-top (+ (or pad-top 0) row-pad)
pad-right (+ (or pad-right 0) col-pad)
pad-bottom (+ (or pad-bottom 0) row-pad)
pad-left (+ (or pad-left 0) col-pad)
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
pad-top (+ (or pad-top 0) row-pad)
pad-right (+ (or pad-right 0) col-pad)
pad-bottom (+ (or pad-bottom 0) row-pad)
pad-left (+ (or pad-left 0) col-pad)
layout-points
(layout-content-points bounds parent children objects)]
layout-points
(layout-content-points bounds parent children objects)]
(if (d/not-empty? layout-points)
(-> layout-points
(gpo/merge-parent-coords-bounds parent-bounds)
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))
(if (d/not-empty? layout-points)
(-> layout-points
(gpo/merge-parent-coords-bounds parent-bounds)
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))))
@@ -12,36 +12,36 @@
(defn layout-content-points
[bounds parent {:keys [row-tracks column-tracks]}]
(let [parent-id (:id parent)
parent-bounds @(get bounds parent-id)
hv #(gpo/start-hv parent-bounds %)
vv #(gpo/start-vv parent-bounds %)]
(d/concat-vec
(->> row-tracks
(mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (vv (:size %))))))
(->> column-tracks
(mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (hv (:size %)))))))))
(let [parent-id (:id parent)
parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [hv #(gpo/start-hv parent-bounds %)
vv #(gpo/start-vv parent-bounds %)]
(d/concat-vec
(->> row-tracks
(mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (vv (:size %))))))
(->> column-tracks
(mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (hv (:size %)))))))))))
(defn layout-content-bounds
[bounds {:keys [layout-padding] :as parent} layout-data]
(let [parent-id (:id parent)
parent-bounds @(get bounds parent-id)
(let [parent-id (:id parent)
parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
pad-top (or pad-top 0)
pad-right (or pad-right 0)
pad-bottom (or pad-bottom 0)
pad-left (or pad-left 0)
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
pad-top (or pad-top 0)
pad-right (or pad-right 0)
pad-bottom (or pad-bottom 0)
pad-left (or pad-left 0)
layout-points (layout-content-points bounds parent layout-data)]
layout-points (layout-content-points bounds parent layout-data)]
(if (d/not-empty? layout-points)
(-> layout-points
(gpo/merge-parent-coords-bounds parent-bounds)
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))
(if (d/not-empty? layout-points)
(-> layout-points
(gpo/merge-parent-coords-bounds parent-bounds)
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))))
@@ -31,13 +31,17 @@
(and (ctl/fill-width? child)
(ctl/grid-layout? child))
(let [children
(->> (cfh/get-immediate-children objects (:id child))
(remove ctl/position-absolute?)
(map #(vector @(get bounds (:id %)) %)))
layout-data (gd/calc-layout-data child @(get bounds (:id child)) children bounds objects true)]
(max (ctl/child-min-width child)
(gpo/width-points (gb/layout-content-bounds bounds child layout-data))))
(let [child-bounds-ref (get bounds (:id child))]
(if child-bounds-ref
(let [children
(->> (cfh/get-immediate-children objects (:id child))
(remove ctl/position-absolute?)
(keep #(when-let [b (get bounds (:id %))]
[@b %])))
layout-data (gd/calc-layout-data child @child-bounds-ref children bounds objects true)]
(max (ctl/child-min-width child)
(gpo/width-points (gb/layout-content-bounds bounds child layout-data))))
(ctl/child-min-width child)))
(ctl/fill-width? child)
(ctl/child-min-width child)
@@ -63,11 +67,15 @@
(let [children
(->> (cfh/get-immediate-children objects (dm/get-prop child :id))
(remove ctl/position-absolute?)
(map (fn [child] [@(get bounds (:id child)) child])))
(keep (fn [c]
(when-let [b (get bounds (:id c))]
[@b c]))))
layout-data (gd/calc-layout-data child (:points child) children bounds objects true)
auto-bounds (gb/layout-content-bounds bounds child layout-data)]
(max (ctl/child-min-height child)
(gpo/height-points auto-bounds)))
(if auto-bounds
(max (ctl/child-min-height child)
(gpo/height-points auto-bounds))
(ctl/child-min-height child)))
(ctl/fill-height? child)
(ctl/child-min-height child)
@@ -13,6 +13,11 @@
[app.common.types.text :as txt]))
(defn add-variant
"Add a variant component to a file with two variants, each with a root shape.
:variant-label [:name Board]
{:root2-label} [:name Board] # [Component :component2-label]
{:root1-label} [:name Board] # [Component :component1-label]
"
[file variant-label component1-label root1-label component2-label root2-label
& {:keys [variant1-params variant2-params]
:or {variant1-params {} variant2-params {}}}]
+2
View File
@@ -901,8 +901,10 @@
(let [shape (get objects shape-id)]
(println (str/pad (str (str/repeat " " level)
(when (:main-instance shape) "{")
(when (:is-variant-container shape) "{{")
(:name shape)
(when (:main-instance shape) "}")
(when (:is-variant-container shape) "}}")
(when (seq (:touched shape)) "*")
(when show-ids (str/format " %s" (:id shape))))
{:length 20
+15 -2
View File
@@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.flags :as flags]
[app.common.math :as mth]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
[clojure.set :as set]
@@ -217,7 +218,10 @@
attributes or other things that may be attached).
- Consider nil values, empty strings or empty lists all equal.
- Normalize numeric values (legacy) into strings.
- No value is equal than the default value."
- No value is equal than the default value.
- Numeric attrs (e.g. line-height) compare with float tolerance so
editor/WASM round-trips like \"1.3333333333333333\" vs \"1.33333\"
do not count as a real style change (avoids detaching tokens)."
[key value1 value2]
(when (text-node-attr? key)
(let [default-value (get default-text-attrs key)
@@ -229,7 +233,16 @@
$)))
value1' (normalize-value value1)
value2' (normalize-value value2)]
(not= value1' value2'))))
(cond
(= value1' value2')
false
:else
(let [n1 (when (string? value1') (d/parse-double value1'))
n2 (when (string? value2') (d/parse-double value2'))]
(if (and (some? n1) (some? n2))
(not (mth/close? n1 n2))
true))))))
(defn- compare-text-content
"Given two content text structures, conformed by maps and vectors,
@@ -0,0 +1,230 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns common-tests.files.repair-test
"Tests for the validate / repair functions in app.common.files.validate
and app.common.files.repair.
The tests generate cases of broken files and check that the validation functions
generate accurate errors, and that the repair functions return the file to
a stable state."
(:require
[app.common.files.repair :as cfr]
[app.common.files.validate :as cfv]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.variants :as thv]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(t/deftest repair-main-instance-not-a-variant
(t/testing "detect and repair a variant component whose root shape is not a variant"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
(ths/update-shape :root1 :variant-id nil))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1 :page-label :page1)]
(t/is (= 2 (count errors))) ;; There are two different checks that detect the same problem
(t/is (= :main-instance-not-a-variant (:code (first errors))))
(t/is (nil? errors'))
(t/is (= (thi/id :variant1) (:variant-id root1'))))))
(t/deftest repair-invalid-variant-id-variant-component-bad-id
(t/testing "detect and repair a variant component whose variant id does not match the container's id"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
(ths/update-shape :root1 :variant-id (uuid/next)))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1 :page-label :page1)]
(t/is (= 2 (count errors))) ;; There are two different validation that actually check the same problem
(t/is (= :main-instance-invalid-variant-id (:code (first errors))))
(t/is (= :variant-component-bad-id (:code (second errors))))
(t/is (nil? errors'))
(t/is (= (thi/id :variant1) (:variant-id root1'))))))
(t/deftest repair-invalid-variant-properties
(t/testing "detect and repair a second variant component whose properties do not match the first variant component's properties"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Component1 has ["Property 1", "Property 2"], component2 gets ["Property 1", "Property 3"]
;; This breaks validation: prop-names mismatch (missing "Property 2", extra "Property 3")
(thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"}
{:name "Property 2" :value "ValueA"}]})
(thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"}
{:name "Property 3" :value "ValueB"}]})
(ths/update-shape :root1 :variant-name "Value1, ValueA")
(ths/update-shape :root2 :variant-name "Value2, ValueB"))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
comp1' (thc/get-component file' :component1)
comp2' (thc/get-component file' :component2)
root1' (ths/get-shape file' :root1)
root2' (ths/get-shape file' :root2)]
(t/is (= 1 (count errors)))
(t/is (= :invalid-variant-properties (:code (first errors))))
(t/is (nil? errors'))
;; After repair, component1's properties are rebuilt to match component2's property names
;; (the first child in the variant container is root2, so prop-names come from component2)
;; "Property 1" keeps its value, "Property 3" is added with empty value, "Property 2" is removed
(t/is (= [{:name "Property 1" :value "Value1"}
{:name "Property 3" :value ""}]
(:variant-properties comp1')))
(t/is (= "Value1" (:variant-name root1')))
;; Component2 is unchanged (it was the reference for the property names)
(t/is (= [{:name "Property 1" :value "Value2"}
{:name "Property 3" :value "ValueB"}]
(:variant-properties comp2')))
(t/is (= "Value2, ValueB" (:variant-name root2'))))))
(t/deftest repair-variant-not-main
(t/testing "detect and repair a non-main-instance shape inside a variant container"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Add a third child to the variant container with :variant-id but NOT a main-instance
(ths/add-sample-shape :bad-shape
:type :frame
:parent-label :variant1
:variant-id (thi/id :variant1)
:variant-name "")
;; Add a child to the bad shape (to verify the repair deletes it too)
(ths/add-sample-shape :bad-child
:type :rect
:parent-label :bad-shape))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
bad-shape' (ths/get-shape file' :bad-shape)
bad-child' (ths/get-shape file' :bad-child)]
(t/is (= 4 (count errors))) ;; The bad container also triggers other errors
(t/is (= :invalid-variant-properties (:code (nth errors 0))))
(t/is (= :variant-not-main (:code (nth errors 1))))
(t/is (= :variant-component-bad-name (:code (nth errors 2))))
(t/is (= :variant-component-bad-id (:code (nth errors 3))))
(t/is (nil? errors'))
(t/is (nil? bad-shape'))
(t/is (nil? bad-child')))))
(t/deftest repair-parent-not-variant
(t/testing "detect and repair a variant shape whose parent is not a variant-container"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Break the variant container
(ths/update-shape :variant1 :is-variant-container false))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
container' (ths/get-shape file' :variant1)]
(t/is (= 2 (count errors))) ;; The error is detected twice, once for each child of the variant container
(t/is (= :parent-not-variant (:code (first errors))))
(t/is (= :parent-not-variant (:code (second errors))))
(t/is (nil? errors'))
(t/is (true? (:is-variant-container container'))))))
(t/deftest repair-variant-main-bad-name
(t/testing "detect and repair a main instance whose name doesn't match the variant container's name"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Change root1's name so it doesn't match the container
(ths/update-shape :root1 :name "WrongName"))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1)]
(t/is (= 1 (count errors)))
(t/is (= :variant-main-bad-name (:code (first errors))))
(t/is (nil? errors'))
(t/is (= "Board" (:name root1'))))))
(t/deftest repair-variant-main-bad-variant-name
(t/testing "detect and repair a variant shape whose :variant-name doesn't match the component's properties"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
(thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"}
{:name "Property 2" :value "ValueA"}]})
(thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"}
{:name "Property 2" :value "ValueB"}]})
;; Change root1's :variant-name to something wrong
(ths/update-shape :root1 :variant-name "WrongVariantName")
(ths/update-shape :root2 :variant-name "Value2, ValueB"))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
root1' (ths/get-shape file' :root1)]
(t/is (= 1 (count errors)))
(t/is (= :variant-main-bad-variant-name (:code (first errors))))
(t/is (nil? errors'))
(t/is (= "Value1, ValueA" (:variant-name root1'))))))
(t/deftest repair-variant-component-bad-name
(t/testing "detect and repair a variant component whose path/name doesn't match the container name"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
;; Update names to have path structure
(ths/update-shape :variant1 :name "Group / Subgroup / Component")
(ths/update-shape :root1 :name "Group / Subgroup / Component")
(ths/update-shape :root2 :name "Group / Subgroup / Component")
;; Update component paths and names
(thc/update-component :component1 {:path "Group / Subgroup" :name "Component"})
(thc/update-component :component2 {:path "Group / Subgroup" :name "Component"})
;; Break component1's name
(thc/update-component :component1 {:name "WrongName"}))
errors (cfv/validate-file file {})
changes (cfr/repair-file file {} errors)
file' (thf/apply-changes file {:redo-changes changes} :validate? false)
errors' (cfv/validate-file file' {})
comp1' (thc/get-component file' :component1)]
(t/is (= 1 (count errors)))
(t/is (= :variant-component-bad-name (:code (first errors))))
(t/is (nil? errors'))
(t/is (= "Group / Subgroup" (:path comp1')))
(t/is (= "Component" (:name comp1'))))))
@@ -0,0 +1,213 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns common-tests.geom-bounds-layout-nil-test
(:require
[app.common.data :as d]
[app.common.geom.bounds-map :as gbm]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes.flex-layout.bounds :as fb]
[app.common.geom.shapes.grid-layout.bounds :as gb]
[app.common.geom.shapes.min-size-layout :as msl]
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[clojure.test :as t]))
;; ---- Helpers ----
(defn- make-rect
[id x y w h]
(-> (cts/setup-shape {:id id
:type :rect
:name (str "rect-" id)
:x x :y y :width w :height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero)))
(defn- make-flex-frame
[id child-ids & {:keys [x y w h dir]
:or {x 0 y 0 w 200 h 200 dir :row}}]
(-> (cts/setup-shape {:id id
:type :frame
:name (str "flex-" id)
:layout :flex
:layout-flex-dir dir
:x x :y y :width w :height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero
:shapes (vec child-ids))))
(defn- make-grid-frame
[id child-ids & {:keys [x y w h dir]
:or {x 0 y 0 w 200 h 200 dir :row}}]
(let [cell-id (uuid/next)]
(-> (cts/setup-shape {:id id
:type :frame
:name (str "grid-" id)
:layout :grid
:layout-grid-dir dir
:layout-grid-columns [{:type :flex :value 1}]
:layout-grid-rows [{:type :flex :value 1}]
:layout-grid-cells
{cell-id {:id cell-id
:row 1
:row-span 1
:column 1
:column-span 1
:shapes (vec child-ids)}}
:layout-padding-type :multiple
:layout-padding {:p1 0 :p2 0 :p3 0 :p4 0}
:layout-gap {:column-gap 0 :row-gap 0}
:x x :y y :width w :height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero
:shapes (vec child-ids)))))
(defn- make-objects
[shapes]
(let [shape-map (into {} (map (fn [s] [(:id s) s]) shapes))]
(reduce-kv (fn [m _id shape]
(if (contains? shape :shapes)
(reduce (fn [m' child-id]
(assoc-in m' [child-id :parent-id] (:id shape)))
m
(:shapes shape))
m))
shape-map
shape-map)))
(defn- bounds-map-from-objects
"Build a bounds map from objects, optionally excluding some IDs."
[objects & {:keys [exclude-ids]}]
(let [full (gbm/objects->bounds-map objects)]
(if (seq exclude-ids)
(apply dissoc full exclude-ids)
full)))
;; ---- Tests for flex layout bounds with nil bounds ----
(t/deftest layout-content-points-with-missing-parent-bounds
(t/testing "layout-content-points returns nil when parent is not in bounds map"
(let [child-id (uuid/next)
parent-id (uuid/next)
child (make-rect child-id 10 10 50 50)
parent (make-flex-frame parent-id [child-id])
objects (make-objects [parent child])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})]
(t/is (nil? (fb/layout-content-points bounds parent [child] objects))))))
(t/deftest layout-content-points-with-missing-child-bounds
(t/testing "layout-content-points skips children with missing bounds"
(let [child1-id (uuid/next)
child2-id (uuid/next)
parent-id (uuid/next)
child1 (make-rect child1-id 10 10 50 50)
child2 (make-rect child2-id 70 10 50 50)
parent (make-flex-frame parent-id [child1-id child2-id])
objects (make-objects [parent child1 child2])
bounds (bounds-map-from-objects objects :exclude-ids #{child1-id})]
(let [result (fb/layout-content-points bounds parent [child1 child2] objects)]
(t/is (some? result))
;; Only child2's bounds should be in the result
(t/is (pos? (count result)))))))
(t/deftest layout-content-bounds-with-missing-parent-bounds
(t/testing "layout-content-bounds returns nil when parent is not in bounds map"
(let [child-id (uuid/next)
parent-id (uuid/next)
child (make-rect child-id 10 10 50 50)
parent (make-flex-frame parent-id [child-id])
objects (make-objects [parent child])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})]
(t/is (nil? (fb/layout-content-bounds bounds parent [child] objects))))))
;; ---- Tests for grid layout bounds with nil bounds ----
(t/deftest grid-layout-content-points-with-missing-parent-bounds
(t/testing "grid layout-content-points returns nil when parent is not in bounds map"
(let [parent-id (uuid/next)
parent (make-grid-frame parent-id [])
objects (make-objects [parent])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})
layout-data {:row-tracks [{:start-p (gpt/point 0 0) :size 100}]
:column-tracks [{:start-p (gpt/point 0 0) :size 100}]}]
(t/is (nil? (gb/layout-content-points bounds parent layout-data))))))
(t/deftest grid-layout-content-bounds-with-missing-parent-bounds
(t/testing "grid layout-content-bounds returns nil when parent is not in bounds map"
(let [parent-id (uuid/next)
parent (make-grid-frame parent-id [])
objects (make-objects [parent])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})
layout-data {:row-tracks [{:start-p (gpt/point 0 0) :size 100}]
:column-tracks [{:start-p (gpt/point 0 0) :size 100}]}]
(t/is (nil? (gb/layout-content-bounds bounds parent layout-data))))))
;; ---- Tests for min-size-layout with nil bounds ----
(t/deftest child-min-width-grid-with-missing-child-bounds
(t/testing "child-min-width falls back when grid layout child bounds are missing"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-grid-dir :row
:layout-item-h-sizing :fill))
objects (make-objects [child grandchild])
;; Exclude grandchild from bounds to simulate missing entry
bounds (bounds-map-from-objects objects :exclude-ids #{grandchild-id})
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(let [result (msl/child-min-width child child-bounds bounds objects)]
(t/is (= (ctl/child-min-width child) result))))))
(t/deftest child-min-height-grid-with-missing-child-bounds
(t/testing "child-min-height falls back when grid layout child bounds are missing"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-grid-dir :column
:layout-item-v-sizing :fill))
objects (make-objects [child grandchild])
bounds (bounds-map-from-objects objects :exclude-ids #{grandchild-id})
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(let [result (msl/child-min-height child child-bounds bounds objects)]
(t/is (= (ctl/child-min-height child) result))))))
(t/deftest child-min-width-grid-with-present-child-bounds
(t/testing "child-min-width handles bounded children in a fill-width grid"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-item-h-sizing :fill))
objects (make-objects [child grandchild])
bounds (bounds-map-from-objects objects)
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(t/is (number? (msl/child-min-width child child-bounds bounds objects))))))
(t/deftest child-min-height-grid-with-present-child-bounds
(t/testing "child-min-height handles bounded children in a fill-height grid"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-item-v-sizing :fill))
objects (make-objects [child grandchild])
bounds (bounds-map-from-objects objects)
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(t/is (number? (msl/child-min-height child child-bounds bounds objects))))))
+2
View File
@@ -22,6 +22,7 @@
[common-tests.files.shapes-builder-test]
[common-tests.files.validate-test]
[common-tests.geom-align-test]
[common-tests.geom-bounds-layout-nil-test]
[common-tests.geom-bounds-map-test]
[common-tests.geom-flex-layout-test]
[common-tests.geom-grid-layout-test]
@@ -95,6 +96,7 @@
'common-tests.files-migrations-test
'common-tests.files.validate-test
'common-tests.geom-align-test
'common-tests.geom-bounds-layout-nil-test
'common-tests.geom-bounds-map-test
'common-tests.geom-flex-layout-test
'common-tests.geom-grid-layout-test
@@ -78,6 +78,14 @@
(def content-changed-line-height
(assoc-in content-base [:children 0 :children 0 :line-height] "1.5"))
;; Token/WASM may store full float precision; editor round-trips often
;; truncate (e.g. CSS / f32). These must compare as equal.
(def content-line-height-full-precision
(assoc-in content-base [:children 0 :children 0 :line-height] "1.3333333333333333"))
(def content-line-height-truncated
(assoc-in content-base [:children 0 :children 0 :line-height] "1.33333"))
(def content-redundant-span-line-height
(assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5"))
@@ -208,6 +216,8 @@
;; Other text-node-attr categories
attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family)
attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height)
attrs-line-height-precision (cttx/get-diff-attrs content-line-height-full-precision
content-line-height-truncated)
attrs-span-line-height (cttx/get-diff-attrs content-base content-redundant-span-line-height)
attrs-roundtrip-line-height (cttx/get-diff-attrs content-token-like-line-height
content-after-editor-roundtrip)
@@ -242,6 +252,7 @@
;; Each text-node-attr category reports correct attr key
(t/is (= #{:font-family} attrs-font-family))
(t/is (= #{:line-height} attrs-line-height))
(t/is (= #{} attrs-line-height-precision))
(t/is (= #{} attrs-span-line-height))
(t/is (= #{} attrs-roundtrip-line-height))
(t/is (= #{} attrs-nil-typography-refs))
+1 -1
View File
@@ -66,7 +66,7 @@ RUN set -eux; \
FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.18.2
ENV OPENCODE_VERSION=1.18.4
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
+4 -4
View File
@@ -78,7 +78,7 @@ services:
# - "443:443"
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
restart: always
ports:
- 9001:8080
@@ -111,7 +111,7 @@ services:
# PENPOT_DISABLE_IPV6_LISTEN: "true"
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
restart: always
volumes:
@@ -180,13 +180,13 @@ services:
PENPOT_SMTP_SSL: "false"
penpot-mcp:
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
restart: always
networks:
- penpot
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
restart: always
depends_on:
+1 -1
View File
@@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca
title="Quick demo: Penpot MCP server in action"
width="100%"
height="480"
src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0"
src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
+1 -1
View File
@@ -588,7 +588,7 @@ PENPOT_FLAGS: [...] enable-auto-file-snapshot # Enable automatic v
# Backend
PENPOT_AUTO_FILE_SNAPSHOT_EVERY: 5 # How many save operations trigger the auto-save-version?
PENPOT_AUTO_FILE_SNAPSHOT_TIIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met?
PENPOT_AUTO_FILE_SNAPSHOT_TIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met?
```
Setting custom values for auto-file-snapshot does not change the behaviour for manual versions.
+6
View File
@@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<p>Useful resources to better understand Penpot</p>
</a>
</li>
<li>
<a href="/user-guide/first-steps/migration-guide">
<h2>Migration Guide →</h2>
<p>Move a design system from Figma to Penpot</p>
</a>
</li>
</ul>
@@ -0,0 +1,31 @@
---
title: Migration Guide
order: 6
desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF.
---
<h1 id="migration-guide">Migration Guide</h1>
<p class="main-paragraph">If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.</p>
<div class="advice">
<p><strong>Open the full guide (PDF)</strong></p>
<p><a href="https://nextcloud.kaleidos.net/index.php/s/mKordyz62QF3PQ4?dir=/&amp;editing=false&amp;openfile=true" target="_blank" rel="noopener"><strong>The Enterprise Guide to Migrating Design Systems from Figma to Penpot</strong></a></p>
</div>
<h2 id="what-the-guide-covers">What the guide covers</h2>
<p>The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.</p>
<ul>
<li><strong>Before you export:</strong> audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.</li>
<li><strong>Static assets:</strong> export SVG, PNG, or JPG from Figma and place them in Penpot.</li>
<li><strong>Complex files and libraries:</strong> use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.</li>
<li><strong>Tokens:</strong> if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.</li>
<li><strong>Validate before you scale:</strong> migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.</li>
<li><strong>People and pilots:</strong> the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.</li>
</ul>
<p>The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.</p>
<h2 id="discuss-the-guide">Questions and discussion</h2>
<p>If you want to ask about a migration, or share how yours is going, use the Community post <a href="https://community.penpot.app/t/the-enterprise-guide-to-migrating-design-systems-to-penpot/10768" target="_blank" rel="noopener">The Enterprise Guide to Migrating Design Systems to Penpot</a>.</p>
@@ -1,6 +1,6 @@
---
title: Troubleshooting WebGL
order: 5
order: 7
desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks.
---
+6 -3
View File
@@ -34,8 +34,11 @@
"watch": "pnpm run watch:app",
"build:app": "clojure -M:dev:shadow-cljs release main",
"build": "pnpm run clear:shadow-cache && pnpm run build:app",
"fmt": "cljfmt fix --parallel=true src/",
"check-fmt": "cljfmt check --parallel=true src/",
"lint": "clj-kondo --parallel --lint src/"
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"lint:clj": "clj-kondo --parallel --lint src/ test/",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js"
}
}
+1 -1
View File
@@ -5,4 +5,4 @@ set -e;
corepack enable;
corepack install;
pnpm install;
pnpx playwright install chromium
pnpm exec playwright install chromium
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -ex
corepack enable;
corepack install;
pnpm install;
pnpm run test;
+29
View File
@@ -0,0 +1,29 @@
import { spawnSync } from "node:child_process";
const BUILD_STEPS = [
{ label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] },
];
const progress = (msg) => process.stderr.write(`${msg}\n`);
for (const step of BUILD_STEPS) {
progress(`${step.label}...`);
const result = spawnSync(step.cmd, step.args, {
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
progress(`${step.label} failed`);
if (result.stdout?.length) process.stdout.write(result.stdout);
if (result.stderr?.length) process.stderr.write(result.stderr);
process.exit(result.status ?? 1);
}
}
progress("Running tests...");
const result = spawnSync(
"node",
["target/tests/test.js", ...process.argv.slice(2)],
{ stdio: "inherit" },
);
process.exit(result.status ?? 1);
+9 -1
View File
@@ -31,4 +31,12 @@
:pseudo-names true
:pretty-print true
:anon-fn-naming-policy :off
:source-map-detail-level :all}}}}}
:source-map-detail-level :all}}}
:test
{:target :esm
:output-dir "target/tests"
:runtime :node
:js-options {:js-provider :import}
:modules
{:test {:init-fn exporter-tests.runner/-main}}}}}
+1 -1
View File
@@ -117,7 +117,7 @@
[file-id paths]
(p/let [prefix (str/concat "penpot.pdfunite." file-id ".")
path (sh/tempfile :prefix prefix :suffix ".pdf")]
(sh/run-cmd! (str "pdfunite " (str/join " " paths) " " path))
(apply sh/run-cmd! "pdfunite" (conj (vec paths) path))
path))
(defn- move-file
+1 -1
View File
@@ -38,7 +38,7 @@
:webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix ".png")]
;; playwright only supports jpg and png, we need to convert it afterwards
(bw/screenshot node {:omit-background? true :type :png :path png-path})
(sh/run-cmd! (str "convert " png-path " -quality 100 WEBP:" path))))
(sh/run-cmd! "convert" png-path "-quality" "100" (str "WEBP:" path))))
(on-object (assoc object :path path))))
(render [uri page]
+11 -27
View File
@@ -10,9 +10,12 @@
["xml-js" :as xml]
[app.browser :as bw]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.types.color :as ctc]
[app.common.uri :as u]
[app.config :as cf]
[app.renderer.svg-gradient :as svg-gradient]
[app.util.mime :as mime]
[app.util.shell :as sh]
[clojure.walk :as walk]
@@ -125,19 +128,23 @@
(letfn [(convert-to-ppm [pngpath]
(let [ppmpath (str/concat pngpath "origin.ppm")]
(l/trace :fn :convert-to-ppm :path ppmpath)
(-> (sh/run-cmd! (str "convert " pngpath " " ppmpath))
(-> (sh/run-cmd! "convert" pngpath ppmpath)
(p/then (constantly ppmpath)))))
(trace-color-mask [pbmpath]
(l/trace :fn :trace-color-mask :pbmpath pbmpath)
(let [svgpath (str/concat pbmpath ".svg")]
(-> (sh/run-cmd! (str "potrace --flat -b svg " pbmpath " -o " svgpath))
(-> (sh/run-cmd! "potrace" "--flat" "-b" "svg" pbmpath "-o" svgpath)
(p/then (constantly svgpath)))))
(generate-color-layer [ppmpath color]
(when-not (ctc/hex-color-string? color)
(ex/raise :type :validation
:code :invalid-color
:hint (str "invalid hex color: " color)))
(l/trace :fn :generate-color-layer :ppmpath ppmpath :color color)
(let [pbmpath (str/concat ppmpath ".mask-" (subs color 1) ".pbm")]
(-> (sh/run-cmd! (str/format "ppmcolormask \"%s\" %s" color ppmpath))
(-> (sh/run-cmd! "ppmcolormask" color ppmpath)
(p/then (fn [stdout]
(-> (sh/write-file! pbmpath stdout)
(p/then (constantly pbmpath)))))
@@ -166,33 +173,11 @@
:else
(update node "attributes" assoc "fill" color))))
(get-stops [data]
(->> (get-in data ["gradient" "stops"])
(mapv (fn [stop-data]
{"type" "element"
"name" "stop"
"attributes" {"offset" (get stop-data "offset")
"stop-color" (get stop-data "color")
"stop-opacity" (get stop-data "opacity")}}))))
(data->gradient-def [id [color data]]
(let [id (str "gradient-" id "-" (subs color 1))]
(if (= type "linear")
{"type" "element"
"name" "linearGradient"
"attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"}
"elements" (get-stops data)}
{"type" "element"
"name" "radialGradient"
"attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"}
"elements" (get-stops data)})))
(get-gradients [id mapping]
(->> mapping
(filter (fn [[_color data]]
(= (get data "type") "gradient")))
(mapv (partial data->gradient-def id))))
(mapv (partial svg-gradient/data->gradient-def id))))
(join-color-layers [{:keys [id x y width height mapping] :as node} layers]
(l/trace :fn :join-color-layers :mapping mapping)
@@ -369,4 +354,3 @@
(assoc :query (u/map->query-string params)))]
(bw/exec! (prepare-options uri)
(partial render uri)))))
@@ -0,0 +1,32 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.renderer.svg-gradient)
(defn- get-stops
[data]
(->> (get-in data ["gradient" "stops"])
(mapv (fn [stop-data]
{"type" "element"
"name" "stop"
"attributes" {"offset" (get stop-data "offset")
"stop-color" (get stop-data "color")
"stop-opacity" (get stop-data "opacity")}}))))
(defn data->gradient-def
[id [color data]]
(let [id (str "gradient-" id "-" (subs color 1))
gradient-type (get-in data ["gradient" "type"])]
(if (= gradient-type "linear")
{"type" "element"
"name" "linearGradient"
"attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"}
"elements" (get-stops data)}
{"type" "element"
"name" "radialGradient"
"attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"}
"elements" (get-stops data)})))
+8 -8
View File
@@ -94,14 +94,14 @@
(.readFile fs/promises fpath))
(defn run-cmd!
[cmd]
[cmd & args]
(p/create
(fn [resolve reject]
(l/trace :fn :run-cmd :cmd cmd)
(proc/exec cmd #js {:encoding "buffer"}
(fn [error stdout _stderr]
;; (l/trace :fn :run-cmd :stdout stdout)
(if error
(reject error)
(resolve stdout)))))))
(l/trace :fn :run-cmd :cmd cmd :args args)
(proc/execFile cmd (clj->js args) #js {:encoding "buffer"}
(fn [error stdout _stderr]
;; (l/trace :fn :run-cmd :stdout stdout)
(if error
(reject error)
(resolve stdout)))))))
@@ -0,0 +1,25 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.renderer-svg-test
(:require
[app.renderer.svg-gradient :as svg-gradient]
[cljs.test :refer [deftest is testing]]))
(def gradient-stops
[{"color" "#000000" "offset" 0 "opacity" 1}
{"color" "#ffffff" "offset" 1 "opacity" 1}])
(deftest creates-the-correct-gradient-element
(doseq [[gradient-type element-name]
[["linear" "linearGradient"]
["radial" "radialGradient"]]]
(testing gradient-type
(let [gradient-data {"type" "gradient"
"gradient" {"type" gradient-type
"stops" gradient-stops}}
result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])]
(is (= element-name (get result "name")))))))
+172
View File
@@ -0,0 +1,172 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.runner
(:require
[app.common.logging :as l]
[cljs.test :as t]
[clojure.string :as str]
[clojure.tools.cli :refer [parse-opts]]
[exporter-tests.renderer-svg-test]
[exporter-tests.shell-test]
[goog.object :as gobj]))
(enable-console-print!)
(def test-namespaces
['exporter-tests.renderer-svg-test
'exporter-tests.shell-test])
(assert (every? find-ns-obj test-namespaces)
"test-namespaces contains a namespace that isn't required in runner.cljs")
(defmethod t/report [:cljs.test/default :begin-test-var]
[m]
(let [v (:var m)]
(println (str " ▸ " (:ns (meta v)) "/" (:name (meta v))))))
(defmethod t/report [:cljs.test/default :end-run-tests]
[result]
(.exit js/process (if (cljs.test/successful? result) 0 1)))
(def ^:private log-levels
#{:trace :debug :info :warn :error})
(def cli-options
[["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. exporter-tests.renderer-svg-test/creates-the-correct-gradient-element"]
["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error"
:parse-fn keyword
:validate [log-levels "must be one of trace, debug, info, warn, error"]]
["-h" "--help"]])
(defn- argv
[]
(let [args (->> (.-argv js/process)
(array-seq)
(drop 2))]
;; `pnpm run test -- --focus ...` forwards the separator to the node
;; process, so drop one leading `--` before handing args to tools.cli.
(cond-> args
(= "--" (first args)) rest)))
(defn- usage
[summary]
(str "Usage: node target/tests/test.js [options]\n\n"
"Options:\n"
summary "\n\n"
"Build first with: pnpm run build:test\n\n"
"Focus examples:\n"
" node target/tests/test.js --focus exporter-tests.renderer-svg-test\n"
" node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element\n\n"
"Log level example:\n"
" node target/tests/test.js --focus exporter-tests.renderer-svg-test --log-level warn"))
(defn- fail!
[message]
(js/console.error message)
(.exit js/process 1))
(defn- parse-focus
[focus]
(let [[ns-name test-name & extra] (str/split focus #"/")]
(cond
(or (str/blank? ns-name) (seq extra))
(fail! (str "Invalid --focus value: " focus))
(some? test-name)
{:ns (symbol ns-name) :test test-name}
:else
{:ns (symbol ns-name)})))
(defn- fixture-value
[ns-obj fixture-name]
(let [value (gobj/get ns-obj (munge fixture-name))]
(when-not (undefined? value)
value)))
(defn- ns-test-vars
[ns-sym]
(when-let [ns-obj (find-ns-obj ns-sym)]
(->> (js-keys ns-obj)
(keep (fn [key]
(some-> (gobj/get ns-obj key)
(.-cljs$lang$var))))
(filter (comp :test meta))
(sort-by (comp :line meta)))))
(defn- ns-fixtures
[ns-sym vars]
(when-let [ns-obj (find-ns-obj ns-sym)]
(let [ns-key (or (some-> vars first meta :ns) ns-sym)
once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures")
each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")]
{:once (when once-fixtures {ns-key once-fixtures})
:each (when each-fixtures {ns-key each-fixtures})})))
(defn- selected-tests
[{:keys [ns test]}]
(when-not (some #{ns} test-namespaces)
(fail! (str "Unknown test namespace: " ns)))
(let [vars (vec (ns-test-vars ns))]
(when (empty? vars)
(fail! (str "No tests found in namespace: " ns)))
(if test
(let [test-sym (symbol test)
test-var (some #(when (= test-sym (:name (meta %))) %) vars)]
(if test-var
{:vars [test-var]
:fixtures (ns-fixtures ns [test-var])}
(fail! (str "Unknown test var: " ns "/" test))))
{:vars vars
:fixtures (ns-fixtures ns vars)})))
(defn- merge-fixtures
[fixtures]
{:once (apply merge (keep :once fixtures))
:each (apply merge (keep :each fixtures))})
(defn- run-test-vars!
[tests]
(let [vars (vec (mapcat :vars tests))
fixtures (merge-fixtures (map :fixtures tests))
env (assoc (t/empty-env)
:once-fixtures (:once fixtures)
:each-fixtures (:each fixtures))
summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})]
(t/set-env! env)
(t/run-block
(concat (t/test-vars-block vars)
[(fn []
(vswap! summary
(partial merge-with +)
(:report-counters (t/get-current-env))))
(fn []
(t/report @summary)
(t/report (assoc @summary :type :end-run-tests)))]))))
(defn- run-focused-test!
[focus]
(run-test-vars! [(selected-tests (parse-focus focus))]))
(defn -main
[]
(let [{:keys [options errors summary]} (parse-opts (argv) cli-options)]
(cond
(seq errors)
(fail! (str/join "\n" errors))
(:help options)
(do
(println (usage summary))
(.exit js/process 0))
:else
(do
(l/setup! {:app (or (:log-level options) :warn)})
(if (:focus options)
(run-focused-test! (:focus options))
(run-test-vars! (map #(selected-tests {:ns %}) test-namespaces)))))))
@@ -0,0 +1,70 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.shell-test
"Tests to verify GHSA-4f36-m4hj-cv86 is fixed: OS Command Injection in SVG exporter.
These tests prove that:
1. execFile does NOT interpret shell metacharacters (safe execution)
2. Malicious colors fail validation regex
3. The injection does NOT execute commands (no RCE)"
(:require
["node:child_process" :as proc]
["node:fs" :as fs]
[cljs.test :as t :include-macros true]))
(def ^:private hex-color-rx
#"^#(?:[0-9a-fA-F]{3}){1,2}$")
(defn- valid-hex-color?
[color]
(and (string? color)
(some? (re-matches hex-color-rx color))))
(t/deftest execfile-does-not-interpret-shell-metacharacters
(t/testing "Proves execFile passes arguments literally (no shell interpretation)"
(t/async done
(let [cmd "echo"
args #js ["$(echo PWNED)"]]
(proc/execFile cmd args #js {:encoding "buffer"}
(fn [error stdout _stderr]
(if error
(do
(t/is false (str "unexpected error: " (.-message error)))
(done))
(let [output (.toString stdout "utf8")]
(t/is (= "$(echo PWNED)\n" output)
"execFile passes $(...) literally, no shell interpretation")
(done)))))))))
(t/deftest malicious-color-fails-validation
(t/testing "Proves malicious colors are rejected by validation"
(let [malicious "#000000$(echo PWNED)"
valid-color "#000000"
short-valid "#abc"]
(t/is (not (valid-hex-color? malicious))
"malicious color with $(...) fails validation")
(t/is (valid-hex-color? valid-color)
"valid 6-digit hex color passes validation")
(t/is (valid-hex-color? short-valid)
"valid 3-digit hex color passes validation"))))
(t/deftest execfile-does-not-execute-injected-commands
(t/testing "Proves execFile does NOT execute injected commands (no RCE)"
(t/async done
(let [marker "/tmp/penpot-exporter-rce-test"
malicious (str "#000000$(touch " marker ")")
cmd "echo"
args #js [malicious]]
(when (fs/existsSync marker)
(fs/unlinkSync marker))
(proc/execFile cmd args #js {:encoding "buffer"}
(fn [_error _stdout _stderr]
;; Command completes (or fails), but no injection occurs
(t/is (not (fs/existsSync marker))
"no RCE: marker file was NOT created")
(when (fs/existsSync marker)
(fs/unlinkSync marker))
(done)))))))
+2 -2
View File
@@ -29,7 +29,7 @@
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w",
"lint:clj": "clj-kondo --parallel --lint ../common/src src/",
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
"lint:js": "exit 0",
"lint:scss": "pnpm exec stylelint '{src,resources}/**/*.scss'",
"build:test": "pnpm run build:wasm && clojure -M:dev:shadow-cljs compile test",
@@ -66,7 +66,7 @@
"@tokens-studio/sd-transforms": "2.0.3",
"@types/node": "^26.1.0",
"@vitest/browser": "4.1.9",
"@vitest/browser-playwright": "^4.1.9",
"@vitest/browser-playwright": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@zip.js/zip.js": "2.8.26",
"autoprefixer": "^10.5.2",
+7 -3
View File
@@ -29,6 +29,7 @@ function isDefined(v) {
}
function mergeBlockData(block, newData) {
if (!block) return undefined;
let data = block.getData();
for (let key of Object.keys(newData)) {
@@ -176,10 +177,12 @@ export function splitBlockPreservingData(state) {
content = Modifier.splitBlock(content, selection);
const blockData = content.blockMap.get(content.selectionBefore.getStartKey()).getData();
const startKey = content.selectionBefore.getStartKey();
const block = content.blockMap.get(startKey);
const blockData = (block && block.getData()) || new Map();
const blockKey = content.selectionAfter.getStartKey();
const blockMap = content.blockMap.update(blockKey, (block) => {
return block.set("data", blockData);
const blockMap = content.blockMap.update(blockKey, (b) => {
return b.set("data", blockData);
});
content = content.set("blockMap", blockMap);
@@ -325,6 +328,7 @@ export function updateBlockData(state, blockKey, data) {
const content = state.getCurrentContent();
const block = content.getBlockForKey(blockKey);
const newBlock = mergeBlockData(block, data);
if (!newBlock) return state;
const blockData = newBlock.getData();
@@ -1,4 +1,7 @@
import { test, expect } from "@playwright/test";
import {
unfoldTokenType,
} from "./helpers";
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
@@ -27,3 +30,47 @@ test("BUG 13958 - Fill token gets detached when editing text shape", async ({ pa
// Assert token is still attached to the shape
await expect(workspacePage.rightSidebar.getByLabel("xx.alias.color.text.default", { exact: true })).toBeVisible();
});
test("Selection change clears text-edition mode so tokens can be applied", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockGetFile("workspace/get-file-13958.json");
await workspacePage.goToWorkspace();
await workspacePage.rectShapeButton.click();
await workspacePage.clickWithDragViewportAt(128, 128, 200, 100);
await workspacePage.clickLeafLayer("Rectangle");
// Enter text editing on the text layer
await workspacePage.clickLeafLayer("Design tokens are a set");
await workspacePage.page.keyboard.press("Enter");
await expect(workspacePage.page.getByTestId("text-editor")).toBeVisible();
// Shift-click another layer to change selection while editing
await workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Rectangle" })
.click({ modifiers: ["Shift"] });
// Text editor should close because selection change emits :interrupt
await expect(workspacePage.page.getByTestId("text-editor")).not.toBeAttached();
// Open tokens tab and try to apply a fill token — should succeed without warning
await page.getByRole("tab", { name: "Tokens" }).click();
const tokensSidebar = page.getByTestId("tokens-sidebar");
await unfoldTokenType(tokensSidebar, "color");
// Right-click a color token and apply as fill
await tokensSidebar
.getByRole('button', { name: '#934846 xx.global.color.red.' })
.click({ button: "right" });
await workspacePage.tokenContextMenuForToken.getByText("Fill").click();
// Verify no warning toast appeared about text editing
await expect(
page.getByRole("alert").filter({
hasText: /Tokens can't be applied while editing text/i,
}),
).not.toBeVisible();
});
+3 -2
View File
@@ -17,6 +17,7 @@ overrides:
postcss@<8.4.31: ^8.4.31
postcss@<8.5.10: ^8.5.10
yaml@>=2.0.0 <2.8.3: ^2.8.3
playwright: 1.61.1
patchedDependencies:
'@zip.js/zip.js@2.8.26': 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95
@@ -74,7 +75,7 @@ importers:
specifier: 4.1.9
version: 4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9)
'@vitest/browser-playwright':
specifier: ^4.1.9
specifier: 4.1.9
version: 4.1.9(playwright@1.61.1)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9)
'@vitest/coverage-v8':
specifier: 4.1.9
@@ -2103,7 +2104,7 @@ packages:
'@vitest/browser-playwright@4.1.9':
resolution: {integrity: sha512-Bq1rOGf9waevzG3EOkO/dene6bvKTUsZMVg8S1i+WH3JcMjuXEjiahP9rAqZRELUqjBySOJsvvSWqK/B3wjKQw==}
peerDependencies:
playwright: '*'
playwright: 1.61.1
vitest: 4.1.9
'@vitest/browser@4.1.9':
+1
View File
@@ -31,3 +31,4 @@ overrides:
postcss@<8.4.31: ^8.4.31
postcss@<8.5.10: ^8.5.10
yaml@>=2.0.0 <2.8.3: ^2.8.3
playwright: 1.61.1
+1 -1
View File
@@ -3,4 +3,4 @@
corepack enable;
corepack install;
pnpm install;
pnpx playwright install chromium;
pnpm exec playwright install chromium;
+103 -84
View File
@@ -20,10 +20,27 @@
[app.util.dom :as dom]
[app.util.websocket :as ws]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(def default-timeout 5000)
(defn normalize-export
[{:keys [object-id name] :as export}]
(assoc export :name (if (str/blank? name)
(str object-id)
name)))
(defn- normalize-exports
[exports]
(mapv normalize-export exports))
(defn- normalize-export-shapes-params
[{:keys [exports] :as params}]
(cond-> params
(seq exports)
(assoc :exports (normalize-exports exports))))
(defn toggle-detail-visibililty
[]
(ptk/reify ::toggle-detail-visibililty
@@ -181,104 +198,106 @@
(defn request-simple-export
[{:keys [export]}]
(ptk/reify ::request-simple-export
ptk/UpdateEvent
(update [_ state]
(cond-> state
(not (use-wasm-export? state export))
(update :export assoc :in-progress true :id uuid/zero)))
(let [export (normalize-export export)]
(ptk/reify ::request-simple-export
ptk/UpdateEvent
(update [_ state]
(cond-> state
(not (use-wasm-export? state export))
(update :export assoc :in-progress true :id uuid/zero)))
ptk/WatchEvent
(watch [_ state _]
(if (use-wasm-export? state export)
(do
(case (:type export)
:pdf (wasm.exports/export-pdf export)
(wasm.exports/export-image export))
(rx/empty))
(let [profile-id (:profile-id state)
params {:exports [export]
:profile-id profile-id
:cmd :export-shapes
:wait true
:is-wasm (wasm-export-enabled? state)}]
(rx/concat
(dwp/force-persist-and-wait 400)
ptk/WatchEvent
(watch [_ state _]
(if (use-wasm-export? state export)
(do
(case (:type export)
:pdf (wasm.exports/export-pdf export)
(wasm.exports/export-image export))
(rx/empty))
(let [profile-id (:profile-id state)
params (normalize-export-shapes-params {:exports [export]
:profile-id profile-id
:cmd :export-shapes
:wait true
:is-wasm (wasm-export-enabled? state)})]
(rx/concat
(dwp/force-persist-and-wait 400)
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [filename mtype uri]}]
(dom/trigger-download-uri filename mtype uri)
(clear-export-state uuid/zero)))
(rx/catch (fn [cause]
(rx/concat
(rx/of (clear-export-state uuid/zero))
(rx/throw cause)))))))))))
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [filename mtype uri]}]
(dom/trigger-download-uri filename mtype uri)
(clear-export-state uuid/zero)))
(rx/catch (fn [cause]
(rx/concat
(rx/of (clear-export-state uuid/zero))
(rx/throw cause))))))))))))
(defn request-multiple-export
[{:keys [exports cmd name]
:or {cmd :export-shapes}
:as params}]
(ptk/reify ::request-multiple-export
ptk/WatchEvent
(watch [_ state _]
(let [resource-id (volatile! nil)
profile-id (:profile-id state)
ws-conn (:ws-conn state)
params (cond->
{:exports exports
:cmd cmd
:profile-id profile-id
:force-multiple true
:is-wasm (wasm-export-enabled? state)}
(some? name)
(assoc :name name))
(let [exports (normalize-exports exports)]
(ptk/reify ::request-multiple-export
ptk/WatchEvent
(watch [_ state _]
(let [resource-id (volatile! nil)
profile-id (:profile-id state)
ws-conn (:ws-conn state)
params (cond->
{:exports exports
:cmd cmd
:profile-id profile-id
:force-multiple true
:is-wasm (wasm-export-enabled? state)}
(some? name)
(assoc :name name))
progress-stream
(->> (ws/get-rcv-stream ws-conn)
(rx/filter ws/message-event?)
(rx/map :payload)
(rx/filter #(= :export-update (:type %)))
(rx/filter #(= @resource-id (:resource-id %)))
(rx/share))
progress-stream
(->> (ws/get-rcv-stream ws-conn)
(rx/filter ws/message-event?)
(rx/map :payload)
(rx/filter #(= :export-update (:type %)))
(rx/filter #(= @resource-id (:resource-id %)))
(rx/share))
stopper
(rx/filter #(or (= "ended" (:status %))
(= "error" (:status %)))
progress-stream)]
stopper
(rx/filter #(or (= "ended" (:status %))
(= "error" (:status %)))
progress-stream)]
(swap! st/ongoing-tasks conj :export)
(swap! st/ongoing-tasks conj :export)
(rx/merge
;; Force that all data is persisted; best effort.
(rx/of ::dwp/force-persist)
(rx/merge
;; Force that all data is persisted; best effort.
(rx/of ::dwp/force-persist)
;; Launch the exportation process and stores the resource id
;; locally.
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [id] :as resource}]
(vreset! resource-id id)
(initialize-export-status exports cmd resource))))
;; Launch the exportation process and stores the resource id
;; locally.
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [id] :as resource}]
(vreset! resource-id id)
(initialize-export-status exports cmd resource))))
;; We proceed to update the export state with incoming
;; progress updates. We delay the stopper for give some time
;; to update the status with ended or errored status before
;; close the stream.
(->> progress-stream
(rx/map update-export-status)
(rx/take-until (rx/delay 500 stopper))
(rx/finalize (fn []
(swap! st/ongoing-tasks disj :export))))
;; We proceed to update the export state with incoming
;; progress updates. We delay the stopper for give some time
;; to update the status with ended or errored status before
;; close the stream.
(->> progress-stream
(rx/map update-export-status)
(rx/take-until (rx/delay 500 stopper))
(rx/finalize (fn []
(swap! st/ongoing-tasks disj :export))))
;; We hide need to hide the ui elements of the export after
;; some interval. We also delay a little bit more the stopper
;; for ensure that after some security time, the stream is
;; completely closed.
(->> progress-stream
(rx/filter #(= "ended" (:status %)))
(rx/take 1)
(rx/delay default-timeout)
(rx/map #(clear-export-state @resource-id))
(rx/take-until (rx/delay 6000 stopper))))))))
;; We hide need to hide the ui elements of the export after
;; some interval. We also delay a little bit more the stopper
;; for ensure that after some security time, the stream is
;; completely closed.
(->> progress-stream
(rx/filter #(= "ended" (:status %)))
(rx/take 1)
(rx/delay default-timeout)
(rx/map #(clear-export-state @resource-id))
(rx/take-until (rx/delay 6000 stopper)))))))))
(defn request-export
[{:keys [exports] :as params}]
+9 -5
View File
@@ -222,7 +222,12 @@
(watch [_ state _]
(if (and (features/active-feature? state "render-wasm/v1")
(contains? cf/flags :available-viewer-wasm))
(let [objects (dsh/lookup-page-objects state file-id page-id)
;; Fallback matches the viewer UI when the URL omits page-id.
(let [page-id (or page-id
(-> (dsh/lookup-file-data state file-id)
:pages
first))
objects (dsh/lookup-page-objects state file-id page-id)
shapes
(reduce-kv
@@ -233,13 +238,12 @@
[]
objects)
;; Creates a stream from the async callback. This stream will only
;; emit one single value after the objects have finished loading
;; in the wasm memory.
;; Positive size required: OffscreenCanvas(0, 0) crashes
;; `_set_render_options` on some browsers.
set-objects-stream
(rx/create
(fn [subs]
(wasm.api/init-canvas-context (js/OffscreenCanvas. 0 0))
(wasm.api/init-canvas-context (js/OffscreenCanvas. 64 64))
(wasm.api/set-objects-callback shapes #(rx/push! subs :done))
nil))]
@@ -34,6 +34,7 @@
[app.config :as cf]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.exports.assets :as de]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.helpers :as dsh]
[app.main.data.notifications :as ntf]
@@ -1147,16 +1148,16 @@
page-id (:current-page-id state)
selected (first (dsh/lookup-selected state))
export {:file-id file-id
:page-id page-id
:object-id selected
;; webp would be preferrable, but PNG is the most supported image MIME type by clipboard APIs.
:type :png
;; Always use 2 to ensure good enough quality for wireframes.
:scale 2
:suffix ""
:enabled true
:name ""}
export (de/normalize-export {:file-id file-id
:page-id page-id
:object-id selected
;; webp would be preferrable, but PNG is the most supported image MIME type by clipboard APIs.
:type :png
;; Always use 2 to ensure good enough quality for wireframes.
:scale 2
:suffix ""
:enabled true
:name ""})
;; Create a deferred promise immediately, before any async operations.
;; Registering the clipboard write NOW preserves the user-gesture security
@@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.math :as mth]
[app.common.schema :as sm]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
@@ -950,7 +951,8 @@
(or (not cap-stops?) (< (count stops) types.fills/MAX-GRADIENT-STOPS))]
(if can-add-stop?
(let [new-stop (-> (clr/interpolate-gradient stops offset)
(let [offset (mth/clamp offset 0 1)
new-stop (-> (clr/interpolate-gradient stops offset)
(split-color-components))
stops (conj stops new-stop)
stops (into [] (sort-by :offset stops))
@@ -973,7 +975,8 @@
stops (mapv split-color-components
(if cap-stops?
(take types.fills/MAX-GRADIENT-STOPS stops)
stops))]
stops))
stops (mapv #(update % :offset (fn [o] (mth/clamp o 0 1))) stops)]
(-> state
(assoc :current-color (get stops stop))
(assoc :stops stops))))))))
@@ -1571,7 +1571,12 @@
(as-> libraries-to-load $
(remove loaded-libraries $)
(conj $ library-id)
(map #(load-library-file file-id %) $))))))
(map #(load-library-file file-id %) $))))
(rx/catch (fn [cause]
(let [error (ex-data cause)]
(if (= (:code error) :circular-library-reference)
(rx/of (ntf/error (tr "errors.circular-library-reference")))
(rx/throw cause)))))))
(rx/of (ptk/reify ::attach-library-finished))
(when (pos? variants-count)
(->> (rp/cmd! :get-library-usage {:file-id library-id})
@@ -221,7 +221,7 @@
(ptk/reify ::deselect-shape
ptk/WatchEvent
(watch [_ _ _]
(rx/of ::dwsp/interrupt))
(rx/of :interrupt ::dwsp/interrupt))
ptk/UpdateEvent
(update [_ state]
(-> state
@@ -236,7 +236,7 @@
(ptk/reify ::shift-select-shapes
ptk/WatchEvent
(watch [_ _ _]
(rx/of ::dwsp/interrupt))
(rx/of :interrupt ::dwsp/interrupt))
ptk/UpdateEvent
(update [_ state]
(let [objects (or objects (dsh/lookup-page-objects state))
@@ -275,7 +275,11 @@
;; the event loop
expand-s (->> (rx/of (dwc/expand-all-parents ids objects))
(rx/observe-on :async))
interrupt-s (rx/of ::dwsp/interrupt)]
;; :interrupt aborts drag-stopper; only emit it when clearing edition
;; (unconditional emit broke marquee selection after #10798).
interrupt-s (if (some? (dm/get-in state [:workspace-local :edition]))
(rx/of :interrupt ::dwsp/interrupt)
(rx/of ::dwsp/interrupt))]
(rx/merge expand-s interrupt-s)))))
(defn select-all
+9 -8
View File
@@ -8,7 +8,7 @@
"Generic error handling"
(:require
[app.common.exceptions :as ex]
[app.common.pprint :as pp]
[app.common.time :as ct]
[app.config :as cf]
[app.main.data.auth :as da]
[app.main.data.event :as ev]
@@ -134,13 +134,14 @@
(with-out-str
(println "Context:")
(println "--------------------")
(println "Hint: " (or (:hint data) (ex-message cause) "--"))
(println "Prof ID: " (str (or profile-id "--")))
(println "Team ID: " (str (or team-id "--")))
(println "Timestamp:" (ct/format-inst (ct/now) :rfc1123))
(println "Hint: " (or (:hint data) (ex-message cause) "--"))
(println "Prof ID: " (str (or profile-id "--")))
(println "Team ID: " (str (or team-id "--")))
(when-let [file-id (or (:file-id data) file-id)]
(println "File ID: " (str file-id)))
(println "Version: " (:full cf/version))
(println "HREF: " (rt/get-current-href))
(println "File ID: " (str file-id)))
(println "Version: " (:full cf/version))
(println "HREF: " (rt/get-current-href))
(println)
(println
@@ -149,7 +150,7 @@
(println "Last events:")
(println "--------------------")
(pp/pprint @st/last-events {:length 200})
(println (st/format-last-events))
(println)))
(catch :default cause
(.error js/console "error on generating report" cause)
+26
View File
@@ -7,6 +7,7 @@
(ns app.main.store
(:require
[app.common.logging :as log]
[app.common.time :as ct]
[app.util.object :as obj]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
@@ -94,6 +95,7 @@
(rx/filter #(not (contains? omitset %)))
(rx/map str)
(rx/pipe (rxo/distinct-contiguous))
(rx/map (fn [event] {:name event :t (ct/now)}))
(rx/scan (fn [buffer event]
(cond-> (conj buffer event)
(> (count buffer) 50)
@@ -102,6 +104,30 @@
(rx/subs! #(reset! buffer (vec %))))
buffer))
(defn format-last-events
"Render the `last-events` buffer as a multi-line string with the
wall-clock time of each event and the delta (ms) since the previous
entry. The delta column is right-padded to 10 chars so the event
names align. Useful for embedding in error reports."
([] (format-last-events @last-events))
([events]
(let [lines
(loop [prev-t nil
xs (seq events)
out (transient [])]
(if xs
(let [{:keys [name t]} (first xs)
iso (ct/format-inst t :iso)
delta (if prev-t
(str "(+" (ct/diff-ms prev-t t) "ms)")
"(+0ms)")
delta-pad (str/pad delta {:length 10 :type :right})]
(recur t
(next xs)
(conj! out (str iso " " delta-pad " " name))))
(persistent! out)))]
(str/join "\n" lines))))
(defn emit!
([] nil)
([event]
@@ -18,6 +18,7 @@
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[rumext.v2 :as mf]))
(def ^:private xf:options
@@ -229,8 +230,11 @@
(partial ug/unlisten "penpot:context-menu:open" on-event)))
(mf/with-effect [ids]
(tm/schedule-on-idle
#(dom/focus! (dom/get-element (first ids)))))
(let [handle (tm/schedule
(fn []
(some-> (dom/get-element (first ids))
(dom/focus!))))]
#(rx/dispose! handle)))
(when (some? levels)
[:> dropdown-content* props
@@ -11,6 +11,7 @@
[app.util.globals :as globals]
[app.util.keyboard :as kbd]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[goog.events :as events]
[rumext.v2 :as mf])
(:import goog.events.EventType))
@@ -45,9 +46,10 @@
(fn []
(let [keys [(events/listen globals/document EventType.CLICK on-click)
(events/listen globals/document EventType.CONTEXTMENU on-click)
(events/listen globals/document EventType.KEYUP on-keyup)]]
(tm/schedule #(mf/set-ref-val! listening-ref true))
#(run! events/unlistenByKey keys)))]
(events/listen globals/document EventType.KEYUP on-keyup)]
timer (tm/schedule #(mf/set-ref-val! listening-ref true))]
#(do (rx/dispose! timer)
(run! events/unlistenByKey keys))))]
(mf/use-effect on-mount)
children))
+20 -19
View File
@@ -560,28 +560,29 @@
on-paste
(mf/use-fn
(fn [event]
(let [paste-data (-> event .-clipboardData (.getData "text"))]
(when (and (string? paste-data)
(re-find #"[,\s]" paste-data))
(dom/prevent-default event)
(dom/stop-propagation event)
(when-let [clipboard-data (.-clipboardData event)]
(let [paste-data (.getData clipboard-data "text")]
(when (and (string? paste-data)
(re-find #"[,\s]" paste-data))
(dom/prevent-default event)
(dom/stop-propagation event)
;; Mark as touched
(swap! form assoc-in [:touched input-name] true)
;; Mark as touched
(swap! form assoc-in [:touched input-name] true)
;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+")
(map str/trim)
(remove str/empty?))]
(doseq [part parts]
(when (valid-item-fn part)
(swap! items conj-dedup {:text part
:valid true
:caution (caution-item-fn part)})))
;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+")
(map str/trim)
(remove str/empty?))]
(doseq [part parts]
(when (valid-item-fn part)
(swap! items conj-dedup {:text part
:valid true
:caution (caution-item-fn part)})))
;; Reset input value and mark as untouched after successful paste
(reset! value "")
(swap! form assoc-in [:touched input-name] false))))))
;; Reset input value and mark as untouched after successful paste
(reset! value "")
(swap! form assoc-in [:touched input-name] false)))))))
on-blur
(mf/use-fn
+23 -41
View File
@@ -40,6 +40,7 @@
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
[app.main.ui.hooks :refer [use-focus-timer-ref]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.nitrate.nitrate-form]
[app.util.dom :as dom]
@@ -94,6 +95,14 @@
(def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon")
(def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle")
(defn schedule-focus-by-id!
[ref element-id]
(when-let [h (mf/ref-val ref)]
(ts/dispose! h))
(mf/set-ref-val! ref
(ts/schedule
#(dom/focus-and-untabbable! (dom/get-element element-id)))))
(mf/defc sidebar-project*
{::mf/private true}
[{:keys [item is-selected]}]
@@ -112,6 +121,8 @@
project-id (get item :id)
focus-timer-ref (use-focus-timer-ref)
on-click
(mf/use-fn
(mf/deps project-id)
@@ -123,14 +134,9 @@
(mf/deps project-id)
(fn [event]
(when (kbd/enter? event)
(st/emit!
(dcm/go-to-dashboard-files :project-id project-id))
(ts/schedule
(fn []
(when-let [title (dom/get-element (str project-id))]
(dom/set-attribute! title "tabindex" "0")
(dom/focus! title)
(dom/set-attribute! title "tabindex" "-1")))))))
(schedule-focus-by-id! focus-timer-ref (str project-id))
(st/emit! (dcm/go-to-dashboard-files :project-id project-id)))))
on-menu-click
(mf/use-fn
@@ -228,6 +234,8 @@
focused? (mf/use-state false)
emit! (mf/use-memo #(f/debounce st/emit! 500))
focus-timer-ref (use-focus-timer-ref)
on-search-blur
(mf/use-fn
(fn [_]
@@ -254,13 +262,7 @@
(mf/use-fn
(fn [e]
(when (kbd/enter? e)
(ts/schedule
(fn []
(let [search-title (dom/get-element (str "dashboard-search-title"))]
(when search-title
(dom/set-attribute! search-title "tabindex" "0")
(dom/focus! search-title)
(dom/set-attribute! search-title "tabindex" "-1")))))
(schedule-focus-by-id! focus-timer-ref "dashboard-search-title")
(dom/prevent-default e)
(dom/stop-propagation e))))
@@ -948,6 +950,8 @@
nitrate? (contains? cf/flags :nitrate)
focus-timer-ref (use-focus-timer-ref)
go-projects
(mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent)))
@@ -957,12 +961,7 @@
(fn []
(st/emit!
(dcm/go-to-dashboard-recent :team-id team-id))
(ts/schedule
(fn []
(when-let [projects-title (dom/get-element "dashboard-projects-title")]
(dom/set-attribute! projects-title "tabindex" "0")
(dom/focus! projects-title)
(dom/set-attribute! projects-title "tabindex" "-1"))))))
(schedule-focus-by-id! focus-timer-ref "dashboard-projects-title")))
go-fonts
(mf/use-fn
@@ -975,13 +974,7 @@
(fn []
(st/emit!
(dcm/go-to-dashboard-fonts :team-id team-id))
(ts/schedule
(fn []
(let [font-title (dom/get-element "dashboard-fonts-title")]
(when font-title
(dom/set-attribute! font-title "tabindex" "0")
(dom/focus! font-title)
(dom/set-attribute! font-title "tabindex" "-1")))))))
(schedule-focus-by-id! focus-timer-ref "dashboard-fonts-title")))
go-drafts
(mf/use-fn
@@ -994,12 +987,7 @@
(mf/deps team-id default-project-id)
(fn []
(st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id))
(ts/schedule
(fn []
(when-let [title (dom/get-element "dashboard-drafts-title")]
(dom/set-attribute! title "tabindex" "0")
(dom/focus! title)
(dom/set-attribute! title "tabindex" "-1"))))))
(schedule-focus-by-id! focus-timer-ref "dashboard-drafts-title")))
go-libs
(mf/use-fn
@@ -1012,13 +1000,7 @@
(fn []
(st/emit!
(dcm/go-to-dashboard-libraries :team-id team-id))
(ts/schedule
(fn []
(let [libs-title (dom/get-element "dashboard-libraries-title")]
(when libs-title
(dom/set-attribute! libs-title "tabindex" "0")
(dom/focus! libs-title)
(dom/set-attribute! libs-title "tabindex" "-1")))))))
(schedule-focus-by-id! focus-timer-ref "dashboard-libraries-title")))
pinned-projects
(mf/with-memo [projects]
@@ -322,25 +322,26 @@
(let [trigger-el (mf/ref-val trigger-ref)
tooltip-el (mf/ref-val tooltip-ref)]
(when (and trigger-el tooltip-el)
(ts/raf
(fn []
(let [origin-brect (dom/get-bounding-rect trigger-el)
tooltip-brect (dom/get-bounding-rect tooltip-el)
window-size (dom/get-window-size)]
(when-let [[new-placement placement-rect]
(find-matching-placement
placement
tooltip-brect
origin-brect
window-size
offset)]
(dom/set-css-property! tooltip-el "inset-block-start"
(str (:top placement-rect) "px"))
(dom/set-css-property! tooltip-el "inset-inline-start"
(str (:left placement-rect) "px"))
(let [raf-id (ts/raf
(fn []
(let [origin-brect (dom/get-bounding-rect trigger-el)
tooltip-brect (dom/get-bounding-rect tooltip-el)
window-size (dom/get-window-size)]
(when-let [[new-placement placement-rect]
(find-matching-placement
placement
tooltip-brect
origin-brect
window-size
offset)]
(dom/set-css-property! tooltip-el "inset-block-start"
(str (:top placement-rect) "px"))
(dom/set-css-property! tooltip-el "inset-inline-start"
(str (:left placement-rect) "px"))
(when (not= new-placement placement)
(reset! placement* new-placement)))))))))))
(when (not= new-placement placement)
(reset! placement* new-placement))))))]
#(ts/cancel-af! raf-id)))))))
[:> :div props
children
+20 -19
View File
@@ -121,28 +121,29 @@
on-paste
(mf/use-fn
(fn [event]
(let [paste-data (-> event .-clipboardData (.getData "text"))]
(when (and (string? paste-data)
(re-find #"[,\s]" paste-data))
(dom/prevent-default event)
(dom/stop-propagation event)
(when-let [clipboard-data (.-clipboardData event)]
(let [paste-data (.getData clipboard-data "text")]
(when (and (string? paste-data)
(re-find #"[,\s]" paste-data))
(dom/prevent-default event)
(dom/stop-propagation event)
;; Mark as touched
(swap! form assoc-in [:touched name] true)
;; Mark as touched
(swap! form assoc-in [:touched name] true)
;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+")
(map str/trim)
(remove str/empty?))]
(doseq [part parts]
(when (valid-item-fn part)
(swap! items conj-dedup {:text part
:valid true
:caution (caution-item-fn part)})))
;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+")
(map str/trim)
(remove str/empty?))]
(doseq [part parts]
(when (valid-item-fn part)
(swap! items conj-dedup {:text part
:valid true
:caution (caution-item-fn part)})))
;; Reset input value and mark as untouched after successful paste
(reset! value "")
(swap! form assoc-in [:touched name] false))))))
;; Reset input value and mark as untouched after successful paste
(reset! value "")
(swap! form assoc-in [:touched name] false)))))))
on-blur
(mf/use-fn
+10
View File
@@ -281,6 +281,16 @@
(mf/set-ref-val! ref val))
(mf/ref-val ref)))
;; FIXME: replace with rumext
(defn use-focus-timer-ref
"Returns a ref for scheduling focus timers and disposes any pending
timer on component unmount."
[]
(let [ref (mf/use-ref nil)]
(mf/with-effect []
#(some-> (mf/ref-val ref) ts/dispose!))
ref))
;; FIXME: rename to use-focus-objects
(defn with-focus-objects
([objects]
@@ -79,15 +79,21 @@
{:type :gradient
:gradient fill-color-gradient}
(and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1))
(and (string? fill-color)
(cc/hex-color-string? fill-color)
(some? fill-opacity)
(not= fill-opacity 1))
{:type :transparent
:hex fill-color
:opacity fill-opacity}
(string? fill-color)
(and (string? fill-color)
(cc/hex-color-string? fill-color))
{:type :solid
:hex fill-color
:map-to fill-color}))
:map-to fill-color}
:else nil))
(defn- retrieve-colors
"Given a text shape returns a triple with the values:
-49
View File
@@ -10,9 +10,7 @@
["rxjs" :as rxjs]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.pprint :as pp]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.auth :refer [is-authenticated?]]
[app.main.data.common :as dcm]
[app.main.errors :as errors]
@@ -348,53 +346,6 @@
[:> button* {:variant "primary" :on-click on-reload}
(tr "labels.reload-page")]]]))
(defn- generate-report
[data]
(try
(let [team-id (:current-team-id @st/state)
profile-id (:profile-id @st/state)
trace (:app.main.errors/trace data)
instance (:app.main.errors/instance data)]
(with-out-str
(println "Hint: " (or (:hint data) (ex-message instance) "--"))
(println "Prof ID: " (str (or profile-id "--")))
(println "Team ID: " (str (or team-id "--")))
(println "URI: " cf/public-uri)
(when-let [file-id (:file-id data)]
(println "File ID:" (str file-id)))
(println)
(println "Data:")
(loop [data data]
(-> (d/without-qualified data)
(dissoc :explain)
(d/update-when :data (constantly "(...)"))
(pp/pprint {:level 8 :length 10}))
(println)
(when-let [explain (:explain data)]
(print explain))
(when (and (= :server-error (:type data))
(contains? data :data))
(recur (:data data))))
(println "Trace:")
(println trace)
(println)
(println "Last events:")
(pp/pprint @st/last-events {:length 200})
(println)))
(catch :default cause
(.error js/console "error on generating report.txt" cause)
nil)))
(mf/defc internal-error*
[{:keys [on-reset report] :as props}]
(let [report-uri (mf/use-ref nil)
+2 -3
View File
@@ -44,7 +44,6 @@
[app.main.ui.workspace.webgl-unavailable-modal]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.i18n :as i18n :refer [tr]]
[goog.events :as events]
[okulary.core :as l]
@@ -177,7 +176,7 @@
(mf/with-effect []
(let [focus-out #(st/emit! (dw/workspace-focus-lost))
key (events/listen globals/window "blur" focus-out)]
key (events/listen js/window "blur" focus-out)]
(partial events/unlistenByKey key)))
(mf/with-effect [file-id page-id]
@@ -252,7 +251,7 @@
(let [handle-wasm-render
(fn [_]
(reset! first-frame-rendered? true))
listener-key (events/listen globals/document "penpot:wasm:render" handle-wasm-render)]
listener-key (events/listen js/document "penpot:wasm:render" handle-wasm-render)]
(fn []
(events/unlistenByKey listener-key))))
@@ -233,6 +233,7 @@
(mf/deps on-add-stop-preview)
(fn [^js e]
(let [offset (-> (event->offset e)
(mth/clamp 0 1)
(mth/precision 2))]
(when on-add-stop-preview
(on-add-stop-preview offset)))))
@@ -61,7 +61,7 @@
nil)))
(defn- styles-fn [shape styles content]
(let [data (if (= (.getText ^js content) "")
(let [data (if (and content (= (.getText ^js content) ""))
(-> ^js (.getData content)
(.toJS)
(js->clj :keywordize-keys true))
@@ -105,14 +105,14 @@
(mf/use-fn
(fn [^js event]
(dom/prevent-default event)
(let [clipboard-data (.-clipboardData event)
text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
(text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paste"))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) "")))))
(when-let [clipboard-data (.-clipboardData event)]
(let [text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
(text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paste"))))
(when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) ""))))
on-copy
(mf/use-fn
@@ -189,6 +189,7 @@
lv (-> (gpt/to-vec from-p to-p) (gpt/unit))
nv (gpt/normal-left lv)
offset (-> (gsp/project-t position [from-p to-p] nv)
(mth/clamp 0 1)
(mth/precision 2))
new-stop (cc/interpolate-gradient stops offset)
stops (conj stops new-stop)
@@ -56,51 +56,52 @@
(defn process-pointer-move
[viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y]
(when-let [image-data (mf/ref-val canvas-image-data)]
(when-let [zoom-view-node (dom/get-element "picker-detail")]
(when-not (mf/ref-val zoom-view-context)
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
(let [canvas-width 260
canvas-height 140
{brx :left bry :top} (dom/get-bounding-rect viewport-node)
(when viewport-node
(when-let [image-data (mf/ref-val canvas-image-data)]
(when-let [zoom-view-node (dom/get-element "picker-detail")]
(when-not (mf/ref-val zoom-view-context)
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
(let [canvas-width 260
canvas-height 140
{brx :left bry :top} (dom/get-bounding-rect viewport-node)
x (mth/floor (- client-x brx))
y (mth/floor (- client-y bry))
x (mth/floor (- client-x brx))
y (mth/floor (- client-y bry))
img-width (unchecked-get image-data "width")
img-height (unchecked-get image-data "height")
img-width (unchecked-get image-data "width")
img-height (unchecked-get image-data "height")
zoom-context (mf/ref-val zoom-view-context)
zoom-context (mf/ref-val zoom-view-context)
sx (- x 32)
sy (if (cfg/check-browser? :safari) y (- y 17))
sw 65
sh 35
dx 0
dy 0
dw canvas-width
dh canvas-height]
sx (- x 32)
sy (if (cfg/check-browser? :safari) y (- y 17))
sw 65
sh 35
dx 0
dy 0
dw canvas-width
dh canvas-height]
(when (obj/get zoom-context "imageSmoothingEnabled")
(obj/set! zoom-context "imageSmoothingEnabled" false))
(.clearRect zoom-context 0 0 canvas-width canvas-height)
(.drawImage zoom-context canvas sx sy sw sh dx dy dw dh)
(when (obj/get zoom-context "imageSmoothingEnabled")
(obj/set! zoom-context "imageSmoothingEnabled" false))
(.clearRect zoom-context 0 0 canvas-width canvas-height)
(.drawImage zoom-context canvas sx sy sw sh dx dy dw dh)
;; Only pick color when cursor is within canvas bounds to avoid garbage pixels
(when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height))
(let [offset (* (+ (* y img-width) x) 4)
rgba (unchecked-get image-data "data")
r (d/check-num (obj/get rgba (+ 0 offset)) 255)
g (d/check-num (obj/get rgba (+ 1 offset)) 255)
b (d/check-num (obj/get rgba (+ 2 offset)) 255)
a (d/check-num (obj/get rgba (+ 3 offset)) 255)
color [r g b a]]
;; Store latest color synchronously so the click handler always reads
;; the correct pixel even before the rAF fires (fixes race condition)
(mf/set-ref-val! last-picked-color color)
(timers/raf
(fn []
(st/emit! (dwc/pick-color color))))))))))
;; Only pick color when cursor is within canvas bounds to avoid garbage pixels
(when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height))
(let [offset (* (+ (* y img-width) x) 4)
rgba (unchecked-get image-data "data")
r (d/check-num (obj/get rgba (+ 0 offset)) 255)
g (d/check-num (obj/get rgba (+ 1 offset)) 255)
b (d/check-num (obj/get rgba (+ 2 offset)) 255)
a (d/check-num (obj/get rgba (+ 3 offset)) 255)
color [r g b a]]
;; Store latest color synchronously so the click handler always reads
;; the correct pixel even before the rAF fires (fixes race condition)
(mf/set-ref-val! last-picked-color color)
(timers/raf
(fn []
(st/emit! (dwc/pick-color color)))))))))))
(mf/defc pixel-overlay*
@@ -260,18 +261,19 @@
(defn- viewport->canvas-coords
"Maps client (viewport) coordinates to device-pixel canvas coordinates."
[viewport-node client-x client-y]
(let [{brx :left bry :top} (dom/get-bounding-rect viewport-node)
dpr (wasm.api/get-dpr)
x (mth/floor (- client-x brx))
y (mth/floor (- client-y bry))]
[(mth/floor (* x dpr))
(mth/floor (* y dpr))]))
(when viewport-node
(let [{brx :left bry :top} (dom/get-bounding-rect viewport-node)
dpr (wasm.api/get-dpr)
x (mth/floor (- client-x brx))
y (mth/floor (- client-y bry))]
[(mth/floor (* x dpr))
(mth/floor (* y dpr))])))
(defn process-pointer-move-wasm
"Updates the magnifier loupe with the canvas region under the cursor. The
actual color is only read on click (see `pick-color-at-wasm`)."
[viewport-node canvas zoom-view-context client-x client-y]
(when canvas
(when (and canvas viewport-node)
(when-let [zoom-view-node (dom/get-element "picker-detail")]
(when-not (mf/ref-val zoom-view-context)
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
@@ -309,7 +311,7 @@
the correct color even on GPUs where a raw WebGL `readPixels` returned
values with their byte order swapped."
[viewport-node canvas client-x client-y]
(when canvas
(when (and canvas viewport-node)
(let [[canvas-x canvas-y] (viewport->canvas-coords viewport-node client-x client-y)
img-width (.-width canvas)
img-height (.-height canvas)]
@@ -370,7 +372,7 @@
handle-draw-picker-canvas
(mf/use-callback
(fn []
(when canvas
(when (and canvas viewport-node)
;; Read current mouse position from ref so the loupe refreshes on
;; each render even without a mouse-move.
(let [{mx :x my :y} (mf/ref-val initial-mouse-pos)]
@@ -7,62 +7,75 @@
(ns app.main.ui.workspace.viewport.viewport-ref
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.util.dom :as dom]
[app.util.mouse :as mse]
[goog.events :as events]
[rumext.v2 :as mf])
(:import goog.events.EventType))
[rumext.v2 :as mf]))
(defonce viewport-ref (atom nil))
(defonce current-observer (atom nil))
(defonce viewport-brect (atom nil))
(defn init-observer
[node on-change-bounds]
(defn- init-observer
[node]
(let [on-change-bounds
(fn [_]
(let [brect (dom/get-bounding-rect node)
brect (gpt/point (d/parse-integer (:left brect))
(d/parse-integer (:top brect)))]
(reset! viewport-brect brect)))
(let [observer (js/ResizeObserver. on-change-bounds)]
(when (some? @current-observer)
(.disconnect @current-observer))
observer
(js/ResizeObserver. on-change-bounds)]
(reset! current-observer observer)
(when (some? node)
(.observe observer node))))
(defn on-change-bounds
[_]
(when @viewport-ref
(let [brect (dom/get-bounding-rect @viewport-ref)
brect (gpt/point (d/parse-integer (:left brect))
(d/parse-integer (:top brect)))]
(reset! viewport-brect brect))))
(.observe observer node)
observer))
(defn create-viewport-ref
[]
(let [ref (mf/use-ref nil)]
[ref
(mf/use-memo
#(fn [node]
(mf/set-ref-val! ref node)
(reset! viewport-ref node)
(when (some? node)
(events/listen node EventType.MOUSELEAVE (fn [] (st/emit! (mse/->BlurEvent)))))
(init-observer node on-change-bounds)))]))
(let [node-ref (mf/use-ref nil)
handler-ref (mf/use-ref nil)
observer-ref (mf/use-ref nil)
callback (mf/use-fn
(fn [node]
;; Dispose all previous resources
(when-let [observer (mf/ref-val observer-ref)]
(.disconnect ^js observer)
(mf/set-ref-val! observer-ref nil))
(when-let [handler (mf/ref-val handler-ref)]
(when-let [node (mf/ref-val node-ref)]
(.removeEventListener ^js node "mouseleave" handler)
(mf/set-ref-val! handler-ref nil)))
;; Reset the ref values to the current node (can be nil)
(mf/set-ref-val! node-ref node)
(reset! viewport-ref node)
(when (some? node)
(let [handler (fn [] (st/emit! (mse/->BlurEvent)))
observer (init-observer node)]
(.addEventListener ^js node "mouseleave" handler)
(mf/set-ref-val! handler-ref handler)
(mf/set-ref-val! observer-ref observer)))))]
[node-ref callback]))
(defn point->viewport
[pt]
(let [zoom (dm/get-in @st/state [:workspace-local :zoom] 1)]
(when (and (some? @viewport-ref)
(some? @viewport-brect))
(let [vbox (.. ^js @viewport-ref -viewBox -baseVal)
brect @viewport-brect
box (gpt/point (.-x vbox) (.-y vbox))
zoom (gpt/point zoom)]
(let [zoom (d/nilv @refs/selected-zoom 1)
viewport-node @viewport-ref
viewport-brect @viewport-brect]
(-> (gpt/subtract pt brect)
(when (and (some? viewport-brect)
(some? viewport-node))
(let [vbox (.. ^js viewport-node -viewBox -baseVal)
box (gpt/point (.-x vbox) (.-y vbox))
zoom (gpt/point zoom)]
(-> (gpt/subtract pt viewport-brect)
(gpt/divide zoom)
(gpt/add box))))))
@@ -71,8 +84,8 @@
Unlike point->viewport, this does NOT convert to canvas coordinates -
it just subtracts the viewport's bounding rect offset."
[pt]
(when (some? @viewport-brect)
(gpt/subtract pt @viewport-brect)))
(when-let [brect @viewport-brect]
(gpt/subtract pt brect)))
(defn inside-viewport?
[target]
+8 -7
View File
@@ -34,6 +34,7 @@
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.exports.assets :as de]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.persistence :as dwp]
[app.main.data.plugins :as dp]
@@ -1547,13 +1548,13 @@
:profile-id (:profile-id @st/state)
:wait true
:is-wasm false
:exports [{:file-id file-id
:page-id page-id
:object-id id
:name (:name shape)
:type (:type value :png)
:suffix (:suffix value "")
:scale (:scale value 1)}]}]
:exports [(de/normalize-export {:file-id file-id
:page-id page-id
:object-id id
:name (:name shape)
:type (:type value :png)
:suffix (:suffix value "")
:scale (:scale value 1)})]}]
(js/Promise.
(fn [resolve reject]
;; The exporter renders the file from its persisted
+11 -3
View File
@@ -1347,10 +1347,15 @@
(or (:panning local) (:zooming local))))
(defn finalize-view-interaction!
"Ends the view interaction and triggers a full-quality render."
"Ends an in-progress pan/zoom view interaction and triggers a full-quality
render. No-ops when no view interaction is active.
`finish-panning` runs on every pointerup, so without this guard we would
call `internal-render` (and WASM `reset_canvas`) on plain clicks."
[]
(view-interaction-end!)
(internal-render 0 0))
(when @view-interaction-active?
(view-interaction-end!)
(internal-render 0 0)))
(def render-finish
(letfn [(do-render []
@@ -2175,6 +2180,9 @@
browser (sr/translate-browser cf/browser)
dpr (get-dpr)
[css-w css-h] (canvas-css-size canvas dpr)
;; Avoid 0×0 Skia/GL surfaces (crashes on some browsers).
css-w (mth/max 1 css-w)
css-h (mth/max 1 css-h)
can-listen? (fn? (.-addEventListener ^js canvas))]
(when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
+7
View File
@@ -699,6 +699,13 @@
(when (some? node)
(.setAttribute node attr value)))
(defn focus-and-untabbable!
[^js node]
(when (some? node)
(set-attribute! node "tabindex" "0")
(focus! node)
(set-attribute! node "tabindex" "-1")))
(defn set-style!
[^js node ^string style value]
(when (some? node)
+7 -7
View File
@@ -115,13 +115,13 @@
([e]
(get-data e "penpot/data"))
([e data-type]
(let [dt (.-dataTransfer e)
data (.getData dt data-type)]
(cond-> data
(and (some? data) (not= data "")
(or (str/starts-with? data-type "penpot")
(= data-type "application/json")))
(t/decode-str)))))
(when-let [dt (.-dataTransfer e)]
(let [data (.getData dt data-type)]
(cond-> data
(and (some? data) (not= data "")
(or (str/starts-with? data-type "penpot")
(= data-type "application/json")))
(t/decode-str))))))
(defn get-files
[e]
+5 -3
View File
@@ -60,12 +60,14 @@
(defn get-editor-block-data
[block]
(-> (.getData ^js block)
(immutable-map->map)))
(when (some? block)
(-> (.getData ^js block)
(immutable-map->map))))
(defn get-editor-block-type
[block]
(.getType ^js block))
(when (some? block)
(.getType ^js block)))
(defn get-editor-current-block-data
[state]
+4
View File
@@ -82,6 +82,10 @@
(defn read-as-text
[entry]
(when (nil? entry)
(ex/raise :type :assertion
:code :invalid-entry
:hint "cannot read zip entry: entry is nil"))
(let [writer (new zip/TextWriter)]
(.getData entry writer)))
+9 -3
View File
@@ -7,6 +7,7 @@
(ns app.worker.import
(:refer-clojure :exclude [resolve])
(:require
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.logging :as log]
[app.common.schema :as sm]
@@ -44,10 +45,15 @@
(def conjv (fnil conj []))
(defn- read-zip-manifest
(defn read-zip-manifest
[zip-reader]
(->> (rx/from (uz/get-entry zip-reader "manifest.json"))
(rx/mapcat uz/read-as-text)
(rx/mapcat (fn [entry]
(if (nil? entry)
(rx/throw (ex/error :type :validation
:code :invalid-penpot-file
:hint "Not a valid Penpot file: manifest.json is missing"))
(uz/read-as-text entry))))
(rx/map json/decode)))
(defn slurp-uri
@@ -98,7 +104,7 @@
(if (= (:type manifest) "penpot/export-files")
(let [manifest (decode-manifest manifest)]
(assoc file :type :binfile-v3 :files (:files manifest)))
(assoc file :type :legacy-zip :body body))))
(assoc file :type :unknown))))
(rx/finalize (partial uz/close zip-reader))))
(= "application/octet-stream" mtype)
@@ -0,0 +1,99 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.data.exports-assets-test
(:require
[app.common.uuid :as uuid]
[app.main.data.exports.assets :as de]
[app.main.data.persistence :as dwp]
[app.main.repo :as repo]
[app.main.store :as st]
[app.util.dom :as dom]
[app.util.websocket :as ws]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.events :as the]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(def ^:private export {:id (uuid/next)
:object-id (uuid/next)
:type :png
:suffix ""
:scale 1})
(defn- export-with-name
[name]
(merge export {:name name}))
(defn- test-state
[]
{:profile-id (:id export)
:ws-conn nil})
(t/deftest normalize-export-preserves-existing-name
(t/is (= (export-with-name "Layer 1")
(de/normalize-export (export-with-name "Layer 1")))))
(t/deftest normalize-export-replaces-nil-name-with-object-id
(t/is (= (export-with-name (str (:object-id export)))
(de/normalize-export (assoc export :name nil)))))
(t/deftest normalize-export-replaces-empty-name-with-object-id
(t/is (= (export-with-name (str (:object-id export)))
(de/normalize-export (assoc export :name "")))))
(t/deftest request-simple-export-sends-normalized-export
(t/async done
(let [export (export-with-name "")
observed (atom nil)]
(mock/with-mocks {repo/cmd! (mock/stub (fn [_ params]
(reset! observed params)
(rx/of {:filename "export.png"
:mtype "image/png"
:uri "blob:export"})))
dwp/force-persist-and-wait (mock/stub (fn [_] (rx/of ::force-persisted)))
dom/trigger-download-uri (mock/stub (fn [& _] nil))}
(fn [done']
(let [completed (fn [_state]
(t/is (= (export-with-name (str (:object-id export)))
(-> @observed :exports first))))]
(ptk/emit! (the/prepare-store (test-state) done' completed)
(de/request-simple-export {:export export})
:the/end)))
done))))
(t/deftest request-multiple-export-sends-normalized-enabled-exports
(t/async done
(let [exports [{:id "enabled-1"
:object-id "enabled-1"
:shape {:id "enabled-1"}
:type :png
:suffix ""
:scale 1
:enabled true
:name ""}]
observed (atom nil)]
(mock/with-mocks {repo/cmd! (mock/stub (fn [_ params]
(reset! observed params)
(rx/of {:id (:id export)})))
ws/get-rcv-stream (mock/stub (fn [_] (rx/empty)))
st/ongoing-tasks (atom #{})}
(fn [done']
(let [completed (fn [_state]
(t/is (= [{:id "enabled-1"
:object-id "enabled-1"
:shape {:id "enabled-1"}
:type :png
:suffix ""
:scale 1
:enabled true
:name "enabled-1"}]
(:exports @observed))))]
(ptk/emit! (the/prepare-store (test-state) done' completed)
(de/request-multiple-export {:exports exports})
:the/end)))
done))))
@@ -0,0 +1,49 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.data.store-test
"Unit tests for app.main.store.
Tests cover:
- format-last-events empty, single, multi, column alignment"
(:require
[app.main.store :as st]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]))
(t/deftest format-last-events-empty
(t/testing "empty events produce empty string"
(t/is (= "" (st/format-last-events [])))))
(t/deftest format-last-events-single
(t/testing "a single event shows (+0ms) and its name"
(let [result (st/format-last-events [{:name ":test/event" :t (js/Date. 1000)}])]
(t/is (str/includes? result "(+0ms)"))
(t/is (str/includes? result ":test/event"))
(t/is (= 1 (count (str/split result "\n")))))))
(t/deftest format-last-events-multi
(t/testing "multiple events show correct deltas and event names"
(let [events [{:name ":event/a" :t (js/Date. 0)}
{:name ":event/b" :t (js/Date. 500)}
{:name ":event/c" :t (js/Date. 2500)}]
lines (str/split (st/format-last-events events) "\n")]
(t/is (= 3 (count lines)))
(t/is (some #(str/includes? % ":event/a") lines))
(t/is (some #(str/includes? % ":event/b") lines))
(t/is (some #(str/includes? % ":event/c") lines))
(t/is (str/includes? (nth lines 0) "(+0ms)"))
(t/is (str/includes? (nth lines 1) "(+500ms)"))
(t/is (str/includes? (nth lines 2) "(+2000ms)")))))
(t/deftest format-last-events-alignment
(t/testing "event names start at the same column across all lines"
(let [events [{:name ":evt-a" :t (js/Date. 0)}
{:name ":evt-b" :t (js/Date. 500)}]
lines (str/split (st/format-last-events events) "\n")
col-a (.indexOf (nth lines 0) ":evt-a")
col-b (.indexOf (nth lines 1) ":evt-b")]
(t/is (pos? col-a))
(t/is (= col-a col-b)))))
+12
View File
@@ -7,8 +7,10 @@
[frontend-tests.basic-shapes-test]
[frontend-tests.code-gen-style-test]
[frontend-tests.copy-as-svg-test]
[frontend-tests.data.exports-assets-test]
[frontend-tests.data.nitrate-test]
[frontend-tests.data.repo-test]
[frontend-tests.data.store-test]
[frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test]
[frontend-tests.data.workspace-colors-test]
@@ -47,6 +49,7 @@
[frontend-tests.plugins.value-objects-test]
[frontend-tests.render-wasm.process-objects-test]
[frontend-tests.svg-fills-test]
[frontend-tests.text-editor-paste-guard-test]
[frontend-tests.tokens.import-export-test]
[frontend-tests.tokens.logic.token-actions-test]
[frontend-tests.tokens.logic.token-data-test]
@@ -60,7 +63,10 @@
[frontend-tests.util-object-test]
[frontend-tests.util-range-tree-test]
[frontend-tests.util-simple-math-test]
[frontend-tests.util-text-editor-test]
[frontend-tests.util-webapi-test]
[frontend-tests.util-zip-test]
[frontend-tests.util.dom.dnd-test]
[frontend-tests.worker-snap-test]
[goog.object :as gobj]))
@@ -82,6 +88,8 @@
'frontend-tests.copy-as-svg-test
'frontend-tests.data.nitrate-test
'frontend-tests.data.repo-test
'frontend-tests.data.store-test
'frontend-tests.data.exports-assets-test
'frontend-tests.errors-test
'frontend-tests.main-errors-test
'frontend-tests.data.uploads-test
@@ -132,10 +140,14 @@
'frontend-tests.ui.ds-controls-numeric-input-test
'frontend-tests.ui.measures-menu-props-test
'frontend-tests.render-wasm.process-objects-test
'frontend-tests.text-editor-paste-guard-test
'frontend-tests.util-object-test
'frontend-tests.util-range-tree-test
'frontend-tests.util-simple-math-test
'frontend-tests.util-text-editor-test
'frontend-tests.util-webapi-test
'frontend-tests.util.dom.dnd-test
'frontend-tests.util-zip-test
'frontend-tests.worker-snap-test])
(assert (every? find-ns-obj test-namespaces)
@@ -0,0 +1,62 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns frontend-tests.text-editor-paste-guard-test
"Regression tests for the Cannot read properties of undefined
(reading getData) family of bugs. Each test verifies that the inner
getData call is now guarded by an outer when-let on clipboardData
so that a synthetic event with no clipboardData no longer throws."
(:require
[cljs.test :as t :include-macros true]))
(defn- guarded-get-data-text
"Mirrors the body of the fixed paste handlers in main/ui/forms.cljs and
main/ui/components/forms.cljs."
[event]
(when-let [clipboard-data (.-clipboardData event)]
(.getData clipboard-data "text")))
(defn- guarded-get-data-text-plain
"Mirrors the body of the fixed paste handler in
main/ui/workspace/shapes/text/v3_editor.cljs."
[event]
(when-let [clipboard-data (.-clipboardData event)]
(.getData clipboard-data "text/plain")))
(t/deftest guarded-paste-handlers-do-not-throw-on-missing-clipboardData
(t/testing "event without clipboardData returns nil (no throw)"
(t/is (nil? (guarded-get-data-text #js {})))
(t/is (nil? (guarded-get-data-text-plain #js {}))))
(t/testing "event with explicit nil clipboardData returns nil"
(t/is (nil? (guarded-get-data-text #js {:clipboardData nil})))
(t/is (nil? (guarded-get-data-text-plain #js {:clipboardData nil}))))
(t/testing "event with valid clipboardData returns the text"
(let [text-cb (fn [t] (if (= t "text") "hello" nil))
text-plain-cb (fn [t] (if (= t "text/plain") "hello" nil))
cb #js {:getData (fn [t] (if (= t "text") "hello" nil))}
cbp #js {:getData (fn [t] (if (= t "text/plain") "hello" nil))}]
(t/is (= "hello" (guarded-get-data-text #js {:clipboardData cb})))
(t/is (= "hello" (guarded-get-data-text-plain #js {:clipboardData cbp}))))))
;; Mirrors the fixed styles-fn body in main/ui/workspace/shapes/text/editor.cljs.
;; The fix adds an (and content ...) guard so getText and getData are never
;; called on a nil content object.
(defn- guarded-styles-fn-branch
"Returns the data that styles-fn would use for a given content. When content
is nil, the function falls back to the styles-only branch and returns :fallback
(the real function calls legacy.txt/styles-to-attrs which we don't exercise
here — we only verify the guard itself prevents the getText/getData throws)."
[content]
(if (and content (= (.getText ^js content) ""))
(-> ^js (.getData content)
(.toJS)
(js->clj :keywordize-keys true))
:fallback))
(t/deftest guarded-styles-fn-branch-does-not-throw-on-nil-content
(t/testing "nil content falls back to the styles branch (no throw)"
(t/is (= :fallback (guarded-styles-fn-branch nil)))
(t/is (= :fallback (guarded-styles-fn-branch js/undefined)))))
@@ -0,0 +1,23 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns frontend-tests.util.dom.dnd-test
(:require
[app.util.dom.dnd :as dnd]
[cljs.test :as t :include-macros true]))
(t/deftest get-data-returns-nil-when-event-has-no-dataTransfer
(t/testing "event without dataTransfer"
(t/is (nil? (dnd/get-data #js {}))))
(t/testing "event with explicit nil dataTransfer"
(t/is (nil? (dnd/get-data #js {:dataTransfer nil}))))
(t/testing "explicit data-type also returns nil for missing dataTransfer"
(t/is (nil? (dnd/get-data #js {} "penpot/data")))))
(t/deftest get-data-reads-from-dataTransfer
(t/testing "dataTransfer with matching key returns the value (non-decoded type)"
(let [dt #js {:getData (fn [_type] "hello")}]
(t/is (= "hello" (dnd/get-data #js {:dataTransfer dt} "text/plain"))))))
@@ -0,0 +1,18 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns frontend-tests.util-text-editor-test
(:require
[app.util.text-editor :as te]
[cljs.test :as t :include-macros true]))
(t/deftest get-editor-block-data-returns-nil-for-nil-block
(t/is (nil? (te/get-editor-block-data nil)))
(t/is (nil? (te/get-editor-block-data js/undefined))))
(t/deftest get-editor-block-type-returns-nil-for-nil-block
(t/is (nil? (te/get-editor-block-type nil)))
(t/is (nil? (te/get-editor-block-type js/undefined))))
@@ -0,0 +1,44 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns frontend-tests.util-zip-test
(:require
[app.util.zip :as-alias uz]
[app.worker.import :as worker.import]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[promesa.core :as p]))
(t/deftest read-as-text-nil-entry-raises-typed-error
(t/testing "read-as-text guards against nil entry"
(t/is (thrown-with-msg? js/Error #"nil"
(app.util.zip/read-as-text nil)))))
(t/deftest read-zip-manifest-missing-throws-validation-error
(t/async
done
(t/testing "read-zip-manifest rejects ZIPs without manifest.json"
(mock/with-mocks
{app.util.zip/get-entry (mock/stub (fn [_ _] (p/resolved nil)))}
(fn [done']
(->> (worker.import/read-zip-manifest #js {})
(rx/subs!
(fn [_]
(t/is false "expected validation error to be thrown")
(done'))
(fn [err]
(let [data (ex-data err)]
(t/is (= :invalid-penpot-file (:code data))
"missing manifest.json raises typed :invalid-penpot-file error")
(t/is (string? (:hint data))
"missing manifest.json error carries a :hint")
(t/is (re-find #"manifest\.json" (:hint data))
"missing manifest.json :hint mentions manifest.json")
(t/is (nil? (re-find #"getData" (:hint data)))
"missing manifest.json :hint does not leak the raw TypeError text")
(done'))))))
done))))
+3
View File
@@ -1490,6 +1490,9 @@ msgstr "The fonts %s could not be loaded"
msgid "errors.cannot-upload"
msgstr "Cannot upload the media file."
msgid "errors.circular-library-reference"
msgstr "Cannot add library: this would create a circular dependency"
#: src/app/main/ui/comments.cljs:737, src/app/main/ui/comments.cljs:767, src/app/main/ui/comments.cljs:864
msgid "errors.character-limit-exceeded"
msgstr "Character limit exceeded"
+3
View File
@@ -1501,6 +1501,9 @@ msgstr "No se han podido cargar las fuentes %s"
msgid "errors.cannot-upload"
msgstr "No se puede cargar el archivo multimedia."
msgid "errors.circular-library-reference"
msgstr "No se puede añadir la biblioteca: crearía una dependencia circular"
#: src/app/main/ui/comments.cljs:737, src/app/main/ui/comments.cljs:767, src/app/main/ui/comments.cljs:864
msgid "errors.character-limit-exceeded"
msgstr "Se ha superado el límite de caracteres"
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more