mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 05:09:34 -04:00
Compare commits
46
Commits
issue-10587
...
2.17.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d2c37e52c | ||
|
|
f7fd3e1cd5 | ||
|
|
f7bdc9786c | ||
|
|
88a52d1098 | ||
|
|
2318866f8d | ||
|
|
4da6499197 | ||
|
|
7ac61e0597 | ||
|
|
509f5395cb | ||
|
|
d835baefec | ||
|
|
d04cbf175e | ||
|
|
764b62906b | ||
|
|
30943f1074 | ||
|
|
b4659df5b2 | ||
|
|
1ae9334064 | ||
|
|
ef593514f2 | ||
|
|
040080749b | ||
|
|
fadb3124a0 | ||
|
|
25618febcd | ||
|
|
5e5465a0fe | ||
|
|
adda7e6645 | ||
|
|
c8d1f5b397 | ||
|
|
bbc7e9bee9 | ||
|
|
4b994d20aa | ||
|
|
6c2b61e1ad | ||
|
|
458fa41036 | ||
|
|
6063a45c3c | ||
|
|
63e0c536f0 | ||
|
|
af120feb1f | ||
|
|
5a0cee44b1 | ||
|
|
aad9bc8f65 | ||
|
|
0b072b22b0 | ||
|
|
170d129a5a | ||
|
|
eda5aa76f0 | ||
|
|
b54c1f316a | ||
|
|
d94b139071 | ||
|
|
d5d6c61ba4 | ||
|
|
f7c312021b | ||
|
|
45405a018b | ||
|
|
8bf411c347 | ||
|
|
9b88d35664 | ||
|
|
e4d88b3ab4 | ||
|
|
66b978c01e | ||
|
|
52e5e0bec6 | ||
|
|
7430ffe718 | ||
|
|
b3091399cd | ||
|
|
b6629c0034 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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))))))
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}}}]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))))
|
||||
@@ -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))
|
||||
|
||||
@@ -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)"; \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@ set -e;
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpx playwright install chromium
|
||||
pnpm exec playwright install chromium
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run test;
|
||||
@@ -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);
|
||||
@@ -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}}}}}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)})))
|
||||
@@ -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")))))))
|
||||
@@ -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)))))))
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Generated
+3
-2
@@ -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':
|
||||
|
||||
@@ -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
|
||||
@@ -3,4 +3,4 @@
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpx playwright install chromium;
|
||||
pnpm exec playwright install chromium;
|
||||
@@ -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}]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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)))))
|
||||
@@ -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))))
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -272,6 +272,7 @@ The Penpot MCP server can be configured using environment variables.
|
||||
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
|
||||
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
|
||||
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` |
|
||||
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |
|
||||
| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) |
|
||||
| `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) |
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ export class PenpotMcpServer {
|
||||
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
|
||||
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10);
|
||||
this.tenant = process.env.PENPOT_TENANT ?? "default";
|
||||
const toolTimeoutSecs = parseInt(process.env.PENPOT_MCP_TOOL_TIMEOUT_S ?? "120", 10);
|
||||
|
||||
this.configLoader = new ConfigurationLoader(process.cwd());
|
||||
this.apiDocs = new ApiDocs();
|
||||
@@ -147,7 +148,7 @@ export class PenpotMcpServer {
|
||||
this.redisBridge = new RedisBridge(redisUri, this.tenant);
|
||||
}
|
||||
|
||||
this.pluginBridge = new PluginBridge(this, this.webSocketPort, this.redisBridge);
|
||||
this.pluginBridge = new PluginBridge(this, this.webSocketPort, toolTimeoutSecs, this.redisBridge);
|
||||
this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,11 @@ interface ClientConnection {
|
||||
* over these connections.
|
||||
*/
|
||||
export class PluginBridge {
|
||||
public static readonly MULTIUSER_CONNECTION_ERROR_MESSAGE = `No Penpot instance connected for user token. Please ensure that Penpot is connected and that the MCP client connection is using the correct token.`;
|
||||
|
||||
private readonly logger = createLogger("PluginBridge");
|
||||
private readonly wsServer: WebSocketServer;
|
||||
|
||||
private readonly connectedClients: Map<WebSocket, ClientConnection> = new Map();
|
||||
private readonly clientsByToken: Map<string, ClientConnection> = new Map();
|
||||
private readonly pendingTasks: Map<string, AbstractPluginTask<any, any>> = new Map();
|
||||
@@ -37,12 +40,13 @@ export class PluginBridge {
|
||||
* holding the relevant plugin's WebSocket connection (which may be this same
|
||||
* instance) via Redis, rather than dispatched directly over a local socket.
|
||||
* @param taskTimeoutSecs - Timeout, in seconds, for plugin task execution
|
||||
* (defaults to {@link DEFAULT_TASK_TIMEOUT_SECS})
|
||||
*/
|
||||
constructor(
|
||||
public readonly mcpServer: PenpotMcpServer,
|
||||
private port: number,
|
||||
private readonly redisBridge?: RedisBridge,
|
||||
private taskTimeoutSecs: number = 30
|
||||
private readonly taskTimeoutSecs: number,
|
||||
private readonly redisBridge?: RedisBridge
|
||||
) {
|
||||
this.wsServer = new WebSocketServer({ port: port });
|
||||
this.setupWebSocketHandlers();
|
||||
@@ -131,9 +135,10 @@ export class PluginBridge {
|
||||
/**
|
||||
* Removes a client connection and releases all resources associated with it.
|
||||
*
|
||||
* Clears the per-connection keep-alive interval and removes the connection
|
||||
* from both the socket-keyed and token-keyed indexes. Safe to call with a
|
||||
* socket that is not (or no longer) registered.
|
||||
* Clears the per-connection keep-alive interval and removes the connection from the
|
||||
* socket-keyed index. The token-keyed index entry (and, in multi-instance mode, the
|
||||
* token's Redis task subscription) is removed only if it is owned by the given
|
||||
* connection. Safe to call with a socket that is not (or no longer) registered.
|
||||
*
|
||||
* @param ws - The WebSocket whose connection state should be removed
|
||||
*/
|
||||
@@ -145,12 +150,18 @@ export class PluginBridge {
|
||||
clearInterval(connection.pingInterval);
|
||||
this.connectedClients.delete(ws);
|
||||
if (connection.userToken) {
|
||||
this.clientsByToken.delete(connection.userToken);
|
||||
// Perform the token-keyed cleanup only if this connection owns the token registration.
|
||||
// A connection rejected as a duplicate carries the same token but must not remove token associations.
|
||||
if (this.clientsByToken.get(connection.userToken) !== connection) {
|
||||
this.logger.debug("Removed connection does not own its token registration; skipping token cleanup");
|
||||
} else {
|
||||
this.clientsByToken.delete(connection.userToken);
|
||||
|
||||
if (this.redisBridge) {
|
||||
this.redisBridge
|
||||
.unsubscribeFromTasks(connection.userToken)
|
||||
.catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel"));
|
||||
if (this.redisBridge) {
|
||||
this.redisBridge
|
||||
.unsubscribeFromTasks(connection.userToken)
|
||||
.catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +200,35 @@ export class PluginBridge {
|
||||
this.logger.info(`Task ${response.id} completed: success=${response.success}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a still-pending task with the given error, releasing its correlation state.
|
||||
*
|
||||
* Clears the task's timeout (if armed) and removes the task from the pending-task
|
||||
* index before rejecting its promise. Safe to call for a task that has already been
|
||||
* settled (e.g. by a response or a timeout), in which case nothing happens.
|
||||
*
|
||||
* @param taskId - The ID of the task to reject
|
||||
* @param error - The error with which to reject the task
|
||||
* @returns Whether the task was still pending and has been rejected
|
||||
*/
|
||||
private rejectPendingTask(taskId: string, error: Error): boolean {
|
||||
const pendingTask = this.pendingTasks.get(taskId);
|
||||
if (!pendingTask) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeoutHandle = this.taskTimeouts.get(taskId);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
this.taskTimeouts.delete(taskId);
|
||||
}
|
||||
this.pendingTasks.delete(taskId);
|
||||
|
||||
pendingTask.rejectWithError(error);
|
||||
this.logger.info(`Task ${taskId} rejected: ${error.message}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the client connection to use for executing a task.
|
||||
*
|
||||
@@ -207,9 +247,7 @@ export class PluginBridge {
|
||||
|
||||
const connection = this.clientsByToken.get(sessionContext.userToken);
|
||||
if (!connection) {
|
||||
throw new Error(
|
||||
`No plugin instance connected for user token. Please ensure the plugin is running and connected with the correct token.`
|
||||
);
|
||||
throw new Error(PluginBridge.MULTIUSER_CONNECTION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
return connection;
|
||||
@@ -257,6 +295,10 @@ export class PluginBridge {
|
||||
* `resolveWithResult`/`rejectWithError` methods. The same correlation and timeout
|
||||
* handling therefore applies regardless of the transport.
|
||||
*
|
||||
* When routing via Redis, the task is rejected immediately (rather than timing out)
|
||||
* if the published request reached no instance, i.e. if no instance holds a plugin
|
||||
* connection for the session's user token, or if publishing fails outright.
|
||||
*
|
||||
* @param task - The task to dispatch
|
||||
* @param useRedis - Whether to route the request via Redis (multi-instance) rather
|
||||
* than directly over the local WebSocket connection
|
||||
@@ -279,9 +321,17 @@ export class PluginBridge {
|
||||
|
||||
// register the task for result correlation, then publish the request via Redis
|
||||
this.pendingTasks.set(task.id, task);
|
||||
void redisBridge.sendTaskRequest(userToken, task.toRequest(), (response) =>
|
||||
this.handlePluginTaskResponse(response)
|
||||
);
|
||||
void redisBridge
|
||||
.sendTaskRequest(userToken, task.toRequest(), (response) => this.handlePluginTaskResponse(response))
|
||||
.then((receiverCount) => {
|
||||
// fail fast when no instance received the request (no connection with matching user token in any instance)
|
||||
if (receiverCount === 0) {
|
||||
this.rejectPendingTask(task.id, new Error(PluginBridge.MULTIUSER_CONNECTION_ERROR_MESSAGE));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.rejectPendingTask(task.id, error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
|
||||
// on timeout, release the response-channel subscription, since no response
|
||||
// will arrive to trigger its self-unsubscribe.
|
||||
@@ -300,14 +350,13 @@ export class PluginBridge {
|
||||
|
||||
// Set up a timeout to reject the task if no response is received
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
const pendingTask = this.pendingTasks.get(task.id);
|
||||
if (pendingTask) {
|
||||
this.pendingTasks.delete(task.id);
|
||||
this.taskTimeouts.delete(task.id);
|
||||
onTimeout?.();
|
||||
pendingTask.rejectWithError(
|
||||
if (
|
||||
this.rejectPendingTask(
|
||||
task.id,
|
||||
new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`)
|
||||
);
|
||||
)
|
||||
) {
|
||||
onTimeout?.();
|
||||
}
|
||||
}, this.taskTimeoutSecs * 1000);
|
||||
|
||||
|
||||
@@ -91,12 +91,16 @@ export class RedisBridge {
|
||||
* @param userToken - The user token identifying the target plugin's request channel
|
||||
* @param request - The serialized plugin task request, passed through verbatim
|
||||
* @param onResponse - Handler invoked with the response when it arrives
|
||||
* @returns The number of instances that received the request. A count of 0 means no
|
||||
* instance is subscribed to the token's request channel (i.e. the plugin is not
|
||||
* connected anywhere); the request was dropped, no response will ever arrive, and
|
||||
* the response subscription has already been released.
|
||||
*/
|
||||
async sendTaskRequest(
|
||||
userToken: string,
|
||||
request: PluginTaskRequest,
|
||||
onResponse: TaskResponseHandler
|
||||
): Promise<void> {
|
||||
): Promise<number> {
|
||||
const responseChannel = this.responseChannel(request.id);
|
||||
const requestChannel = this.requestChannel(userToken);
|
||||
|
||||
@@ -113,7 +117,19 @@ export class RedisBridge {
|
||||
|
||||
await this.subscriber.subscribe(responseChannel);
|
||||
// publish only once the response subscription is confirmed
|
||||
await this.publisher.publish(requestChannel, JSON.stringify(request));
|
||||
let receiverCount: number;
|
||||
try {
|
||||
receiverCount = await this.publisher.publish(requestChannel, JSON.stringify(request));
|
||||
} catch (error) {
|
||||
// the request was never delivered, so no response can arrive
|
||||
await this.unsubscribeFromResponse(request.id);
|
||||
throw error;
|
||||
}
|
||||
if (receiverCount === 0) {
|
||||
// no subscriber received the request, so no response can arrive
|
||||
await this.unsubscribeFromResponse(request.id);
|
||||
}
|
||||
return receiverCount;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Executable
+478
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/ci - CI orchestration script for Penpot monorepo
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Constants
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
LOG_DIR="$PROJECT_ROOT/.ci-logs"
|
||||
|
||||
# Color codes
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Available modules
|
||||
ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library")
|
||||
|
||||
# Module commands
|
||||
declare -A LINT_CMD=(
|
||||
[frontend]="pnpm run lint:clj && pnpm run lint:js && pnpm run lint:scss"
|
||||
[backend]="pnpm run lint"
|
||||
[common]="pnpm run lint:clj"
|
||||
[render-wasm]="./lint"
|
||||
[exporter]="pnpm run lint:clj"
|
||||
[mcp]=""
|
||||
[plugins]="pnpm run lint"
|
||||
[library]="pnpm run lint"
|
||||
)
|
||||
|
||||
declare -A TEST_CMD=(
|
||||
[frontend]="pnpm run test:quiet"
|
||||
[backend]="clojure -M:dev:test"
|
||||
[common]="clojure -M:dev:test && pnpm run test:quiet"
|
||||
[render-wasm]="./test"
|
||||
[exporter]="pnpm run test:quiet"
|
||||
[mcp]="pnpm run test"
|
||||
[plugins]="pnpm run test"
|
||||
[library]="pnpm run test"
|
||||
)
|
||||
|
||||
declare -A FMT_CHECK_CMD=(
|
||||
[frontend]="pnpm run check-fmt:clj && pnpm run check-fmt:js && pnpm run check-fmt:scss"
|
||||
[backend]="pnpm run check-fmt"
|
||||
[common]="pnpm run check-fmt:clj && pnpm run check-fmt:js"
|
||||
[render-wasm]="cargo fmt --check"
|
||||
[exporter]="pnpm run check-fmt:clj"
|
||||
[mcp]="pnpm run fmt:check"
|
||||
[plugins]="pnpm run format:check"
|
||||
[library]="pnpm run check-fmt"
|
||||
)
|
||||
|
||||
declare -A FMT_FIX_CMD=(
|
||||
[frontend]="pnpm run fmt"
|
||||
[backend]="pnpm run fmt"
|
||||
[common]="pnpm run fmt:clj && pnpm run fmt:js"
|
||||
[render-wasm]="cargo fmt"
|
||||
[exporter]="pnpm run fmt:clj"
|
||||
[mcp]="pnpm run fmt"
|
||||
[plugins]="pnpm run format"
|
||||
[library]="pnpm run fmt"
|
||||
)
|
||||
|
||||
# Default options
|
||||
MODULES=()
|
||||
TASKS=("lint" "test" "fmt")
|
||||
FIX_MODE=false
|
||||
FAIL_FAST=false
|
||||
VERBOSE=true
|
||||
DRY_RUN=false
|
||||
|
||||
# Results tracking
|
||||
declare -A RESULTS=()
|
||||
declare -a FAILED_LOGS=()
|
||||
|
||||
timestamp() {
|
||||
date "+%H:%M:%S"
|
||||
}
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[$(timestamp)]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[$(timestamp)] ✓${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[$(timestamp)] ✗${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[$(timestamp)] ⚠${NC} $1"
|
||||
}
|
||||
|
||||
log_header() {
|
||||
local module=$1
|
||||
local current=$2
|
||||
local total=$3
|
||||
echo ""
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${CYAN} ${BOLD}[$current/$total] $module${NC}"
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
}
|
||||
|
||||
run_task() {
|
||||
local module=$1
|
||||
local task=$2
|
||||
local cmd=$3
|
||||
local logfile="$LOG_DIR/${module}-${task}.log"
|
||||
|
||||
if [[ -z "$cmd" ]]; then
|
||||
log_warning "$task: not defined for $module, skipping"
|
||||
RESULTS["$module:$task"]="SKIPPED"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_info "$task: would run '$cmd' in $module/"
|
||||
RESULTS["$module:$task"]="DRY-RUN"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Running ${BOLD}$task${NC} for $module..."
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo -e "${BLUE} cmd: $cmd${NC}"
|
||||
fi
|
||||
|
||||
local exit_code=0
|
||||
(cd "$PROJECT_ROOT/$module" && eval "$cmd") > "$logfile" 2>&1 || exit_code=$?
|
||||
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
log_success "$task passed for $module ${BLUE}(${duration}s)${NC}"
|
||||
RESULTS["$module:$task"]="PASSED"
|
||||
return 0
|
||||
else
|
||||
log_error "$task failed for $module ${BLUE}(${duration}s)${NC}"
|
||||
echo -e " ${RED}log: $logfile${NC}"
|
||||
RESULTS["$module:$task"]="FAILED"
|
||||
FAILED_LOGS+=("$logfile")
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo -e "${YELLOW} --- last 30 lines ---${NC}"
|
||||
tail -n 30 "$logfile" | sed 's/^/ /'
|
||||
echo -e "${YELLOW} ---------------------${NC}"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_module() {
|
||||
local module=$1
|
||||
local failed=false
|
||||
|
||||
for task in "${TASKS[@]}"; do
|
||||
local cmd=""
|
||||
case $task in
|
||||
lint)
|
||||
cmd="${LINT_CMD[$module]:-}"
|
||||
;;
|
||||
test)
|
||||
cmd="${TEST_CMD[$module]:-}"
|
||||
;;
|
||||
fmt)
|
||||
if [[ "$FIX_MODE" == "true" ]]; then
|
||||
cmd="${FMT_FIX_CMD[$module]:-}"
|
||||
else
|
||||
cmd="${FMT_CHECK_CMD[$module]:-}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! run_task "$module" "$task" "$cmd"; then
|
||||
failed=true
|
||||
if [[ "$FAIL_FAST" == "true" ]]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$failed" == "true" ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
echo ""
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${CYAN} ${BOLD}SUMMARY${NC}"
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
|
||||
local total=0
|
||||
local passed=0
|
||||
local failed=0
|
||||
local skipped=0
|
||||
|
||||
for module in "${MODULES[@]}"; do
|
||||
for task in "${TASKS[@]}"; do
|
||||
local result="${RESULTS[$module:$task]:-N/A}"
|
||||
total=$((total + 1))
|
||||
case $result in
|
||||
PASSED)
|
||||
passed=$((passed + 1))
|
||||
echo -e " ${GREEN}✓${NC} $module:$task"
|
||||
;;
|
||||
FAILED)
|
||||
failed=$((failed + 1))
|
||||
echo -e " ${RED}✗${NC} $module:$task"
|
||||
;;
|
||||
SKIPPED)
|
||||
skipped=$((skipped + 1))
|
||||
echo -e " ${YELLOW}○${NC} $module:$task"
|
||||
;;
|
||||
DRY-RUN)
|
||||
echo -e " ${BLUE}~${NC} $module:$task"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}Total:${NC} $total ${GREEN}Passed:${NC} $passed ${RED}Failed:${NC} $failed ${YELLOW}Skipped:${NC} $skipped"
|
||||
|
||||
if [[ $failed -gt 0 ]]; then
|
||||
echo ""
|
||||
echo -e " ${RED}${BOLD}Failed logs:${NC}"
|
||||
for logfile in "${FAILED_LOGS[@]}"; do
|
||||
echo -e " ${RED}•${NC} $logfile"
|
||||
done
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_module() {
|
||||
local mod=$1
|
||||
for valid in "${ALL_MODULES[@]}"; do
|
||||
if [[ "$valid" == "$mod" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS] [MODULES...]
|
||||
|
||||
CI orchestration script for Penpot monorepo.
|
||||
|
||||
MODULES:
|
||||
frontend, backend, common, render-wasm, exporter, mcp, plugins, library
|
||||
Use --all to run all modules.
|
||||
|
||||
OPTIONS:
|
||||
--all Run all modules
|
||||
--exclude MOD Exclude a module (can be repeated)
|
||||
--lint Run lint only
|
||||
--no-lint Skip lint
|
||||
--test Run tests only
|
||||
--no-test Skip tests
|
||||
--fmt Run format check only
|
||||
--no-fmt Skip format check
|
||||
--fix Run format fix instead of check
|
||||
--fail-fast Stop on first failure
|
||||
--verbose Show command output on failure (default)
|
||||
--quiet Suppress command output
|
||||
--dry-run Show what would run without executing
|
||||
--clean Remove .ci-logs directory
|
||||
--help Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
$(basename "$0") frontend backend # Run lint, test, fmt on frontend and backend
|
||||
$(basename "$0") --all --no-test # Run lint and fmt on all modules
|
||||
$(basename "$0") --lint common # Run only lint on common
|
||||
$(basename "$0") --fix frontend # Fix formatting in frontend
|
||||
$(basename "$0") --all --fail-fast --verbose
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
main() {
|
||||
local exclude_modules=()
|
||||
local run_all=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--all)
|
||||
run_all=true
|
||||
shift
|
||||
;;
|
||||
--exclude)
|
||||
exclude_modules+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--lint)
|
||||
TASKS=("lint")
|
||||
shift
|
||||
;;
|
||||
--no-lint)
|
||||
TASKS=("${TASKS[@]/lint/}")
|
||||
shift
|
||||
;;
|
||||
--test)
|
||||
TASKS=("test")
|
||||
shift
|
||||
;;
|
||||
--no-test)
|
||||
TASKS=("${TASKS[@]/test/}")
|
||||
shift
|
||||
;;
|
||||
--fmt)
|
||||
TASKS=("fmt")
|
||||
shift
|
||||
;;
|
||||
--no-fmt)
|
||||
TASKS=("${TASKS[@]/fmt/}")
|
||||
shift
|
||||
;;
|
||||
--fix)
|
||||
FIX_MODE=true
|
||||
shift
|
||||
;;
|
||||
--fail-fast)
|
||||
FAIL_FAST=true
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--quiet)
|
||||
VERBOSE=false
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--clean)
|
||||
if [[ -d "$LOG_DIR" ]]; then
|
||||
rm -rf "$LOG_DIR"
|
||||
echo "Cleaned $LOG_DIR"
|
||||
else
|
||||
echo "No logs to clean"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
--help | -h)
|
||||
usage
|
||||
;;
|
||||
-*)
|
||||
echo -e "${RED}Error: Unknown option $1${NC}" >&2
|
||||
echo "Run '$(basename "$0") --help' for usage." >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if validate_module "$1"; then
|
||||
MODULES+=("$1")
|
||||
else
|
||||
echo -e "${RED}Error: Unknown module '$1'${NC}" >&2
|
||||
echo "Valid modules: ${ALL_MODULES[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Clean empty entries from TASKS after removal
|
||||
TASKS=("${TASKS[@]// /}")
|
||||
TASKS=("${TASKS[@]/#/}")
|
||||
local clean_tasks=()
|
||||
for t in "${TASKS[@]}"; do
|
||||
if [[ -n "$t" ]]; then
|
||||
clean_tasks+=("$t")
|
||||
fi
|
||||
done
|
||||
TASKS=("${clean_tasks[@]}")
|
||||
|
||||
# Set modules
|
||||
if [[ "$run_all" == "true" ]]; then
|
||||
MODULES=("${ALL_MODULES[@]}")
|
||||
fi
|
||||
|
||||
# Apply exclusions
|
||||
if [[ ${#exclude_modules[@]} -gt 0 ]]; then
|
||||
local filtered=()
|
||||
for mod in "${MODULES[@]}"; do
|
||||
local excluded=false
|
||||
for ex in "${exclude_modules[@]}"; do
|
||||
if [[ "$mod" == "$ex" ]]; then
|
||||
excluded=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$excluded" == "false" ]]; then
|
||||
filtered+=("$mod")
|
||||
fi
|
||||
done
|
||||
MODULES=("${filtered[@]}")
|
||||
fi
|
||||
|
||||
# Validate
|
||||
if [[ ${#MODULES[@]} -eq 0 ]]; then
|
||||
echo -e "${RED}Error: No modules specified.${NC}" >&2
|
||||
echo "Use --all or specify modules: ${ALL_MODULES[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${#TASKS[@]} -eq 0 ]]; then
|
||||
echo -e "${RED}Error: No tasks selected.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Print configuration
|
||||
echo ""
|
||||
echo -e "${CYAN}${BOLD}Penpot CI Orchestrator${NC}"
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e " ${BOLD}Modules:${NC} ${MODULES[*]}"
|
||||
echo -e " ${BOLD}Tasks:${NC} ${TASKS[*]}"
|
||||
echo -e " ${BOLD}Fix mode:${NC} $FIX_MODE"
|
||||
echo -e " ${BOLD}Fail-fast:${NC} $FAIL_FAST"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e " ${BOLD}Mode:${NC} ${YELLOW}DRY RUN${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Run
|
||||
local total_modules=${#MODULES[@]}
|
||||
local current=0
|
||||
local modules_failed=0
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
for module in "${MODULES[@]}"; do
|
||||
current=$((current + 1))
|
||||
log_header "$module" "$current" "$total_modules"
|
||||
|
||||
if ! run_module "$module"; then
|
||||
modules_failed=$((modules_failed + 1))
|
||||
if [[ "$FAIL_FAST" == "true" ]]; then
|
||||
log_error "Fail-fast: stopping due to failure in $module"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local total_duration=$((end_time - start_time))
|
||||
|
||||
print_summary || true
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}Duration:${NC} ${total_duration}s"
|
||||
echo ""
|
||||
|
||||
if [[ $modules_failed -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user