diff --git a/.github/scripts/playwright-summary.jq b/.github/scripts/playwright-summary.jq
new file mode 100644
index 0000000000..034b582267
--- /dev/null
+++ b/.github/scripts/playwright-summary.jq
@@ -0,0 +1,41 @@
+def specs: [.. | objects | select(has("tests") and has("file"))];
+def dur: [.tests[].results[]?.duration // 0] | add;
+
+specs as $s
+| ($s | map(select(any(.tests[]; .status == "unexpected")))) as $failed
+| ($s | map(select(any(.tests[]; .status == "flaky")))) as $flaky
+| ($s | map(select(any(.tests[]; .status == "skipped")))) as $skipped
+| ($s | length) as $total
+| ($s | map(dur) | add // 0 | . / 1000 | floor) as $cpu
+| (if ($failed | length) > 0 then "❌"
+ elif ($flaky | length) > 0 then "⚠️"
+ else "✅" end) as $icon
+
+| "## \($icon) Integration tests\n\n"
++ "| Total | Passed | Flaky | Failed | Skipped | Test time |\n"
++ "|---|---|---|---|---|---|\n"
++ "| \($total) | \($total - ($failed|length) - ($flaky|length) - ($skipped|length)) "
++ "| \($flaky|length) | \($failed|length) | \($skipped|length) | \($cpu / 60 | floor)m |\n"
+
++ (if ($failed | length) > 0 then
+ "\n### Failed\n\n"
+ + ($failed | map("- `\(.file):\(.line)` — \(.title)") | join("\n")) + "\n"
+ else "" end)
+
++ (if ($flaky | length) > 0 then
+ "\n### Flaky (passed on retry)\n\n"
+ + ($flaky
+ | map({ t: "`\(.file):\(.line)` — \(.title)",
+ r: ([.tests[].results[]? | select(.status == "failed")] | length) })
+ | sort_by(-.r)
+ | map("- \(.t) _(\(.r) \(if .r == 1 then "retry" else "retries" end))_")
+ | join("\n")) + "\n"
+ else "" end)
+
++ (if $total > 0 then
+ "\nSlowest specs\n\n"
+ + ($s | map({ t: "`\(.file)` — \(.title)", d: (dur / 1000 | floor) })
+ | sort_by(-.d) | .[0:5]
+ | map("- \(.t) — \(.d)s") | join("\n"))
+ + "\n\n\n"
+ else "" end)
diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml
index e785f2c84e..b31450ac60 100644
--- a/.github/workflows/build-bundle.yml
+++ b/.github/workflows/build-bundle.yml
@@ -17,15 +17,19 @@ on:
required: true
default: 'develop'
+# Literal group name: under `workflow_call`, `github.workflow` resolves to the
+# caller's workflow, which put this workflow and the other reusable one called
+# by the same caller into a single shared group, and left a manual dispatch of
+# the same ref in a group of its own, free to race on the same artifacts.
concurrency:
- group: ${{ github.workflow }}-${{ inputs.gh_ref }}
+ group: build-bundle-${{ inputs.gh_ref }}
cancel-in-progress: true
jobs:
# ── 1. Decide whether there is anything to build ───────────────────────
check:
name: Check current bundle
- runs-on: penpot-runner-01
+ runs-on: penpot-standar-runner
timeout-minutes: 10
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
@@ -75,7 +79,7 @@ jobs:
# ── 2. Build and upload, only when needed ──────────────────────────────
build:
name: Build and Upload Penpot Bundle
- runs-on: penpot-runner-01
+ runs-on: penpot-standar-runner
timeout-minutes: 90
needs: check
if: needs.check.outputs.exists == 'false'
@@ -116,7 +120,7 @@ jobs:
# ── 3. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
- runs-on: penpot-runner-01
+ runs-on: penpot-standar-runner
timeout-minutes: 5
needs: [check, build]
if: failure()
diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml
index 2da1ab2a31..961ad1dca9 100644
--- a/.github/workflows/build-develop.yml
+++ b/.github/workflows/build-develop.yml
@@ -5,6 +5,10 @@ on:
schedule:
- cron: '16 5-20 * * 1-5'
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: true
+
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
@@ -19,7 +23,7 @@ jobs:
with:
gh_ref: "develop"
- build-admin-console-docker:
+ build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml
index b0340d329b..53de6376ce 100644
--- a/.github/workflows/build-docker-devenv.yml
+++ b/.github/workflows/build-docker-devenv.yml
@@ -6,7 +6,7 @@ on:
jobs:
build-and-push:
name: Build and push DevEnv Docker image
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
steps:
- name: Set common environment variables
diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml
index 0d03490194..b7bb794776 100644
--- a/.github/workflows/build-docker.yml
+++ b/.github/workflows/build-docker.yml
@@ -16,8 +16,12 @@ on:
required: true
default: 'develop'
+# Literal group name: under `workflow_call`, `github.workflow` resolves to the
+# caller's workflow, which put this workflow and the other reusable one called
+# by the same caller into a single shared group, and left a manual dispatch of
+# the same ref in a group of its own, free to race on the same artifacts.
concurrency:
- group: ${{ github.workflow }}-${{ inputs.gh_ref }}
+ group: build-docker-${{ inputs.gh_ref }}
cancel-in-progress: true
env:
@@ -32,12 +36,12 @@ jobs:
# ── 1. Resolve the build key and check the whole set at once ───────────
prepare:
name: Prepare
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
timeout-minutes: 15
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
- build_key: ${{ steps.vars.outputs.build_key }}
+ sha: ${{ steps.vars.outputs.sha }}
exists: ${{ steps.check.outputs.exists }}
steps:
@@ -55,6 +59,7 @@ jobs:
run: |
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
+ echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
BUNDLE_VERSION=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
@@ -63,15 +68,10 @@ jobs:
--output text)
echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT
- # Image content = bundle + docker build context, so the build key
- # combines both.
- CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12)
- echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT
-
# The image set is a single block, so a single set-level check is
# enough: `promote` drops a marker object in S3 only after every
# image was built AND every branch tag was moved. Marker present
- # means there is nothing at all to do for this build key.
+ # means there is nothing at all to do for this commit.
- name: Check if this image set is already built
id: check
env:
@@ -81,13 +81,13 @@ jobs:
run: |
if aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
- --key "markers/images-${{ steps.vars.outputs.build_key }}" \
+ --key "markers/images-sha-${{ steps.vars.outputs.sha }}" \
> /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
{
echo "### ⏭️ Image set build skipped"
echo ""
- echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`."
+ echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.sha }}\`."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "exists=false" >> $GITHUB_OUTPUT
@@ -97,7 +97,7 @@ jobs:
# prune stale bundles while at it.
mkdir -p "$BUNDLE_CACHE"
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
- ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip"
+ ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
if [ ! -f "$ZIP" ]; then
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
@@ -107,7 +107,7 @@ jobs:
# ── 2. One build per image, in parallel, only when needed ──────────────
build:
name: Build ${{ matrix.image }}
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
timeout-minutes: 60
needs: prepare
if: needs.prepare.outputs.exists == 'false'
@@ -169,7 +169,7 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
- ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip"
+ ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.bundle_version }}.zip"
if [ ! -f "$ZIP" ]; then
echo "Bundle not found in host cache; falling back to S3."
mkdir -p "$BUNDLE_CACHE"
@@ -209,7 +209,7 @@ jobs:
sbom: true
# Immutable tag only; branch tags are moved atomically for the
# whole image set by the `promote` job.
- tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }}
+ tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.sha }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
@@ -220,7 +220,7 @@ jobs:
# the S3 marker guarantees the branch tags were already moved.
promote:
name: Promote image set
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
timeout-minutes: 10
needs: [prepare, build]
@@ -245,7 +245,7 @@ jobs:
for image in $ALL_IMAGES; do
docker buildx imagetools create \
-t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \
- "${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}"
+ "${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.sha }}"
done
# The marker is written LAST: its presence certifies that all five
@@ -257,17 +257,17 @@ jobs:
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
echo "${{ github.run_id }}" | aws s3 cp - \
- "s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}"
+ "s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}"
{
echo "### ✅ Image set promoted"
echo ""
- echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`."
+ echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`."
} >> "$GITHUB_STEP_SUMMARY"
# ── 4. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
timeout-minutes: 5
needs: [prepare, build, promote]
if: failure()
diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml
index 2ae5ee13b0..1523e4d7df 100644
--- a/.github/workflows/build-staging.yml
+++ b/.github/workflows/build-staging.yml
@@ -5,6 +5,10 @@ on:
schedule:
- cron: '36 5-20 * * 1-5'
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: true
+
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
@@ -19,7 +23,7 @@ jobs:
with:
gh_ref: "staging"
- build-admin-console-docker:
+ build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml
index f488c911a7..aa6a2b8357 100644
--- a/.github/workflows/build-tag.yml
+++ b/.github/workflows/build-tag.yml
@@ -6,6 +6,12 @@ on:
tags:
- '*'
+# Keyed by ref and never cancelling: pushing 2.17.2 shortly after 2.17.2-RC1
+# must not abort the release already in flight.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref_name }}
+ cancel-in-progress: false
+
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
@@ -20,10 +26,18 @@ jobs:
with:
gh_ref: ${{ github.ref_name }}
+ build-docker-admin-console:
+ uses: ./.github/workflows/build-docker-admin-console.yml
+ secrets: inherit
+ with:
+ gh_ref: ${{ github.ref_name }}
+
notify:
name: Notifications
runs-on: ubuntu-24.04
- needs: build-docker
+ needs:
+ - build-docker
+ - build-docker-admin-console
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
@@ -37,7 +51,9 @@ jobs:
publish-final-tag:
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
- needs: build-docker
+ needs:
+ - build-docker
+ - build-docker-admin-console
uses: ./.github/workflows/release.yml
secrets: inherit
with:
diff --git a/.github/workflows/build-tmp-tokens.yml b/.github/workflows/build-tmp-tokens.yml
new file mode 100644
index 0000000000..838a97a617
--- /dev/null
+++ b/.github/workflows/build-tmp-tokens.yml
@@ -0,0 +1,24 @@
+name: _TMP TOKENS
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '46 5-20 * * 1-5'
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: true
+
+jobs:
+ build-bundle:
+ uses: ./.github/workflows/build-bundle.yml
+ secrets: inherit
+ with:
+ gh_ref: "hiru-tokens-in-libs"
+
+ build-docker:
+ needs: build-bundle
+ uses: ./.github/workflows/build-docker.yml
+ secrets: inherit
+ with:
+ gh_ref: "hiru-tokens-in-libs"
diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml
index cbc8e109cd..2666957893 100644
--- a/.github/workflows/plugins-deploy-package.yml
+++ b/.github/workflows/plugins-deploy-package.yml
@@ -34,7 +34,7 @@ permissions:
jobs:
deploy:
- runs-on: penpot-runner-01
+ runs-on: penpot-standar-runner
steps:
- name: Checkout
uses: actions/checkout@v6
diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml
index d7a3377772..02f839c062 100644
--- a/.github/workflows/tests-backend.yml
+++ b/.github/workflows/tests-backend.yml
@@ -32,7 +32,7 @@ jobs:
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
diff --git a/.github/workflows/tests-common.yml b/.github/workflows/tests-common.yml
index 5996c82742..3164fe5089 100644
--- a/.github/workflows/tests-common.yml
+++ b/.github/workflows/tests-common.yml
@@ -30,7 +30,7 @@ jobs:
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
diff --git a/.github/workflows/tests-composable-suite.yml b/.github/workflows/tests-composable-suite.yml
index 1ad48c0edf..3b1d68be21 100644
--- a/.github/workflows/tests-composable-suite.yml
+++ b/.github/workflows/tests-composable-suite.yml
@@ -38,7 +38,7 @@ jobs:
composable-test-suite:
if: ${{ !github.event.pull_request.draft }}
name: "Run composable test suite (mocked backend)"
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml
new file mode 100644
index 0000000000..1ed37d95c8
--- /dev/null
+++ b/.github/workflows/tests-exporter.yml
@@ -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
diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml
index 14011a5110..58ba335e38 100644
--- a/.github/workflows/tests-frontend.yml
+++ b/.github/workflows/tests-frontend.yml
@@ -34,7 +34,7 @@ jobs:
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-integration.yml
index b028676aa7..343e0dcef6 100644
--- a/.github/workflows/tests-integration.yml
+++ b/.github/workflows/tests-integration.yml
@@ -5,11 +5,37 @@ defaults:
shell: bash
on:
+ workflow_dispatch:
+ inputs:
+ gh_ref:
+ description: 'Name of the branch or ref'
+ type: string
+ required: true
+ default: 'develop'
+
+ shards:
+ description: 'Shard layout (JSON array)'
+ type: choice
+ required: true
+ default: '[1, 2, 3, 4]'
+ options:
+ - '[1, 2, 3, 4]'
+ - '[1, 2, 3, 4, 5, 6]'
+ - '[1, 2]'
+ - '[1]'
+
+ workers:
+ description: 'Playwright workers per shard'
+ type: string
+ required: true
+ default: '2'
+
pull_request:
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
+ - '.github/workflows/tests-integration.yml'
types:
- opened
@@ -25,25 +51,41 @@ on:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
+ - '.github/workflows/tests-integration.yml'
concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
cancel-in-progress: true
jobs:
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
+ timeout-minutes: 30
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
+ outputs:
+ bundle_key: ${{ steps.vars.outputs.bundle_key }}
+
steps:
+ # An empty `ref` makes checkout fall back to its default (the PR merge
+ # ref on pull_request, the pushed ref on push).
- name: Checkout repository
uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.gh_ref }}
+
+ # The cache key must come from the SHA actually checked out: on a manual
+ # run `github.sha` points at the dispatching ref, not at `gh_ref`.
+ - name: Extract cache key
+ id: vars
+ run: |
+ echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Build Bundle
working-directory: ./frontend
@@ -53,41 +95,156 @@ jobs:
- name: Store Bundle Cache
uses: actions/cache@v5
with:
- key: "integration-bundle-${{ github.sha }}"
+ key: ${{ steps.vars.outputs.bundle_key }}
path: frontend/resources/public
test-integration:
if: ${{ !github.event.pull_request.draft }}
- name: "Integration Tests"
- runs-on: penpot-runner-02
+ name: "Integration Tests (${{ matrix.shard }})"
+ runs-on: penpot-extended-runner
+ timeout-minutes: 40
+
+ needs: build-integration
+
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4]') }}
+
+ container:
+ image: penpotapp/devenv:latest
+ volumes:
+ - /var/cache/github-runner/m2:/root/.m2
+ - /var/cache/github-runner/gitlib:/root/.gitlibs
+ - /var/cache/github-runner/ms-playwright:/ms-playwright
+ env:
+ PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
+
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.gh_ref }}
+
+ - name: Restore Cache
+ uses: actions/cache/restore@v5
+ with:
+ key: ${{ needs.build-integration.outputs.bundle_key }}
+ path: frontend/resources/public
+
+ - name: Install deps
+ working-directory: ./frontend
+ run: |
+ corepack enable;
+ corepack install;
+ pnpm install --frozen-lockfile;
+
+ # No-op once the shared volume is warm; keeps the first run working.
+ - name: Install Playwright Chromium
+ working-directory: ./frontend
+ run: pnpm exec playwright install chromium
+
+ # `strategy.job-total` is the matrix size, so the shard denominator
+ # follows the `shards` input without being hardcoded.
+ - name: Run Tests
+ working-directory: ./frontend
+ env:
+ WORKERS: ${{ inputs.workers }}
+ run: |
+ WORKERS=${WORKERS:-2}
+ echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
+ pnpm exec playwright test --project default \
+ --workers="$WORKERS" \
+ --shard=${{ matrix.shard }}/${{ strategy.job-total }} \
+ --reporter=blob
+
+ - name: Upload blob report
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: integration-blob-report-${{ matrix.shard }}
+ path: frontend/blob-report/
+ overwrite: true
+ retention-days: 3
+
+ - name: Upload test result
+ uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: integration-tests-result-${{ matrix.shard }}
+ path: frontend/test-results/
+ overwrite: true
+ if-no-files-found: ignore
+ retention-days: 3
+
+ merge-reports:
+ if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
+ name: "Merge Integration Reports"
+ runs-on: penpot-extended-runner
+ timeout-minutes: 15
+
+ needs: test-integration
+
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
- needs: build-integration
-
steps:
- name: Checkout Repository
uses: actions/checkout@v6
-
- - name: Restore Cache
- uses: actions/cache/restore@v5
with:
- key: "integration-bundle-${{ github.sha }}"
- path: frontend/resources/public
+ ref: ${{ inputs.gh_ref }}
- - name: Run Tests
+ - name: Install deps
working-directory: ./frontend
run: |
- ./scripts/test-e2e
+ corepack enable;
+ corepack install;
+ pnpm install --frozen-lockfile;
- - name: Upload test result
+ - name: Download blob reports
+ uses: actions/download-artifact@v7
+ with:
+ path: frontend/all-blob-reports
+ pattern: integration-blob-report-*
+ merge-multiple: true
+
+ - name: Merge into HTML report
+ working-directory: ./frontend
+ env:
+ PLAYWRIGHT_JSON_OUTPUT_NAME: report.json
+ run: |
+ pnpm exec playwright merge-reports \
+ --reporter=html,json,list ./all-blob-reports
+
+ - name: Test summary
+ if: always()
+ working-directory: ./frontend
+ run: |
+ if [ ! -f report.json ]; then
+ echo "No report produced (all shards failed early)." >> "$GITHUB_STEP_SUMMARY"
+ exit 0
+ fi
+ jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY"
+
+ # Kept for 30 days so flakiness rates can be aggregated across runs
+ # without scraping job logs.
+ - name: Upload JSON report
uses: actions/upload-artifact@v7
if: always()
with:
- name: integration-tests-result
- path: frontend/test-results/
+ name: integration-json-report
+ path: frontend/report.json
overwrite: true
- retention-days: 3
+ if-no-files-found: ignore
+ retention-days: 30
+
+ - name: Upload HTML report
+ uses: actions/upload-artifact@v7
+ with:
+ name: integration-html-report
+ path: frontend/playwright-report/
+ overwrite: true
+ retention-days: 7
diff --git a/.github/workflows/tests-library.yml b/.github/workflows/tests-library.yml
index 4c84965f4c..a5b565a892 100644
--- a/.github/workflows/tests-library.yml
+++ b/.github/workflows/tests-library.yml
@@ -32,7 +32,7 @@ jobs:
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml
index 6f8eadcadd..f51c56b8a3 100644
--- a/.github/workflows/tests-mcp.yml
+++ b/.github/workflows/tests-mcp.yml
@@ -1,4 +1,4 @@
-name: "MCP CI"
+name: "CI: MCP"
on:
pull_request:
@@ -28,7 +28,7 @@ jobs:
test-mcp:
if: ${{ !github.event.pull_request.draft }}
name: "Test MCP"
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
container: penpotapp/devenv:latest
steps:
diff --git a/.github/workflows/tests-plugin-api-suite.yml b/.github/workflows/tests-plugin-api-suite.yml
index e589c5414c..de6e2080bb 100644
--- a/.github/workflows/tests-plugin-api-suite.yml
+++ b/.github/workflows/tests-plugin-api-suite.yml
@@ -53,7 +53,7 @@ jobs:
api-test-suite-mocked:
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
name: "Run Plugin API Test Suite (mocked)"
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
@@ -95,7 +95,7 @@ jobs:
# api-test-suite-live:
# if: ${{ github.event_name == 'workflow_dispatch' }}
# name: Run Plugin API Test Suite (live)
- # runs-on: penpot-runner-02
+ # runs-on: penpot-extended-runner
# container:
# image: penpotapp/devenv:latest
#
diff --git a/.github/workflows/tests-plugins.yml b/.github/workflows/tests-plugins.yml
index 1415c73db7..b1bbdd0992 100644
--- a/.github/workflows/tests-plugins.yml
+++ b/.github/workflows/tests-plugins.yml
@@ -30,7 +30,7 @@ jobs:
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
diff --git a/.github/workflows/tests-wasm.yml b/.github/workflows/tests-wasm.yml
index 424d4f908f..9f26175554 100644
--- a/.github/workflows/tests-wasm.yml
+++ b/.github/workflows/tests-wasm.yml
@@ -30,7 +30,7 @@ jobs:
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
- runs-on: penpot-runner-02
+ runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
diff --git a/.gitignore b/.gitignore
index 76da22b35f..cc695a46e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@ opencode.json
!AGENTS.md
!CODE_OF_CONDUCT.md
!SECURITY.md
+!HIGHLIGHTS.md
/*.png
/*.svg
/*.sql
@@ -58,6 +59,8 @@ opencode.json
/docker/images/bundle*
/exporter/target
/exporter/.shadow-cljs
+/exporter/resources/wasm/
+/exporter/src/app/wasm/shared.js
/frontend/.storybook/preview-body.html
/frontend/.storybook/preview-head.html
/frontend/playwright-report/
@@ -88,6 +91,7 @@ opencode.json
/blob-report/
/playwright/.cache/
/render-wasm/target/
+/media-processor/dist/
/**/node_modules
/**/.yarn/*
/.pnpm-store
@@ -95,6 +99,7 @@ opencode.json
/.idea
*.iml
/.claude
+/CLAUDE.md
/.playwright-mcp
/.devenv/mcp/
/opencode.json
diff --git a/.nvmrc b/.nvmrc
index 87d8620cc6..3648bfc346 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v24.18.1
+v24.19.0
diff --git a/.opencode/agents/commiter.md b/.opencode/agents/commiter.md
deleted file mode 100644
index a5e128e4d1..0000000000
--- a/.opencode/agents/commiter.md
+++ /dev/null
@@ -1,55 +0,0 @@
----
-name: commiter
-description: Git commit assistant
-mode: subagent
-permission:
- read: allow
- glob: allow
- grep: allow
- edit: deny
- webfetch: deny
- websearch: deny
- task: deny
- skill: deny
- lsp: deny
- todowrite: deny
- question: deny
- external_directory: deny
- bash: allow
----
-
-## Role
-
-You are the Penpot commit assistant. You produce git commits that follow the
-repository's commit conventions. You do not implement features, review code, or
-push branches — you commit.
-
-## Required Reading
-
-Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md`
-end-to-end**. It is the authoritative source for the commit message format, the
-emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it
-exactly — do not improvise the format and do not restate its contents here.
-
-## Pre-commit Workflow
-
-1. **Stage the files** specified by the calling agent. Do not ask for
- confirmation — the calling agent knows exactly which files to commit.
-2. Run `git diff --staged` to review the content. If you see secrets (API
- keys, tokens, passwords, private keys, `.env` values), debug prints, or
- anything that does not match the stated intent, STOP and tell the user
- before committing.
-3. Following the format in the doc, draft the message and run
- `git commit -m "" -m ""` (or `git commit -F -` if the body has
- unusual characters). The `AI-assisted-by` trailer value is provided by the
- calling agent — use it verbatim.
-
-## Constraints
-
-- Do not push. Pushing is a separate workflow handled by the user.
-- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations.
-- Do not pass `--author`. Author identity comes from the local git config.
-- Do not amend a commit you did not create in this session, unless the user explicitly asks.
-- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks.
-- Do not add untracked files that were not created in this session.
-- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response.
diff --git a/.opencode/commands/implement-plan.md b/.opencode/commands/implement-plan.md
index f6cb42f6b8..8ecd1bd537 100644
--- a/.opencode/commands/implement-plan.md
+++ b/.opencode/commands/implement-plan.md
@@ -1,13 +1,10 @@
---
-description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent
+description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
agent: build
---
-# Implement Plan
-
This command is run once a plan is ready (for example, from plan mode). Execute
-the plan already prepared in the current session context — it does not take
-extra arguments. Follow these steps in order.
+the plan already prepared in the current session context. Follow these steps in order.
## 1. Create the issue
@@ -32,12 +29,11 @@ Implement the prepared plan from the session context. Work methodically, keeping
changes focused on what the issue requires. Do not commit — the commit happens in
step 4.
-## 4. Commit with the commiter subagent
+## 4. Commit with the create-commit skill
-After the implementation is complete, delegate the commit to the **`commiter`**
-subagent. Give it a brief summary of what was implemented and why, the issue
-reference (`issue-NNNN`), and the model name you are running as so it sets the
-`AI-assisted-by` trailer correctly. The subagent owns the commit format and
-conventions.
+After the implementation is complete, load the **`create-commit`** skill and
+follow its workflow to commit the changes. Provide a brief summary of what was
+implemented and why, the issue reference (`issue-NNNN`), and the model name you
+are running as so the `AI-assisted-by` trailer is set correctly.
Do not push. Pushing is handled separately by the user.
diff --git a/.opencode/commands/resolve-git-conflicts.md b/.opencode/commands/resolve-git-conflicts.md
new file mode 100644
index 0000000000..1b17ca0001
--- /dev/null
+++ b/.opencode/commands/resolve-git-conflicts.md
@@ -0,0 +1,40 @@
+---
+description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase
+agent: build
+---
+
+# Fix Git Conflicts
+
+Resolve conflicts in the local repository. The user handles finishing the
+rebase themselves — you must **never** run `git rebase --continue`,
+`git rebase --skip`, `git merge --continue`, or anything similar.
+
+## Phase 1 — Understand the problem (read-only)
+
+1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
+2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
+ - Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
+ - Inspect both sides — `git show :` and `git show :` — plus `git log`/`git show` on the commits involved to understand intent.
+ - Identify what each side changed and why, and how they should be combined.
+
+## Phase 2 — Present the resolution plan
+
+3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
+ - What each side changed and why.
+ - Your proposed resolution and the reasoning behind it.
+ - How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
+4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
+5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
+
+## Phase 3 — Execute
+
+6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
+
+## Phase 4 — Stage and verify
+
+7. **Stage every resolved file** with `git add `. Do not stage unrelated untracked files unless clearly part of the resolution.
+8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
+
+## Phase 5 — Report
+
+9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
diff --git a/.opencode/commands/review.md b/.opencode/commands/review.md
deleted file mode 100644
index 3e6f59cee5..0000000000
--- a/.opencode/commands/review.md
+++ /dev/null
@@ -1,79 +0,0 @@
-Act as a senior software engineer and perform a thorough code review.
-
-## Instructions
-
-1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
-2. Determine the diff or code to review from the provided context.
-3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks.
-4. Read the diff and the surrounding context for each changed file.
-5. Review across all five axes: correctness, readability, architecture, security, performance.
-6. Produce the review using this structure:
- - **Summary**: One-paragraph overview of the change and its impact
- - **Critical/High Findings**: Blockers that must be fixed (with file:line, severity, description, and proposed fix)
- - **Other Findings**: Medium/Low issues and suggestions
- - **Testing Recommendations**: Missing test coverage or test quality issues
- - **Positive Observations**: What was done well (brief, specific)
- - **Verdict**: Approve / Request Changes / Needs Discussion
-7. For each finding:
- - State the severity (Critical / High / Medium / Low / Suggestion)
- - Identify the file and line
- - Describe failure circumstances
- - **For Critical/High**: Provide a concrete fix with a code snippet showing the corrected code
- - **For Medium/Low**: Describe the fix clearly; code snippet optional
- - If multiple approaches exist, briefly note trade-offs
-8. **Perform a second review pass if the change is complex:**
- - **Complex indicators**: Critical/High findings, multiple files (>5), architectural changes, security-sensitive code, >300 lines changed
- - **Skip for simple changes**: Typo fixes, formatting, small bug fixes (<50 lines), single-file changes with no findings
- - Second pass checks:
- - Validate severity assignments: Are Critical/High findings truly blockers?
- - Catch missed issues: Edge cases, error paths, test gaps overlooked in first pass
- - Remove false positives: Discard findings that aren't real issues
- - Verify fixes: Are the proposed solutions actually correct and complete?
-
-## Strong Rules
-
-1. Do not invent problems. Every finding must be real and actionable.
-2. Do not modify any code and do not create a commit — this command only reviews.
-3. Be specific and constructive. "This could be better" is not helpful — explain why and how.
-4. Prioritize by impact. One structural issue outweighs ten nits.
-5. If tests are missing for new functionality, flag it as High severity.
-
-## Context
-
-$ARGUMENTS
-
-## Expected Format
-
-```
-## Review Summary
-[1-2 sentences on what the change does and overall assessment]
-
-## Critical/High Findings
-### [Severity] file.ts:123
-**Issue**: [Description of the problem]
-**Impact**: [What could go wrong]
-**Fix**:
-```[language]
-// Current code
-[problematic code]
-
-// Fixed code
-[corrected code]
-```
-[Optional: note trade-offs if multiple approaches exist]
-
-## Other Findings
-### [Severity] file.ts:456
-**Issue**: [Description]
-**Fix**: [Clear description; code snippet optional]
-
-## Testing Recommendations
-[List specific test cases that should be added]
-
-## Positive Observations
-[2-3 specific things done well]
-
-## Verdict
-[Approve / Request Changes / Needs Discussion]
-[If Request Changes: list the must-fix items]
-```
diff --git a/.opencode/skills/code-review-and-quality/SKILL.md b/.opencode/skills/code-review/SKILL.md
similarity index 91%
rename from .opencode/skills/code-review-and-quality/SKILL.md
rename to .opencode/skills/code-review/SKILL.md
index a0f75e7f99..7fa581efa0 100644
--- a/.opencode/skills/code-review-and-quality/SKILL.md
+++ b/.opencode/skills/code-review/SKILL.md
@@ -1,5 +1,5 @@
---
-name: code-review-and-quality
+name: code-review
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
---
@@ -106,6 +106,8 @@ For detailed security guidance, see `security-and-hardening`.
| **Low:** | Minor, optional | Author may ignore — formatting, style preferences |
| **Suggestion:** | Worth considering | Not required, but improves the code |
+**Unique finding IDs.** Assign every finding a stable identifier: `F1`, `F2`, `F3`, … numbered in order of severity (Critical first, then High, Medium, Low, Suggestion). Use the ID everywhere the finding is mentioned — in section headers, in the verdict, in follow-up discussion. Never renumber within a review. Example: `**F3 (High)** — `app/validate.cljs:42` — duplicate branch logic…`.
+
For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not.
Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list.
@@ -122,11 +124,11 @@ Briefly explain what the code does and give an overall assessment.
### Critical and High-Priority Issues
-List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
+List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. Each finding gets its unique ID (`F1`, `F2`, …). For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
### Other Findings
-List medium- and low-priority issues, including maintainability and design concerns.
+List medium- and low-priority issues, including maintainability and design concerns. Continue the ID sequence started above (`F3`, `F4`, …).
### Suggested Refactoring
@@ -148,6 +150,8 @@ Choose one:
- **Approve with minor changes** — Good to merge after addressing low/medium issues
- **Request changes** — Critical or high issues must be resolved before merge
+List the finding IDs the verdict depends on (e.g. "Request changes: F1, F4").
+
## Change Sizing
Small, focused changes are easier to review, faster to merge, and safer to deploy.
@@ -231,25 +235,13 @@ For supply-chain risk triage, follow the `security-and-hardening` skill.
## Verification
-After review is complete:
+Before emitting the verdict, verify the change as it stands. This is the reviewer's own due diligence — it covers the state of the code at review time, not the later resolution of findings (fixing findings is the author's job; confirming them is a new review):
-- [ ] All Critical issues are resolved
-- [ ] All Required (no-prefix) changes are resolved or explicitly deferred with justification
-- [ ] Tests pass
+- [ ] Tests pass — run them yourself, don't trust the claim
- [ ] Build succeeds
- [ ] The verification story is documented (what changed, how it was verified)
- [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite
-## Multi-Model Review Pattern
-
-Use different models for different review perspectives:
-
-```
-Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call
-```
-
-Different models have different blind spots.
-
## See Also
- For detailed security review guidance, see `security-and-hardening`
diff --git a/.opencode/skills/create-commit/SKILL.md b/.opencode/skills/create-commit/SKILL.md
new file mode 100644
index 0000000000..790ba1b585
--- /dev/null
+++ b/.opencode/skills/create-commit/SKILL.md
@@ -0,0 +1,47 @@
+---
+name: create-commit
+description: Stage, review, and commit files following Penpot commit conventions.
+---
+
+# Skill: create-commit
+
+Produce a git commit that follows Penpot's commit message conventions. This
+skill owns the commit format, staging review, and safety checks — it does not
+implement features or push.
+
+## When to Use
+
+- After code changes are complete and files need to be committed
+- When delegated by a workflow step (e.g. implement-plan) to handle the commit
+
+## Required Reading
+
+Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
+is the authoritative source for the commit message format, the emoji menu,
+subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
+
+## Workflow
+
+1. **Stage the files** specified by the calling context. Do not ask for
+ confirmation.
+2. Run `git diff --staged` to review the content. If you see secrets (API keys,
+ tokens, passwords, private keys, `.env` values), debug prints, or anything
+ that does not match the stated intent, **STOP** and tell the user before
+ committing.
+3. Draft the message following the format in the memory doc, wrapping the body
+ at 72 characters per line, and run:
+ ```bash
+ git commit -m "" -m ""
+ ```
+ (or `git commit -F -` if the body has unusual characters).
+4. The `AI-assisted-by` trailer value is provided by the calling context — use
+ it verbatim.
+
+## Constraints
+
+- Do not push. Pushing is a separate workflow handled by the user.
+- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm`.
+- Do not pass `--author`. Author identity comes from the local git config.
+- Do not amend a commit you did not create in this session, unless explicitly asked.
+- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
+- Do not add untracked files that were not created in this session.
diff --git a/.opencode/skills/plan-review/SKILL.md b/.opencode/skills/plan-review/SKILL.md
new file mode 100644
index 0000000000..4386d701cc
--- /dev/null
+++ b/.opencode/skills/plan-review/SKILL.md
@@ -0,0 +1,315 @@
+---
+name: plan-review
+description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
+---
+
+# Plan Review
+
+## Overview
+
+Multi-dimensional plan review with quality gates. Every plan gets reviewed before implementation starts — no exceptions. Review covers six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality.
+
+**The approval standard:** Approve a plan when it is specific enough that a skilled implementer could execute it without guessing, the task ordering is sound, and risks are acknowledged. Perfect plans don't exist — the goal is confidence that implementation won't derail. Don't block a plan because it isn't exactly how you would have structured it. If it's executable and well-organized, approve it.
+
+## When to Use
+
+- After the planner skill produces a plan
+- Before starting implementation on any non-trivial task
+- When reviewing a plan written by another agent or a human
+- When a plan feels too large, vague, or risky to start
+
+**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
+
+## The Six-Axis Review
+
+Every plan gets evaluated across these dimensions:
+
+### 1. Completeness
+
+Does the plan cover everything needed to implement successfully?
+
+- Is the **context** clear? (What problem, why now, what's the goal?)
+- Are **affected modules** identified with paths?
+- Are **architecture decisions** documented with rationale?
+- Is there a **testing strategy**?
+- Are **verification commands** explicit (not "run the tests")?
+- Are **open questions** listed (not buried in someone's head)?
+- Is there a **parallelization** assessment for multi-task plans?
+
+**Missing any of these is a gap, not a nit.**
+
+### 2. Task Quality
+
+Are the tasks well-defined and independently executable?
+
+- Does every task have **acceptance criteria**? (Testable, not vague)
+- Does every task have **verification steps**?
+- Are tasks **sized appropriately**? (XS–M is ideal, L is acceptable, XL must be split)
+- Are **dependencies** between tasks explicitly stated?
+- Are **files likely touched** listed?
+- Is each task a **single, self-contained change**? (Not "implement the whole feature")
+- Could a skilled implementer pick up any task and execute it without asking clarifying questions?
+
+### 3. Architecture & Sequencing
+
+Is the plan structured so implementation flows correctly?
+
+- Does implementation order follow the **dependency graph** (foundations first)?
+- Are tasks **vertically sliced** (feature paths) rather than horizontally layered?
+- Does each task leave the system in a **working state**?
+- Are there **checkpoints** between major phases?
+- Are **high-risk tasks early** (fail fast)?
+- Is the total plan a reasonable number of tasks? (More than ~15 tasks suggests the scope should be split into multiple plans)
+
+### 4. Risk Coverage
+
+Are the hard parts acknowledged and mitigated?
+
+- Are **edge cases** identified?
+- Are **breaking changes** or **migration concerns** noted?
+- Are **security implications** considered?
+- Are **performance implications** considered?
+- Are **external dependencies** or integration risks flagged?
+- Is there a plan for **rollback** if something goes wrong?
+- Are **data integrity** risks addressed (what happens if a migration fails mid-way)?
+
+### 5. Actionability
+
+Can an implementer actually execute this?
+
+- Are **file paths** specific (not "update the relevant files")?
+- Are **function/method names** mentioned where applicable?
+- Are **verification commands** copy-pasteable (not "run the linter")?
+- Are **test commands** project-specific (not generic)?
+- Is the **code shape** described where the implementation isn't obvious?
+- Are **conventions** referenced (naming, patterns, existing utilities to reuse)?
+- Does the plan reference **existing code** the implementer should read first?
+
+### 6. Proposed Code Quality *(when the plan includes implementation details)*
+
+If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria:
+
+- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
+- **Readability:** Are proposed names descriptive and consistent with project conventions?
+- **Architecture:** Do proposed abstractions follow existing patterns? Are they justified (not over-engineered)?
+- **Security:** Do proposed APIs validate input at boundaries? Any injection/XSS vectors in the design?
+- **Performance:** Do proposed data structures avoid N+1 patterns? Any unbounded operations in the design?
+
+**When to apply:** Only when the plan includes specific code snippets, type definitions, API contracts, or function signatures. Plans that only describe "what" without showing "how" skip this axis.
+
+## Structural Remedies
+
+When you flag a structural problem in a plan, propose the fix — not just the problem:
+
+- **A task is too large (XL):** Split it into vertical slices. Each slice should be independently testable.
+- **Missing acceptance criteria:** Draft 2–3 specific, testable conditions for the task.
+- **Wrong sequencing:** Identify the dependency and propose the correct order.
+- **No checkpoints:** Suggest where checkpoints should go (typically after every 2–3 tasks).
+- **Vague verification:** Replace "run tests" with the actual project command.
+- **Horizontal slicing:** Restructure into vertical feature paths.
+- **Missing risk section:** Draft the risks you can identify from the plan content.
+
+Prefer the remedy that makes the plan immediately actionable over one that just flags the gap.
+
+## Plan Sizing
+
+Plans should be scoped to a single deliverable:
+
+```
+1–5 tasks → Good. A focused feature or bug fix.
+6–10 tasks → Acceptable for a moderate feature.
+11–15 tasks → Large. Consider splitting into phases.
+15+ tasks → Too large. Split into multiple plans.
+```
+
+**What counts as "one plan":** A self-contained set of changes that delivers a single coherent capability. If you can describe the goal in one sentence, it's one plan.
+
+## Categorize Findings
+
+Label every comment with its severity so the author knows what's required vs optional:
+
+| Prefix | Meaning | Author Action |
+|--------|---------|---------------|
+| *(no prefix)* | Required change | Must address before implementation starts |
+| **Critical:** | Blocks implementation | Missing security consideration, data integrity risk, fundamentally wrong approach |
+| **Nit:** | Minor, optional | Author may ignore — wording, formatting |
+| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
+| **FYI** | Informational only | No action needed — context for future reference |
+
+**Lead with what matters.** Order findings by leverage: missing risks and wrong sequencing first, then task quality gaps, then completeness, then nits. If you have one critical sequencing problem and ten nits, the sequencing problem *is* the review.
+
+## Review Process
+
+### Step 1: Understand the Goal
+
+Before evaluating structure, understand intent:
+
+```
+- What is this plan trying to accomplish?
+- What problem does it solve?
+- What does "done" look like?
+```
+
+### Step 2: Check Completeness First
+
+Scan for missing sections before diving into content:
+
+```
+- Context present?
+- Affected modules listed?
+- Architecture decisions documented?
+- Risks acknowledged?
+- Testing strategy defined?
+- Verification commands explicit?
+```
+
+### Step 3: Review Task Quality
+
+Walk through each task:
+
+```
+For each task:
+1. Can I tell exactly what to build?
+2. Are acceptance criteria specific and testable?
+3. Is the size reasonable (not XL)?
+4. Are dependencies clear?
+5. Would I know which files to touch?
+```
+
+### Step 4: Validate Sequencing
+
+Check the dependency graph:
+
+```
+- Are foundations built first?
+- Does each task leave the system working?
+- Are checkpoints placed correctly?
+- Are high-risk items early?
+- Is it vertically sliced?
+```
+
+### Step 5: Assess Actionability
+
+Put yourself in the implementer's shoes:
+
+```
+- Could I pick up task 1 and start coding without asking any questions?
+- Are the verification commands copy-pasteable?
+- Are file paths and function names specific?
+- Is existing code referenced where I'd need to read it?
+```
+
+### Step 6: Verify the Verification Story
+
+Check that the plan can actually confirm it worked:
+
+```
+- What tests should pass after implementation?
+- What build/compile commands are relevant?
+- What manual checks are needed?
+- How do we know the feature works end-to-end?
+```
+
+### Step 7: Evaluate Proposed Code Quality *(if applicable)*
+
+If the plan includes code snippets, types, or API designs:
+
+```
+- Load code-review skill for criteria
+- Check proposed signatures for edge cases
+- Verify naming follows project conventions
+- Confirm abstractions follow existing patterns
+- Scan for security vectors in proposed APIs
+- Check for performance issues in proposed data structures
+```
+
+## Review Checklist
+
+```markdown
+## Review: [Plan title]
+
+### Completeness
+- [ ] Context explains the problem and goal
+- [ ] Affected modules are listed with paths
+- [ ] Architecture decisions have rationale
+- [ ] Testing strategy is defined
+- [ ] Verification commands are explicit and project-specific
+- [ ] Open questions are listed
+
+### Task Quality
+- [ ] Every task has acceptance criteria
+- [ ] Every task has verification steps
+- [ ] Tasks are sized XS–M (L acceptable, XL must be split)
+- [ ] Task dependencies are stated
+- [ ] Files likely touched are listed
+
+### Architecture & Sequencing
+- [ ] Order follows dependency graph (foundations first)
+- [ ] Vertically sliced (not horizontal layers)
+- [ ] Each task leaves system working
+- [ ] Checkpoints exist between phases
+- [ ] High-risk tasks are early
+
+### Risk Coverage
+- [ ] Edge cases identified
+- [ ] Breaking changes / migrations noted
+- [ ] Security implications considered
+- [ ] Performance implications considered
+- [ ] Rollback strategy exists (if applicable)
+
+### Actionability
+- [ ] File paths are specific
+- [ ] Verification commands are copy-pasteable
+- [ ] Existing code to read is referenced
+- [ ] Conventions and patterns are noted
+
+### Proposed Code Quality *(if plan includes implementation details)*
+- [ ] Proposed types/signatures handle edge cases
+- [ ] Proposed names follow project conventions
+- [ ] Proposed abstractions follow existing patterns
+- [ ] No security vectors in proposed APIs
+- [ ] No performance issues in proposed structures
+
+### Verdict
+- [ ] **Approve** — Ready to implement
+- [ ] **Request changes** — Gaps must be addressed
+```
+
+## Common Rationalizations
+
+| Rationalization | Reality |
+|---|---|
+| "I'll figure out the details during implementation" | That's how you discover blocking dependencies mid-task. Surface them now. |
+| "The tasks are obvious, no need for criteria" | Write them anyway. Explicit criteria surface hidden assumptions. |
+| "It's just a small feature, it doesn't need a plan" | Small features have edge cases too. 3 tasks with criteria takes 5 minutes. |
+| "The plan is good enough" | "Good enough" without acceptance criteria means the implementer defines "done" — and they might define it differently. |
+| "I'll add verification steps later" | Later never comes. The plan is the contract — define verification now. |
+| "Risks are minimal" | Every change has risks. If you can't name them, you haven't thought about them. |
+| "The file paths are obvious" | They're obvious to the author. The implementer might not know the codebase. |
+| "The code in the plan is fine, it'll get reviewed later" | Plan-level code review catches design problems before implementation — fixing them after coding is more expensive. |
+
+## Red Flags
+
+- No acceptance criteria on any task
+- Tasks that say "implement the feature" without specifics
+- No verification steps anywhere in the plan
+- All tasks are XL-sized
+- No checkpoints between phases
+- Dependency order isn't considered (e.g., API handler before domain model)
+- No testing strategy
+- Verification commands are generic ("run tests") instead of project-specific
+- Plan has 20+ tasks (scope too large for one plan)
+- No risk section on a plan with migrations, breaking changes, or security implications
+- Horizontal slicing (all domain, then all services, then all API)
+- File paths are vague ("update the relevant files")
+- Missing open questions section despite stated unknowns
+- Proposed code ignores project conventions or existing patterns
+- Proposed types use gratuitous `any`/`unknown`/optional without justification
+- Proposed APIs don't validate input at boundaries
+
+## See Also
+
+- For producing plans, use the `planner` skill
+- For reviewing implemented code, use `code-review` — also the criteria source for axis 6
+- For security-specific concerns, see `security-and-hardening`
+- For testing strategy guidance, see `testing`
diff --git a/.opencode/skills/planner/SKILL.md b/.opencode/skills/planner/SKILL.md
index d1802652e2..3598a0dc16 100644
--- a/.opencode/skills/planner/SKILL.md
+++ b/.opencode/skills/planner/SKILL.md
@@ -1,13 +1,13 @@
---
name: planner
-description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to .opencode/plans/YYYY-MM-DD-.md.
+description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-.md.
---
# Planner
Read-only senior software architect role for Penpot. Produces structured
-implementation plans that engineers or other agents can execute. Never writes
-or modifies code.
+implementation plans with task breakdowns that engineers or other agents can
+execute. Never writes or modifies code.
## When to Use
@@ -18,24 +18,29 @@ or modifies code.
- The user asks "how would I implement X?" or "what's involved in fixing Y?".
- The user is about to start non-trivial work and wants a bite-sized task
breakdown.
+- A task feels too large or vague to start.
+- Work needs to be parallelized across multiple agents or sessions.
Do **not** use this skill to actually implement anything — it is read-only.
+**When NOT to use:** Single-file changes with obvious scope, or when the spec
+already contains well-defined tasks.
+
## Role
-You are a Senior Software Architect working on Penpot, an open-source design
-tool. Your sole responsibility is planning and analysis — you do NOT write or
-modify code.
+You help users understand the Penpot codebase, design solutions, and produce
+implementation plans that other agents or developers can execute. The plan
+tells them what to build and how to verify it, task by task.
-You help users understand the codebase, design solutions, and create detailed
-implementation plans that other agents or developers can execute. Document
-everything they need to know: which files to touch for each task, code patterns,
-tests, and how to verify correctness. Apply DRY and KISS principles.
+The implementer reads the project's agent docs (`AGENTS.md`, project memories
+such as `mem:critical-info`, `mem:testing`, and each module's core memory)
+before working. Reference those memories instead of re-explaining tooling,
+conventions, or test design — explain in the plan only what they do not cover.
Do **not** suggest commit messages or commit names anywhere in your plans or
-responses — committing is the developer's responsibility.
+responses — committing is the implementer's responsibility.
-## Required Reading Before Planning
+## CRITICAL: Required Reading Before Planning
Before drafting any plan, work through the project's own guidance:
@@ -50,6 +55,8 @@ Before drafting any plan, work through the project's own guidance:
Skipping this step is the #1 cause of incorrect or incomplete plans.
+---
+
## The Planning Process
### Phase 1: Architecture Analysis
@@ -64,16 +71,42 @@ Skipping this step is the #1 cause of incorrect or incomplete plans.
### Phase 2: Task Breakdown
-Implementation order follows the monorepo's dependency graph:
-`frontend -> common`, `backend -> common`, `exporter -> common`,
-`frontend -> render-wasm`. Build shared foundations first, then layer
-consumers on top.
+#### Identify the Dependency Graph
+
+Map what depends on what, following the monorepo's module dependency graph:
+
+```
+common (shared types, schemas — no deps)
+ │
+ ├── backend (depends common)
+ │ ├── RPC handlers
+ │ └── persistence / migrations
+ │
+ ├── frontend (depends common, render-wasm)
+ │ ├── UI components
+ │ └── state / API integration
+ │
+ ├── exporter (depends common)
+ │
+ └── render-wasm (consumed by frontend)
+```
+
+Implementation order follows the dependency graph bottom-up: build shared
+foundations first, then layer consumers on top.
#### Slice Vertically
Instead of building all of common, then all of backend, then all of frontend —
build one complete feature path at a time:
+**Bad (horizontal slicing):**
+```
+Task 1: Build all common types
+Task 2: Build all backend handlers
+Task 3: Build all frontend components
+```
+
+**Good (vertical slicing):**
```
Task 1: common data types + schema ← foundation
Task 2: backend RPC handler + persistence
@@ -89,39 +122,58 @@ Each task follows this structure:
```markdown
## Task [N]: [Short descriptive title]
-**Description:** One paragraph explaining what this task accomplishes.
+**Description:** One or two paragraphs explaining what this task accomplishes.
+Should be clear and concise.
+
+**Rationale:** Why this task exists and why this approach over the obvious
+alternatives — design decisions, trade-offs, constraints discovered during
+analysis. One or two sentences; skip only if genuinely trivial.
+
+**Code sketch (optional):** Signature-, type-, or shape-level example when the
+intended interface is non-obvious. Keep it short — a skeleton that fixes the
+contract (function signature, model fields, error shape), never a full
+implementation. Omit when the task is mechanical.
**Acceptance criteria:**
- [ ] [Specific, testable condition]
- [ ] [Specific, testable condition]
**Verification:**
-- [ ] Tests pass (module-specific test command)
-- [ ] Lint/formatter passes (module-specific check command)
+- [ ] Relevant tests pass (module-specific test command).
+- [ ] Lint/formatter passes (module-specific check command), if applicable.
+- [ ] The core flow works end-to-end, if applicable.
**Dependencies:** [Task numbers this depends on, or "None"]
**Files likely touched:**
- `path/to/file.clj`
- `path/to/file_test.clj`
+
+**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
```
Replace "module-specific test command" with the actual commands for the module
-(e.g. `clojure -M:dev:test` for backend/common, `npx shadow-cljs compile test && npx karma start` for frontend,
-or the commands noted in the module's core memory).
+(e.g. `clojure -M:dev:test` for backend/common,
+`npx shadow-cljs compile test && npx karma start` for frontend, or the
+commands noted in the module's core memory).
+
+When possible, design each task with TDD in mind: acceptance criteria double
+as a test list, and the natural first step of the task is writing those tests
+before the implementation. Some tasks resist this (config, migrations, pure
+wiring) — for those, keep the usual verification steps.
#### Estimate Scope
-| Size | Files | Scope |
-|------|-------|-------|
-| **XS** | 1 | Single function, config change, or schema tweak |
-| **S** | 1-2 | One handler or component method |
-| **M** | 3-5 | One vertical feature slice |
-| **L** | 5-8 | Multi-component feature |
-| **XL** | 8+ | **Too large — break it down further** |
+| Size | Files | Scope | Example |
+|------|-------|-------|---------|
+| **XS** | 1 | Single function, config change, or schema tweak | Add a validation rule |
+| **S** | 1-2 | One handler or component method | Add a new RPC endpoint |
+| **M** | 3-5 | One vertical feature slice | Bookmark CRUD with tests |
+| **L** | 5-8 | Multi-component feature | Search with filtering and pagination |
+| **XL** | 8+ | **Too large — break it down further** | — |
-If a task is L or larger, break it into smaller tasks. Agents perform best on
-S and M tasks.
+If a task is XL, it should be broken into smaller tasks. Agents perform best
+on S and M tasks.
**When to break a task down further:**
- It would take more than one focused session
@@ -141,11 +193,11 @@ Arrange tasks so that:
Add explicit checkpoints with the relevant module commands:
```markdown
-## Checkpoint: After Tasks 1-3
-- [ ] All tests pass (module-specific command)
-- [ ] Lint/format passes (module-specific command)
-- [ ] Core flow works end-to-end
-- [ ] Review with human before proceeding
+### Checkpoint: After Tasks 1-3
+- [ ] Relevant tests pass (module-specific command).
+- [ ] The relevant build or compilation passes, if applicable.
+- [ ] The core flow works end-to-end.
+- [ ] Review with human before proceeding.
```
## Requirements
@@ -159,7 +211,7 @@ Add explicit checkpoints with the relevant module commands:
- Apply DRY and KISS principles to the proposed implementation.
- Define a testing strategy aligned with each affected module's tooling.
- Every task must have acceptance criteria and verification steps.
-- Checkpoints must exist between major phases.
+- Checkpoints must exist after every 2-3 tasks.
## Constraints
@@ -168,7 +220,8 @@ Add explicit checkpoints with the relevant module commands:
`.opencode/plans/`.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
-- You do **not** execute shell commands beyond read-only searches.
+- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
+ `find`, `cat`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
@@ -188,8 +241,9 @@ slug is lowercase, hyphen-separated, and a short summary of the task
(e.g. `add-batch-get-profiles-for-file-comments`). Create the
`.opencode/plans/` directory if it does not exist.
-Always attempt the write. If the user explicitly provides a target file path,
-use that path instead of the default.
+IMPORTANT: The plan agent has write permission specifically for
+`.opencode/plans/` — always attempt the write. If the user explicitly provides
+a target file path, use that path instead of the default.
### Plan Document Template
@@ -212,41 +266,75 @@ use that path instead of the default.
security implications.]
## Approach
-[Step-by-step implementation plan with file paths, function names, and code
-shape where applicable. Group steps into atomic, ordered tasks.]
+[A short strategy summary: 3-5 sentences describing the overall approach and
+the shape of the dependency graph (what depends on what, what gets built
+first). High-level only — the task-by-task detail lives in the Task List.]
## Task List
-### Phase 1: Foundation
-- [ ] Task 1: ...
-- [ ] Task 2: ...
+Each task uses the full task structure defined in
+[Write Tasks](#write-tasks) — description, rationale, acceptance criteria,
+verification, dependencies, files, estimated scope, and optional code sketch.
+Never reduce a task to a one-line checkbox; the plan must be self-contained
+and executable without other context.
-### Checkpoint: Phase 1
-- [ ] Tests pass, lint/formatter clean (module-specific commands)
+Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks
+into phases, milestones, or sprints; ordering and dependencies are already
+captured per task. Insert a checkpoint after every 2-3 tasks.
-### Phase 2: Core Features
-- [ ] Task 3: ...
-- [ ] Task 4: ...
+## Task 1: [Short descriptive title]
-### Checkpoint: Phase 2
-- [ ] End-to-end flow works
+**Description:** [What this task accomplishes.]
-### Phase 3: Polish
-- [ ] Task 5: ...
-- [ ] Task 6: ...
+**Rationale:** [Why this approach over the alternatives.]
-### Checkpoint: Complete
-- [ ] All acceptance criteria met
-- [ ] Ready for review
+**Acceptance criteria:**
+- [ ] [Specific, testable condition]
-## Testing Strategy
-[How to verify: which test commands to run per module, what cases to cover,
-manual verification steps, lint/format checks. Consult each module's core
-memory for the exact commands.]
+**Verification:**
+- [ ] Relevant tests pass (module-specific command).
+
+**Dependencies:** None
+
+**Files likely touched:**
+- `path/to/file`
+
+**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
+
+**Code sketch (optional):** [Short contract-level example, only if the shape
+is non-obvious.]
+
+## Task 2: [Short descriptive title]
+
+[Same structure as Task 1.]
+
+## Task 3: [Short descriptive title]
+
+[Same structure as Task 1.]
+
+### Checkpoint: After Tasks 1-3
+- [ ] Relevant tests pass (module-specific command).
+- [ ] The relevant build or compilation passes, if applicable.
+- [ ] The core flow works end-to-end.
+- [ ] Review with human before proceeding.
+
+## Task 4: [Short descriptive title]
+
+[Same structure as Task 1.]
+
+## Task 5: [Short descriptive title]
+
+[Same structure as Task 1.]
+
+## Verification & Testing
+[How to verify each task and the whole plan: the project's real test, lint,
+build, and run commands (extracted during Required Reading), coverage
+expectations, and manual checks. Consult each module's core memory for the
+exact commands.]
## Parallelization Opportunities
- **Safe to parallelize:** Independent feature slices across separate
- modules, tests for already-implemented features
+ modules, tests for already-implemented features, documentation
- **Must be sequential:** Shared common schema changes, database migrations
- **Needs coordination:** Features that share a contract (define the contract
first, then parallelize)
@@ -259,13 +347,31 @@ When the plan is purely analytical (e.g. a code review or feasibility study
with no implementation), skip the **Approach** and **Task List** sections and
lead with **Findings** instead, keeping the rest of the structure.
+## Common Rationalizations
+
+| Rationalization | Reality |
+|---|---|
+| "I'll figure it out as I go" | That's how you end up with a tangled mess and rework. 10 minutes of planning saves hours. |
+| "The tasks are obvious" | Write them down anyway. Explicit tasks surface hidden dependencies and forgotten edge cases. |
+| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. |
+| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. |
+
+## Red Flags
+
+- Delivering prose without a task breakdown
+- Tasks that say "implement the feature" without acceptance criteria
+- No verification steps in the plan
+- All tasks are XL-sized
+- No checkpoints between tasks
+- Dependency order isn't considered
+
## Verification Checklist
-Before starting implementation, confirm:
+Before delivering the plan, confirm:
- [ ] Every task has acceptance criteria
- [ ] Every task has a verification step
- [ ] Task dependencies are identified and ordered correctly
-- [ ] No task touches more than ~5 files
-- [ ] Checkpoints exist between major phases
-- [ ] The human has reviewed and approved the plan
+- [ ] No task is XL or larger — break it down instead
+- [ ] Checkpoints exist after every 2-3 tasks
+- [ ] The plan is ready for human review
diff --git a/.opencode/skills/ste/SKILL.md b/.opencode/skills/ste/SKILL.md
new file mode 100644
index 0000000000..a53456ccf0
--- /dev/null
+++ b/.opencode/skills/ste/SKILL.md
@@ -0,0 +1,78 @@
+---
+name: ste
+description: Write or rewrite text in ASD-STE100 Simplified Technical English. ONLY use this skill when the user explicitly invokes it by name — i.e. they type "/ste" or literally write "use the ste skill" / "apply ASD-STE100". Do NOT trigger it on paraphrased intent such as "simplify this", "make it clearer", "write technical documentation", or "shorter sentences please" — the user has deliberately scoped this skill to explicit invocation only. For those requests, respond normally without loading this skill unless they name it.
+---
+
+# ASD-STE100 Simplified Technical English
+
+Apply the ASD-STE100 standard to all prose you produce in this task. Do not announce that you use STE, do not name the standard, and do not explain the style unless the user asks. If the user later asks you to "write more naturally," ask one short question to confirm they want to leave STE before you drop it.
+
+Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance.
+
+## Step 0 — Classify the text
+
+Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section.
+
+## Core rules
+
+### Sentences
+- Procedural: maximum **20 words** per sentence.
+- Descriptive: maximum **25 words** per sentence.
+- Maximum **6 sentences** per paragraph. One topic per paragraph.
+- One instruction per sentence. Two actions in one sentence only if they occur at the same time.
+- Put a condition BEFORE its command: "If the pressure decreases, close the valve."
+- Do not omit articles, subjects, or verbs to save words. "Ensure file exists" is wrong; "Make sure that the file exists" is correct. Keep the word "that" after verbs like "make sure."
+- Numbers, units with numbers, abbreviations, quoted strings, code identifiers, and proper nouns each count as one word.
+
+### Verbs
+- Allowed forms only: infinitive, imperative, simple present, simple past, simple future, and past participle used as an adjective.
+- Never use present perfect or continuous forms. "We have received" → "We received." "is being tested" → a simple form.
+- Never use an -ing form as a verb. An -ing word is allowed only inside a technical name ("the mounting bracket," "logging").
+- Active voice. Passive is allowed only in descriptive text when the agent is unknown or unimportant.
+- Instructions use the imperative: "Open the panel," not "You must open the panel" or "The panel should be opened."
+- Express actions as verbs, not nouns: "compress the file," not "perform compression of the file."
+- Modals: use **can** (possibility), **will** (future), **must** (requirement). Do not use should, would, could, may, might. A hedge becomes a fact or a "can": "an explosion can occur."
+- No phrasal verbs: "go down" → "decrease," "set up" → "install," "carry out" → "do."
+
+### Words
+- One word, one meaning, one part of speech, used consistently. Never rotate synonyms: pick one name for a thing and repeat it.
+- Before drafting, replace unapproved vocabulary. Read `references/word-substitutions.md` and apply it; it is the working dictionary for this skill.
+- Domain-specific nouns (part names, tool names, product names, UI labels) and domain verbs (drill, ream, boot, compile) are your **technical nouns/verbs** — keep them as-is, use each consistently, and do not verb a noun or noun a verb.
+- Noun clusters: maximum **3 words** ("overhead panel light" is the limit). Longer clusters get decomposed with prepositions or hyphenated on first use: "main-gear-door retraction-winch handle."
+- American English spelling.
+- No Latin abbreviations: "e.g." → "for example," "i.e." → "that is," delete "etc."
+
+### Punctuation
+- No semicolons — write two sentences.
+- Parentheses only for references, abbreviations, and item numbers.
+- Hyphenate words that act as one unit; a hyphenated word counts as one word.
+- No contractions.
+
+### Warnings, cautions, notes
+- **WARNING** = risk of injury or death. **CAUTION** = risk of damage. **NOTE** = information only, never an instruction.
+- Start a warning or caution with the command or condition, then give the risk:
+ "WARNING: Do not touch the terminal. The terminal has a dangerous voltage."
+- Notes obey the 25-word descriptive limit.
+
+## Step 2 — Self-check pass
+
+After drafting, scan your text once for each of these and fix every hit before you respond:
+
+1. Any sentence over the 20/25-word limit for its type
+2. Contractions, semicolons
+3. "should," "would," "could," "may," "might"
+4. "has been," "have been," "had been," "is being," "was being"
+5. -ing words used as verbs
+6. Missing articles (a/an/the/this) before nouns
+7. Synonym rotation (the same object under two names)
+8. Any word in the unapproved column of `references/word-substitutions.md`
+9. Warnings that state the risk before the command
+
+## Reference files
+
+- `references/word-substitutions.md` — unapproved → approved word mappings and one-meaning rulings. Read it before drafting; it is short.
+- `references/examples.md` — worked before/after rewrites (procedural, descriptive, warnings, common mistakes). Read it when rewriting existing text or when unsure how a rule applies.
+
+## What NOT to touch
+
+Code blocks, command strings, file paths, error messages, quoted UI text, and proper nouns stay exactly as written. STE applies to the prose around them.
diff --git a/.opencode/skills/ste/references/examples.md b/.opencode/skills/ste/references/examples.md
new file mode 100644
index 0000000000..b111db6229
--- /dev/null
+++ b/.opencode/skills/ste/references/examples.md
@@ -0,0 +1,67 @@
+# Worked before/after examples
+
+## Verb forms
+
+| Before | After |
+|---|---|
+| We have received the technical reports from HQ. | We received the technical reports from HQ. |
+| This device has been being used at Boeing since 2005. | Boeing started to use this device in 2005. |
+| The test is continued by the operator. | Continue the test. |
+| The screws should be replaced. | Replace the screws. |
+| The system is currently running diagnostics. | The system does diagnostic tests now. |
+
+## Vocabulary and phrasing
+
+| Before | After |
+|---|---|
+| Ensure file exists before running. | Make sure that the file exists before you run the command. |
+| Rotate the cover until the jacks are accessible. | Turn the cover until you can get access to the jacks. |
+| Extend the jack until the wheels are clear of the ground. | Extend the jack until the wheels do not touch the ground. |
+| Make sure that these steps are followed. | Obey these steps. |
+| Utilize approximately 3 liters of water. | Use about 3 liters of water. |
+| Perform a compression of the log files prior to upload. | Compress the log files before you upload them. |
+
+## Noun clusters
+
+| Before | After |
+|---|---|
+| Main gear door retraction winch handle | Main-gear-door retraction-winch handle |
+| Runway light connection resistance calibration | Calibration of the resistance of the runway light connection |
+| Remove the engine transmission housing attachment bolts. | Remove the bolts that attach the transmission housing to the engine. |
+
+## Procedural rewrite (condition first, one instruction per sentence)
+
+Before:
+> You should check that the reservoir has been filled with the correct fluid and, after attaching a clear tube to the bleed screw while ensuring its free end is submerged, the pedal can be pumped and held so that opening the screw releases trapped air.
+
+After:
+> 1. Fill the reservoir with the correct fluid.
+> 2. Attach a clear tube to the bleed screw.
+> 3. Put the free end of the tube in a container of fluid.
+> 4. Push the pedal three times. Hold the pedal down.
+> 5. Open the bleed screw one half turn. Air and fluid flow into the tube.
+> 6. Close the bleed screw. Release the pedal.
+> 7. If air continues to come out, do steps 4 thru 6 again.
+
+## Warnings and cautions (command first, then risk)
+
+Before:
+> Note that serious data loss may potentially occur if the --force flag is used against production.
+
+After:
+> CAUTION: Do not use the --force flag on the production database. The flag deletes the rows that do not match the source.
+
+Before:
+> Touching the terminal could result in electrocution.
+
+After:
+> WARNING: Do not touch the terminal. The terminal has a dangerous voltage.
+
+## Common mistakes checklist
+
+- Dropped articles: "Insert pin in bracket" → "Insert the pin in the bracket."
+- Synonym rotation: check/verify/confirm for the same action → one term, everywhere.
+- Hedges: "you may want to," "it is recommended that" → an imperative or "must."
+- Instruction buried in a NOTE: notes never instruct. Move the instruction to a numbered step.
+- Semicolon joining two clauses → two sentences.
+- "There are three bolts on the panel" → "The panel has three bolts."
diff --git a/.opencode/skills/ste/references/word-substitutions.md b/.opencode/skills/ste/references/word-substitutions.md
new file mode 100644
index 0000000000..8cda2511c1
--- /dev/null
+++ b/.opencode/skills/ste/references/word-substitutions.md
@@ -0,0 +1,68 @@
+# Word substitutions and one-meaning rulings
+
+Compiled from public secondary sources (STEMG/ASD public pages, TechScribe, Acrolinx, training materials). This is a working approximation, not the official ASD dictionary. When a word is not listed here and feels formal or Latin-derived, prefer the shortest common alternative.
+
+## Unapproved → approved
+
+| Do not use | Use instead |
+|---|---|
+| utilize, leverage, employ | use |
+| commence, initiate, begin, originate | start |
+| terminate, cease, conclude | stop, end |
+| ensure, verify, confirm, validate, check | make sure (that), examine |
+| perform, conduct, execute, carry out | do |
+| facilitate, assist | help |
+| obtain, acquire, procure | get |
+| sufficient, adequate | enough |
+| approximately | about |
+| prior to | before |
+| subsequent to, following (prep.) | after |
+| adjacent to | near |
+| accomplish | do |
+| additional, supplementary | more |
+| attempt | try |
+| require, necessitate | need, must |
+| mandatory | necessary |
+| indicate, signify | show |
+| observe (=watch) | look at, examine |
+| rotate | turn |
+| deactivate | turn off, set to off |
+| activate, energize (unless technical verb) | turn on, start |
+| toxic | poisonous |
+| in order to | to |
+| via, by means of | through, with |
+| due to, owing to | because of |
+| in the event of/that | if |
+| accessible | (rewrite: "you can get access to") |
+| remainder | rest |
+| demonstrate | show |
+| modify, alter | change |
+| construct, fabricate, build | assemble, make |
+| retain | keep |
+| locate (=find) | find |
+| depress (a button) | push, press |
+| proceed | continue, go |
+
+## One meaning, one part of speech (canonical rulings)
+
+- **close** — verb only: to move to a position that stops flow, or to operate a circuit breaker. The adjective is unapproved → use **near** ("do not go near the propeller").
+- **test** — noun only: "do a test," never "test the system."
+- **check** — do not use as a verb for verification → "make sure that" or "examine."
+- **follow** — means only "come after." For rules and steps use **obey**: "Obey the safety instructions."
+- **fall** — means only "move down by gravity." For quantities use **decrease**. Never the season.
+- **oil** — noun only. "Oil the bearing" → "Put oil on the bearing" / "Lubricate the bearing."
+- **right** — direction only, never "correct."
+- **clear** — "without blockage." "Wheels are clear of the ground" → "wheels do not touch the ground."
+- **help** — verb only; the noun is **aid** ("with the aid of a mirror").
+- **above / below** — physical position only. For quantities: **more than / less than**.
+- **about** — two approved senses: "approximately" and "on the subject of." Use carefully.
+- **turn** — the general verb for rotation; "turn on / turn off" for power state is standard.
+- **level** — approved as noun and adjective (documented exception to the one-POS rule).
+
+## Frequent-offender function words
+
+- **should / would / could / may / might** — never. Requirement → **must**. Possibility → **can**. Future → **will**.
+- **etc.** — delete, or write the full list.
+- **e.g. / i.e.** — "for example" / "that is."
+- **any / appropriate / applicable / relevant** as hedges — replace with the specific thing meant.
+- **there is / there are** openers — rewrite with a real subject: "There are three bolts on the panel" → "The panel has three bolts."
diff --git a/.opencode/skills/testing/SKILL.md b/.opencode/skills/testing/SKILL.md
index 5ad5d04e6e..29ab7112f8 100644
--- a/.opencode/skills/testing/SKILL.md
+++ b/.opencode/skills/testing/SKILL.md
@@ -34,7 +34,8 @@ Before writing any test, read:
2. Module-specific testing memory for the affected module:
- `mem:common/testing` — CLJC unit tests
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E
- - `mem:backend/core` — JVM clojure.test conventions
+ - `mem:backend/testing` — JVM clojure.test conventions
+ - `mem:exporter/testing` — exporter unit tests
## Key Rules
diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md
index d5c0ccf2af..d482959f94 100644
--- a/.opencode/skills/update-changelog/SKILL.md
+++ b/.opencode/skills/update-changelog/SKILL.md
@@ -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 (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:
- [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/))
- (by @contributor) [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/))
+- (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
@@ -440,9 +477,15 @@ There are exactly two types:
release, but the PR is being released elsewhere — the fix may not
actually ship here.
2. **PR is in the milestone, but the issue it closes is in a different
- milestone (or has no milestone).** The PR is being released here, but
- the issue it fixes is being released in a different version (or never
- tracked in a milestone) — the changelog pairing is misleading.
+ milestone.** The PR is being released here, but the issue it fixes is
+ being released in a different version — the changelog pairing is
+ misleading.
+
+ **Exception — issue with no milestone is NOT an anomaly.** Milestones
+ are only required for issues tracked in the "Main" project. A milestone
+ PR that closes an issue with no milestone references an issue from
+ another (probably private) project; that is expected and the issue is
+ not part of this changelog. Do not report it.
**Anything else is not an anomaly.** Other discrepancies (exclusion
labels on in-changelog issues, missing valid issues, unmerged PR
@@ -598,6 +641,10 @@ for pr_num in sorted(changelog_prs):
if get_pr_milestone(pr_num) != MILESTONE: continue
for issue_num in pr.get('closing_issues', []):
issue_ms = get_issue_milestone(issue_num)
+ # No milestone = issue from another (probably private) project —
+ # milestones are only required for the "Main" project. Not an
+ # anomaly, and the issue never belongs in this changelog.
+ if issue_ms is None: continue
if issue_ms != MILESTONE:
anomalies_b.append({
'pr': pr_num,
@@ -620,7 +667,7 @@ with open(OUTPUT, 'w') as f:
f.write('## Summary\n\n')
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
- f.write(f'- **PR in {MILESTONE}, closing issue in different milestone or no milestone:** {n_b}\n')
+ f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n')
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
# --- Anomalies section ---
@@ -649,7 +696,7 @@ with open(OUTPUT, 'w') as f:
f.write('\n')
if n_b:
- f.write(f'\n### PR in {MILESTONE}, closing issue in different milestone or no milestone\n\n')
+ f.write(f'\n### PR in {MILESTONE}, closing issue in a different milestone\n\n')
by_pr = {}
for b in anomalies_b:
by_pr.setdefault(b['pr'], []).append(b)
@@ -684,8 +731,11 @@ milestone mismatches between issues and their referenced PRs:
1. **Issue in milestone, referenced PR in different milestone or no milestone** —
the changelog claims a fix here, but the PR is released elsewhere.
-2. **PR in milestone, closing issue in different milestone or no milestone** —
+2. **PR in milestone, closing issue in a different milestone** —
the PR is released here, but the issue it fixes belongs to another version.
+ (An issue with *no* milestone belongs to another, probably private,
+ project — milestones are only required on the "Main" project — so it is
+ neither an anomaly nor a changelog candidate.)
**Rule violations are not in the report** — they are workflow errors the
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
@@ -732,6 +782,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
@@ -754,8 +812,11 @@ self-contained and clickable in any Markdown viewer.
- **Anomaly = milestone mismatch only.** The report contains only milestone
mismatches: (1) the issue is in this milestone but the referenced PR is
in a different milestone (or unassigned), and (2) the PR is in this
- milestone but the issue it closes is in a different milestone (or
- unassigned). These are anomalies because the changelog pairing is
+ milestone but the issue it closes is in a different milestone. An
+ *unassigned* (milestone-less) issue closed by a milestone PR is **not**
+ an anomaly: milestones are required only for the "Main" project, so such
+ issues come from another (probably private) project and are not changelog
+ candidates. These anomalies are reported because the changelog pairing is
*misleading* — the human needs to decide whether the milestone or the
changelog is wrong. All other discrepancies (exclusion labels, missing
valid issues, unmerged PR references, duplicates, stale milestone
diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md
index 708d30fc5d..44dc8f3b75 100644
--- a/.serena/memories/backend/core.md
+++ b/.serena/memories/backend/core.md
@@ -5,7 +5,9 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
## Focused memories
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
-- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-subtleties`
+- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`.
+- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`.
+- Embedded Ladybug graph experiment, projection, incremental sync, console, and risks: `mem:backend/graph-experiment`
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
@@ -101,10 +103,5 @@ misleading linter/compiler output. See `mem:scripts/paren-repair`.
## Testing
-IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline.
-
-* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
-* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
-* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
-* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
-
+Backend test commands, coverage rules, and conventions: `mem:backend/testing`.
+Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
diff --git a/.serena/memories/backend/graph-experiment.md b/.serena/memories/backend/graph-experiment.md
new file mode 100644
index 0000000000..a7d382b91f
--- /dev/null
+++ b/.serena/memories/backend/graph-experiment.md
@@ -0,0 +1,632 @@
+# Graph Experiment
+
+## Scope
+
+- Purpose: project Penpot file data into an embedded Ladybug graph database.
+- Purpose: keep the graph current with Penpot file changes.
+- Purpose: expose a read-only graph console for backend debugging.
+- This is an experiment, not a replacement for PostgreSQL file storage.
+- The graph subsystem is off unless `:graph` is in the backend flags.
+- The main Penpot frontend has no graph feature code for this subsystem.
+- The graph console is a backend-served HTML template with JavaScript.
+
+## Memory Links
+
+- Read `mem:backend/core` for backend architecture, HTTP routes, DB rules, and test commands.
+- Read `mem:backend/rpc-db-worker-subtleties` for RPC and message bus behavior.
+- Read `mem:backend/http-storage-filedata-subtleties` for file data loading and realization.
+- Read `mem:common/changes-architecture` for the change record vocabulary.
+- Read `mem:frontend/routing-app-shell-subtleties` for the existing notification WebSocket.
+- Read `mem:prod-infra/core` for Redis or Valkey message bus topology.
+
+## Branch Surface
+
+- The graph experiment adds about 6,336 lines and changes about 27 files.
+- The graph implementation lives under `backend/src/app/graph/`.
+- The graph console lives at `backend/resources/app/templates/graph-console.tmpl`.
+- The existing debug page gains graph links in `backend/resources/app/templates/debug.tmpl`.
+- The existing debug HTTP routes gain graph handlers in `backend/src/app/http/debug.clj`.
+- The backend system passes the message bus to the debug route component in `backend/src/app/main.clj`.
+- The backend adds Ladybug and Arrow dependencies in `backend/deps.edn`.
+- The backend adds JVM options for Ladybug and Arrow native access.
+- The common flag registry adds `:graph` in `common/src/app/common/flags.cljc`.
+- The graph experiment adds `graph_sync_parity_test.clj` and `graph_binder_gate_test.clj`.
+
+## System Model
+
+### Storage layers
+
+- PostgreSQL remains the source of truth for Penpot files.
+- The graph database stores a projection of one file.
+- A persistent graph uses a `.lbug` path under `PENPOT_GRAPH_DIR`.
+- The default graph directory is `/tmp/penpot-graph`.
+- A debug session uses a Ladybug `:memory:` database.
+- A debug session database lives inside the backend JVM process.
+- A debug session does not survive a backend restart.
+- A debug session does not store file data back to PostgreSQL.
+
+### Two graph update paths
+
+- Cold projection reads the complete file and rebuilds the graph.
+- Incremental sync reads file change records and updates the open graph.
+- Both paths must produce the same graph for the same file state.
+- The parity test treats cold projection as the reference path.
+- A reload discards the session graph and uses cold projection again.
+
+## Main Namespaces
+
+### `app.graph.ladybug`
+
+- Opens and closes Ladybug `Database` and `Connection` objects.
+- Installs and loads the Ladybug JSON extension.
+- Executes Cypher statements.
+- Executes prepared statements.
+- Binds scalar parameters.
+- Formats UUID, string, integer, number, JSON, and timestamp values.
+- Formats compound values such as arrays, maps, and structs.
+- Converts Ladybug values back to Clojure values.
+- Limits normal query results to 200 rows by default.
+- Detects result truncation with `:truncated?`.
+- Uses query timeout `0` by default.
+- Query timeout `0` disables the timeout.
+- Provides `validate-on-connection!` for parse, bind, and read-only checks.
+- `exec-prepared-on-connection!` prepares every statement before the first execution.
+- A prepare failure stops the batch before a mutation runs.
+
+### `app.graph.schema`
+
+- Provides the public schema facade.
+- Exposes schema version `penpot-graph-slice-4`.
+- Delegates node and relationship definitions to `app.graph.schema.nodes`.
+
+### `app.graph.schema.nodes`
+
+- Holds the single registry for graph node tables.
+- Generates node DDL.
+- Generates relationship DDL.
+- Maps Penpot shape types to graph tables.
+- Projects source attributes into graph attributes.
+- Formats graph column values.
+- Quotes reserved graph labels such as `Group` and `Boolean`.
+- Defines container tables and shape tables.
+- Defines `IsChildOf`, `IsInstanceOf`, `RefersTo`, and `FillsSwapSlot`.
+
+### `app.graph.schema.contract`
+
+- Records deliberate graph contract decisions.
+- Renames graph columns such as `:revn` to `revision`.
+- Drops attributes that do not belong in this graph slice.
+- Records attributes that the graph does not project.
+- Applies per-table dropped attributes.
+- Defines type overrides for vectors, transforms, colors, maps, and JSON arrays.
+- Maps selected map keys to the frontend JSON naming convention.
+- `:background-blur` remains a declared unprojected attribute.
+
+### `app.graph.schema.projection`
+
+- Derives projected schemas from canonical Malli schemas.
+- Builds the projected document schema.
+- Builds projected shape schemas.
+- Selects the schema for each shape type.
+
+### `app.graph.schema.types`
+
+- Maps Malli types to Ladybug types.
+- Maps matrices to `DOUBLE[6]`.
+- Maps points to `DOUBLE[2]`.
+- Maps rectangles to `DOUBLE[4]`.
+- Maps colors to `UINT32`.
+- Maps collections to Ladybug arrays.
+- Maps `:map-of` schemas to `MAP`.
+- Maps closed scalar maps to `STRUCT`.
+- Maps other complex values to `JSON`.
+
+### `app.graph.schema.values`
+
+- Coerces source values to graph column values.
+- Writes fixed vectors with deterministic order.
+- Packs colors into the graph color representation.
+- Sorts set values when deterministic output is needed.
+
+### `app.graph.arrow`
+
+- Loads projection rows with Apache Arrow.
+- Creates temporary staged node and relationship tables.
+- Uses `COPY ... FROM (MATCH ...)` for bulk loading.
+- Groups relationship loads by source and target table pair.
+- Resolves relationship endpoints with joins.
+- Does not use `createArrowRelTable` for UUID relationship endpoints.
+- Keeps the Arrow `RootAllocator` alive until Ladybug releases staged buffers.
+- Closes the allocator after the connection and database close sequence.
+
+### `app.graph.ingest`
+
+- Fetches a complete file with `bfc/get-file` and `:realize? true`.
+- Rejects missing files.
+- Rejects files without file data.
+- Can run file data validation before projection.
+- Creates the DDL.
+- Loads nodes and edges through Arrow.
+- Executes post-load transforms.
+- Writes graph metadata last.
+- Treats the final metadata write as the complete-build marker.
+- Supports a persistent database path and an open connection.
+
+### `app.graph.projection.document`
+
+- Projects `Document`, `Page`, `Component`, and supported shape nodes.
+- Skips the page root frame.
+- Creates `IsChildOf` edges from shapes to parents.
+- Creates page edges to the document.
+- Creates component edges to the document.
+- Stores page order in `Page.index` and edge `position`.
+- Reverses the stored `:shapes` list for Penpot z-order.
+- Adds `page-id` to every projected shape.
+- Propagates an instance head `component-id` to descendants.
+- Stops component inheritance at a non-Frame shape with its own component ID.
+- Skips deleted components during cold projection.
+- Logs unsupported shape types and missing shape records.
+
+### `app.graph.projection.transforms`
+
+- Runs after the base nodes and edges load.
+- `link-component-instances` creates `IsInstanceOf` edges.
+- A Frame needs `component-file` to qualify as an instance head.
+- `link-shape-refs` creates `RefersTo` edges from `shape-ref`.
+- Ladybug limits multi-label relationship `MERGE` statements.
+- The transform emits one statement for each shape-table pair.
+- `link-swap-slots` creates `FillsSwapSlot` edges.
+- Swap slot IDs come from `swap-slot-` entries in `touched`.
+- The transform removes swap slot entries from `touched` after edge creation.
+- The transform order matters because it reads and then changes `touched`.
+
+### `app.graph.meta`
+
+- Stores graph provenance in `GraphMeta`.
+- Stores schema version, source revision, producer, and build time.
+- The source revision identifies the file revision used for cold projection.
+
+### `app.graph.stats` and `app.graph.report`
+
+- `app.graph.stats` counts graph nodes and relationships from the live catalog.
+- `app.graph.report` prints ingest information for REPL use.
+
+## Cold Projection Flow
+
+1. Get the file row and realized file data from PostgreSQL.
+2. Read the file revision from the file row.
+3. Build the node and edge projection.
+4. Create all graph tables from the graph schema.
+5. Load node rows with Arrow.
+6. Load relationship rows with Arrow.
+7. Run `CHECKPOINT;`.
+8. Run the registered derived transforms.
+9. Write `GraphMeta` as the final build step.
+10. Return file ID, file revision, database path, projection stats, and transform stats.
+
+### Projection node groups
+
+- `Document` contains file-level attributes without the file data blob.
+- `Document.options` receives file-level options from the data blob.
+- `Page` contains page attributes without the page object map.
+- `Component` contains component attributes without component object maps.
+- Shape tables contain the supported shape attributes.
+- The graph stores selected derived attributes such as `page-id`.
+
+### Projection relationship groups
+
+- Structural edges use `IsChildOf`.
+- Page and component edges point to `Document`.
+- Derived edges come from the post-load transform registry.
+
+## Incremental Sync
+
+### Change source
+
+- `app.rpc.commands.files-update` persists the file update first.
+- The same command publishes a `:file-change` message to the file topic.
+- The topic key is the file UUID.
+- The message contains the file ID, profile ID, session ID, revision, version, and changes.
+- Library changes also publish a team-topic message.
+- The graph session only consumes the file-topic `:file-change` messages.
+
+### Session subscription
+
+- `app.graph.debug/start-sync-loop!` creates a channel with a dropping buffer of 64.
+- The session subscribes the channel to the file UUID topic.
+- The loop reads one message at a time.
+- The loop ignores message types other than `:file-change`.
+- The loop stops when the channel closes.
+- `destroy-session!` closes the channel and purges its message bus subscription.
+
+### Session state
+
+- Sessions are stored in a global `defonce` atom.
+- The map key is the string form of `profile-id`.
+- One profile has one graph session.
+- Loading another file first destroys the old session.
+- A session stores the Ladybug database and connection.
+- A session stores a shared lock for graph access.
+- A session stores file metadata.
+- A session stores the incremental sync index.
+- A session stores the message bus channel.
+- A session stores load time and profile ID.
+- The session keeps projection statistics but drops full projection rows after index creation.
+
+### Sync index
+
+- `build-index` starts from the complete cold projection.
+- The index stores the graph file ID and document ID.
+- The index stores the current graph revision.
+- The index stores page IDs, names, and positions.
+- The index stores component IDs, names, and deleted state.
+- The index stores shape table, parent, position, frame, page, and component context.
+- The index stores child IDs by parent ID.
+- The index supports later change application without another PostgreSQL file read.
+
+### Change application
+
+- `apply-changes!` processes the change list in source order.
+- Each supported change returns a new index and a list of Cypher statements.
+- Unsupported changes enter the `:skipped` result.
+- Supported changes enter the `:applied` result.
+- The function collects all statements before it executes them.
+- The function appends a document revision statement when at least one change applies.
+- The index revision advances only when at least one change applies.
+- A larger incoming revision than the index revision creates a warning.
+- A revision gap does not trigger catch-up.
+
+### Shape change rules
+
+- `:add-obj` reuses `projection.document/denormalized-shape`.
+- `:add-obj` creates the shape node and its parent edge.
+- `:mod-obj` applies supported `:set` operations to graph columns.
+- `:mod-obj` keeps false and zero values as values.
+- `:del-obj` deletes shapes in deep post-order.
+- `:mov-objects` detaches shapes from the old parent.
+- `:mov-objects` closes the old sibling position gap.
+- `:mov-objects` inserts shapes at the new position.
+- `:mov-objects` updates `parent_id` and `frame_id`.
+- `:mov-objects` rewrites container `shapes` values.
+- The parent columns and child lists must match a cold projection.
+
+### Page and component change rules
+
+- Page add creates a projected page node and a document edge.
+- Page delete removes the page subtree.
+- Page modification updates supported page attributes.
+- Component add creates a component node and document edge.
+- Component modification updates supported component attributes.
+- Component delete uses a soft-delete state.
+- Component restore removes the soft-delete state.
+- Component purge removes the component node and document edge.
+- Component sync paths need more parity coverage than the current tests provide.
+
+## Session Locking
+
+- The sync loop and HTTP handlers share one lock per session.
+- The lock protects one Ladybug connection from concurrent access.
+- Queries acquire the lock before binder validation and execution.
+- Graph data export acquires the lock before catalog reads.
+- Session export acquires the lock before `EXPORT DATABASE`.
+- A long query blocks sync for the same session.
+- A sync batch blocks queries for the same session.
+- Ladybug connection thread safety is not assumed.
+
+## Graph Query Rules
+
+- The console accepts Cypher text.
+- Blank query text raises a validation error.
+- The query first passes Ladybug prepare and bind checks.
+- The query must pass the engine read-only analysis.
+- A mutating query is rejected.
+- The graph console does not provide a write path.
+- A session graph is rebuilt from the file by Reload.
+- Normal query results have a 200-row limit.
+- Query results use string values for the HTML console representation.
+- JSON requests receive a Transit JSON response with the query and result.
+- HTML requests receive the rendered console with the result.
+
+## Graph Data Export
+
+### G6 data
+
+- `/dbg/actions/graph-data` reads the live Ladybug database.
+- It does not read the sync index for nodes and edges.
+- It therefore shows database drift if a batch fails after index update.
+- Node export covers all registered node tables.
+- Relationship export reads the Ladybug relationship catalog.
+- Relationship export includes source, target, relationship name, and position.
+- Node and relationship export uses a 100,000-row limit.
+- The response reports `truncated` when a limit cuts the result.
+- The response reports buffer-manager memory usage.
+
+### `.lbug` export
+
+- `source=file` rebuilds the persistent graph from PostgreSQL file data.
+- `source=file` runs a synchronous full ingest for each request.
+- `source=session` exports the caller profile's live in-memory graph.
+- Session export uses Ladybug `EXPORT DATABASE` to Parquet files.
+- Session export creates a new `.lbug` database with `IMPORT DATABASE`.
+- The temporary Parquet staging directory is deleted after import.
+- The final session `.lbug` file remains in the system temporary directory.
+- The HTTP response streams the database file to the caller.
+
+## HTTP Routes and Access
+
+- The graph routes live in `backend/src/app/http/debug.clj`.
+- The graph route list is added only when `:graph` is enabled.
+- `/dbg/graph` serves the graph console page.
+- `/dbg/actions/graph-files` returns the profile file tree.
+- `/dbg/actions/graph-load` loads a file into the profile session.
+- `/dbg/actions/graph-unload` closes the profile session.
+- `/dbg/actions/graph-reload` rebuilds the loaded file graph.
+- `/dbg/actions/graph-query` runs a read-only Cypher query.
+- `/dbg/actions/graph-sync-status` returns the sync state.
+- `/dbg/actions/graph-data` returns nodes and edges for G6.
+- `/dbg/actions/graph-export` streams a `.lbug` database.
+- The `/dbg` session middleware remains active.
+- The `/dbg` admin middleware remains active.
+- A devenv host with a profile ID passes the debug authorization rule.
+- Other hosts need a profile email in the configured admin set.
+- `/dbg/actions/graph-files` lists reachable teams, projects, and files.
+- The file tree query has a 500-file limit.
+- The graph handlers resolve graph namespaces at call time.
+- The backend requires `app.graph.debug` and `app.graph.ingest` when the flag is on.
+- Ladybug native loading then fails during route initialization instead of first use.
+
+## Console Frontend
+
+### Page type
+
+- `graph-console.tmpl` is a backend resource template.
+- It is not a Rumext component.
+- It is not part of the main frontend route table.
+- The page uses browser `fetch` calls and a browser WebSocket.
+- The page loads G6 version `5.1.1` from jsDelivr.
+
+### File tree
+
+- The page fetches `/dbg/actions/graph-files`.
+- The response contains team, project, and file groups.
+- The page creates the tree with DOM APIs.
+- A file click submits the graph load form.
+- The page shows a message when no file exists.
+
+### Graph rendering
+
+- The page fetches `/dbg/actions/graph-data`.
+- The page converts graph nodes and edges to G6 data.
+- The page skips repaint when the node and edge signature does not change.
+- The page marks added, removed, and changed graph entities.
+- The page supports tree, dagre, circular, force, and combo layouts.
+- The page supports collapsed container combos.
+- The page has render guards at 4,000 nodes and 8,000 edges.
+- The `?safe` query option bypasses the render guard.
+- The page shows graph size by node count and relationship count.
+- The page shows buffer-manager memory in MiB.
+- The page reports a CDN failure when G6 is undefined.
+
+### Query result filtering
+
+- A query can return `filter_*` columns with node IDs.
+- The HTML result table hides columns with the `filter_` prefix.
+- The JSON result keeps the full result.
+- The graph view uses the hidden IDs to select matching nodes.
+- The graph view re-runs the query after graph refresh.
+- This keeps the query filter aligned with the current graph.
+- A user column named `filter_*` follows the same hiding rule.
+
+### Node inspector
+
+- A node click creates a query for that node.
+- The inspector calls `/dbg/actions/graph-query` with JSON negotiation.
+- The inspector displays the full projected row.
+- The inspector uses table and ID values from the graph data.
+
+## WebSocket Data Flow
+
+1. The page opens `/ws/notifications` with a random `session-id` query value.
+2. The page sends `:subscribe-file` with a Transit UUID value.
+3. The server makes sure that the file exists and that the profile has read permission.
+4. The server subscribes the connection to the file topic.
+5. `files_update` publishes `:file-change` to the same topic.
+6. The graph session consumes the message from its message bus subscription.
+7. The WebSocket server sends the message to the browser connection.
+8. The browser adds the change to the changelog.
+9. The browser fetches sync status after 150 milliseconds.
+10. The browser fetches graph data after a 400-millisecond debounce.
+11. The browser repaints the G6 graph when the graph data changes.
+
+### WebSocket reconnect behavior
+
+- The page reconnects after three seconds when the socket closes.
+- The page resubscribes to the file after the socket opens.
+- The page refreshes sync status after reconnect.
+- The page refreshes graph data after reconnect.
+- Reconnect does not recover dropped message-bus changes.
+- The page shows the sync error or skipped-change state when the status reports it.
+
+## Feature Flag and Runtime Dependencies
+
+- `:graph` is defined in `common/src/app/common/flags.cljc`.
+- The flag is off by default.
+- `com.ladybugdb/lbug` version `0.19.1` is a backend dependency.
+- `org.apache.arrow/arrow-memory-netty` version `18.2.0` supports Arrow `RootAllocator`.
+- The JVM uses `--enable-native-access=ALL-UNNAMED`.
+- The JVM uses `--add-opens=java.base/java.nio=ALL-UNNAMED`.
+- The JVM uses `--sun-misc-unsafe-memory-access=allow`.
+- The JVM options appear in the development alias and backend launch scripts.
+- A Ladybug version change needs new binder and parity tests.
+- A JDK version change needs a startup test with the graph flag enabled.
+
+## Tests
+
+### `backend-tests.graph-sync-parity-test`
+
+- Uses two Ladybug `:memory:` databases.
+- Does not use PostgreSQL or a live graph session.
+- Projects initial file data into database A.
+- Applies changes to database A through incremental sync.
+- Applies the same changes to file data.
+- Projects the changed file data into database B.
+- Compares every node row and relationship row.
+- Reports differences by table, row key, and column.
+- Covers shape add, shape modification, shape deletion, movement, and page changes.
+- Contains a test that injects a sync defect and expects a graph difference.
+- Does not cover all component change variants.
+- Does not cover every movement insertion mode.
+
+### `backend-tests.graph-binder-gate-test`
+
+- Creates the live graph DDL in a Ladybug `:memory:` database.
+- Prepares each sync statement template without executing it.
+- Detects parse errors and missing tables.
+- Detects missing columns and bad label quoting.
+- Reports the expected read-only classification.
+- Covers reserved node labels across the node registry.
+- Reports an error result for an invalid statement.
+
+### Test gaps
+
+- No automated HTTP handler tests cover graph routes.
+- No automated session lifecycle tests cover load and unload.
+- No automated WebSocket tests cover graph subscription.
+- No automated export tests cover persistent and session sources.
+- Component add, modify, delete, restore, and purge need parity tests.
+- Page delete needs parity coverage.
+- Movement with `:after-shape` needs parity coverage.
+- Buffer overflow and revision gap behavior need tests.
+- Partial batch failure and recovery need tests.
+- Query timeout and long-query behavior need tests.
+
+## Known Risks and Limits
+
+### Dropped changes
+
+- The sync channel uses a dropping buffer of 64.
+- A burst can discard file-change messages.
+- The sync loop logs a revision gap when it sees a larger revision.
+- The sync loop does not fetch missing rows from `file_change`.
+- Reload is the only built-in recovery path.
+
+### Partial batch state
+
+- `apply-changes!` does not provide Ladybug transaction atomicity.
+- A statement failure can leave a partly changed graph.
+- The in-memory index can advance before the database state is complete.
+- `/dbg/actions/graph-data` reads the database and exposes this drift.
+- Reload rebuilds the graph from PostgreSQL file data.
+
+### Query resource use
+
+- The default session query timeout is zero.
+- A costly query can hold the session lock for a long time.
+- The same lock blocks incremental sync.
+- The graph export also holds the same lock during catalog reads.
+- The graph schema has a high memory floor.
+- The console reports about 115 MiB for the wide slice before file data.
+
+### Session lifecycle
+
+- Sessions have no TTL.
+- Sessions remain until unload, replacement, or process shutdown.
+- Each session owns native Ladybug memory.
+- Many profiles can create many native databases.
+- A profile load replaces its previous session.
+- Two browser tabs for one profile share one graph session.
+
+### Temporary files
+
+- Session export leaves the final `.lbug` file in the system temporary directory.
+- Long-lived servers can accumulate exported session databases.
+- The staging directory is deleted after import.
+
+### Browser dependency
+
+- The graph view depends on a runtime CDN request.
+- A network restriction can remove the G6 view.
+- Queries and session status still use backend endpoints without G6.
+
+### Data exposure
+
+- The graph console can list many files available to the profile.
+- The console can load complete projected file data.
+- The console can export a graph database.
+- The console can inspect all projected node attributes.
+- The console is safe only when the `/dbg` access boundary is correct.
+- The graph flag must remain off for deployments that do not need this tool.
+
+### Contract drift
+
+- The graph schema is a deliberate slice of the Penpot file model.
+- New source attributes do not enter the graph automatically in all cases.
+- Dropped and unprojected attributes need an explicit contract decision.
+- `applied_tokens` key mapping depends on the JSON naming function.
+- `filter_*` is a frontend convention, not a graph schema guarantee.
+
+### Ladybug dialect coupling
+
+- Cypher strings contain Ladybug-specific syntax.
+- Label quoting handles reserved labels explicitly.
+- Relationship transforms depend on Ladybug relationship limits.
+- Arrow loading depends on Ladybug `COPY FROM (MATCH ...)` behavior.
+- A dependency upgrade needs schema, binder, Arrow, and parity checks.
+
+## REPL Helpers
+
+- `app.srepl.main` resolves graph functions only when a helper runs.
+- `graph-smoke-test!` runs a basic Ladybug operation.
+- `graph-query-test!` runs a graph query test.
+- `ingest-file-to-graph!` projects a file into a graph database.
+- These helpers use `requiring-resolve` to keep the graph dependency lazy.
+
+## Operational Invariants
+
+- PostgreSQL file data remains authoritative.
+- Cold projection and incremental sync must produce equal graph state.
+- The graph revision must identify the last applied file revision.
+- The document revision must update when a sync batch applies.
+- A missing or skipped change must remain visible in sync status.
+- A graph query from the console must be read-only.
+- A graph session must serialize connection access.
+- Graph routes must remain behind the `:graph` flag and `/dbg` access control.
+- The Arrow allocator must outlive all Ladybug operations that use its buffers.
+- `GraphMeta` must be written after the full ingest and transforms finish.
+
+## Key Files
+
+- `backend/src/app/graph/ladybug.clj`: Ladybug API and query gates.
+- `backend/src/app/graph/arrow.clj`: Arrow bulk load.
+- `backend/src/app/graph/ingest.clj`: Complete file ingest.
+- `backend/src/app/graph/debug.clj`: Session lifecycle, sync loop, query, and export.
+- `backend/src/app/graph/sync.clj`: Incremental change application.
+- `backend/src/app/graph/meta.clj`: Graph provenance.
+- `backend/src/app/graph/stats.clj`: Graph counts.
+- `backend/src/app/graph/report.clj`: REPL ingest report.
+- `backend/src/app/graph/projection/document.clj`: Base document projection.
+- `backend/src/app/graph/projection/transforms.clj`: Derived relationship transforms.
+- `backend/src/app/graph/schema/nodes.clj`: Node and relationship registry.
+- `backend/src/app/graph/schema/contract.clj`: Projection contract decisions.
+- `backend/src/app/graph/schema/projection.clj`: Malli projection schemas.
+- `backend/src/app/graph/schema/types.clj`: Malli-to-Ladybug type mapping.
+- `backend/src/app/graph/schema/values.clj`: Value coercion.
+- `backend/src/app/http/debug.clj`: Graph route registration and handlers.
+- `backend/src/app/http/websocket.clj`: File WebSocket subscription handlers.
+- `backend/src/app/rpc/commands/files_update.clj`: File-change publication.
+- `backend/src/app/main.clj`: Integrant message bus wiring.
+- `backend/resources/app/templates/graph-console.tmpl`: Graph console browser code.
+- `backend/resources/app/templates/debug.tmpl`: Debug page graph links.
+- `common/src/app/common/flags.cljc`: `:graph` feature flag.
+- `backend/test/backend_tests/graph_sync_parity_test.clj`: Cold versus sync parity.
+- `backend/test/backend_tests/graph_binder_gate_test.clj`: Cypher binder gate.
+
+## Development Commands
+
+- Run backend commands from the `backend/` directory.
+- Run focused parity tests with `clojure -M:dev:test --focus backend-tests.graph-sync-parity-test`.
+- Run focused binder tests with `clojure -M:dev:test --focus backend-tests.graph-binder-gate-test`.
+- Run the backend test suite with `clojure -M:dev:test`.
+- Examine Clojure formatting with `pnpm run check-fmt:clj`.
+- Run backend Clojure lint with `pnpm run lint:clj`.
+- Write test output to a file before reading or filtering it.
diff --git a/.serena/memories/backend/http-storage-filedata-subtleties.md b/.serena/memories/backend/http-storage-filedata-subtleties.md
index e9c0962371..188ece7277 100644
--- a/.serena/memories/backend/http-storage-filedata-subtleties.md
+++ b/.serena/memories/backend/http-storage-filedata-subtleties.md
@@ -14,10 +14,7 @@
## Storage and media
-- Storage has a fixed valid bucket set. Backends are `:fs` and `:s3`; default backend comes from deprecated `assets-storage-backend` only when present, otherwise `objects-storage-backend`, defaulting to `:fs`.
-- `put-object!` creates the DB `storage_object` row before writing backend content. Backend writes happen only for newly created rows, so deduplication can skip object writes.
-- Deduplication only applies when requested, when the content can provide a hash, and when bucket metadata is present. Reads exclude soft-deleted storage rows.
-- `sto/resolve` can reuse the current DB connection via `::db/reuse-conn true`; preserve this in transaction-sensitive code.
+- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`.
- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing.
- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8.
- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error.
@@ -28,4 +25,4 @@
- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data.
- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob.
- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written.
-- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
\ No newline at end of file
+- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md
new file mode 100644
index 0000000000..101c77702a
--- /dev/null
+++ b/.serena/memories/backend/storage.md
@@ -0,0 +1,119 @@
+# Backend Storage
+
+## Abstraction
+
+- `app.storage` stores binary objects.
+- Each object has a `storage_object` database row.
+- The row stores the UUID, size, backend, timestamps, and Transit metadata.
+- The backend stores the binary content.
+- Supported backends are `:fs` and `:s3`.
+- FS uses one root directory and a UUID-derived path.
+- S3 uses one configured bucket and an optional prefix.
+- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory.
+- FS and S3 use the same UUID-derived object path. The bucket does not change the path.
+- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend.
+- Deprecated asset-storage config keys remain supported for migration.
+- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases.
+
+## Object Lifecycle
+
+- `put-object!` creates the database row before it writes backend content.
+- Backend content is written only when the row is new.
+- A failed backend write can leave an unreferenced database row.
+- Callers often set `:touched-at` so garbage collection can remove such rows.
+- `get-object` excludes rows with `deleted_at`.
+- Existing object values can remain readable until physical deletion.
+- `:expired-at` blocks reads after the expiration time.
+- `del-object!` sets `deleted_at`. It does not remove backend content.
+- `storage-gc-deleted` removes the database row and backend content after the deletion delay.
+- `storage-gc-touched` finds references before it sets `deleted_at`.
+- `objects-gc` removes deleted domain rows and touches their storage object IDs.
+- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction.
+
+## Connection Reuse Details
+
+### `app.storage/resolve` patterns:
+
+**1. Pool mode (default)** - `(sto/resolve cfg)`
+- Returns storage abstraction from config
+- Uses whatever database pool is available
+- **Safe to call outside transaction context**
+- Used in: `rpc/commands/media.clj:363`, `rpc/commands/auth.clj:327`, `rpc/commands/profile.clj:362`
+
+**2. Connection reuse mode** - `(sto/resolve cfg ::db/reuse-conn true)`
+- Internally calls `db/get-connection cfg` to obtain connectable
+- Configures storage with the specific connection from config
+- **Must be paired with transaction that owns this connection**
+- Used in: `features/fdata.clj:100`, `rpc/commands/media.clj:425`, `rpc/commands/files_thumbnails.clj:307,319`, `binfile/v3.clj:722`
+
+**3. Explicit configuration** - `(sto/configure storage conn)`
+- Sets `::db/conn` on storage map directly
+- Asserts `db/conn? connection` (storage.clj:349)
+- Used inside `db/tx-run!` blocks where `conn` is already available
+- Used in: `tasks/file_gc.clj:256`, `rpc/commands/files_thumbnails.clj:347,371`
+
+### Key Warning (from function notes):
+
+The improved note in `import-storage-objects` and `handle-persistence` warns:
+**Do not reuse the main database connection for storage operations within a transaction.** The storage upload process can fail mid-operation, leaving orphaned objects on the backend. If the outer transaction aborts, pending storage objects become unreconciliable because the storage subsystem registers its pending state in separate transactions.
+
+### Rule of Thumb for `sto/put-object!`:
+
+Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `impl/put-object`) and does not directly use `::db/conn` or `::db/pool`, **all usage of `put-object!` will never run inside a common transaction** (if configured at all). The storage backend operations are independent of the database transaction boundary.
+
+## Deduplication
+
+- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata.
+- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`.
+- The lookup only considers rows with `status='valid'`; pending rows are invisible.
+- A hit whose blob is missing is repaired in place: the same row/id is kept,
+ and `put-object!` rewrites the blob under that id. This heals all existing
+ references to the object. If the rewrite fails, the row is left live and
+ valid for a later retry.
+- The lookup does not include file ID, profile ID, team ID, or organization ID.
+- Objects can therefore share content across users and files within one bucket.
+- Deleted objects are not reused.
+- `tempfile` objects never use deduplication, even when the caller requests it.
+- Use `sto/wrap-with-hash` when the caller already calculated the content hash.
+
+## Bucket Rules
+
+| Bucket | Content and references | Dedup | Direct `/assets/by-id` access | Cleanup |
+| --- | --- | --- | --- | --- |
+| `file-media-object` | Original file images and generated media thumbnails. References: `file_media_object.media_id` and `thumbnail_id`. | Yes | Public | Reference scan. |
+| `team-font-variant` | Font variants in `team_font_variant`. References: `woff1_file_id`, `woff2_file_id`, `otf_file_id`, and `ttf_file_id`. | Yes | Public | Reference scan. |
+| `file-object-thumbnail` | Frame and component thumbnails in `file_tagged_object_thumbnail.media_id`. | Yes | Public | Reference scan. |
+| `file-thumbnail` | File grid thumbnails in `file_thumbnail.media_id`. | Yes | Authentication required | Reference scan. |
+| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. |
+| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. |
+| `tempfile` | Export files, chunked-upload chunks, and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
+| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. |
+| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. |
+| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. |
+
+- The valid bucket set lives in `app.storage/valid-buckets`.
+- `file-media-object` is the default bucket for old rows without bucket metadata.
+- Do not assign a new bucket without adding its access and cleanup behavior.
+- The touched-object collector raises an internal error for an unknown bucket.
+- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`.
+- It does not support `file-data-fragment` or `file-change`.
+
+## Access Rules
+
+- `app.http.assets` decides direct object authentication from the bucket.
+- Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`.
+- Other valid buckets require a session or access-token profile ID.
+- File-media routes also require file read permission.
+- Non-public direct responses set `content-disposition: attachment`.
+- FS responses use `x-accel-redirect` for the configured asset path.
+- S3 responses use a presigned URL and an HTTP redirect.
+
+## File Data
+
+- `file-data-backend` accepts `legacy-db`, `db`, or `storage`.
+- `legacy-db` stores main data in `file.data` and snapshots in `file_change.data`.
+- `db` stores encoded data in `file_data.data`.
+- `storage` stores encoded data in storage subsystem with `file-data` bucket and keeps `data` nil in `file_data` table.
+- The `file_data.metadata.storage-ref-id` value points to the storage object.
+- `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row.
+- File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata.
diff --git a/.serena/memories/backend/testing.md b/.serena/memories/backend/testing.md
new file mode 100644
index 0000000000..66d6f8a246
--- /dev/null
+++ b/.serena/memories/backend/testing.md
@@ -0,0 +1,11 @@
+# Backend Testing
+
+JVM `clojure.test` (kaocha runner) under `backend/test/backend_tests/`.
+
+- READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all JVM test runs.
+- All CLI commands must be executed from the `backend/` subdirectory.
+- Tests are invoked directly via `clojure -M:dev:test` (kaocha) — there is no pnpm wrapper. Kaocha auto-discovers test namespaces, so no runner registration is needed.
+- Coverage: if code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
+- Isolated run: `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace, or `clojure -M:dev:test --focus backend-tests.my-ns-test/my-test-var` for a specific test var.
+- Regression run: `clojure -M:dev:test` to ensure no regressions in related functional areas.
+- If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`.
\ No newline at end of file
diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md
index b032c97180..117e8ff464 100644
--- a/.serena/memories/critical-info.md
+++ b/.serena/memories/critical-info.md
@@ -39,6 +39,7 @@ This is a monorepo. Principles that apply to one module do *not* generally apply
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
- `library/`: design library workflows; core conventions: `mem:library/core`.
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
+- `media-processor/`: TypeScript/Node.js HTTP service for image (sharp) and font (FontForge) processing; core conventions: `mem:media-processor/core`.
The memory is structured in a way that you can get the critical information about the
module. You can read it from `mem:/core`
diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md
index 0651f872fe..db46542c99 100644
--- a/.serena/memories/devenv/core.md
+++ b/.serena/memories/devenv/core.md
@@ -6,6 +6,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
- `penpotdev-infra`: shared `postgres`, `minio`, `minio-setup`, `mailer`, `ldap`. File: `docker-compose.infra.yml`.
- `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `~/.penpot/penpot_workspaces/`), maintained by the developer.
+- Optional overlay `docker-compose.opencode.yml`: added by `instance-compose` as an extra `-f` only when `PENPOT_OPENCODE_CONFIG_DIR` is set (i.e. `run-devenv --opencode-config-dir DIR` ran in this process). Bind-mounts the host dir at `/home/penpot/.config/opencode` (`:z`). Flag-only, per-call; not read from ambient env. Parser `parse-opencode-config-dir` absolutizes (`~`, realpath) because compose resolves relative bind sources against the compose file's dir. Only instances brought up with the flag get the mount.
- All projects join external network `penpot_shared`. Created idempotently by `ensure-devenv-network`, never removed by lifecycle commands.
## Source-of-truth files
@@ -65,7 +66,7 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
## CLI surface
-- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet).
+- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX] [--opencode-config-dir DIR]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). `--opencode-config-dir DIR` bind-mounts DIR at `~/.config/opencode` in-container via the optional overlay above; mount applies at container creation, so changing it requires stop + re-run.
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` stops just that workspace. `--ws 0` or no flag stops ws0; shared infra shuts down only if no other instances remain. `--all` stops every ws highest-first then ws0, then infra.
- `run-devenv`: legacy alias, ws0 non-agentic attached.
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.
diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md
index 3bcf784f49..9b7078045b 100644
--- a/.serena/memories/exporter/core.md
+++ b/.serena/memories/exporter/core.md
@@ -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 ` and Bearer auth.
\ No newline at end of file
+- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter ` and Bearer auth.
diff --git a/.serena/memories/exporter/testing.md b/.serena/memories/exporter/testing.md
new file mode 100644
index 0000000000..189c1e852c
--- /dev/null
+++ b/.serena/memories/exporter/testing.md
@@ -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.
diff --git a/.serena/memories/media-processor/core.md b/.serena/memories/media-processor/core.md
new file mode 100644
index 0000000000..209d4b1a37
--- /dev/null
+++ b/.serena/memories/media-processor/core.md
@@ -0,0 +1,100 @@
+# Media Processor
+
+Stateless HTTP service for Penpot image and font processing. Handles image info extraction, thumbnail generation (sharp), and font conversion (FontForge, woff-tools).
+
+## Tech Stack
+
+- Language: TypeScript
+- Runtime: Node.js
+- Framework: Express
+- Image processing: sharp (libvips)
+- Font processing: FontForge (TTF/OTF), sfnt2woff, woff2_decompress
+- Upload handling: multer (hybrid storage: memory for small, disk for large)
+- Logging: pino (with optional Loki transport)
+- Config validation: Zod
+- Testing: Vitest
+- Package Manager: pnpm
+
+## Project Structure
+
+```
+media-processor/
+├── src/
+│ ├── index.ts # Express app setup, routes, middleware
+│ ├── config.ts # Zod-validated env config, HKDF key derivation
+│ ├── types.ts # TypeScript type definitions
+│ ├── upload.ts # Multer configuration, getFileBuffer helper
+│ ├── upload-storage.ts # Hybrid storage engine (memory < threshold, disk >= threshold)
+│ ├── logger.ts # Pino logger setup
+│ ├── middleware/
+│ │ ├── auth.ts # Timing-safe shared key authentication
+│ │ ├── error-handler.ts # ProcessingError class, centralized error handling
+│ │ └── timeout.ts # Request timeout middleware
+│ ├── routes/
+│ │ ├── health.ts # GET /api/health
+│ │ ├── image.ts # POST /api/image/info, /api/image/thumbnail
+│ │ └── font.ts # POST /api/font/convert
+│ └── services/
+│ ├── image.ts # sharp-based image info/thumbnail generation
+│ ├── font.ts # FontForge/woff-tools font conversion
+│ └── errors.ts # throwValidation, throwRestriction, throwProcessing
+├── test/ # Vitest test files
+├── vitest.config.ts # Test configuration
+├── tsconfig.json # TypeScript configuration
+├── esbuild.config.mjs # Build configuration
+└── package.json # Dependencies and scripts
+```
+
+## Key Conventions
+
+### Auth
+- Requests authenticated via `x-shared-key` header using timing-safe comparison
+- When no key configured, all requests rejected with 403
+- Key derived from `PENPOT_SECRET_KEY` via HKDF (blake2b512) or set directly via `PENPOT_MEDIA_PROCESSOR_SHARED_KEY`
+
+### Resource Limits
+- Image: max pixels, max width/height enforced before processing
+- Font: prlimit wraps FontForge processes with memory (AS) and CPU time limits
+- Concurrency: p-queue limits concurrent requests (default 10)
+- Upload: hybrid storage — memory for files < 10MB, disk for larger; configurable via `PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD`
+- Max file size: configurable (default 350MB)
+
+### Error Handling
+- `throwValidation(code, hint)` — 400 errors for invalid input
+- `throwRestriction(code, hint)` — 413 errors for resource limits exceeded
+- `throwProcessing(code, hint)` — 503 errors for processing failures (e.g., resource limit kills)
+
+### Image Processing
+- EXIF orientation applied before dimension validation and thumbnail generation
+- sharp caching disabled to prevent unbounded memory growth
+- `withoutEnlargement: true` prevents upscaling small images
+
+### Font Conversion
+- Supported formats: TTF, OTF, WOFF, WOFF2
+- SFNT type detected via magic bytes (0x4f54544f = OTF, 0x00010000 = TTF)
+- Temp files cleaned up in finally blocks (best-effort)
+
+## Commands
+
+All commands run from `media-processor/` directory:
+
+- `pnpm run test` — Run Vitest test suite
+- `pnpm run types:check` — TypeScript type checking (tsc --noEmit)
+- `pnpm run fmt` — Format code with Prettier
+- `pnpm run fmt:check` — Check formatting without modifying
+- `pnpm run build` — Build for production (esbuild)
+- `pnpm run start:dev` — Start development server (tsx)
+
+## Docker
+
+- Exposed port: 6065 (configurable via `PENPOT_MEDIA_PROCESSOR_PORT`)
+- Must be deployed on internal Docker network only (not public-facing)
+- Backend communicates via `PENPOT_MEDIA_PROCESSING_SERVICE_URI`
+
+## Testing Principles
+
+Cross-cutting testing principles and anti-patterns: `mem:testing`.
+
+- Run `pnpm run test` after changes
+- Run `pnpm run types:check` after TypeScript changes
+- Run `pnpm run fmt:check` before commits
diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md
index e86eb69a32..1ec5af0308 100644
--- a/.serena/memories/prod-infra/core.md
+++ b/.serena/memories/prod-infra/core.md
@@ -6,7 +6,7 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose
- **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends.
- **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue::`. `PENPOT_REDIS_URI`.
-- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, file-data backends): `mem:backend/http-storage-filedata-subtleties`.
+- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, object lifecycle, and file-data backends): `mem:backend/storage`.
- **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task).
- **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`.
@@ -30,4 +30,4 @@ Penpot in production lives with both: horizontal-scale deployments accept "exact
## See also
- Devenv composition and the ws0-only worker placement: `mem:devenv/core`.
-- Storage backend resolution, dedup, file-data lifecycle: `mem:backend/http-storage-filedata-subtleties`.
+- Storage backend resolution, dedup, bucket behavior, object lifecycle, and file-data lifecycle: `mem:backend/storage`.
diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md
index f57856a17d..dadbae5c2d 100644
--- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md
+++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md
@@ -17,9 +17,32 @@
## Tile/render behavior
+- Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain
+ Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs).
+- `can_render_directly` paints onto Current (no Fills/Strokes blit) for plain geometry and
+ for stroke-free text (SrcOver, no blur/shadows). Multi-style text is fine: span styles
+ live in Paragraph `TextStyle`s. Text skips the `nested_fills` guard (fills are on spans).
+ `draw_text` only `save_layer`s when stroke-group opacity is set; plain fill paint is direct.
+- Plain text fill paint reuses `TextContent.layout` paragraphs when
+ `has_usable_paint_layout` (paragraphs present + version match; during
+ interactive transforms rotation/move skips width check via
+ `modifier_changes_text_layout`, resize falls back to `layout_width` vs
+ `get_width(selrect.width())`), via `text::try_paint_from_layout_cache`.
+ The walker computes `text_layout_cache_rotation_only` from `tree` and
+ passes it into `render_shape`; stroke/shadow paths pass `false`.
+- `TextContentLayout` paragraphs are `Rc`-shared on `Clone` so modifier clones
+ (rotate/pan) keep the paint cache; `needs_update` is paragraphs-empty only.
+ Decorations are skipped when no span requests underline/strike.
+- Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring
+ work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is
+ compose+present only.
- Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame.
- During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately.
- `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render.
- Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush.
-- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters.
-- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling.
\ No newline at end of file
+- Zoom settle wipes the tile texture cache in `set_view_end`. Mid-zoom overlays
+ key tiles by scale; shape edits must `invalidate_cached_tiles_intersecting`
+ the old∪new extrect so those overlays do not keep pre-edit pixels.
+- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling.
+- Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect +
+ blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true.
diff --git a/.serena/memories/scripts/gh.md b/.serena/memories/scripts/gh.md
index ed6adfa8f1..58f73e997d 100644
--- a/.serena/memories/scripts/gh.md
+++ b/.serena/memories/scripts/gh.md
@@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI.
- Finding issues with no milestone.
- Fetching PR details by number or by milestone.
- Comparing milestone issues against CHANGES.md to find missing entries.
+- Listing or inspecting GitHub Security Advisories (GHSA).
## Prerequisites
@@ -72,6 +73,30 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
**Output**: JSON array to stdout; progress to stderr.
+### `advisories`
+
+List or inspect GitHub Security Advisories for the repository.
+
+```bash
+# List all advisories (summary view)
+python3 scripts/gh.py advisories
+
+# Filter by severity
+python3 scripts/gh.py advisories --severity critical
+
+# Filter by state
+python3 scripts/gh.py advisories --state triage
+
+# Get full detail for a single advisory
+python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7
+```
+
+**Summary output fields**: ghsa_id, cve_id, severity, cvss_score, state, summary, cwes, published_at, closed_at, url.
+
+**Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps.
+
+**Output**: JSON to stdout; progress to stderr.
+
## Key principles
- All output is JSON — pipe into `jq` or other tools for further processing.
diff --git a/.serena/memories/testing.md b/.serena/memories/testing.md
index 295da86212..ce301ff5bb 100644
--- a/.serena/memories/testing.md
+++ b/.serena/memories/testing.md
@@ -13,7 +13,7 @@ and helpers, consult:
builders, production-path change helpers
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E integration tests,
live browser verification via nREPL
-- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core`
+- `mem:backend/testing` — JVM `clojure.test` under `backend/test/`
## When to Use
diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md
index d37d4672a5..2fc766d4ad 100644
--- a/.serena/memories/workflow/creating-commits.md
+++ b/.serena/memories/workflow/creating-commits.md
@@ -14,6 +14,8 @@ automatically pull the identity from the local git config `user.name` and `user.
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
Body explaining what changed and why.
+Wrap lines at 72 characters — git log and tooling
+render long lines poorly. Keep each line concise.
AI-assisted-by: model-name
```
@@ -25,3 +27,7 @@ AI-assisted-by: model-name
## Commit Type Emojis
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
+
+## Referencing Issues
+
+Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue.
diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md
index a92c199e22..fa94a9afe0 100644
--- a/.serena/memories/workflow/creating-prs.md
+++ b/.serena/memories/workflow/creating-prs.md
@@ -1,6 +1,12 @@
# Creating Pull Requests
-PR only on explicit request. Branch: issue/feature-specific; fallback `/` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
+PR only on explicit request.
+
+## Branch Naming
+
+- Primary: `issue-NNNN` — one branch per GitHub issue (e.g. `issue-11525`).
+- No issue: free-form descriptive name, dash-separated, no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
+- If the user already created the branch, use it as-is — never rename.
## Target Branch
diff --git a/AGENTS.md b/AGENTS.md
index ac4da5c663..06507f18d1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,6 +34,36 @@ Skipping this step is the #1 cause of incorrect or incomplete work.
---
+## Auto-triggers
+
+- **Security advisory URL pasted** — When the user pastes a URL matching
+ `github.com/penpot/penpot/security/advisories/GHSA-*`, extract the GHSA ID
+ from the URL and run `python3 scripts/gh.py advisories ` to fetch
+ full advisory details before proceeding.
+- **Issue or PR mentioned** — When the user mentions a penpot/penpot issue or
+ PR (URL like `github.com/penpot/penpot/issues/` / `.../pull/`, or a
+ bare `#` when context clearly refers to this repo), fetch details via CLI
+ instead of WebFetch:
+ - Issue → `gh issue view --repo penpot/penpot` (add `--comments` when
+ discussion context matters).
+ - Single PR → `gh pr view --repo penpot/penpot`.
+ - Multiple PRs (list, file, or milestone) → `python3 scripts/gh.py prs ...`.
+ Do this before proceeding. Only use WebFetch if the CLI fails.
+
+## Writing Rules
+
+Writing rules, from Orwell, 1946. These govern prose: docs, PR text, messages. Never touch code or technical terms; swap in everyday words only where precision survives.
+
+1. Never use a metaphor, simile or other figure of speech which you are used to seeing in print.
+2. Never use a long word where a short one will do.
+3. If it is possible to cut a word out, always cut it out.
+4. Never use the passive where you can use the active.
+5. Never use a foreign phrase, a scientific word or a jargon word if you can think of an everyday English equivalent.
+6. Break any of these rules sooner than say anything outright barbarous.
+Review every prose output against these rules before delivering.
+
+---
+
# Memory system
Memories are the **primary project guidance** — not docs or readme files.
@@ -113,4 +143,5 @@ precision while maintaining a strong focus on maintainability and performance.
- `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`.
+- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.
diff --git a/CHANGES.md b/CHANGES.md
index bcfd77b8bc..1618253d1b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,25 @@
# CHANGELOG
+## 2.19.0 (Unreleased)
+
+### :bug: Bugs fixed
+
+- Fix copying text from Penpot to the clipboard not working on MS Windows [#11303](https://github.com/penpot/penpot/issues/11303) (PR: [#11305](https://github.com/penpot/penpot/pull/11305))
+- Fix performance issue with WebGL render [#11240](https://github.com/penpot/penpot/issues/11240) (PR: [#11259](https://github.com/penpot/penpot/pull/11259))
+- Fix comment bubbles rendering on top of workspace dropdown menus [#10283](https://github.com/penpot/penpot/issues/10283) (PR: [#11201](https://github.com/penpot/penpot/pull/11201))
+- Fix inconsistent Mixed label in blur options and numeric inputs across 24 locales (by @filipsajdak) [#11148](https://github.com/penpot/penpot/issues/11148) (PR: [#11151](https://github.com/penpot/penpot/pull/11151))
+- Fix overlay shifting left when shown with top-center alignment in viewer prototype (by @filipsajdak) [#9048](https://github.com/penpot/penpot/issues/9048) (PR: [#10454](https://github.com/penpot/penpot/pull/10454))
+- Fix internal error when clicking the Copy button on the Access Token page (by @0xTHAC0) [#8496](https://github.com/penpot/penpot/issues/8496) (PR: [#11156](https://github.com/penpot/penpot/pull/11156))
+- Fix `disable-registration` flag not preventing non-users from creating accounts in the share prototypes page (by @0xTHAC0) [#5164](https://github.com/penpot/penpot/issues/5164) (PR: [#11199](https://github.com/penpot/penpot/pull/11199))
+
+### :sparkles: New features & Enhancements
+
+- Make backend storage resilient to interrupted writes, missing files and stalled cleanup [#11344](https://github.com/penpot/penpot/issues/11344) (PR: [#11345](https://github.com/penpot/penpot/pull/11345))
+- Implement RTL support in the text editor v3 [#11262](https://github.com/penpot/penpot/issues/11262)
+- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807))
+- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237))
+- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958))
+
## 2.18.0 (Unreleased)
### :bug: Bugs fixed
@@ -11,29 +31,154 @@
- Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359))
- Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541))
- Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535))
-- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523))
- Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381))
+- Fix invalid formulas being accepted in numeric inputs (by @AKnassa) [#9581](https://github.com/penpot/penpot/issues/9581) (PR: [#10659](https://github.com/penpot/penpot/pull/10659))
+- Fix radial gradient handles blowing up in size when rotated on ellipses (by @AKnassa) [#10069](https://github.com/penpot/penpot/issues/10069) (PR: [#10666](https://github.com/penpot/penpot/pull/10666))
+- Fix plugin API validation errors being too generic to diagnose the failure (by @AKnassa) [#10072](https://github.com/penpot/penpot/issues/10072) (PR: [#10667](https://github.com/penpot/penpot/pull/10667))
+- Fix crash with referential integrity error when deleting a component inside a grid (by @Alotor) [#10101](https://github.com/penpot/penpot/issues/10101) (PR: [#10956](https://github.com/penpot/penpot/pull/10956))
+- Fix component copies not preserving rotation when the main component has changes [#10109](https://github.com/penpot/penpot/issues/10109) (PR: [#10574](https://github.com/penpot/penpot/pull/10574))
+- Fix text width and height staying stale after setting growType in the plugin API [#10207](https://github.com/penpot/penpot/issues/10207) (PR: [#9898](https://github.com/penpot/penpot/pull/9898))
+- Fix padding not painted until expanding the 4-sides padding option [#10278](https://github.com/penpot/penpot/issues/10278) (PR: [#10602](https://github.com/penpot/penpot/pull/10602))
+- Fix files with custom fonts breaking with a referential integrity error when moved between teams (by @filipsajdak) [#10496](https://github.com/penpot/penpot/issues/10496) (PR: [#10837](https://github.com/penpot/penpot/pull/10837))
+- Fix clicking overlapping comment bubbles zooming to 20000% without showing the comments [#10526](https://github.com/penpot/penpot/issues/10526) (PR: [#10543](https://github.com/penpot/penpot/pull/10543))
+- Fix user menu subsections in the dashboard not closing when hovering away from the parent option (by @AKnassa) [#10549](https://github.com/penpot/penpot/issues/10549) (PR: [#10639](https://github.com/penpot/penpot/pull/10639))
+- Fix self-hosted env-generated config.js being cached for 7 days so PENPOT_FLAGS changes did not reach already-cached browsers (by @filipsajdak) [#10556](https://github.com/penpot/penpot/issues/10556) (PR: [#11146](https://github.com/penpot/penpot/pull/11146))
+- Fix color of selected text in light theme [#10570](https://github.com/penpot/penpot/issues/10570) (PR: [#10614](https://github.com/penpot/penpot/pull/10614))
+- Fix margin input order being inconsistent with padding inputs and between collapsed and expanded states [#10578](https://github.com/penpot/penpot/issues/10578) (PR: [#10797](https://github.com/penpot/penpot/pull/10797))
+- Fix uncaught DOMException when writing image/svg+xml content to the clipboard (by @AKnassa) [#10596](https://github.com/penpot/penpot/issues/10596) (PR: [#10663](https://github.com/penpot/penpot/pull/10663))
+- Fix tick icons not aligned in the font selector [#10597](https://github.com/penpot/penpot/issues/10597) (PR: [#10774](https://github.com/penpot/penpot/pull/10774))
+- Fix incorrect padding values when multiple shapes are selected [#10598](https://github.com/penpot/penpot/issues/10598) (PR: [#10602](https://github.com/penpot/penpot/pull/10602))
+- Fix integrity errors related to variants not being repaired [#10606](https://github.com/penpot/penpot/issues/10606) (PR: [#10768](https://github.com/penpot/penpot/pull/10768))
+- Fix changing password showing 'Password should be at least 8 characters' error on the old password field (by @AKnassa) [#10626](https://github.com/penpot/penpot/issues/10626) (PR: [#10661](https://github.com/penpot/penpot/pull/10661))
+- Fix stroke caps disappearing when dragging [#10633](https://github.com/penpot/penpot/issues/10633) (PR: [#10634](https://github.com/penpot/penpot/pull/10634))
+- Fix layout padding being saved as string after invalid input in multi-selection, causing persistence errors (by @niwinz) [#10638](https://github.com/penpot/penpot/issues/10638) (PR: [#10758](https://github.com/penpot/penpot/pull/10758))
+- Fix inconsistent theme handling between Penpot and plugins [#10676](https://github.com/penpot/penpot/issues/10676) (PR: [#10677](https://github.com/penpot/penpot/pull/10677))
+- Fix image stroke (strokeImage) support missing in the plugin API Stroke interface [#10682](https://github.com/penpot/penpot/issues/10682) (PR: [#10683](https://github.com/penpot/penpot/pull/10683))
+- Fix SVG images not working as fill in the WebGL renderer [#10705](https://github.com/penpot/penpot/issues/10705) (PR: [#10707](https://github.com/penpot/penpot/pull/10707))
+- Fix background blur not working on text shapes [#10706](https://github.com/penpot/penpot/issues/10706) (PR: [#10712](https://github.com/penpot/penpot/pull/10712))
+- Fix background blur not applying on strokes [#10713](https://github.com/penpot/penpot/issues/10713) (PR: [#10716](https://github.com/penpot/penpot/pull/10716))
+- Fix text shape with empty content breaking workspace updates [#10725](https://github.com/penpot/penpot/issues/10725) (PR: [#10731](https://github.com/penpot/penpot/pull/10731))
+- Fix missing SVG option in the file filters when adding an image fill (by @LuBoys) [#10756](https://github.com/penpot/penpot/issues/10756) (PR: [#10771](https://github.com/penpot/penpot/pull/10771))
+- Update onboarding image [#10779](https://github.com/penpot/penpot/issues/10779) (PR: [#10783](https://github.com/penpot/penpot/pull/10783))
+- Fix main toolbar overlapping the grid edition bar [#10788](https://github.com/penpot/penpot/issues/10788) (PR: [#10789](https://github.com/penpot/penpot/pull/10789))
+- Fix WASM renderer panic when the WebGL context is restored mid-reload [#10810](https://github.com/penpot/penpot/issues/10810) (PR: [#10824](https://github.com/penpot/penpot/pull/10824))
+- Fix nginx frontend forwarding the client Host header to backend/exporter, breaking Istio strict mTLS routing (by @yamila-moreno) [#10835](https://github.com/penpot/penpot/issues/10835) (PR: [#11233](https://github.com/penpot/penpot/pull/11233))
+- Fix tutorial templates with components causing errors [#10839](https://github.com/penpot/penpot/issues/10839)
+- Fix plugin 'Try out' flow crashing when projects have not loaded yet [#10858](https://github.com/penpot/penpot/issues/10858) (PR: [#10859](https://github.com/penpot/penpot/pull/10859))
+- Fix collapsed Fill color section on the design panel for new texts [#10860](https://github.com/penpot/penpot/issues/10860) (PR: [#10972](https://github.com/penpot/penpot/pull/10972))
+- Fix grid item date tooltip in the project view showing 'Will be deleted' instead of creation date (by @0xTHAC0) [#10873](https://github.com/penpot/penpot/issues/10873) (PR: [#11161](https://github.com/penpot/penpot/pull/11161))
+- Merge stop and start measurement shortcut to match current behavior [#10884](https://github.com/penpot/penpot/issues/10884) (PR: [#10906](https://github.com/penpot/penpot/pull/10906))
+- Fix shape size badge displayed twice when a user with Viewer permissions selects a shape [#10893](https://github.com/penpot/penpot/issues/10893) (PR: [#10985](https://github.com/penpot/penpot/pull/10985))
+- Fix main menu being covered by the toolbar [#10902](https://github.com/penpot/penpot/issues/10902) (PR: [#10926](https://github.com/penpot/penpot/pull/10926))
+- Fix font family typography asset persisting across files in newly created text layers [#10925](https://github.com/penpot/penpot/issues/10925) (PR: [#11134](https://github.com/penpot/penpot/pull/11134))
+- Fix error raised when editing justified text [#10944](https://github.com/penpot/penpot/issues/10944) (PR: [#10945](https://github.com/penpot/penpot/pull/10945))
+- Fix MCP WebSocket proxy failing after penpot-mcp container restarts due to stale nginx DNS resolution (by @780Farva) [#10946](https://github.com/penpot/penpot/issues/10946) (PR: [#10947](https://github.com/penpot/penpot/pull/10947))
+- Fix verification email address being unreadable due to low-contrast text on the register success page [#10950](https://github.com/penpot/penpot/issues/10950) (PR: [#10965](https://github.com/penpot/penpot/pull/10965))
+- Fix image swatches displaying a wrong format in the color picker list view [#10951](https://github.com/penpot/penpot/issues/10951) (PR: [#10975](https://github.com/penpot/penpot/pull/10975))
+- Fix text editor crashing when dropping dragged text after selecting all content [#10954](https://github.com/penpot/penpot/issues/10954) (PR: [#10959](https://github.com/penpot/penpot/pull/10959))
+- Fix MCP tokens being usable as API access tokens [#10960](https://github.com/penpot/penpot/issues/10960) (PR: [#10962](https://github.com/penpot/penpot/pull/10962))
+- Add size limit and rate limiting to the send-user-feedback endpoint [#10979](https://github.com/penpot/penpot/issues/10979) (PR: [#10990](https://github.com/penpot/penpot/pull/10990))
+- Fix main menu not keeping alignment when the left sidebar is expanded [#10981](https://github.com/penpot/penpot/issues/10981) (PR: [#10986](https://github.com/penpot/penpot/pull/10986))
+- Fix update-profile-props RPC method accepting undocumented keys [#10991](https://github.com/penpot/penpot/issues/10991) (PR: [#10992](https://github.com/penpot/penpot/pull/10992))
+- Fix import-binfile RPC method schema accepting a file-id parameter [#10993](https://github.com/penpot/penpot/issues/10993) (PR: [#10994](https://github.com/penpot/penpot/pull/10994))
+- Fix assemble-chunks session lookup ignoring the profile-id scope [#11011](https://github.com/penpot/penpot/issues/11011) (PR: [#11012](https://github.com/penpot/penpot/pull/11012))
+- Validate font-id team ownership in create-font-variant [#11013](https://github.com/penpot/penpot/issues/11013) (PR: [#11014](https://github.com/penpot/penpot/pull/11014))
+- Validate team ownership on file library link endpoints [#11015](https://github.com/penpot/penpot/issues/11015) (PR: [#11016](https://github.com/penpot/penpot/pull/11016))
+- Limit object size allocation in the V1 binfile parser [#11017](https://github.com/penpot/penpot/issues/11017) (PR: [#11018](https://github.com/penpot/penpot/pull/11018))
+- Limit recursion depth in the Fressian reader [#11019](https://github.com/penpot/penpot/issues/11019) (PR: [#11020](https://github.com/penpot/penpot/pull/11020))
+- Limit concurrent imports in the import-binfile RPC method [#11023](https://github.com/penpot/penpot/issues/11023) (PR: [#11024](https://github.com/penpot/penpot/pull/11024))
+- Validate content-type on management upload endpoints [#11025](https://github.com/penpot/penpot/issues/11025) (PR: [#11026](https://github.com/penpot/penpot/pull/11026))
+- Fix webhook endpoints allowing unauthorized access via creator-id fallback [#11028](https://github.com/penpot/penpot/issues/11028) (PR: [#11029](https://github.com/penpot/penpot/pull/11029))
+- Escape markdown in user-controlled fields of Mattermost error notifications [#11033](https://github.com/penpot/penpot/issues/11033) (PR: [#11034](https://github.com/penpot/penpot/pull/11034))
+- Enforce file read permission check on asset endpoints [#11035](https://github.com/penpot/penpot/issues/11035) (PR: [#11036](https://github.com/penpot/penpot/pull/11036))
+- Add accumulated storage byte quota for media uploads [#11037](https://github.com/penpot/penpot/issues/11037) (PR: [#11038](https://github.com/penpot/penpot/pull/11038))
+- Add bounding box dimension limit to exports [#11041](https://github.com/penpot/penpot/issues/11041) (PR: [#11042](https://github.com/penpot/penpot/pull/11042))
+- Sanitize embedded scripts in SVG uploads [#11043](https://github.com/penpot/penpot/issues/11043) (PR: [#11044](https://github.com/penpot/penpot/pull/11044))
+- Fix duplicate file ID returning inconsistent error responses [#11045](https://github.com/penpot/penpot/issues/11045) (PR: [#11050](https://github.com/penpot/penpot/pull/11050))
+- Enforce permission checks in WebSocket subscription handlers [#11052](https://github.com/penpot/penpot/issues/11052) (PR: [#11054](https://github.com/penpot/penpot/pull/11054))
+- Fix 'something went wrong' popup when using incremental numerical input interaction [#11053](https://github.com/penpot/penpot/issues/11053) (PR: [#10794](https://github.com/penpot/penpot/pull/10794))
+- Enforce password complexity validation on the backend [#11055](https://github.com/penpot/penpot/issues/11055) (PR: [#11059](https://github.com/penpot/penpot/pull/11059))
+- Normalize string inputs before processing [#11060](https://github.com/penpot/penpot/issues/11060) (PR: [#11061](https://github.com/penpot/penpot/pull/11061))
+- Add cooldown to avoid sending duplicate invitation emails [#11062](https://github.com/penpot/penpot/issues/11062) (PR: [#11063](https://github.com/penpot/penpot/pull/11063))
+- Enable SSRF protection for organization SSO validation [#11064](https://github.com/penpot/penpot/issues/11064) (PR: [#11065](https://github.com/penpot/penpot/pull/11065))
+- Fix clone-file-media-object allowing to clone media objects from files without read access [#11087](https://github.com/penpot/penpot/issues/11087) (PR: [#11090](https://github.com/penpot/penpot/pull/11090))
+- Fix 404 error page logo not visible in dark mode [#11091](https://github.com/penpot/penpot/issues/11091) (PR: [#11167](https://github.com/penpot/penpot/pull/11167))
+- Fix incorrect permission handling when creating an invitation [#11098](https://github.com/penpot/penpot/issues/11098) (PR: [#11099](https://github.com/penpot/penpot/pull/11099))
+- Reject zero or negative total-chunks values in upload sessions [#11103](https://github.com/penpot/penpot/issues/11103) (PR: [#11104](https://github.com/penpot/penpot/pull/11104))
+- Fix import-binfile accepting unsupported version values without validation [#11105](https://github.com/penpot/penpot/issues/11105) (PR: [#11107](https://github.com/penpot/penpot/pull/11107))
+- Fix sessions remaining active on other devices after account deletion [#11114](https://github.com/penpot/penpot/issues/11114) (PR: [#11115](https://github.com/penpot/penpot/pull/11115))
+- Use random UUIDs for share link IDs instead of a predictable scheme [#11116](https://github.com/penpot/penpot/issues/11116) (PR: [#11117](https://github.com/penpot/penpot/pull/11117))
+- Fix plugin manifest fetch hanging indefinitely without timeout [#11119](https://github.com/penpot/penpot/issues/11119) (PR: [#11120](https://github.com/penpot/penpot/pull/11120))
+- Use constant-time comparison for shared key authentication [#11121](https://github.com/penpot/penpot/issues/11121) (PR: [#11122](https://github.com/penpot/penpot/pull/11122))
+- Fix ESC key not closing the comment input box after posting a comment in the workspace [#11128](https://github.com/penpot/penpot/issues/11128) (PR: [#11131](https://github.com/penpot/penpot/pull/11131))
+- Fix token edit modal crashing when resolving tokens with group nodes [#11143](https://github.com/penpot/penpot/issues/11143) (PR: [#11144](https://github.com/penpot/penpot/pull/11144))
+- Fix text editor crashing when pasting into an empty text shape [#11149](https://github.com/penpot/penpot/issues/11149) (PR: [#11150](https://github.com/penpot/penpot/pull/11150))
+- Fix comment avatars appearing on top of rulers when scrolling the canvas (by @filipsajdak) [#11163](https://github.com/penpot/penpot/issues/11163) (PR: [#11168](https://github.com/penpot/penpot/pull/11168))
+- Fix infinite loop of get-teams and get-team-members calls when granting team access from an email link [#11215](https://github.com/penpot/penpot/issues/11215) (PR: [#11223](https://github.com/penpot/penpot/pull/11223))
+- Fix RPC requests bypassing rate limiting with fractional bucket refill intervals [#11253](https://github.com/penpot/penpot/issues/11253) (PR: [#11254](https://github.com/penpot/penpot/pull/11254))
+- Fix tempfile bucket serving objects to any authenticated user instead of only the uploader [#11269](https://github.com/penpot/penpot/issues/11269) (PR: [#11270](https://github.com/penpot/penpot/pull/11270))
+- Fix increasing a value by clicking and dragging in a numeric input [#11274](https://github.com/penpot/penpot/issues/11274) (PR: [#11334](https://github.com/penpot/penpot/pull/11334))
+- Fix notification pill rendering unescaped HTML in the detail section when importing tokens [#11276](https://github.com/penpot/penpot/issues/11276) (PR: [#11275](https://github.com/penpot/penpot/pull/11275))
+- Fix share-link holders reading pages outside the authorized scope via the get-page RPC command [#11281](https://github.com/penpot/penpot/issues/11281) (PR: [#11284](https://github.com/penpot/penpot/pull/11284))
+- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290))
+- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317))
+- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359))
### :sparkles: New features & Enhancements
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
- Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677))
-- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433))
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
-
-
-## 2.17.1 (Unreleased)
+- Highlight the first matching font in the font list when searching (by @ai-mountain) [#3204](https://github.com/penpot/penpot/issues/3204) (PR: [#9512](https://github.com/penpot/penpot/pull/9512), [#10450](https://github.com/penpot/penpot/pull/10450))
+- Preserve token references when copying and pasting properties instead of resolving them to values (by @AKnassa) [#9582](https://github.com/penpot/penpot/issues/9582) (PR: [#10665](https://github.com/penpot/penpot/pull/10665))
+- Add waitForLayoutUpdate method to the plugin API [#10136](https://github.com/penpot/penpot/issues/10136) (PR: [#9898](https://github.com/penpot/penpot/pull/9898))
+- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
+- Simplify MCP server configuration for common MCP clients [#10355](https://github.com/penpot/penpot/issues/10355) (PR: [#10604](https://github.com/penpot/penpot/pull/10604))
+- Remove misleading MCP client JSON snippet from the key-generated modal (by @Shlok1729) [#10399](https://github.com/penpot/penpot/issues/10399) (PR: [#10415](https://github.com/penpot/penpot/pull/10415))
+- Preview font families in the font selector [#10403](https://github.com/penpot/penpot/issues/10403) (PR: [#10411](https://github.com/penpot/penpot/pull/10411))
+- Remember expanded/collapsed state of token sets in the color tokens picker (session scope) [#10551](https://github.com/penpot/penpot/issues/10551) (PR: [#10864](https://github.com/penpot/penpot/pull/10864))
+- Show token sets in reverse order by default in the color tokens picker (by @rhinocap) [#10552](https://github.com/penpot/penpot/issues/10552) (PR: [#10658](https://github.com/penpot/penpot/pull/10658))
+- Add multi-selection and bulk delete support to pages in the workspace sitemap [#10580](https://github.com/penpot/penpot/issues/10580) (PR: [#10581](https://github.com/penpot/penpot/pull/10581))
+- Add a grid/list view toggle for files in the dashboard [#10691](https://github.com/penpot/penpot/issues/10691) (PR: [#10692](https://github.com/penpot/penpot/pull/10692))
+- Migrate Docker images to Docker Hardened Images (DHI) [#10720](https://github.com/penpot/penpot/issues/10720) (PR: [#10732](https://github.com/penpot/penpot/pull/10732), [#10733](https://github.com/penpot/penpot/pull/10733), [#10734](https://github.com/penpot/penpot/pull/10734))
+- Adopt React Aria [#10802](https://github.com/penpot/penpot/issues/10802) (PR: [#10675](https://github.com/penpot/penpot/pull/10675))
+- Add plugin API function for awaiting component updates beyond waitForLayoutUpdate [#10927](https://github.com/penpot/penpot/issues/10927) (PR: [#10964](https://github.com/penpot/penpot/pull/10964))
+- Emit open-workspace-file audit event with file statistics on workspace load [#11106](https://github.com/penpot/penpot/issues/11106) (PR: [#11138](https://github.com/penpot/penpot/pull/11138))
+## 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 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 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 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
@@ -3011,7 +3156,7 @@ is a number of cores)
- Enable penpot SVG metadata only when exporting complete files [Taiga #1914](https://tree.taiga.io/project/penpot/us/1914?milestone=295883)
- Export to PDF all artboards of one page [Taiga #1895](https://tree.taiga.io/project/penpot/us/1895)
- Go to a undo step clicking on a history element of the list [Taiga #1374](https://tree.taiga.io/project/penpot/us/1374)
-- Increment font size by 10 with shift+arrows [1047](https://github.com/penpot/penpot/issues/1047)
+- Increment font size by 10 with shift+arrows [#1047](https://github.com/penpot/penpot/issues/1047)
- New shortcut to detach components Ctrl+Shift+K [Taiga #1799](https://tree.taiga.io/project/penpot/us/1799)
- Set email inputs to type "email", to aid keyboard entry [Taiga #1921](https://tree.taiga.io/project/penpot/issue/1921)
- Use shift+move to move element orthogonally [#823](https://github.com/penpot/penpot/issues/823)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 63b931900f..d905aae9e0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,6 +14,7 @@ Center](https://help.penpot.app/).
- [Reporting Bugs](#reporting-bugs)
- [Pull Requests](#pull-requests)
- [Workflow](#workflow)
+ - [Branch naming](#branch-naming)
- [Format](#format)
- [Title format](#title-format)
- [Description](#description)
@@ -73,6 +74,18 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
4. **Format and lint** — run the checks described in
[Formatting and Linting](#formatting-and-linting) before submitting.
+### Branch naming
+
+Branch names are not enforced, but we recommend the following:
+
+- **`issue-NNNN`** — when working from a GitHub issue, name the branch after
+ it (e.g. `issue-11525`). This makes each PR's origin self-evident.
+- Otherwise, use a short, descriptive name with words separated by hyphens
+ and no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
+
+Since PRs are squash-merged, the branch name does not survive into the
+commit history — what matters is the [PR title](#title-format).
+
### Format
#### Title
diff --git a/HIGHLIGHTS.md b/HIGHLIGHTS.md
new file mode 100644
index 0000000000..e66b4b4497
--- /dev/null
+++ b/HIGHLIGHTS.md
@@ -0,0 +1,26 @@
+# HIGHLIGHTS
+
+## 2.17.0
+
+- Background blur is here
+- WebGL rendering gets stronger
+- MCP connection status and more
+- Design tokens: more visible, more user-friendly
+
+
+## 2.16.0
+
+- Design tokens in the design panel
+- Major community contributions
+- WebGL rendering (beta)
+
+
+## 2.15.0
+
+- AI connected to real design context
+- Multi-directional workflow
+- Your stack, your model, your decision
+
+
+
+
diff --git a/README.md b/README.md
index 5a1d6d60c2..0def50aa6a 100644
--- a/README.md
+++ b/README.md
@@ -160,6 +160,6 @@ 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
+Copyright (c) KALEIDOS SUBSIDIARY SL
```
Penpot is a Kaleidos’ [open source project](https://kaleidos.net/)
diff --git a/backend/deps.edn b/backend/deps.edn
index 2599066e0b..f10437fb75 100644
--- a/backend/deps.edn
+++ b/backend/deps.edn
@@ -17,7 +17,7 @@
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
- io.lettuce/lettuce-core {:mvn/version "7.6.0.RELEASE"}
+ io.lettuce/lettuce-core {:mvn/version "7.7.0.RELEASE"}
;; Minimal dependencies required by lettuce, we need to include them
;; explicitly because clojure dependency management does not support
;; yet the BOM format.
@@ -25,7 +25,7 @@
io.micrometer/micrometer-observation {:mvn/version "1.14.2"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
- com.google.guava/guava {:mvn/version "33.6.0-jre"}
+ com.google.guava/guava {:mvn/version "33.7.1-jre"}
funcool/yetti
{:git/tag "v11.10"
@@ -40,37 +40,44 @@
nrepl/nrepl {:mvn/version "1.7.0"}
org.postgresql/postgresql {:mvn/version "42.7.13"}
- org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"}
+ org.xerial/sqlite-jdbc {:mvn/version "3.53.4.0"}
com.zaxxer/HikariCP {:mvn/version "7.1.0"}
- io.whitfin/siphash {:mvn/version "2.0.0"}
+ io.whitfin/siphash {:mvn/version "3.0.0"}
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.6.1-359"}
+ org.passay/passay {:mvn/version "2.0.0"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
- org.jsoup/jsoup {:mvn/version "1.23.1"}
+ org.jsoup/jsoup {:mvn/version "1.23.2"}
at.yawk.lz4/lz4-java
- {:mvn/version "1.11.1"}
+ {:mvn/version "1.11.2"}
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
dawran6/emoji {:mvn/version "0.2.0"}
- markdown-clj/markdown-clj {:mvn/version "1.12.8"}
+ markdown-clj/markdown-clj {:mvn/version "1.12.9"}
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
- software.amazon.awssdk/s3 {:mvn/version "2.50.1"}
- software.amazon.awssdk/sts {:mvn/version "2.50.1"}}
+ software.amazon.awssdk/s3 {:mvn/version "2.54.5"}
+ software.amazon.awssdk/sts {:mvn/version "2.54.5"}
+
+ com.ladybugdb/lbug {:mvn/version "0.19.1"}
+ ;; Required by Arrow RootAllocator (lbug only pulls arrow-memory-core).
+ org.apache.arrow/arrow-memory-netty {:mvn/version "18.2.0"}}
:paths ["src" "resources" "target/classes"]
:aliases
{:dev
{:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
- "--enable-native-access=ALL-UNNAMED"]
+ "--enable-native-access=ALL-UNNAMED"
+ ;; Arrow jars are on the classpath (unnamed module), not module-path.
+ "--add-opens=java.base/java.nio=ALL-UNNAMED"]
:extra-deps
{com.bhauman/rebel-readline {:mvn/version "0.1.11"}
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
diff --git a/backend/dev/script-fix-sobjects.clj b/backend/dev/script-fix-sobjects.clj
index 3194ce0716..114e21684a 100644
--- a/backend/dev/script-fix-sobjects.clj
+++ b/backend/dev/script-fix-sobjects.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
;; This is an example on how it can be executed:
;; clojure -Scp $(cat classpath) -M dev/script-fix-sobjects.clj
diff --git a/backend/dev/user.clj b/backend/dev/user.clj
index 16908f4cab..9e13c8e7af 100644
--- a/backend/dev/user.clj
+++ b/backend/dev/user.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns user
(:require
diff --git a/backend/package.json b/backend/package.json
index 9fbce288ab..c6baf43f73 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -4,23 +4,25 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
- "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
+ "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"dependencies": {
- "luxon": "^3.4.4",
- "sax": "^1.6.0"
+ "eventsource-parser": "^3.0.6",
+ "luxon": "^3.7.2",
+ "sax": "^1.6.1"
},
"devDependencies": {
"nodemon": "^3.1.14",
"source-map-support": "^0.5.21",
- "ws": "^8.21.0"
+ "ws": "^8.21.1"
},
"scripts": {
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
- "fmt:clj": "cljfmt fix --parallel=true src/ test/"
+ "fmt:clj": "cljfmt fix --parallel=true src/ test/",
+ "test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.mjs"
}
}
diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml
index d789e2c2f8..a0b16465e9 100644
--- a/backend/pnpm-lock.yaml
+++ b/backend/pnpm-lock.yaml
@@ -8,12 +8,15 @@ importers:
.:
dependencies:
+ eventsource-parser:
+ specifier: ^3.0.6
+ version: 3.1.0
luxon:
- specifier: ^3.4.4
+ specifier: ^3.7.2
version: 3.7.2
sax:
- specifier: ^1.6.0
- version: 1.6.0
+ specifier: ^1.6.1
+ version: 1.6.1
devDependencies:
nodemon:
specifier: ^3.1.14
@@ -22,8 +25,8 @@ importers:
specifier: ^0.5.21
version: 0.5.21
ws:
- specifier: ^8.21.0
- version: 8.21.0
+ specifier: ^8.21.1
+ version: 8.21.1
packages:
@@ -39,9 +42,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
- brace-expansion@5.0.7:
- resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
- engines: {node: 18 || 20 || >=22}
+ brace-expansion@5.0.9:
+ resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
+ engines: {node: 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -63,6 +66,10 @@ packages:
supports-color:
optional: true
+ eventsource-parser@3.1.0:
+ resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
+ engines: {node: '>=18.0.0'}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -130,8 +137,8 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
- sax@1.6.0:
- resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
+ sax@1.6.1:
+ resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
engines: {node: '>=11.0.0'}
semver@7.8.5:
@@ -165,8 +172,8 @@ packages:
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
- ws@8.21.0:
- resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
+ ws@8.21.1:
+ resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -188,7 +195,7 @@ snapshots:
binary-extensions@2.3.0: {}
- brace-expansion@5.0.7:
+ brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -216,6 +223,8 @@ snapshots:
optionalDependencies:
supports-color: 5.5.0
+ eventsource-parser@3.1.0: {}
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -247,7 +256,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
- brace-expansion: 5.0.7
+ brace-expansion: 5.0.9
ms@2.1.3: {}
@@ -274,7 +283,7 @@ snapshots:
dependencies:
picomatch: 2.3.2
- sax@1.6.0: {}
+ sax@1.6.1: {}
semver@7.8.5: {}
@@ -301,4 +310,4 @@ snapshots:
undefsafe@2.0.5: {}
- ws@8.21.0: {}
+ ws@8.21.1: {}
diff --git a/backend/pnpm-workspace.yaml b/backend/pnpm-workspace.yaml
index e69de29bb2..b3fbd9192b 100644
--- a/backend/pnpm-workspace.yaml
+++ b/backend/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+minimumReleaseAgeExclude:
+ - brace-expansion@5.0.8 || 5.0.9
diff --git a/backend/resources/app/email/request-file-access-yourpenpot-view/en.html b/backend/resources/app/email/request-file-access-yourpenpot-view/en.html
index 57f0bbb781..f26a4b65da 100644
--- a/backend/resources/app/email/request-file-access-yourpenpot-view/en.html
+++ b/backend/resources/app/email/request-file-access-yourpenpot-view/en.html
@@ -191,7 +191,7 @@
file named “{{file-name|abbreviate:25}}”.
- Since this file is in your Penpot team, you can provide access by sending a view-only link.
+ Since this file is in your Personal Projects, you can provide access by sending a view-only link.
This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
To proceed, please click the button below to generate and send the view-only link:
diff --git a/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt b/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt
index 397f3c821d..c52649dd37 100644
--- a/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt
+++ b/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt
@@ -2,7 +2,7 @@ Hello!
{{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have view-only access to the file named “{{file-name|abbreviate:25}}”.
-Since this file is in your Penpot team, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
+Since this file is in your Personal Projects, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
To proceed, please click the link below to generate and send the view-only link:
diff --git a/backend/resources/app/email/request-file-access-yourpenpot/en.html b/backend/resources/app/email/request-file-access-yourpenpot/en.html
index ca0b00ee27..cf3161a6b6 100644
--- a/backend/resources/app/email/request-file-access-yourpenpot/en.html
+++ b/backend/resources/app/email/request-file-access-yourpenpot/en.html
@@ -191,7 +191,7 @@
“{{file-name|abbreviate:25}}”.
- Please note that the file is currently in Your Penpot 's team, so direct access cannot be
+ Please note that the file is currently in Personal Projects, so direct access cannot be
granted. However, you have two options to provide the requested access:
diff --git a/backend/resources/app/email/request-file-access-yourpenpot/en.txt b/backend/resources/app/email/request-file-access-yourpenpot/en.txt
index 81f5d5c72e..e33a0bf80a 100644
--- a/backend/resources/app/email/request-file-access-yourpenpot/en.txt
+++ b/backend/resources/app/email/request-file-access-yourpenpot/en.txt
@@ -5,7 +5,7 @@ Hello!
{{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named “{{file-name|abbreviate:25}}”.
-Please note that the file is currently in Your Penpot 's team, so direct access cannot be granted. However, you have two options to provide the requested access:
+Please note that the file is currently in Personal Projects, so direct access cannot be granted. However, you have two options to provide the requested access:
- Move the File to Another Team:
diff --git a/backend/resources/app/templates/debug.tmpl b/backend/resources/app/templates/debug.tmpl
index 42894b570c..64ddd9d150 100644
--- a/backend/resources/app/templates/debug.tmpl
+++ b/backend/resources/app/templates/debug.tmpl
@@ -190,6 +190,20 @@ Debug Main Page
+
+
+
+ {% if graph-enabled %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
{% endblock %}
diff --git a/backend/resources/app/templates/graph-console.tmpl b/backend/resources/app/templates/graph-console.tmpl
new file mode 100644
index 0000000000..8746e65ec3
--- /dev/null
+++ b/backend/resources/app/templates/graph-console.tmpl
@@ -0,0 +1,1758 @@
+{% extends "app/templates/base.tmpl" %}
+
+{% block title %}
+Graph Console
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+{% if session %}
+
+
+{% endif %}
+{% endblock %}
diff --git a/backend/resources/app/templates/styles.css b/backend/resources/app/templates/styles.css
index bbcc3fbb48..56c74d594a 100644
--- a/backend/resources/app/templates/styles.css
+++ b/backend/resources/app/templates/styles.css
@@ -143,6 +143,35 @@ nav > div:not(:last-child) {
height: fit-content;
}
+/* A widget that holds a table rather than a form: full width, and tall
+ enough to be worth scrolling inside. */
+.dashboard.wide {
+ margin-top: 0px;
+}
+
+.widget.wide {
+ max-width: none;
+ width: 100%;
+}
+
+.widget.wide .scroll-box {
+ max-height: 320px;
+ overflow-y: auto;
+ margin-top: 10px;
+}
+
+.widget.wide table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.widget.wide th {
+ text-align: left;
+ position: sticky;
+ top: 0;
+ background: white;
+}
+
.widget input[type=submit] {
outline: none;
border: 1px solid gray;
diff --git a/backend/resources/climit.edn b/backend/resources/climit.edn
index 7d8234499b..ded9b5c9b8 100644
--- a/backend/resources/climit.edn
+++ b/backend/resources/climit.edn
@@ -39,4 +39,16 @@
{:permits 3}
:create-file-snapshot/by-profile
- {:permits 1 :queue 2 :timeout 60000}}
+ {:permits 1 :queue 2 :timeout 60000}
+
+ :send-user-feedback/global
+ {:permits 4}
+
+ :send-user-feedback/by-profile
+ {:permits 1 :queue 3}
+
+ :import-binfile/global
+ {:permits 4}
+
+ :import-binfile/by-profile
+ {:permits 1 :queue 2}}
diff --git a/backend/resources/rlimit.edn b/backend/resources/rlimit.edn
index 118f30f70a..68b3153848 100644
--- a/backend/resources/rlimit.edn
+++ b/backend/resources/rlimit.edn
@@ -1,11 +1,308 @@
-;; Example rlimit.edn file
^{:refresh "30s"}
{:default
[[:default :window "200000/h"]]
- ;; #{:main/get-teams}
- ;; [[:burst :bucket "5/5/5s"]]
+ ;; ═══════════════════════════════════════════════
+ ;; Auth & Identity — public, unauthenticated
+ ;; ═══════════════════════════════════════════════
+ #{:main/login-with-password}
+ [[:auth-password :bucket "100/50/1m"]]
- ;; #{:main/get-profile}
- ;; [[:burst :bucket "60/60/1m"]]
- }
+ #{:main/login-with-ldap}
+ [[:auth-ldap :bucket "20/10/5m"]]
+
+ #{:main/register-profile}
+ [[:auth-register :bucket "20/10/15m"]]
+
+ #{:main/request-profile-recovery
+ :main/prepare-register-profile}
+ [[:auth-recovery :bucket "100/50/5m"]]
+
+ #{:main/recover-profile
+ :main/verify-token}
+ [[:auth-token :bucket "100/50/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; SSRF vectors — URL fetch endpoints
+ ;; ═══════════════════════════════════════════════
+ #{:main/create-file-media-object-from-url}
+ [[:url-fetch :bucket "100/50/5m"]]
+
+ #{:main/create-webhook
+ :main/update-webhook}
+ [[:webhook-validation :bucket "20/10/5m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Search — full sequential scan risk
+ ;; ═══════════════════════════════════════════════
+ #{:main/search-files}
+ [[:search :bucket "60/30/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Feedback & Invitations — email-sending
+ ;; ═══════════════════════════════════════════════
+ #{:main/send-user-feedback
+ :main/create-team-invitations}
+ [[:email-send :bucket "30/15/5m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Media & File heavy ops
+ ;; ═══════════════════════════════════════════════
+ #{:main/upload-file-media-object}
+ [[:image-upload :bucket "200/100/1m"]]
+
+ #{:main/create-file-object-thumbnail
+ :main/delete-file-object-thumbnails
+ :main/get-file-object-thumbnails}
+ [[:thumbnail-ops :bucket "5000/3000/1m"]]
+
+ #{:main/get-file-data-for-thumbnail
+ :main/create-file-thumbnail}
+ [[:thumbnail-data :bucket "100/50/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; UI navigation reads — high frequency
+ ;; ═══════════════════════════════════════════════
+ #{:main/get-teams}
+ [[:get-teams :bucket "5000/2500/30s"]]
+
+ #{:main/get-team-members}
+ [[:get-team-members :bucket "4000/2000/30s"]]
+
+ #{:main/get-profile}
+ [[:get-profile :bucket "500/250/30s"]]
+
+ #{:main/get-font-variants}
+ [[:get-font-variants :bucket "250/125/30s"]]
+
+ #{:main/get-comment-threads}
+ [[:get-comment-threads :bucket "500/250/30s"]]
+
+ #{:main/get-profiles-for-file-comments}
+ [[:get-profiles-for-file-comments :bucket "300/150/30s"]]
+
+ #{:main/get-file-libraries}
+ [[:get-file-libraries :bucket "200/100/30s"]]
+
+ #{:main/get-projects}
+ [[:get-projects :bucket "120/60/30s"]]
+
+ #{:main/get-team-recent-files
+ :main/get-unread-comment-threads}
+ [[:get-team-recent :bucket "120/60/30s"]]
+
+ #{:main/get-page}
+ [[:get-page :bucket "150/75/30s"]]
+
+ #{:main/get-access-tokens
+ :main/get-subscription-usage}
+ [[:get-access-tokens :bucket "150/75/30s"]]
+
+ #{:main/get-enabled-flags}
+ [[:get-enabled-flags :bucket "250/125/30s"]]
+
+ #{:main/get-builtin-templates}
+ [[:get-builtin-templates :bucket "200/100/30s"]]
+
+ #{:main/get-project
+ :main/get-project-files}
+ [[:get-project-info :bucket "80/40/30s"]]
+
+ #{:main/get-file}
+ [[:get-file :bucket "180/90/1m"]]
+
+ #{:main/get-team-shared-files
+ :main/get-team-info
+ :main/get-team-users
+ :main/get-team-invitations
+ :main/get-team-deleted-files
+ :main/get-sso-provider}
+ [[:get-team-info :bucket "60/30/30s"]]
+
+ #{:main/get-comments
+ :main/get-file-snapshots
+ :main/get-library-usage
+ :main/has-file-libraries}
+ [[:get-misc-list :bucket "300/150/30s"]]
+
+ #{:main/get-comment-thread
+ :main/get-library-file-references}
+ [[:get-misc-single :bucket "60/30/30s"]]
+
+ #{:main/get-file-info
+ :main/get-view-only-bundle
+ :main/get-all-projects
+ :main/get-owned-teams
+ :main/get-team-stats
+ :main/get-file-summary
+ :main/get-file-stats
+ :main/get-file-fragment}
+ [[:get-light :bucket "60/30/30s"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; File mutations — editing active
+ ;; ═══════════════════════════════════════════════
+ #{:main/update-file}
+ [[:update-file :bucket "1000/500/1m"]]
+
+ #{:main/create-file
+ :main/rename-file
+ :main/duplicate-file
+ :main/move-files}
+ [[:file-create :bucket "60/30/1m"]]
+
+ #{:main/delete-file}
+ [[:file-delete :bucket "80/40/1m"]]
+
+ #{:main/set-file-shared
+ :main/update-file-library-sync-status
+ :main/ignore-file-library-sync-status
+ :main/link-file-to-library
+ :main/unlink-file-from-library
+ :main/create-file-snapshot
+ :main/restore-file-snapshot
+ :main/update-file-snapshot
+ :main/delete-file-snapshot
+ :main/lock-file-snapshot
+ :main/unlock-file-snapshot}
+ [[:file-mutations :bucket "80/40/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Project mutations
+ ;; ═══════════════════════════════════════════════
+ #{:main/create-project}
+ [[:project-create :bucket "100/50/1m"]]
+
+ #{:main/delete-project
+ :main/rename-project
+ :main/duplicate-project
+ :main/move-project
+ :main/update-project-pin}
+ [[:project-mutations :bucket "40/20/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Team mutations
+ ;; ═══════════════════════════════════════════════
+ #{:main/create-team
+ :main/update-team
+ :main/delete-team
+ :main/update-team-photo
+ :main/update-team-member-role
+ :main/delete-team-member
+ :main/leave-team
+ :main/create-team-with-invitations
+ :main/create-team-access-request
+ :main/permanently-delete-team-files
+ :main/restore-deleted-team-files}
+ [[:team-mutations :bucket "60/30/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Comment operations
+ ;; ═══════════════════════════════════════════════
+ #{:main/create-comment-thread
+ :main/create-comment
+ :main/update-comment
+ :main/delete-comment
+ :main/mark-all-threads-as-read}
+ [[:comment-basic :bucket "30/15/1m"]]
+
+ #{:main/update-comment-thread
+ :main/update-comment-thread-status
+ :main/update-comment-thread-position
+ :main/update-comment-thread-frame
+ :main/delete-comment-thread}
+ [[:comment-thread :bucket "80/40/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Profile operations
+ ;; ═══════════════════════════════════════════════
+ #{:main/update-profile
+ :main/update-profile-props
+ :main/update-profile-photo
+ :main/update-profile-password
+ :main/update-profile-notifications
+ :main/delete-profile
+ :main/delete-profile-photo
+ :main/request-email-change}
+ [[:profile-mutations :bucket "30/15/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Font operations
+ ;; ═══════════════════════════════════════════════
+ #{:main/create-font-variant
+ :main/delete-font
+ :main/delete-font-variant
+ :main/update-font
+ :main/download-font
+ :main/download-font-family}
+ [[:font-ops :bucket "100/50/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Access tokens
+ ;; ═══════════════════════════════════════════════
+ #{:main/create-access-token
+ :main/delete-access-token}
+ [[:access-token :bucket "60/30/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Export / Import
+ ;; ═══════════════════════════════════════════════
+ #{:main/export-binfile
+ :main/import-binfile
+ :main/clone-template}
+ [[:export-import :bucket "80/40/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Upload sessions
+ ;; ═══════════════════════════════════════════════
+ #{:main/create-upload-session
+ :main/upload-chunk
+ :main/assemble-file-media-object}
+ [[:upload-session :bucket "100/50/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Webhooks
+ ;; ═══════════════════════════════════════════════
+ #{:main/get-webhooks
+ :main/delete-webhook}
+ [[:webhook-read :bucket "20/10/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Share links
+ ;; ═══════════════════════════════════════════════
+ #{:main/create-share-link
+ :main/delete-share-link}
+ [[:share-link :bucket "10/5/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Organization operations
+ ;; ═══════════════════════════════════════════════
+ #{:main/add-team-to-organization
+ :main/remove-team-from-org
+ :main/all-org-members-in-team
+ :main/all-team-members-in-orgs
+ :main/get-owned-organizations-summary
+ :main/get-leave-org-summary
+ :main/leave-org
+ :main/check-org-members
+ :main/get-team-invitation-token
+ :main/delete-team-invitation
+ :main/check-team-external-invitations}
+ [[:org-ops :bucket "20/10/1m"]]
+
+ ;; ═══════════════════════════════════════════════
+ ;; Audit & stats
+ ;; ═══════════════════════════════════════════════
+ #{:main/push-audit-events}
+ [[:audit-events :bucket "1000/500/1m"]]
+
+ #{:main/logout
+ :main/get-error-report
+ :main/get-error-reports
+ :main/get-current-mcp-token
+ :main/get-nitrate-connectivity
+ :main/check-nitrate-sso
+ :main/redeem-nitrate-activation-code
+ :main/create-demo-profile
+ :main/get-subscription-warning}
+ [[:misc-light :bucket "100/50/1m"]]}
diff --git a/backend/scripts/_env b/backend/scripts/_env
index 120bb648bc..5e4b02b80a 100644
--- a/backend/scripts/_env
+++ b/backend/scripts/_env
@@ -4,6 +4,7 @@ export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key
+export PENPOT_MEDIA_PROCESSOR_SHARED_KEY=super-secret-media-processor-key
# DEPRECATED: only used for subscriptions
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
@@ -12,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
# docker/devenv/defaults.env and injected via the main service's env block.
+if [ -f /home/selfsigned.crt ]; then
+ export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt;
+fi
+
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
@@ -21,6 +26,8 @@ if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then
__worker_flag="enable-backend-worker"
fi
+export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
+
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
enable-login-with-password \
@@ -36,6 +43,7 @@ export PENPOT_FLAGS="\
enable-feature-fdata-objects-map \
enable-audit-log \
enable-transit-readable-response \
+ disable-remote-media-processing \
enable-demo-users \
enable-user-feedback \
disable-secure-session-cookies \
@@ -85,7 +93,8 @@ export JAVA_OPTS="\
-XX:-OmitStackTraceInFastThrow \
--sun-misc-unsafe-memory-access=allow \
--enable-preview \
- --enable-native-access=ALL-UNNAMED";
+ --enable-native-access=ALL-UNNAMED \
+ --add-opens=java.base/java.nio=ALL-UNNAMED";
function setup_minio() {
if [ "${PENPOT_OBJECTS_STORAGE_BACKEND}" != "s3" ]; then
@@ -97,5 +106,3 @@ function setup_minio() {
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
}
-
-
diff --git a/backend/scripts/manage.py b/backend/scripts/manage.py
index 56141571e7..e0e7780595 100755
--- a/backend/scripts/manage.py
+++ b/backend/scripts/manage.py
@@ -4,7 +4,7 @@
# 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
+# Copyright (c) KALEIDOS SUBSIDIARY SL
import argparse
import json
diff --git a/backend/scripts/run.template.sh b/backend/scripts/run.template.sh
index cff4afc870..19f47e6c0a 100644
--- a/backend/scripts/run.template.sh
+++ b/backend/scripts/run.template.sh
@@ -18,7 +18,7 @@ if [ -f ./environ ]; then
source ./environ
fi
-export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS"
+export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS"
ENTRYPOINT=${1:-app.main};
diff --git a/backend/src/app/auth.clj b/backend/src/app/auth.clj
index 1f978f357c..54ccc04303 100644
--- a/backend/src/app/auth.clj
+++ b/backend/src/app/auth.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.auth
(:require
@@ -14,10 +14,21 @@
:iterations 3
:parallelism 2})
+(def ^:private weak-options
+ {:alg :pbkdf2+sha256
+ :iterations 100})
+
(defn derive-password
[password]
(hashers/derive password default-options))
+(defn derive-password-weak
+ "Derives a password using a fast algorithm (pbkdf2+sha256, 100 iterations).
+ Intended for demo users only — they are already gated behind the
+ `demo-users` config flag which is disabled in production."
+ [password]
+ (hashers/derive password weak-options))
+
(defn verify-password
[attempt password]
(try
diff --git a/backend/src/app/auth/ldap.clj b/backend/src/app/auth/ldap.clj
index 659e990c91..8dae1291d6 100644
--- a/backend/src/app/auth/ldap.clj
+++ b/backend/src/app/auth/ldap.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.auth.ldap
(:require
diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj
index 09d34532b9..b3fdc80d7e 100644
--- a/backend/src/app/auth/oidc.clj
+++ b/backend/src/app/auth/oidc.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.auth.oidc
"OIDC client implementation."
@@ -42,31 +42,52 @@
;; OIDC PROVIDER (GENERIC)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+(defn- raise-invalid-sso-config
+ "Raise a controlled validation error for OIDC provider configuration failures."
+ [& {:keys [hint cause] :as params}]
+ (throw (ex-info (or hint "invalid-sso-config")
+ (-> params
+ (dissoc :cause)
+ (assoc :type :validation
+ :code :invalid-sso-config))
+ cause)))
+
(defn- discover-oidc-config
[cfg {:keys [base-uri skip-ssrf-check?] :as provider}]
- (let [uri (u/join base-uri ".well-known/openid-configuration")
- rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
+ (let [uri (u/join base-uri ".well-known/openid-configuration")]
+ (try
+ (let [rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
+ (if (= 200 (:status rsp))
+ (let [data (-> rsp :body json/decode)
+ token-uri (get data :token_endpoint)
+ auth-uri (get data :authorization_endpoint)
+ user-uri (get data :userinfo_endpoint)
+ jwks-uri (get data :jwks_uri)
+ logout-uri (get data :end_session_endpoint)]
- (if (= 200 (:status rsp))
- (let [data (-> rsp :body json/decode)
- token-uri (get data :token_endpoint)
- auth-uri (get data :authorization_endpoint)
- user-uri (get data :userinfo_endpoint)
- jwks-uri (get data :jwks_uri)
- logout-uri (get data :end_session_endpoint)]
+ (-> provider
+ (assoc :token-uri token-uri)
+ (assoc :auth-uri auth-uri)
+ (assoc :user-uri user-uri)
+ (assoc :jwks-uri jwks-uri)
+ (assoc :logout-uri logout-uri)))
- (-> provider
- (assoc :token-uri token-uri)
- (assoc :auth-uri auth-uri)
- (assoc :user-uri user-uri)
- (assoc :jwks-uri jwks-uri)
- (assoc :logout-uri logout-uri)))
-
- (ex/raise :type ::internal
- :code :invalid-sso-config
- :hint "unable to discover OIDC configuration"
- :discover-uri uri
- :response-status-code (:status rsp)))))
+ (raise-invalid-sso-config
+ :hint "unable to discover OIDC configuration"
+ :discover-uri uri
+ :response-status-code (:status rsp))))
+ (catch Throwable cause
+ ;; Controlled raises above are ExceptionInfo and would otherwise be
+ ;; re-wrapped by this catch, dropping fields like :response-status-code.
+ (if (and (ex/error? cause)
+ (= :invalid-sso-config (:code (ex-data cause))))
+ (throw cause)
+ ;; Wrap SSRF blocks, DNS failures, TLS errors, etc. — from the caller's
+ ;; perspective these are all "bad/unreachable issuer URL".
+ (raise-invalid-sso-config
+ :hint "unable to discover OIDC configuration"
+ :discover-uri uri
+ :cause cause))))))
(def ^:private default-oidc-scopes
#{"openid" "profile" "email"})
@@ -107,16 +128,29 @@
(defn- fetch-oidc-jwks
[cfg jwks-uri {:keys [skip-ssrf-check?]}]
- (let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
- (if (= 200 status)
- (-> body json/decode :keys process-oidc-jwks)
- (ex/raise :type ::internal
- :code :unable-to-fetch-sso-jwks
- :hint "unable to retrieve JWKs (unexpected response status code)"
- :response-status-code status))))
+ (try
+ (let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
+ (if (= 200 status)
+ (-> body json/decode :keys process-oidc-jwks)
+ (raise-invalid-sso-config
+ :hint "unable to retrieve JWKs (unexpected response status code)"
+ :jwks-uri jwks-uri
+ :response-status-code status)))
+ (catch Throwable cause
+ (if (and (ex/error? cause)
+ (= :invalid-sso-config (:code (ex-data cause))))
+ (throw cause)
+ (raise-invalid-sso-config
+ :hint "unable to retrieve JWKs"
+ :jwks-uri jwks-uri
+ :cause cause)))))
(defn- populate-jwks
- "Fetch and Add (if possible) JWK's to the OIDC provider"
+ "Fetch and add JWKs to the OIDC provider.
+
+ When `:strict-jwks?` is set (organization SSO), failures raise a controlled
+ validation error. Otherwise JWKS is best-effort: log and continue without keys
+ so global OIDC/GitLab providers can still initialize if JWKS is temporarily down."
[cfg provider]
(try
(if-let [jwks (when-let [jwks-uri (:jwks-uri provider)]
@@ -124,20 +158,28 @@
(assoc provider :jwks jwks)
provider)
(catch Throwable cause
- (l/warn :hint "unable to fetch JWKs for the OIDC provider"
- :provider (str (:id provider))
- :cause cause)
- provider)))
+ (if (:strict-jwks? provider)
+ (if (and (ex/error? cause)
+ (= :invalid-sso-config (:code (ex-data cause))))
+ (throw cause)
+ (raise-invalid-sso-config
+ :hint "unable to retrieve JWKs"
+ :provider (:id provider)
+ :cause cause))
+ (do
+ (l/warn :hint "unable to fetch JWKs for the OIDC provider"
+ :provider (str (:id provider))
+ :cause cause)
+ provider)))))
(defn- prepare-oidc-provider
[cfg params]
(when-not (and (string? (:base-uri params))
(string? (:client-id params))
(string? (:client-secret params)))
- (ex/raise :type ::internal
- :code :invalid-sso-config
- :hint "missing params for provider initialization"
- :provider (:id params)))
+ (raise-invalid-sso-config
+ :hint "missing params for provider initialization"
+ :provider (:id params)))
(try
(if (and (string? (:token-uri params))
@@ -150,11 +192,13 @@
(with-meta provider {::discovered true})))
(catch Throwable cause
- (ex/raise :type ::internal
- :type :invalid-sso-config
- :hint "unexpected exception on configuring provider"
- :provider (:id params)
- :cause cause))))
+ (if (and (ex/error? cause)
+ (= :invalid-sso-config (:code (ex-data cause))))
+ (throw cause)
+ (raise-invalid-sso-config
+ :hint "unexpected exception on configuring provider"
+ :provider (:id params)
+ :cause cause)))))
(defmethod ig/assert-key ::providers/generic
[_ params]
@@ -322,10 +366,9 @@
[cfg params]
(when-not (and (string? (:client-id params))
(string? (:client-secret params)))
- (ex/raise :type ::internal
- :code :invalid-sso-config
- :hint "missing params for provider initialization"
- :provider (:id params)))
+ (raise-invalid-sso-config
+ :hint "missing params for provider initialization"
+ :provider (:id params)))
(try
(let [provider (populate-jwks cfg params)]
@@ -336,11 +379,13 @@
:client-secret (d/obfuscate-string (:client-secret provider)))
provider)
(catch Throwable cause
- (ex/raise :type ::internal
- :type :invalid-sso-config
- :hint "unexpected exception on configuring provider"
- :provider (:id params)
- :cause cause))))
+ (if (and (ex/error? cause)
+ (= :invalid-sso-config (:code (ex-data cause))))
+ (throw cause)
+ (raise-invalid-sso-config
+ :hint "unexpected exception on configuring provider"
+ :provider (:id params)
+ :cause cause)))))
(defmethod ig/init-key ::providers/gitlab
[_ cfg]
@@ -620,9 +665,6 @@
(some? (:external-session-id state))
(assoc :external-session-id (:external-session-id state))
- (some? (:token/expires-in tdata))
- (assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)}))
-
;; If state token comes with props, merge them. The state token
;; props can contain pm_ and utm_ prefixed query params.
(map? (:props state))
@@ -650,6 +692,15 @@
(assoc :query (u/map->query-string params)))]
(redirect-response uri))))
+(defn- redirect-with-organization-sso-error
+ [{:keys [dest-url organization-id organization-name]}]
+ (-> (str (or dest-url (cf/get :public-uri)))
+ (u/append-query-param :sso-error true)
+ (u/append-query-param :organization-id organization-id)
+ (cond-> organization-name
+ (u/append-query-param :organization-name organization-name))
+ (redirect-response)))
+
(defn- redirect-to-register
[cfg info provider]
(let [info (assoc info
@@ -765,6 +816,82 @@
;; ORG SSO HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+(defn- organization-sso-oauth-failure-reason
+ [error]
+ (case (d/name error)
+ "access_denied" "access-denied"
+ ("temporarily_unavailable" "server_error") "provider-unavailable"
+ ("invalid_request" "unauthorized_client" "invalid_scope") "invalid-configuration"
+ "provider-error"))
+
+(defn- organization-sso-exception-failure-reason
+ [cause]
+ (let [data (ex-data cause)
+ status (or (:response-status data)
+ (:response-status-code data)
+ (:http-status data))
+ network-error?
+ (loop [current cause]
+ (cond
+ (nil? current)
+ false
+
+ (or (instance? java.net.ConnectException current)
+ (instance? java.net.UnknownHostException current)
+ (instance? java.net.http.HttpTimeoutException current)
+ (instance? javax.net.ssl.SSLException current))
+ true
+
+ (identical? current (ex-cause current))
+ false
+
+ :else
+ (recur (ex-cause current))))]
+ (if (or network-error?
+ (and (number? status) (<= 500 status 599)))
+ "provider-unavailable"
+ (case (:code data)
+ :unable-to-fetch-access-token "token-exchange-failed"
+ :unable-to-retrieve-user-info "user-info-failed"
+ :incomplete-user-info "incomplete-user-info"
+ :invalid-sso-config "invalid-configuration"
+ :unable-to-fetch-sso-jwks "provider-unavailable"
+ :unable-to-auth "access-denied"
+ "unexpected-error"))))
+
+(defn- submit-organization-sso-auth-event
+ [cfg request profile-id organization-id name & {:keys [failure-reason]}]
+ (audit/submit cfg {:type "action"
+ :name name
+ :profile-id profile-id
+ :ip-addr (inet/parse-request request)
+ :props (d/without-nils
+ {:organization-id organization-id
+ :failure-reason failure-reason})
+ :context (audit/prepare-context-from-request request)}))
+
+(defn submit-organization-sso-auth-started-event
+ [cfg request profile-id organization-id]
+ (submit-organization-sso-auth-event
+ cfg request profile-id organization-id "organization-sso-auth-started"))
+
+(defn submit-organization-sso-auth-failed-event
+ [cfg request profile-id organization-id cause]
+ (submit-organization-sso-auth-event
+ cfg request profile-id organization-id "organization-sso-auth-failed"
+ :failure-reason (organization-sso-exception-failure-reason cause)))
+
+(defn- submit-organization-sso-oauth-failed-event
+ [cfg request state-token error]
+ (try
+ (let [state (tokens/verify cfg {:token state-token :iss "oidc"})]
+ (when (:dest-url state)
+ (submit-organization-sso-auth-event
+ cfg request (some-> (session/get-session request) :profile-id)
+ (:organization-id state) "organization-sso-auth-failed"
+ :failure-reason (organization-sso-oauth-failure-reason error))))
+ (catch Exception _ nil)))
+
(defn- non-blank-uri
[value]
(when-not (str/blank? value) value))
@@ -776,7 +903,7 @@
(defn prepare-organization-sso-provider
"Build an OIDC provider map dynamically from the Nitrate organization SSO config.
- Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
+ Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
[cfg {:keys [client-id client-secret issuer]}]
(prepare-oidc-provider cfg
{:type "oidc"
@@ -786,7 +913,9 @@
(str/rtrim "/")
(str "/"))
:scopes default-oidc-scopes
- :skip-ssrf-check? true}))
+ ;; Organization SSO is configured by customers; discovery
+ ;; and JWKS failures must surface as controlled errors.
+ :strict-jwks? true}))
(defn build-organization-sso-auth-redirect-uri
"Build the OIDC authorization redirect URI for an organization SSO config.
@@ -796,16 +925,24 @@
issuer (organization-sso-discovery-uri sso)
dest-url (or dest-url (str (cf/get :public-uri)))]
(when-not issuer
- (ex/raise :type :validation
- :code :invalid-sso-config
- :hint "missing issuer"))
- (let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
- state-token (tokens/generate cfg {:iss "oidc"
- :dest-url dest-url
- :organization-id organization-id
- :issuer issuer
- :exp (ct/in-future "4h")})]
- (build-auth-redirect-uri oidc-provider state-token))))
+ (raise-invalid-sso-config
+ :hint "missing issuer"
+ :organization-id organization-id))
+ (try
+ (let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
+ state-token (tokens/generate cfg {:iss "oidc"
+ :dest-url dest-url
+ :organization-id organization-id
+ :issuer issuer
+ :exp (ct/in-future "4h")})]
+ (build-auth-redirect-uri oidc-provider state-token))
+ (catch Throwable cause
+ (if (and (ex/error? cause)
+ (= :invalid-sso-config (:code (ex-data cause))))
+ (throw (ex-info (ex-message cause)
+ (assoc (ex-data cause) :organization-id organization-id)
+ (ex-cause cause)))
+ (throw cause))))))
(def ^:private probe-auth-code "penpot-sso-config-probe")
@@ -888,10 +1025,53 @@
{::yres/status 200
::yres/body {:redirect-uri uri}}))
+(defn- organization-sso-callback-handler
+ "Handle the organization-SSO branch of the OIDC callback: state carries
+ :dest-url — exchange the authorization code with the OIDC provider to
+ verify authentication actually occurred, then redirect back to dest-url."
+ [cfg request state code]
+ (let [dest-url (:dest-url state)]
+ (try
+ (let [organization-id (:organization-id state)
+ sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
+ provider (prepare-organization-sso-provider cfg sso)
+ _info (get-info cfg provider state code)
+ session (session/get-session request)
+ exp (ct/in-future {:minutes 15})]
+ (when (and session organization-id)
+ (let [props (-> (or (:props session) {})
+ (update :sso assoc organization-id exp))]
+ (session/update-session (::session/manager cfg) (assoc session :props props))))
+ (submit-organization-sso-auth-event
+ cfg request (:profile-id session) organization-id "organization-sso-auth-succeeded")
+ (redirect-response dest-url))
+ (catch Throwable cause
+ (let [{:keys [code]} (ex-data cause)]
+ (binding [l/*context* (errors/request->context request)]
+ (if (some? code)
+ (l/warn :hint "organization sso callback failed"
+ :code code
+ :message (ex-message cause)
+ :organization-id (:organization-id state))
+ (l/err :hint "unexpected error on organization sso callback"
+ :organization-id (:organization-id state)
+ :cause cause))))
+ (submit-organization-sso-auth-failed-event
+ cfg request (some-> (session/get-session request) :profile-id)
+ (:organization-id state) cause)
+ (let [organization-id (:organization-id state)
+ organization-name (:name (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))]
+ (redirect-with-organization-sso-error
+ {:dest-url dest-url
+ :organization-id organization-id
+ :organization-name organization-name}))))))
+
(defn- callback-handler
[cfg {:keys [params] :as request}]
(if-let [error (get params :error)]
- (redirect-with-error "unable-to-auth" error)
+ (do
+ (submit-organization-sso-oauth-failed-event cfg request (:state params) error)
+ (redirect-with-error "unable-to-auth" error))
(try
(let [code (get params :code)
state (get params :state)
@@ -899,18 +1079,8 @@
;; Organization SSO flow: state carries :dest-url — exchange the authorization
;; code with the OIDC provider to verify authentication actually occurred.
- (if-let [dest-url (:dest-url state)]
- (let [organization-id (:organization-id state)
- sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
- provider (prepare-organization-sso-provider cfg sso)
- info (get-info cfg provider state code)
- session (session/get-session request)
- exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
- (when (and session organization-id)
- (let [props (-> (or (:props session) {})
- (update :sso assoc organization-id exp))]
- (session/update-session (::session/manager cfg) (assoc session :props props))))
- (redirect-response dest-url))
+ (if (:dest-url state)
+ (organization-sso-callback-handler cfg request state code)
(let [provider (resolve-provider cfg state)
info (get-info cfg provider state code)
diff --git a/backend/src/app/auth/passwords.clj b/backend/src/app/auth/passwords.clj
new file mode 100644
index 0000000000..d4e8cef200
--- /dev/null
+++ b/backend/src/app/auth/passwords.clj
@@ -0,0 +1,56 @@
+;; 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 SUBSIDIARY SL
+
+(ns app.auth.passwords
+ "Password strength validation using Passay library."
+ (:require
+ [app.common.exceptions :as ex])
+ (:import
+ [org.passay PasswordData]
+ [org.passay.data EnglishCharacterData]
+ [org.passay.rule CharacterCharacteristicsRule CharacterRule]))
+
+(defonce ^:private passay-code->translation-key
+ {"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase"
+ "INSUFFICIENT_UPPERCASE" "errors.weak-password.insufficient-uppercase"
+ "INSUFFICIENT_DIGIT" "errors.weak-password.insufficient-digits"
+ "INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"})
+
+(defonce ^:private character-characteristics-rule
+ (CharacterCharacteristicsRule.
+ 4
+ (into-array org.passay.rule.CharacterRule
+ [(CharacterRule. EnglishCharacterData/LowerCase 1)
+ (CharacterRule. EnglishCharacterData/UpperCase 1)
+ (CharacterRule. EnglishCharacterData/Digit 1)
+ (CharacterRule. EnglishCharacterData/Special 1)])))
+
+(defn validate-password
+ "Validates password strength.
+ Returns nil if valid, or raises exception if invalid.
+ Checks:
+ - Minimum length of 8 characters
+ - At least 1 lowercase letter
+ - At least 1 uppercase letter
+ - At least 1 digit
+ - At least 1 special character"
+ [password]
+ (when (< (count password) 8)
+ (ex/raise :type :validation
+ :code :weak-password
+ :hint "password must be at least 8 characters"
+ :details ["errors.weak-password.too-short"]))
+
+ (let [password-data (PasswordData. password)
+ char-result (.validate character-characteristics-rule password-data)]
+ (when-not (.isValid char-result)
+ (ex/raise :type :validation
+ :code :weak-password
+ :hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character"
+ :details (->> (.getDetails char-result)
+ (mapv #(.getErrorCode %))
+ (mapv passay-code->translation-key)
+ (filterv some?))))))
diff --git a/backend/src/app/binfile/cleaner.clj b/backend/src/app/binfile/cleaner.clj
index 66964b5358..c846a90149 100644
--- a/backend/src/app/binfile/cleaner.clj
+++ b/backend/src/app/binfile/cleaner.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.binfile.cleaner
"A collection of helpers for perform cleaning of artifacts; mainly
diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj
index a5b73564ea..16652c3526 100644
--- a/backend/src/app/binfile/common.clj
+++ b/backend/src/app/binfile/common.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.binfile.common
"A binfile related file processing common code, used for different
@@ -27,7 +27,6 @@
[app.features.file-migrations :as fmigr]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
- [app.storage :as sto]
[app.util.blob :as blob]
[app.util.pointer-map :as pmap]
[app.worker :as-alias wrk]
@@ -654,27 +653,6 @@
(db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout = 0"])
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])))
-(defn invalidate-thumbnails
- [cfg file-id]
- (let [storage (sto/resolve cfg)
-
- sql-1
- (str "update file_tagged_object_thumbnail "
- " set deleted_at = now() "
- " where file_id=? returning media_id")
-
- sql-2
- (str "update file_thumbnail "
- " set deleted_at = now() "
- " where file_id=? returning media_id")]
-
- (run! #(sto/touch-object! storage %)
- (sequence
- (keep :media-id)
- (concat
- (db/exec! cfg [sql-1 file-id])
- (db/exec! cfg [sql-2 file-id]))))))
-
(defn process-file
[cfg {:keys [id] :as file}]
(let [libs (delay (get-resolved-file-libraries cfg file))]
@@ -723,6 +701,7 @@
(-> (select-keys file file-attrs)
(assoc :data nil)
(dissoc :team-id)
+ (dissoc :metadata)
(dissoc :migrations)))
(defn- file->file-data-params
@@ -748,9 +727,17 @@
(fmigr/upsert-migrations! conn file))
(let [file (encode-file cfg file)]
- (db/insert! conn :file
- (file->params file)
- (assoc opts ::db/return-keys false))
+ (try
+ (db/insert! conn :file
+ (file->params file)
+ (assoc opts ::db/return-keys false))
+ (catch org.postgresql.util.PSQLException cause
+ (if (db/duplicate-key-error? cause)
+ (ex/raise :type :not-found
+ :code :object-not-found
+ :hint "file already exists"
+ :cause cause)
+ (throw cause))))
(->> (file->file-data-params file)
(fdata/upsert! cfg))
@@ -866,8 +853,8 @@
(defn get-resolved-file-libraries
"Get all file libraries including itself. Returns an instance of
LoadableWeakValueMap that allows do not have strong references to
- the loaded libraries and reduce possible memory pressure on having
- all this libraries loaded at same time on processing file validation
+ the loaded libraries and reduce memory pressure on having
+ all this libraries at the same time on processing file validation
or file migration.
This still requires at least one library at time to be loaded while
@@ -879,3 +866,47 @@
(cons (:id file)))
load-fn #(get-file cfg % :migrate? false)]
(weak/loadable-weak-value-map library-ids load-fn {id file})))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; EXTERNAL LIBRARY RESOLUTION HELPERS
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn slugify-name
+ "Slugify a library name for cross-environment matching.
+ Lowercases, replaces non-alphanumeric runs with '-', strips
+ leading/trailing '-'."
+ [name]
+ (str/slug name))
+
+(def ^:private sql:get-files-names
+ "SELECT id, name FROM file WHERE id = ANY(?)")
+
+(defn get-files-names
+ "Return [{:id uuid :name string}] for the given file ids."
+ [cfg ids]
+ (db/run! cfg
+ (fn [{:keys [::db/conn]}]
+ (let [ids-arr (db/create-array conn "uuid" ids)]
+ (db/exec! conn [sql:get-files-names ids-arr])))))
+
+(def ^:private sql:get-shared-files-for-team
+ "SELECT f.id, f.name, f.project_id
+ FROM file AS f
+ JOIN project AS p ON (p.id = f.project_id)
+ WHERE p.team_id = ?
+ AND f.is_shared = true
+ AND f.deleted_at IS NULL
+ AND p.deleted_at IS NULL")
+
+(defn get-shared-files-for-team
+ "Return [{:id uuid :name string}] for all shared files in a team."
+ [cfg team-id]
+ (db/run! cfg
+ (fn [{:keys [::db/conn]}]
+ (db/exec! conn [sql:get-shared-files-for-team team-id]))))
+
+(defn find-shared-files-by-slug
+ "Return all shared files in `team-id` whose slugified name equals `slug`."
+ [cfg team-id slug]
+ (->> (get-shared-files-for-team cfg team-id)
+ (filter #(= slug (slugify-name (:name %))))))
diff --git a/backend/src/app/binfile/migrations.clj b/backend/src/app/binfile/migrations.clj
index ce60eb0f68..9fa5a4b4e9 100644
--- a/backend/src/app/binfile/migrations.clj
+++ b/backend/src/app/binfile/migrations.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.binfile.migrations
"A binfile related migrations handling"
diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj
index 8dc4120159..0f00a7564e 100644
--- a/backend/src/app/binfile/v1.clj
+++ b/backend/src/app/binfile/v1.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.binfile.v1
"A custom, perfromance and efficiency focused binfile format impl"
@@ -174,6 +174,10 @@
(assert-mark m :obj)
(let [size (read-long! input)]
(assert (pos? size) "incorrect header size found on reading header")
+ (when (> size bfc/max-object-size)
+ (ex/raise :type :validation
+ :code :max-file-size-reached
+ :hint (dm/str "unable to import object with size " size " bytes")))
(let [buff (byte-array size)]
(read-bytes! input buff)
(fres/decode buff)))))
diff --git a/backend/src/app/binfile/v2.clj b/backend/src/app/binfile/v2.clj
index 347074586b..9e9644c47c 100644
--- a/backend/src/app/binfile/v2.clj
+++ b/backend/src/app/binfile/v2.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.binfile.v2
"A sqlite3 based binary file exportation with support for exportation
diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj
index 952cb69e8f..9d4801dd4a 100644
--- a/backend/src/app/binfile/v3.clj
+++ b/backend/src/app/binfile/v3.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.binfile.v3
"A ZIP based binary file exportation"
@@ -42,6 +42,7 @@
[datoteka.io :as io])
(:import
java.io.File
+ java.io.FilterInputStream
java.io.InputStream
java.io.OutputStreamWriter
java.lang.AutoCloseable
@@ -67,7 +68,16 @@
[:relations {:optional true}
[:vector
- [:tuple ::sm/uuid ::sm/uuid]]]])
+ [:tuple ::sm/uuid ::sm/uuid]]]
+
+ ;; TODO: rename to :links
+ [:external-libraries {:optional true}
+ [:vector
+ [:map
+ [:id ::sm/uuid]
+ [:name :string]
+ [:slug :string]
+ [:used-by {:optional true} [:vector ::sm/uuid]]]]]])
(def ^:private schema:storage-object
[:map {:title "StorageObject"}
@@ -217,14 +227,12 @@
(.flush writer))
(.closeEntry output))
+
(defn- get-file
- [{:keys [::bfc/embed-assets ::bfc/include-libraries] :as cfg} file-id]
+ [{:keys [::bfc/export-type] :as cfg} file-id]
- (when (and include-libraries embed-assets)
- (throw (IllegalArgumentException.
- "the `include-libraries` and `embed-assets` are mutally excluding options")))
-
- (let [detach? (and (not embed-assets) (not include-libraries))]
+ (let [detach? (= export-type :detach-libraries)
+ embed? (= export-type :merge-libraries)]
(db/tx-run! cfg (fn [cfg]
(cond-> (bfc/get-file cfg file-id
{:realize? true
@@ -234,7 +242,7 @@
(-> (ctf/detach-external-references file-id)
(dissoc :libraries))
- embed-assets
+ embed?
(update :data #(bfc/embed-assets cfg % file-id))
:always
@@ -371,12 +379,34 @@
(write-entry! output path encoded-tokens)))))
(defn- export-files
- [{:keys [::bfc/ids ::bfc/include-libraries ::output] :as cfg}]
- (let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids)))
- rels (if include-libraries
+ [{:keys [::bfc/ids ::bfc/export-type ::output] :as cfg}]
+
+ (let [original-ids ids
+ ids (into ids (when (= export-type :include-libraries) (bfc/get-libraries cfg ids)))
+ rels (if (= export-type :include-libraries)
(->> (bfc/get-files-rels cfg ids)
(mapv (juxt :file-id :library-file-id)))
- [])]
+ [])
+
+ ;; Compute external libraries: referenced by original files but
+ ;; not included in the export set. Only relevant for :link-later.
+ external-libs
+ (when (= export-type :link-later)
+ (let [original-rels (bfc/get-files-rels cfg original-ids)
+ lib-ids (into #{} (map :library-file-id) original-rels)]
+ (when (seq lib-ids)
+ (let [lib-names (bfc/get-files-names cfg lib-ids)]
+ (->> lib-names
+ (mapv (fn [{:keys [id name]}]
+ (let [slug (bfc/slugify-name name)]
+ (when-not (str/blank? slug)
+ {:id id
+ :name name
+ :slug slug
+ :used-by (->> original-rels
+ (filter #(= (:library-file-id %) id))
+ (mapv :file-id))}))))
+ (filterv some?))))))]
(vswap! bfc/*state* assoc :files (d/ordered-map))
@@ -389,12 +419,14 @@
;; Write manifest file
(let [files (:files @bfc/*state*)
- params {:type "penpot/export-files"
- :version 1
- :generated-by (str "penpot/" (:full cf/version))
- :refer "penpot"
- :files (vec (vals files))
- :relations rels}]
+ params (cond-> {:type "penpot/export-files"
+ :version 1
+ :generated-by (str "penpot/" (:full cf/version))
+ :referer "penpot"
+ :files (vec (vals files))
+ :relations rels}
+ (seq external-libs)
+ (assoc :external-libraries external-libs))]
(write-entry! output "manifest.json" params))))
;; --- IMPORT IMPL
@@ -430,6 +462,32 @@
[^ZipFile input ^ZipEntry entry]
(.getInputStream input entry))
+(defn- size-limiting-stream
+ "Wraps an InputStream to enforce a maximum number of decompressed bytes.
+ Raises :validation :max-file-size-reached when the limit is exceeded."
+ ^InputStream
+ [^InputStream input ^long max-size]
+ (let [counter (atom 0)
+ on-read (fn [n]
+ (when (pos? n)
+ (when (> (swap! counter + (long n)) max-size)
+ (ex/raise :type :validation
+ :code :max-file-size-reached
+ :hint (str "stream exceeded max size: " max-size))))
+ n)]
+ (proxy [FilterInputStream] [input]
+ (read
+ ([]
+ (let [b (.read input)]
+ (when (pos? b) (on-read 1))
+ b))
+ ([^bytes buf]
+ (on-read (.read input buf 0 (alength buf))))
+ ([^bytes buf off]
+ (on-read (.read input buf (int off) (- (alength buf) (int off)))))
+ ([^bytes buf off len]
+ (on-read (.read input buf (int off) (int len))))))))
+
(defn- zip-entry-reader
[^ZipFile input ^ZipEntry entry]
(-> (zip-entry-stream input entry)
@@ -438,10 +496,12 @@
(defn- zip-entry-storage-content
"Wraps a ZipFile and ZipEntry into a penpot storage compatible
object and avoid creating temporal objects"
- [input entry]
- (let [hash (delay (->> entry
- (zip-entry-stream input)
- (sto.impl/calculate-hash)))]
+ [input entry & {:keys [max-size]}]
+ (let [stream-fn (fn []
+ (cond-> (zip-entry-stream input entry)
+ max-size (size-limiting-stream max-size)))
+ hash (delay (->> (stream-fn)
+ (sto.impl/calculate-hash)))]
(reify
sto.impl/IContentObject
(get-size [_]
@@ -458,7 +518,7 @@
(throw (UnsupportedOperationException. "not implemented")))
(make-input-stream [_ _]
- (zip-entry-stream input entry))
+ (stream-fn))
(make-output-stream [_ _]
(throw (UnsupportedOperationException. "not implemented"))))))
@@ -734,7 +794,7 @@
:plugin-data plugin-data}))
(defn- import-file
- [{:keys [::db/conn ::bfc/project-id] :as cfg} {file-id :id file-name :name}]
+ [{:keys [::db/conn ::bfc/project-id ::manifest] :as cfg} {file-id :id file-name :name}]
(let [file-id' (bfc/lookup-index file-id)
file (read-file cfg file-id)
media (read-file-media cfg file-id)
@@ -801,8 +861,10 @@
(assoc :data data)
(assoc :name file-name)
(assoc :project-id project-id)
+ (assoc :metadata (d/without-nils
+ {:generated-by (get manifest :generated-by)
+ :referer (or (get manifest :referer) (get manifest :refer))}))
(dissoc :options))
-
file (bfc/process-file cfg file)
file (ctf/check-file file)]
@@ -833,6 +895,13 @@
[{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}]
(events/tap :progress {:section :storage-objects})
+ ;; IMPORTANT: we strongly do not reuse the main connection that can
+ ;; run inside a transaction because the storage upload process can
+ ;; fail in the middle of uploading and leave garbage on the underlying
+ ;; backend, if we participate in the main transaction and it aborts
+ ;; we will lose all registry of the pending to reconcile blobs
+ ;; what the storage subsystem registers in other parallel
+ ;; transaction
(let [storage (sto/resolve cfg)
entries (keep (match-storage-entry-fn) entries)]
@@ -844,9 +913,9 @@
ext (cmedia/mtype->extension (:content-type object))
path (str "objects/" id ext)
- content (->> path
- (get-zip-entry input)
- (zip-entry-storage-content input))]
+ content (zip-entry-storage-content input
+ (get-zip-entry input path)
+ :max-size (::bfc/import-max-object-size cfg))]
(when (not= (:size object) (sto/get-size content))
(ex/raise :type :validation
@@ -856,6 +925,15 @@
:expected-size (:size object)
:found-size (sto/get-size content)))
+ (when-let [max (::bfc/import-max-object-size cfg)]
+ (when (> (sto/get-size content) max)
+ (ex/raise :type :validation
+ :code :max-file-size-reached
+ :hint (str "storage object exceeds maximum size: " (sto/get-size content))
+ :path path
+ :max max
+ :found (sto/get-size content))))
+
(when-let [hash (get object :hash)]
(when (not= hash (sto/get-hash content))
(ex/raise :type :validation
@@ -880,6 +958,104 @@
(vswap! bfc/*state* update :index assoc id (:id sobject)))))))
+(defn- add-to-file
+ "Add a resolved library entry to a file in the file-grouped resolution.
+ `key` is :done (auto-linked) or :pending (needs resolution)."
+ [acc file-id file-name key entry]
+ (update acc file-id (fn [file]
+ (let [file (or file {:id file-id
+ :name file-name
+ :done []
+ :pending []})]
+ (update file key conj entry)))))
+
+(defn- compute-link-decisions
+ "Returns a map of {old-lib-id -> {:library-id ... :library ...}} for external
+ libraries that should be auto-linked (single candidate AND importer has edit
+ permission). Libraries with zero or multiple candidates, or where the importer
+ lacks permission, are excluded — their refs should remain dangling."
+ [{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/profile-id] :as cfg}]
+ (reduce
+ (fn [acc ext-lib]
+ (let [slug (:slug ext-lib)]
+ (if (nil? slug)
+ acc
+ (let [matching (into [] (bfc/find-shared-files-by-slug cfg team-id slug))]
+ (if (not= 1 (count matching))
+ acc
+ (let [library (first matching)
+ perms (bfc/get-file-permissions conn profile-id (:id library))]
+ (if (:can-edit perms)
+ (assoc acc (:id ext-lib) {:library-id (:id library)
+ :library library})
+ acc)))))))
+ {}
+ (:external-libraries manifest)))
+
+(defn- resolve-and-link-libraries
+ "For each external library in the manifest, resolve candidates by slug.
+ Auto-links single matches (creating DB rows) and builds a file-grouped
+ resolution map keyed by imported file-id (new UUID)."
+
+ [{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/timestamp] :as cfg} files-info]
+ (assert (uuid? team-id) "team-id should be provided")
+
+ (let [file-ids (keys files-info)
+ decisions (compute-link-decisions cfg)]
+
+ (reduce
+ (fn [acc ext-lib]
+ (assert (contains? ext-lib :id) "expected `:id` on ext-lib")
+ (assert (contains? ext-lib :name) "expected `:name` on ext-lib")
+ (assert (contains? ext-lib :used-by) "expected `:used-by` on ext-lib")
+ (assert (contains? ext-lib :slug) "expected `:slug` on ext-lib")
+
+ (let [used-by (into #{} (map bfc/lookup-index) (:used-by ext-lib))]
+ (cond
+ ;; No slug → skip
+ (nil? (:slug ext-lib))
+ acc
+
+ ;; Has decision → auto-link (single match + can-edit)
+ (contains? decisions (:id ext-lib))
+ (let [{:keys [library-id]} (get decisions (:id ext-lib))
+ used-by (filter used-by file-ids)]
+ (doseq [file-id used-by]
+ (let [rel-params {:file-id file-id :library-file-id library-id}]
+ (db/insert! conn :file-library-rel rel-params
+ {::db/on-conflict-do-nothing? true})
+ (bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))
+ (let [entry {:id (:id ext-lib)
+ :name (:name ext-lib)
+ :linked-to library-id}]
+ (reduce (fn [acc file-id]
+ (add-to-file acc file-id (get files-info file-id) :done entry))
+ acc used-by)))
+
+ ;; Has candidates but no decision → multi-match or no permission → pending
+ :else
+ (let [matching-libraries (into [] (bfc/find-shared-files-by-slug cfg team-id (:slug ext-lib)))]
+ (if (empty? matching-libraries)
+ acc
+ (let [candidates (mapv (fn [lib]
+ (let [project-id (:project-id lib)
+ project (bfc/get-project cfg project-id)
+ project-name (:name project)]
+ {:id (:id lib)
+ :name (:name lib)
+ :project-id project-id
+ :project-name project-name}))
+ matching-libraries)
+ entry {:id (:id ext-lib)
+ :name (:name ext-lib)
+ :candidates candidates}]
+ (reduce (fn [acc file-id]
+ (add-to-file acc file-id (get files-info file-id) :pending entry))
+ acc used-by)))))))
+
+ {}
+ (:external-libraries manifest))))
+
(defn- import-files*
[{:keys [::manifest] :as cfg}]
(bfc/disable-database-timeouts! cfg)
@@ -888,18 +1064,58 @@
(import-storage-objects cfg)
- (let [files (get manifest :files)
- result (reduce (fn [result file]
- (let [name' (get file :name)
- file (assoc file :name name')]
- (conj result (import-file cfg file))))
- []
- files)]
+ ;; Pre-resolve external libraries and add their id mappings to the index
+ ;; BEFORE importing files. This allows relink-refs (inside process-file)
+ ;; to correctly remap :component-file references to the destination library.
+ ;; Only remap when a link will actually be created (single match + can-edit).
+ (let [decisions (compute-link-decisions cfg)]
+ (doseq [[old-lib-id {:keys [library-id]}] decisions]
+ (l/trc :hint "pre-resolving external library"
+ :old-id (str old-lib-id)
+ :new-id (str library-id))
+ (vswap! bfc/*state* update :index assoc old-lib-id library-id)))
+
+ (let [files (get manifest :files)
+ file-ids (reduce (fn [result file]
+ (let [name' (get file :name)
+ file (assoc file :name name')]
+ (conj result (import-file cfg file))))
+ []
+ files)
+ ;; Build map of file-id to file-name for resolution
+ files-info (into {} (map (fn [file-id manifest-file]
+ [file-id (:name manifest-file)])
+ file-ids
+ files))]
(import-file-relations cfg)
- (bfm/apply-pending-migrations! cfg)
- result))
+ (let [resolution (resolve-and-link-libraries cfg files-info)]
+
+ (bfm/apply-pending-migrations! cfg)
+ {:file-ids file-ids
+ :resolution resolution})))
+
+(defn- invalidate-thumbnails
+ [cfg file-id]
+ (let [storage (sto/resolve cfg ::db/reuse-conn true)
+
+ sql-1
+ (str "update file_tagged_object_thumbnail "
+ " set deleted_at = now() "
+ " where file_id=? returning media_id")
+
+ sql-2
+ (str "update file_thumbnail "
+ " set deleted_at = now() "
+ " where file_id=? returning media_id")]
+
+ (run! #(sto/touch-object! storage %)
+ (sequence
+ (keep :media-id)
+ (concat
+ (db/exec! cfg [sql-1 file-id])
+ (db/exec! cfg [sql-2 file-id]))))))
(defn- import-file-and-overwrite*
[{:keys [::manifest ::bfc/file-id] :as cfg}]
@@ -924,10 +1140,11 @@
(import-storage-objects cfg)
(import-file cfg file)
- (bfc/invalidate-thumbnails cfg file-id)
+ (invalidate-thumbnails cfg file-id)
(bfm/apply-pending-migrations! cfg)
- [file-id])))
+ {:file-ids [file-id]
+ :resolution {}})))
(defn- import-files
[{:keys [::bfc/timestamp ::bfc/input] :or {timestamp (ct/now)} :as cfg}]
@@ -938,6 +1155,15 @@
(let [manifest (-> (read-manifest input)
(validate-manifest))
entries (read-zip-entries input)
+
+ _ (when-let [max (::bfc/import-max-zip-entries cfg)]
+ (when (> (count entries) max)
+ (ex/raise :type :validation
+ :code :too-many-zip-entries
+ :hint (str "zip file has too many entries: " (count entries))
+ :max max
+ :found (count entries))))
+
cfg (-> cfg
(assoc ::entries entries)
(assoc ::manifest manifest)
@@ -975,12 +1201,11 @@
"Do the exportation of a specified file in custom penpot binary
format. There are some options available for customize the output:
- `::bfc/include-libraries`: additionally to the specified file, all the
- linked libraries also will be included (including transitive
- dependencies).
-
- `::bfc/embed-assets`: instead of including the libraries, embed in the
- same file library all assets used from external libraries."
+ `::bfc/export-type`: determines how linked libraries are handled.
+ Valid values: `:include-libraries` (include linked libraries),
+ `:merge-libraries` (embed library assets in the file),
+ `:detach-libraries` (treat assets as basic objects),
+ `:link-later` (preserve component metadata for relinking on import)."
[{:keys [::bfc/ids] :as cfg} output]
@@ -996,6 +1221,7 @@
tp (ct/tpoint)
ab (volatile! false)
cs (volatile! nil)]
+
(try
(l/info :hint "start exportation" :export-id (str id))
(binding [bfc/*state* (volatile! (bfc/initial-state))]
diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj
index 27ca224a88..6f69047382 100644
--- a/backend/src/app/config.clj
+++ b/backend/src/app/config.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.config
(:refer-clojure :exclude [get])
@@ -52,7 +52,7 @@
:redis-uri "redis://redis/0"
- :file-data-backend "legacy-db"
+ :file-data-backend "db"
:objects-storage-backend "fs"
:objects-storage-fs-directory "assets"
@@ -94,7 +94,11 @@
;; SSRF protection
:ssrf-allowed-hosts #{}
- :ssrf-extra-blocked-cidrs #{}})
+ :ssrf-extra-blocked-cidrs #{}
+
+ ;; Binfile import limits
+ :binfile-import-max-object-size (* 1024 1024 100) ;; 100 MiB
+ :binfile-import-max-zip-entries (* 500 1000)}) ;; 500,000
(def schema:config
(do #_sm/optional-keys
@@ -121,6 +125,7 @@
[:exporter-shared-key {:optional true} :string]
[:admin-console-shared-key {:optional true} :string]
[:nexus-shared-key {:optional true} :string]
+ [:media-processor-shared-key {:optional true} :string]
[:management-api-key {:optional true} :string]
[:telemetry-uri {:optional true} :string]
@@ -147,6 +152,13 @@
[:imagemagick-width-limit {:optional true} :string]
[:imagemagick-height-limit {:optional true} :string]
+ [:media-processing-service-uri {:optional true} ::sm/uri]
+ [:media-processing-service-timeout {:optional true} ::sm/int]
+
+ ;; Binfile import limits (PENPOT_BINFILE_IMPORT_*)
+ [:binfile-import-max-object-size {:optional true} ::sm/int]
+ [:binfile-import-max-zip-entries {:optional true} ::sm/int]
+
[:deletion-delay {:optional true} ::ct/duration]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]
@@ -190,6 +202,7 @@
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
+ [:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
[:auth-token-cookie-name {:optional true} :string]
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj
index f10b0089a0..8f7719d705 100644
--- a/backend/src/app/db.clj
+++ b/backend/src/app/db.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.db
(:refer-clojure :exclude [get run!])
@@ -31,8 +31,8 @@
com.zaxxer.hikari.HikariDataSource
com.zaxxer.hikari.HikariPoolMXBean
com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory
- io.whitfin.siphash.SipHasher
- io.whitfin.siphash.SipHasherContainer
+ io.whitfin.siphash.SipHash
+ io.whitfin.siphash.SipHashContext
java.io.InputStream
java.io.OutputStream
java.sql.Connection
@@ -701,12 +701,12 @@
;; --- Locks
(def ^:private siphash-state
- (SipHasher/container
- (uuid/get-bytes uuid/zero)))
+ (SipHash/context
+ (uuid/get-bytes uuid/zero)))
(defn uuid->hash-code
[o]
- (.hash ^SipHasherContainer siphash-state
+ (.hash ^SipHashContext siphash-state
^bytes (uuid/get-bytes o)))
(defn- xact-check-param
diff --git a/backend/src/app/db/sql.clj b/backend/src/app/db/sql.clj
index dc0f54a01a..b81e0c3832 100644
--- a/backend/src/app/db/sql.clj
+++ b/backend/src/app/db/sql.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.db.sql
(:refer-clojure :exclude [update])
diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj
index e069b2908b..24b62ebd76 100644
--- a/backend/src/app/email.clj
+++ b/backend/src/app/email.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.email
"Main api for send emails."
@@ -505,13 +505,13 @@
:schema schema:request-file-access))
(def request-file-access-yourpenpot
- "File access on Your Penpot request email."
+ "File access on Personal Projects request email."
(template-factory
:id ::request-file-access-yourpenpot
:schema schema:request-file-access))
(def request-file-access-yourpenpot-view
- "File access on Your Penpot view mode request email."
+ "File access on Personal Projects view mode request email."
(template-factory
:id ::request-file-access-yourpenpot-view
:schema schema:request-file-access))
diff --git a/backend/src/app/email/blacklist.clj b/backend/src/app/email/blacklist.clj
index e54d546f4f..b56cfd42be 100644
--- a/backend/src/app/email/blacklist.clj
+++ b/backend/src/app/email/blacklist.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.email.blacklist
"Email blacklist provider"
diff --git a/backend/src/app/email/whitelist.clj b/backend/src/app/email/whitelist.clj
index c8c0f5b310..8b08c0c599 100644
--- a/backend/src/app/email/whitelist.clj
+++ b/backend/src/app/email/whitelist.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.email.whitelist
"Email whitelist provider"
diff --git a/backend/src/app/features/fdata.clj b/backend/src/app/features/fdata.clj
index 412ca223cf..43484a696b 100644
--- a/backend/src/app/features/fdata.clj
+++ b/backend/src/app/features/fdata.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.features.fdata
"A `fdata/*` related feature migration helpers"
@@ -12,6 +12,7 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
+ [app.common.types.file :as ctf]
[app.common.types.objects-map :as omap]
[app.config :as cf]
[app.db :as db]
@@ -150,6 +151,13 @@
(cond
(= backend "storage")
+ ;; IMPORTANT: we strongly do not reuse the main connection that can
+ ;; run inside a transaction because the storage upload process can
+ ;; fail in the middle of uploading and leave garbage on the underlying
+ ;; backend, if we participate in the main transaction and it aborts
+ ;; we will lose all registry of the pending to reconcile blobs
+ ;; what the storage subsystem registers in other parallel
+ ;; transaction
(let [storage (sto/resolve cfg)
content (sto/content data)
sobject (sto/put-object! storage
@@ -159,15 +167,17 @@
:content-type "application/octet-stream"
:file-id file-id
:id id})
- metadata {:storage-ref-id (:id sobject)}
+ metadata (-> (:metadata params)
+ (assoc :storage-ref-id (:id sobject)))
params (-> params
(assoc :metadata metadata)
(assoc :data nil))]
(upsert-in-database cfg params))
(= backend "db")
- (->> (dissoc params :metadata)
- (upsert-in-database cfg))
+ (let [metadata (dissoc (:metadata params) :storage-ref-id)
+ params (assoc params :metadata metadata)]
+ (upsert-in-database cfg params))
(= backend "legacy-db")
(cond
@@ -213,18 +223,11 @@
[backend]
(or backend (cf/get :file-data-backend)))
-(def ^:private schema:metadata
- [:map {:title "Metadata"}
- [:storage-ref-id {:optional true} ::sm/uuid]])
-
-(def decode-metadata-with-schema
- (sm/decoder schema:metadata sm/json-transformer))
-
(defn decode-metadata
[metadata]
(some-> metadata
(db/decode-json-pgobject)
- (decode-metadata-with-schema)))
+ (ctf/decode-file-metadata)))
(def ^:private schema:update-params
[:map {:closed true}
@@ -232,7 +235,7 @@
[:type [:enum "main" "snapshot" "fragment"]]
[:file-id ::sm/uuid]
[:backend {:optional true} [:enum "db" "legacy-db" "storage"]]
- [:metadata {:optional true} [:maybe schema:metadata]]
+ [:metadata {:optional true} ctf/schema:file-metadata]
[:data {:optional true} bytes?]
[:created-at {:optional true} ::ct/inst]
[:modified-at {:optional true} [:maybe ::ct/inst]]
diff --git a/backend/src/app/features/file_migrations.clj b/backend/src/app/features/file_migrations.clj
index 2334cbc121..f7a4ffdcb1 100644
--- a/backend/src/app/features/file_migrations.clj
+++ b/backend/src/app/features/file_migrations.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.features.file-migrations
"Backend specific code for file migrations. Implemented as permanent feature of files."
diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj
index 2c29b2b5de..9be4b1b572 100644
--- a/backend/src/app/features/file_snapshots.clj
+++ b/backend/src/app/features/file_snapshots.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.features.file-snapshots
(:require
@@ -326,8 +326,11 @@
(let [file (d/update-when row :metadata fdata/decode-metadata)
vern (rand-int Integer/MAX_VALUE)
+ ;; We reuse the main connection here for storage operations
+ ;; becaue the main operations are touching and we need them
+ ;; to be atomic with the current transaction
storage
- (sto/resolve cfg {::db/reuse-conn true})
+ (sto/resolve cfg ::db/reuse-conn true)
snapshot
(get-snapshot cfg file-id snapshot-id)]
diff --git a/backend/src/app/features/logical_deletion.clj b/backend/src/app/features/logical_deletion.clj
index 0b87006616..5dc80ab4f1 100644
--- a/backend/src/app/features/logical_deletion.clj
+++ b/backend/src/app/features/logical_deletion.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.features.logical-deletion
"A code related to handle logical deletion mechanism"
diff --git a/backend/src/app/graph/arrow.clj b/backend/src/app/graph/arrow.clj
new file mode 100644
index 0000000000..55be1b8b64
--- /dev/null
+++ b/backend/src/app/graph/arrow.clj
@@ -0,0 +1,370 @@
+;; 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.graph.arrow
+ "Bulk Ladybug ingest through in-memory Arrow.
+
+ Rows are built as Arrow `VectorSchemaRoot`s in the JVM's off-heap memory,
+ handed to Ladybug as a virtual table, and `COPY`d into the real one. No file
+ is written and no value is rendered as text for the engine to re-parse, so
+ nothing in this path needs escaping. Arrow carries MAP, STRUCT, fixed-size
+ arrays and multi-line strings natively.
+
+ The type language is Ladybug's, read recursively by `app.graph.schema.values`;
+ this namespace adds the matching Arrow `Field` and a writer for each shape.
+ `values/coerce` shapes a value first — a matrix into six doubles, a colour
+ into a packed integer — exactly as it does for the Cypher path, so the two
+ writers cannot disagree.
+
+ Engine facts this file depends on, each verified against lbug 0.19.1:
+
+ - An Arrow table is **not** a `COPY` source identifier, but it *is* a
+ MATCH-able node label: `COPY T FROM (MATCH (n:stg) RETURN n.a AS a, …)`.
+ - A MAP vector's `entries` child struct must be non-nullable, and
+ `MapVector/getWriter` silently promotes it to a sparse union — so map
+ vectors are built from an explicit `Field` and filled child-first.
+ - Ladybug quotes the column and table names it interpolates into the staged
+ table's DDL, and does not quote a STRUCT member name. So a top-level field
+ arrives plain and a struct member whose name is a reserved word (`column`)
+ arrives backticked.
+ - `createArrowRelTable` resolves a UUID-keyed endpoint only from a
+ `FixedSizeBinary(16)` column carrying the `arrow.uuid` extension, so edges
+ are staged as a node table and joined by the `COPY` subquery instead."
+ (:require
+ [app.common.json :as json]
+ [app.graph.ladybug :as ladybug]
+ [app.graph.schema.nodes :as nodes]
+ [app.graph.schema.values :as values]
+ [clojure.string :as str])
+ (:import
+ com.ladybugdb.Connection
+ com.ladybugdb.QueryResult
+ java.nio.charset.StandardCharsets
+ java.util.ArrayList
+ java.util.List
+ org.apache.arrow.memory.BufferAllocator
+ org.apache.arrow.memory.RootAllocator
+ org.apache.arrow.vector.BigIntVector
+ org.apache.arrow.vector.BitVector
+ org.apache.arrow.vector.complex.ListVector
+ org.apache.arrow.vector.complex.MapVector
+ org.apache.arrow.vector.complex.StructVector
+ org.apache.arrow.vector.FieldVector
+ org.apache.arrow.vector.Float8Vector
+ org.apache.arrow.vector.TimeStampMicroVector
+ org.apache.arrow.vector.types.FloatingPointPrecision
+ org.apache.arrow.vector.types.pojo.ArrowType$Bool
+ org.apache.arrow.vector.types.pojo.ArrowType$FloatingPoint
+ org.apache.arrow.vector.types.pojo.ArrowType$Int
+ org.apache.arrow.vector.types.pojo.ArrowType$List
+ org.apache.arrow.vector.types.pojo.ArrowType$Map
+ org.apache.arrow.vector.types.pojo.ArrowType$Struct
+ org.apache.arrow.vector.types.pojo.ArrowType$Timestamp
+ org.apache.arrow.vector.types.pojo.ArrowType$Utf8
+ org.apache.arrow.vector.types.pojo.Field
+ org.apache.arrow.vector.types.pojo.FieldType
+ org.apache.arrow.vector.types.pojo.Schema
+ org.apache.arrow.vector.types.TimeUnit
+ org.apache.arrow.vector.UInt4Vector
+ org.apache.arrow.vector.VarCharVector
+ org.apache.arrow.vector.VectorSchemaRoot))
+
+(set! *warn-on-reflection* true)
+
+;; --------------------------------------------------------------- allocator
+
+(defn with-allocator!
+ "Invoke `(f allocator)` with a fresh Arrow `RootAllocator`.
+
+ The allocator must outlive the Ladybug connection, because Ladybug releases
+ its references to the staged buffers only when the Arrow tables are dropped —
+ which happens on connection close at the latest. Closing it first surfaces as
+ `IllegalStateException: Memory was leaked`, *thrown while unwinding*, which
+ hides whatever actually failed. Any diagnostic here must catch inside this
+ scope."
+ [f]
+ (with-open [allocator (RootAllocator.)]
+ (f allocator)))
+
+;; ------------------------------------------------------ Ladybug type → Field
+
+(def ^:private scalar-arrow-type
+ "Ladybug scalar → Arrow type. `UUID` and `JSON` ride as UTF-8: Ladybug
+ accepts a string into either column and does the conversion itself, which is
+ cheaper than teaching this side two more binary layouts."
+ {"STRING" #(ArrowType$Utf8.)
+ "UUID" #(ArrowType$Utf8.)
+ "JSON" #(ArrowType$Utf8.)
+ "INT64" #(ArrowType$Int. 64 true)
+ "UINT32" #(ArrowType$Int. 32 false)
+ "DOUBLE" #(ArrowType$FloatingPoint. FloatingPointPrecision/DOUBLE)
+ "BOOLEAN" #(ArrowType$Bool.)
+ "TIMESTAMP" #(ArrowType$Timestamp. TimeUnit/MICROSECOND nil)})
+
+(defn column-field
+ "Arrow `Field` for a column of `ladybug-type`, recursively.
+
+ `nullable?` is false only where Arrow's own invariants demand it — a MAP's
+ `entries` struct and its key."
+ (^Field [^String field-name ladybug-type]
+ (column-field field-name ladybug-type true))
+ (^Field [^String field-name ladybug-type nullable?]
+ (cond
+ ;; A list first: `STRUCT(…)[]` starts with `STRUCT(` but is a list of them.
+ (ladybug/list-type? ladybug-type)
+ (Field. field-name (FieldType. nullable? (ArrowType$List.) nil)
+ [(column-field "item" (values/list-element ladybug-type))])
+
+ (ladybug/map-type? ladybug-type)
+ (let [[key-type value-type] (values/map-types ladybug-type)]
+ (Field. field-name (FieldType. nullable? (ArrowType$Map. false) nil)
+ [(Field. "entries" (FieldType. false (ArrowType$Struct.) nil)
+ [(column-field "key" key-type false)
+ (column-field "value" value-type)])]))
+
+ (ladybug/struct-type? ladybug-type)
+ (Field. field-name (FieldType. nullable? (ArrowType$Struct.) nil)
+ ;; Backticks kept: Ladybug quotes none of these when it names the
+ ;; staged struct's fields, so `column` has to arrive quoted.
+ (mapv (fn [[field field-type]] (column-field field field-type))
+ (values/struct-fields-quoted ladybug-type)))
+
+ :else
+ (if-let [mk (get scalar-arrow-type ladybug-type)]
+ (Field. field-name (FieldType. nullable? (mk) nil) nil)
+ (throw (ex-info (str "no Arrow mapping for Ladybug type: " ladybug-type)
+ {:ladybug-type ladybug-type}))))))
+
+;; ------------------------------------------------------------------- writer
+
+(defn- utf8
+ ^bytes [v]
+ (.getBytes (if (keyword? v) (name v) (str v)) StandardCharsets/UTF_8))
+
+(defn- epoch-micros
+ ^long [v]
+ (let [^java.time.Instant inst
+ (cond
+ (instance? java.time.Instant v) v
+ (instance? java.util.Date v) (.toInstant ^java.util.Date v)
+ :else (java.time.Instant/parse (str v)))]
+ (+ (* (.getEpochSecond inst) 1000000) (long (quot (.getNano inst) 1000)))))
+
+(defn- write-scalar!
+ [^FieldVector fv ladybug-type ^long idx v]
+ (case ladybug-type
+ ("STRING" "UUID") (.setSafe ^VarCharVector fv idx (utf8 v))
+ ;; A JSON column holds JSON, not a Clojure value's print form: `str` on a
+ ;; map yields `{:fill-color "#000000"}`, which is EDN and which every
+ ;; consumer of `fills`, `content` or `position_data` would fail to parse.
+ ;; Same encoder the Cypher path uses (`app.graph.ladybug/format-json`).
+ "JSON" (.setSafe ^VarCharVector fv idx
+ (.getBytes ^String (json/encode v)
+ StandardCharsets/UTF_8))
+ "INT64" (.setSafe ^BigIntVector fv idx (long v))
+ "UINT32" (.setSafe ^UInt4Vector fv idx (unchecked-int (long v)))
+ "DOUBLE" (.setSafe ^Float8Vector fv idx (double v))
+ "BOOLEAN" (.setSafe ^BitVector fv idx (if v 1 0))
+ "TIMESTAMP" (.setSafe ^TimeStampMicroVector fv idx (epoch-micros v))
+ (throw (ex-info (str "no Arrow writer for Ladybug type: " ladybug-type)
+ {:ladybug-type ladybug-type}))))
+
+(defn write-value!
+ "Write already-coerced `v` into `fv` at `idx`, per `ladybug-type`.
+
+ `map-key-fn` renders the keys of a `MAP(STRING, …)`, for the same reason
+ `app.graph.ladybug/format-typed-value` takes one: the right spelling is a
+ property of the column, not of the writer."
+ ;; `idx` is deliberately unhinted: Clojure only accepts primitive args on fns
+ ;; of four or fewer, and the map-key renderer has to travel with the value.
+ [^FieldVector fv ladybug-type idx v map-key-fn]
+ (if (nil? v)
+ (.setNull fv (int idx))
+ (cond
+ (ladybug/list-type? ladybug-type)
+ (let [^ListVector lv fv
+ child (.getDataVector lv)
+ element-type (values/list-element ladybug-type)
+ elements (vec (if (or (sequential? v) (set? v)) v [v]))
+ start (.startNewValue lv (int idx))]
+ (dotimes [i (count elements)]
+ (write-value! child element-type (+ start i) (nth elements i) map-key-fn))
+ (.endValue lv (int idx) (count elements)))
+
+ (ladybug/map-type? ladybug-type)
+ (let [^MapVector mv fv
+ ^StructVector entries (.getDataVector mv)
+ [key-type value-type] (values/map-types ladybug-type)
+ key-vec (.getChild entries "key")
+ value-vec (.getChild entries "value")
+ render-key (if (and map-key-fn (= "STRING" key-type)) map-key-fn identity)
+ pairs (vec (seq v))
+ start (.startNewValue mv (int idx))]
+ (dotimes [i (count pairs)]
+ (let [[k mv'] (nth pairs i)
+ at (+ start i)]
+ ;; The entries struct is non-nullable: every slot must be defined.
+ (.setIndexDefined entries (int at))
+ (write-value! key-vec key-type at (render-key k) nil)
+ (write-value! value-vec value-type at mv' map-key-fn)))
+ (.endValue mv (int idx) (count pairs)))
+
+ (ladybug/struct-type? ladybug-type)
+ (let [^StructVector sv fv]
+ (.setIndexDefined sv (int idx))
+ (doseq [[quoted-field field-type] (values/struct-fields-quoted ladybug-type)]
+ ;; The child is named with its backticks; the coerced value is keyed
+ ;; without them.
+ (write-value! (.getChild sv quoted-field) field-type idx
+ (get v (str/replace quoted-field "`" "")) map-key-fn)))
+
+ :else
+ (write-scalar! fv ladybug-type (long idx) v))))
+
+;; ------------------------------------------------------------------ batches
+
+(defn- fill-vector!
+ [^VectorSchemaRoot root ^String field-name ladybug-type rows value-fn map-key-fn]
+ (let [^FieldVector fv (.getVector root field-name)]
+ (.allocateNew fv)
+ (dotimes [i (count rows)]
+ (write-value! fv ladybug-type i
+ (values/coerce ladybug-type (value-fn (nth rows i)))
+ map-key-fn))
+ (.setValueCount fv (count rows))))
+
+(defn- node-batch
+ "One `VectorSchemaRoot` holding every projected row of `table`.
+
+ Fields carry the plain column name. Ladybug quotes every identifier it
+ interpolates into the staged table's DDL, so a name that is a reserved word
+ (`Page.index`, `Document.options`) arrives unquoted and a name arriving
+ pre-quoted comes out doubly backticked and fails to parse. The `COPY`
+ projection below is Cypher, not DDL, so it quotes the same names itself."
+ ^VectorSchemaRoot [^BufferAllocator allocator table rows]
+ (let [columns (nodes/column-keys table)
+ fields (mapv (fn [k] (column-field (nodes/column-name table k)
+ (nodes/column-ladybug-type table k)))
+ columns)
+ root (VectorSchemaRoot/create (Schema. ^List fields) allocator)]
+ (doseq [k columns]
+ (fill-vector! root (nodes/column-name table k)
+ (nodes/column-ladybug-type table k)
+ rows #(get % k) (nodes/column-map-key-fn table k)))
+ (.setRowCount root (count rows))
+ root))
+
+(def ^:private edge-fields
+ "Edge staging columns. `id` is the staging table's own key — Ladybug wants a
+ first column to key the virtual table on — and `from`/`to` land as STRING,
+ hence the cast in the join."
+ [(Field. "id" (FieldType. true (ArrowType$Utf8.) nil) nil)
+ (Field. "from" (FieldType. true (ArrowType$Utf8.) nil) nil)
+ (Field. "to" (FieldType. true (ArrowType$Utf8.) nil) nil)
+ (Field. "position" (FieldType. true (ArrowType$Int. 64 true) nil) nil)])
+
+(defn- edge-batch
+ ^VectorSchemaRoot [^BufferAllocator allocator edges]
+ (let [root (VectorSchemaRoot/create (Schema. ^List edge-fields) allocator)
+ ^VarCharVector iv (.getVector root "id")
+ ^VarCharVector fv (.getVector root "from")
+ ^VarCharVector tv (.getVector root "to")
+ ^BigIntVector pv (.getVector root "position")
+ n (count edges)]
+ (doseq [^FieldVector v [iv fv tv pv]] (.allocateNew v))
+ (dotimes [i n]
+ (let [{:keys [from-id to-id position]} (nth edges i)]
+ (.setSafe iv i (utf8 i))
+ (.setSafe fv i (utf8 from-id))
+ (.setSafe tv i (utf8 to-id))
+ (if (nil? position) (.setNull pv i) (.setSafe pv i (long position)))))
+ (doseq [^FieldVector v [iv fv tv pv]] (.setValueCount v n))
+ (.setRowCount root n)
+ root))
+
+;; ------------------------------------------------------------------ staging
+
+(defn- batches
+ ^List [^VectorSchemaRoot root]
+ (doto (ArrayList.) (.add root)))
+
+(defn- check!
+ [^QueryResult result hint data]
+ (when-not (.isSuccess result)
+ (throw (ex-info (str hint ": " (.getErrorMessage result))
+ (assoc data :err (.getErrorMessage result))))))
+
+(defn- with-staged-table!
+ "Create Arrow table `staging-name` from `root`, run `(f)`, always drop it."
+ [^Connection conn ^BufferAllocator allocator ^String staging-name
+ ^VectorSchemaRoot root data f]
+ (try
+ (with-open [^QueryResult r (.createArrowTable conn staging-name (batches root) allocator)]
+ (check! r "createArrowTable failed" data))
+ (f)
+ (finally
+ ;; Dropped even on failure: the staged buffers stay referenced by Ladybug
+ ;; until it is, and the allocator's leak check fires on close otherwise.
+ (try (.close ^QueryResult (.dropArrowTable conn staging-name))
+ (catch Throwable _ nil)))))
+
+(defn- copy-node-table!
+ [^Connection conn table ^String staging-name]
+ (let [projection (str/join ", " (for [k (nodes/column-keys table)
+ :let [c (nodes/cypher-property-key table k)]]
+ (str "n." c " AS " c)))
+ statement (str "COPY `" table "` FROM (MATCH (n:" staging-name ") "
+ "RETURN " projection ");")]
+ (with-open [^QueryResult r (.query conn statement)]
+ (check! r (str "COPY node table failed: " table)
+ {:table table :statement statement}))))
+
+(defn- copy-edge-group!
+ "Load one FROM/TO pair of `IsChildOf`.
+
+ `createArrowRelTable` is unusable here — it cannot resolve endpoints against a
+ UUID-keyed node table — so the edge list is staged as a node table and the
+ endpoints are resolved by the subquery. The `WHERE` is clause-level because
+ this dialect prohibits an inline pattern `WHERE`, and both sides are pinned by
+ label so the join cannot reach outside the pair."
+ [^Connection conn from-table to-table ^String staging-name]
+ (let [statement (str "COPY `IsChildOf` FROM ("
+ "MATCH (e:" staging-name "), "
+ "(a:" (nodes/match-label from-table) "), "
+ "(b:" (nodes/match-label to-table) ") "
+ "WHERE a.id = cast(e.from AS UUID) "
+ "AND b.id = cast(e.to AS UUID) "
+ "RETURN a.id, b.id, e.position) "
+ "(from='" from-table "', to='" to-table "');")]
+ (with-open [^QueryResult r (.query conn statement)]
+ (check! r (str "COPY edge group failed: " from-table " -> " to-table)
+ {:from-table from-table :to-table to-table :statement statement}))))
+
+(defn- staging-name
+ [prefix & parts]
+ (str/replace (str/join "_" (cons (str "stg_" prefix) parts)) #"[^A-Za-z0-9_]" "_"))
+
+;; --------------------------------------------------------------------- load
+
+(defn load-projection!
+ "Load projected nodes and edges into an open Ladybug connection.
+
+ `allocator` must outlive `conn` — see `with-allocator!`."
+ [^Connection conn {:keys [nodes edges]} ^BufferAllocator allocator]
+ (doseq [[table rows] (sort-by key nodes)
+ :when (seq rows)]
+ (let [name (staging-name "node" table)]
+ (with-open [root (node-batch allocator table rows)]
+ (with-staged-table! conn allocator name root {:table table}
+ #(copy-node-table! conn table name)))))
+ (doseq [[[from-table to-table] group]
+ (sort-by key (group-by (juxt :from-table :to-table) edges))
+ :when (seq group)]
+ (let [name (staging-name "edge" from-table to-table)]
+ (with-open [root (edge-batch allocator group)]
+ (with-staged-table! conn allocator name root
+ {:from-table from-table :to-table to-table}
+ #(copy-edge-group! conn from-table to-table name))))))
diff --git a/backend/src/app/graph/debug.clj b/backend/src/app/graph/debug.clj
new file mode 100644
index 0000000000..277b1ae177
--- /dev/null
+++ b/backend/src/app/graph/debug.clj
@@ -0,0 +1,383 @@
+;; 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.graph.debug
+ "In-memory Ladybug sessions for the debug graph console."
+ (:require
+ [app.common.exceptions :as ex]
+ [app.common.logging :as l]
+ [app.common.time :as ct]
+ [app.graph.ingest :as graph.ingest]
+ [app.graph.ladybug :as ladybug]
+ [app.graph.schema.nodes :as nodes]
+ [app.graph.sync :as graph.sync]
+ [app.msgbus :as mbus]
+ [clojure.java.io :as io]
+ [clojure.string :as str]
+ [promesa.exec.csp :as sp])
+ (:import
+ com.ladybugdb.Connection
+ com.ladybugdb.Database))
+
+(set! *warn-on-reflection* true)
+
+(def default-query
+ "Default console query, written to be self-explanatory in the textarea.
+ The `filter_*` columns carry node ids for the graph-view result filter;
+ the results table hides them (see `hide-filter-columns` and the
+ template's `renderQueryOutput`)."
+ (str "MATCH (s)-[r]->(t)\n"
+ "// WHERE some condition\n"
+ "RETURN label(s) AS src, s.name,\n"
+ " label(r) AS rel,\n"
+ " t.name, label(t) AS tgt,\n"
+ "\n"
+ "// filter_* columns omitted from table; these needed for graph view\n"
+ "s.id AS filter_src_id, t.id AS filter_tgt_id;"))
+
+(defonce ^:private sessions
+ (atom {}))
+
+(defn- session-key
+ [profile-id]
+ (str profile-id))
+
+(defn- destroy-session!
+ [{:keys [conn db sync-ch msgbus]}]
+ (when sync-ch
+ (sp/close! sync-ch)
+ (when msgbus
+ (mbus/purge! msgbus [sync-ch])))
+ (when conn
+ (ex/ignoring (.close ^Connection conn)))
+ (when db
+ (ex/ignoring (.close ^Database db))))
+
+(defn- slim-ingest-meta
+ "Drop full projection rows from session meta.
+
+ `build-index` needs `:nodes`/`:edges` once; keeping them in the session
+ duplicates the entire graph on the JVM heap for every Load."
+ [meta]
+ (update meta :projection #(select-keys % [:stats])))
+
+(defn- format-cell
+ [value]
+ (cond
+ (nil? value) "NULL"
+ (string? value) value
+ :else (str value)))
+
+(defn- format-query-result
+ [{:keys [columns rows truncated?]}]
+ {:columns (mapv str columns)
+ :rows (mapv (fn [row]
+ (mapv format-cell row))
+ rows)
+ :truncated? truncated?
+ :row-count (count rows)})
+
+(defn- apply-file-change!
+ [conn profile-id {:keys [changes revn file-id]}]
+ (try
+ (some-> (get @sessions (session-key profile-id))
+ (as-> current
+ (when (= file-id (:file-id current))
+ (let [lock (:lock current)
+ result (locking lock
+ (graph.sync/apply-changes!
+ conn (:index current) changes revn))
+ sync-at (ct/now)]
+ (swap! sessions assoc-in [(session-key profile-id) :index]
+ (:index result))
+ (swap! sessions update-in [(session-key profile-id) :meta]
+ (fn [meta]
+ (cond-> (-> meta
+ (update :sync dissoc :error)
+ (assoc-in [:sync :last-at] sync-at)
+ (assoc-in [:sync :last-applied] (:applied result))
+ (assoc-in [:sync :last-skipped] (:skipped result)))
+ (seq (:applied result))
+ (assoc :revn (:revn result)))))
+ (when (seq (:skipped result))
+ (l/dbg :hint "graph sync skipped changes"
+ :file-id (str file-id)
+ :revn revn
+ :skipped (:skipped result)))))))
+ (catch Throwable cause
+ (l/wrn :hint "graph sync failed"
+ :file-id (str file-id)
+ :cause cause)
+ (swap! sessions assoc-in [(session-key profile-id) :meta :sync :error]
+ (ex-message cause)))))
+
+(defn- start-sync-loop!
+ [{:keys [conn profile-id file-id] :as session}]
+ (if-let [msgbus (:msgbus session)]
+ (let [sync-ch (sp/chan :buf (sp/dropping-buffer 64))]
+ (mbus/sub! msgbus :topic file-id :chan sync-ch)
+ ;; Recur ONLY while the channel is open. A bare `(recur)` after
+ ;; `take!` returns nil would spin forever and pin this Connection
+ ;; (and its Ladybug Database native memory) across every Load.
+ (sp/go-loop []
+ (when-let [message (sp/take! sync-ch)]
+ (when (= :file-change (:type message))
+ (apply-file-change! conn profile-id message))
+ (recur)))
+ (assoc session :sync-ch sync-ch))
+ session))
+
+(defn session-info
+ "Return a public view of the current session for `profile-id`, if any."
+ [profile-id]
+ (when-let [{:keys [file-id meta loaded-at index]} (get @sessions (session-key profile-id))]
+ {:file-id file-id
+ :name (:name meta)
+ :revn (:revn meta)
+ :graph-revn (:revn index)
+ :schema-version (:schema-version meta)
+ :projection (:projection meta)
+ :sync (:sync meta)
+ :loaded-at (ct/format-inst loaded-at :iso)}))
+
+(defn sync-status
+ "Return incremental sync status for the active session."
+ [profile-id]
+ (when-let [session (get @sessions (session-key profile-id))]
+ (let [{:keys [file-id meta index loaded-at]} session]
+ {:file-id file-id
+ :revn (:revn meta)
+ :graph-revn (:revn index)
+ :sync (:sync meta)
+ :loaded-at (ct/format-inst loaded-at :iso)})))
+
+(defn unload-session!
+ "Close and discard the in-memory graph for `profile-id`."
+ [profile-id]
+ (when-let [session (get @sessions (session-key profile-id))]
+ (destroy-session! session))
+ (swap! sessions dissoc (session-key profile-id)))
+
+(defn load-session!
+ "Ingest `file-id` into a new in-memory Ladybug database for `profile-id`."
+ [cfg profile-id file-id]
+ (unload-session! profile-id)
+ (let [^Database db (Database.)
+ ^Connection conn (Connection. db)
+ msgbus (::mbus/msgbus cfg)]
+ (.setQueryTimeout conn 0)
+ (ladybug/ensure-extensions! conn)
+ (try
+ (let [meta (graph.ingest/ingest-on-connection! cfg conn file-id
+ :db-path ":memory:"
+ :skip-stats? true
+ :skip-validation? true)
+ index (graph.sync/build-index file-id (:revn meta) (:projection meta))
+ ;; Discard projection rows after indexing — they are only needed
+ ;; to seed the sync index and would otherwise leak heap on each Load.
+ meta (slim-ingest-meta meta)
+ session
+ ;; :lock serializes access to the shared Connection between the
+ ;; msgbus sync loop (writes) and HTTP handlers (reads); the Java
+ ;; binding gives no thread-safety guarantee for one Connection.
+ (-> {:db db
+ :conn conn
+ :lock (Object.)
+ :file-id file-id
+ :meta meta
+ :index index
+ :msgbus msgbus
+ :profile-id profile-id
+ :loaded-at (ct/now)}
+ start-sync-loop!)]
+ (swap! sessions assoc (session-key profile-id) session)
+ meta)
+ (catch Throwable cause
+ (destroy-session! {:conn conn :db db :msgbus msgbus})
+ (throw cause)))))
+
+(defn query-session!
+ "Run a read-only `statement` against the in-memory graph for `profile-id`.
+
+ The statement is bound against the live schema before it runs, so a query
+ naming a table or a property that does not exist reports the binder's own
+ message and executes nothing. The engine's read/write analysis then decides
+ whether it may run at all: the console is an inspection surface, and a
+ session graph is rebuilt from the file by Reload, so a mutation from here
+ would produce a graph no rebuild reproduces."
+ [profile-id statement]
+ (when (str/blank? statement)
+ (ex/raise :type :validation
+ :code :missing-query
+ :hint "cypher query is required"))
+ (if-let [{:keys [conn lock]} (get @sessions (session-key profile-id))]
+ (locking lock
+ (let [{:keys [ok? error read-only?]} (ladybug/validate-on-connection! conn statement)]
+ (when-not ok?
+ (ex/raise :type :validation
+ :code :graph-query-invalid
+ :hint error))
+ (when-not read-only?
+ (ex/raise :type :validation
+ :code :graph-query-not-read-only
+ :hint "the graph console runs read-only queries"))
+ (-> (ladybug/query-on-connection! conn statement)
+ format-query-result)))
+ (ex/raise :type :not-found
+ :code :graph-session-not-loaded
+ :hint "load a file graph before running queries")))
+
+(def ^:private export-max-rows
+ "Row cap for graph-view export queries; far above expected per-file node
+ and edge counts. `:truncated` in the export signals when it was hit."
+ 100000)
+
+(defn- export-nodes
+ [conn]
+ (reduce
+ (fn [acc {:keys [table]}]
+ (let [stmt (str "MATCH (n:" (nodes/match-label table)
+ ") RETURN n.id AS id, n.name AS name;")
+ {:keys [rows truncated?]}
+ (ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
+ (-> acc
+ (update :nodes into
+ (map (fn [[id label]]
+ {:id (str id) :label (str label) :table table}))
+ rows)
+ (update :truncated? #(or % truncated?)))))
+ {:nodes [] :truncated? false}
+ nodes/node-types))
+
+(defn rel-tables
+ "Every relationship table in the open database, with whether it carries a
+ `position` property.
+
+ Read from the catalog rather than listed here, so a newly ported transform's
+ rel table appears in the graph view without the console being told about it."
+ [conn]
+ (for [[table] (:rows (ladybug/query-on-connection!
+ conn "CALL show_tables() WHERE type = 'REL' RETURN name;"
+ :max-rows 1000))
+ :let [props (->> (ladybug/query-on-connection!
+ conn (str "CALL table_info('" table "') RETURN *;")
+ :max-rows 1000)
+ :rows
+ (into #{} (map (comp str second))))]]
+ {:table table :position? (contains? props "position")}))
+
+(defn- export-edges
+ [conn]
+ (reduce
+ (fn [acc {:keys [table position?]}]
+ (let [stmt (str "MATCH (a)-[r:`" table "`]->(b) "
+ "RETURN a.id AS source, b.id AS target, "
+ (if position? "r.position" "NULL") " AS position, "
+ "'" table "' AS rel;")
+ {:keys [rows truncated?]}
+ (ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
+ (-> acc
+ (update :edges into
+ (map (fn [[source target position rel]]
+ (cond-> {:source (str source)
+ :target (str target)
+ :rel (str rel)}
+ (some? position) (assoc :position position))))
+ rows)
+ (update :truncated? #(or % truncated?)))))
+ {:edges [] :truncated? false}
+ (rel-tables conn)))
+
+(defn- bm-usage-bytes
+ "Buffer-manager memory in use by this session's in-memory database
+ (`CALL bm_info()` → [mem_limit mem_usage]); nil if the call fails."
+ [conn]
+ (ex/ignoring
+ (-> (ladybug/query-on-connection! conn "CALL bm_info() RETURN *;" :max-rows 1)
+ :rows first second)))
+
+(defn export-graph-data!
+ "Export the node/edge inventory of the in-memory graph for `profile-id`
+ as plain data for the debug graph view. Returns nil when no session is
+ loaded. Queries the Ladybug database (not the sync index) so the view
+ reflects actual DB state, including drift."
+ [profile-id]
+ (when-let [{:keys [conn lock file-id index]} (get @sessions (session-key profile-id))]
+ (locking lock
+ (let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn)
+ {:keys [edges] edges-truncated? :truncated?} (export-edges conn)]
+ {:file-id (str file-id)
+ :revn (:revn index)
+ :truncated (boolean (or nodes-truncated? edges-truncated?))
+ :bm-bytes (bm-usage-bytes conn)
+ :nodes nodes
+ :edges edges}))))
+
+(defn- delete-tree!
+ [^java.io.File file]
+ (when (.exists file)
+ (doseq [f (reverse (file-seq file))]
+ (.delete ^java.io.File f))))
+
+(defn export-session-database!
+ "Materialize the in-memory session graph of `profile-id` as a `.lbug` file.
+
+ The console's graph is in-memory and live-synced, so it can differ from a
+ fresh projection of the same file — which is exactly when someone wants to
+ take it away and query it elsewhere. There is no \"save this database\"
+ primitive, so the transfer goes through Ladybug's `EXPORT DATABASE` (Parquet
+ per table) into a fresh on-disk database via `IMPORT DATABASE`.
+
+ Note the round trip drops table comments. Nothing in the graph is addressed
+ by a table comment: every table is resolved by name, so the loss costs
+ nothing.
+
+ Returns the path of the written database, or nil when no session is loaded.
+ The caller owns the file and must delete it once streamed."
+ [profile-id]
+ (when-let [{:keys [conn lock file-id]} (get @sessions (session-key profile-id))]
+ (let [stamp (System/nanoTime)
+ staging (io/file (System/getProperty "java.io.tmpdir")
+ (str "penpot-graph-session-" file-id "-" stamp))
+ db-path (str (io/file (System/getProperty "java.io.tmpdir")
+ (str file-id "-session-" stamp ".lbug")))]
+ (try
+ (locking lock
+ (ladybug/exec-on-connection!
+ conn [(str "EXPORT DATABASE '" (.getAbsolutePath staging)
+ "' (format='parquet');")]))
+ (ladybug/with-connection! db-path
+ (fn [target]
+ (ladybug/exec-on-connection!
+ target [(str "IMPORT DATABASE '" (.getAbsolutePath staging) "';")
+ "CHECKPOINT;"])))
+ db-path
+ (finally
+ (delete-tree! staging))))))
+
+(defn- hide-filter-columns
+ "Drop `filter_*` columns from a query result before HTML table render;
+ they exist to feed node ids to the graph-view filter, not for reading.
+ The JSON response path keeps the full result."
+ [{:keys [columns rows] :as result}]
+ (let [idxs (vec (keep-indexed
+ (fn [i c] (when-not (str/starts-with? (str c) "filter_") i))
+ columns))]
+ (if (or (empty? idxs) (= (count idxs) (count columns)))
+ result
+ (assoc result
+ :columns (mapv (vec columns) idxs)
+ :rows (mapv (fn [row] (mapv (vec row) idxs)) rows)))))
+
+(defn console-context
+ "Build template data for the graph debug console page."
+ [profile-id & {:keys [query query-result error message]}]
+ {:session (session-info profile-id)
+ :query (or query default-query)
+ :query-result (some-> query-result hide-filter-columns)
+ :error error
+ :message message
+ :default-query default-query})
diff --git a/backend/src/app/graph/ingest.clj b/backend/src/app/graph/ingest.clj
new file mode 100644
index 0000000000..af0644ee4d
--- /dev/null
+++ b/backend/src/app/graph/ingest.clj
@@ -0,0 +1,106 @@
+;; 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.graph.ingest
+ "Penpot file -> Ladybug graph projection."
+ (:require
+ [app.binfile.common :as bfc]
+ [app.common.exceptions :as ex]
+ [app.common.logging :as l]
+ [app.common.types.file :as ctf]
+ [app.db :as db]
+ [app.graph.arrow :as graph.arrow]
+ [app.graph.ladybug :as ladybug]
+ [app.graph.meta :as graph.meta]
+ [app.graph.projection.document :as projection.document]
+ [app.graph.projection.transforms :as projection.transforms]
+ [app.graph.schema :as schema]
+ [app.graph.stats :as stats]
+ [app.srepl.helpers :as h])
+ (:import
+ com.ladybugdb.Connection
+ org.apache.arrow.memory.BufferAllocator))
+
+(defn- fetch-file!
+ [system file-id]
+ (let [file-id (h/parse-uuid file-id)
+ file (db/run! system #(bfc/get-file % file-id :realize? true))]
+ (when-not file
+ (ex/raise :type :not-found
+ :code :file-not-found
+ :file-id (str file-id)))
+ (when-not (:data file)
+ (ex/raise :type :validation
+ :code :file-without-data
+ :hint "file has no data to project"
+ :file-id (str file-id)))
+ [file-id file]))
+
+(defn- ingest-on-connection*!
+ [system ^Connection conn file-id ^BufferAllocator allocator
+ {:keys [db-path skip-stats? skip-validation?] :or {skip-stats? true}}]
+ (let [[file-id file] (fetch-file! system file-id)
+ db-path (or db-path (ladybug/db-path-for-file file-id))
+ data (:data file)]
+ (when-not skip-validation?
+ (ctf/check-file-data data))
+ (l/inf :hint "graph ingest"
+ :file-id (str file-id)
+ :revn (:revn file)
+ :db-path db-path
+ :schema schema/schema-version)
+ (let [ddl (schema/ddl-statements)
+ {:keys [nodes edges stats]}
+ (projection.document/projection-data data file)]
+ (ladybug/exec-on-connection! conn ddl)
+ (graph.arrow/load-projection! conn {:nodes nodes :edges edges} allocator)
+ (ladybug/exec-on-connection! conn ["CHECKPOINT;"])
+ (let [transforms (projection.transforms/apply-transforms! system conn data file)]
+ ;; Written last: its presence doubles as the build-complete marker.
+ (graph.meta/write! conn {:file-id file-id
+ :revn (:revn file)})
+ {:file-id file-id
+ :revn (:revn file)
+ :name (or (:name data) (:name file))
+ :db-path db-path
+ :schema-version schema/schema-version
+ :projection {:stats stats
+ :nodes nodes
+ :edges edges}
+ :transforms transforms
+ :stats (when-not skip-stats?
+ (stats/summarize-connection conn))}))))
+
+(defn ingest-on-connection!
+ "Project `file-id` into an already open Ladybug `conn`.
+
+ Takes an `:arrow-alloc` when the caller already owns one; otherwise it makes
+ a short-lived allocator around this call. A caller that opened the connection
+ itself should pass its own, because the allocator has to be closed *after*
+ the connection — see `app.graph.arrow/with-allocator!`."
+ [system ^Connection conn file-id & {:keys [arrow-alloc] :as opts}]
+ (if arrow-alloc
+ (ingest-on-connection*! system conn file-id arrow-alloc opts)
+ (graph.arrow/with-allocator!
+ (fn [allocator] (ingest-on-connection*! system conn file-id allocator opts)))))
+
+(defn ingest-file!
+ [system file-id & {:keys [db-path reset-db? skip-stats? skip-validation?]
+ :or {reset-db? true}}]
+ (let [db-path (or db-path (ladybug/db-path-for-file (h/parse-uuid file-id)))]
+ (when reset-db?
+ (ladybug/reset-db-path! db-path))
+ ;; Allocator outermost: Ladybug holds the staged Arrow buffers until its
+ ;; tables are dropped, which is no later than connection close, so the
+ ;; allocator must be closed after the connection and the database.
+ (graph.arrow/with-allocator!
+ (fn [allocator]
+ (ladybug/with-connection! db-path
+ (fn [conn]
+ (ingest-on-connection*! system conn file-id allocator
+ {:db-path db-path
+ :skip-stats? skip-stats?
+ :skip-validation? skip-validation?})))))))
diff --git a/backend/src/app/graph/ladybug.clj b/backend/src/app/graph/ladybug.clj
new file mode 100644
index 0000000000..81d117c26e
--- /dev/null
+++ b/backend/src/app/graph/ladybug.clj
@@ -0,0 +1,504 @@
+;; 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.graph.ladybug
+ "Ladybug access layer for graph-backed Penpot.
+
+ Uses the embedded Java API (`com.ladybugdb/lbug`)."
+ (:require
+ [app.common.exceptions :as ex]
+ [app.common.json :as json]
+ [app.graph.schema.values :as values]
+ [clojure.string :as str]
+ [datoteka.fs :as fs])
+ (:import
+ com.ladybugdb.Connection
+ com.ladybugdb.Database
+ com.ladybugdb.FlatTuple
+ com.ladybugdb.PreparedStatement
+ com.ladybugdb.QueryResult
+ com.ladybugdb.Value))
+
+(set! *warn-on-reflection* true)
+
+(defn default-graph-dir
+ []
+ (or (System/getenv "PENPOT_GRAPH_DIR") "/tmp/penpot-graph"))
+
+(defn db-path-for-file
+ [file-id]
+ (str (fs/path (default-graph-dir) (str file-id ".lbug"))))
+
+(defn- memory-db-path?
+ [db-path]
+ (= db-path ":memory:"))
+
+(defn reset-db-path!
+ [db-path]
+ (when-not (memory-db-path? db-path)
+ (when (fs/exists? db-path)
+ (fs/delete db-path))))
+
+(defn escape-cypher-string
+ [s]
+ (-> (str s)
+ (str/replace "\\" "\\\\")
+ (str/replace "'" "\\'")))
+
+(defn format-uuid
+ [id]
+ (str "uuid('" (str id) "')"))
+
+(defn format-string
+ [s]
+ (str "'" (escape-cypher-string s) "'"))
+
+(defn format-int
+ [n]
+ (str (long n)))
+
+(defn format-number
+ [n]
+ (if (== n (long n))
+ (format-int n)
+ (str (double n))))
+
+(defn format-json
+ [v]
+ (str "json('" (escape-cypher-string (json/encode v)) "')"))
+
+(defn format-timestamp
+ "Ladybug TIMESTAMP literal of the form `timestamp('')`."
+ [v]
+ (let [s (cond
+ (instance? java.time.Instant v)
+ (.toString ^java.time.Instant v)
+
+ (instance? java.util.Date v)
+ (.toString (.toInstant ^java.util.Date v))
+
+ (string? v)
+ v
+
+ :else
+ (str v))]
+ (str "timestamp('" (escape-cypher-string s) "')")))
+
+(defn format-value
+ [v]
+ (cond
+ (nil? v) "NULL"
+ (uuid? v) (format-uuid v)
+ (instance? java.time.Instant v) (format-timestamp v)
+ (instance? java.util.Date v) (format-timestamp v)
+ (string? v) (format-string v)
+ (number? v) (format-number v)
+ (boolean? v) (if v "true" "false")
+ (keyword? v) (format-string (name v))
+ (map? v) (format-json v)
+ (coll? v) (format-json v)
+ :else (format-string (str v))))
+
+(defn map-type?
+ "Is `ladybug-type` a MAP column?"
+ [ladybug-type]
+ (and (string? ladybug-type)
+ (str/starts-with? ladybug-type "MAP(")
+ (not (str/ends-with? ladybug-type "]"))))
+
+(defn list-type?
+ "Is this a list or fixed-size array type? Checked before MAP and STRUCT,
+ since `STRUCT(…)[]` starts with `STRUCT(` but is a list of them."
+ [ladybug-type]
+ (and (string? ladybug-type)
+ (some? (re-matches #".+\[\d*\]$" ladybug-type))))
+
+(defn struct-type?
+ [ladybug-type]
+ (and (string? ladybug-type)
+ (str/starts-with? ladybug-type "STRUCT(")
+ (not (list-type? ladybug-type))))
+
+(declare format-typed-value)
+
+(defn- format-typed-list
+ "Cypher LIST literal, elements formatted by the element type.
+
+ Handles `T[]` and the fixed-size `T[n]` alike: the size constrains the column,
+ not the literal."
+ [ladybug-type v]
+ (let [element (second (re-matches #"(.+?)\[\d*\]$" ladybug-type))
+ elems (if (or (sequential? v) (set? v)) (seq v) [v])]
+ (str "[" (str/join ", " (map #(format-typed-value element %) elems)) "]")))
+
+(defn- format-struct
+ "Cypher STRUCT literal, `{field: value, …}`.
+
+ *Every* declared field is emitted, NULL where the value has none: a struct
+ literal's type is its field list, so omitting a field yields a different type
+ and Ladybug refuses the implicit cast (`STRUCT(m2 DOUBLE, m4 DOUBLE)` cannot
+ be assigned to `STRUCT(m1 …, m2 …, m3 …, m4 …)`). Penpot's layout margins are
+ exactly that case — a shape sets only the sides it overrides."
+ [ladybug-type v]
+ (let [fields (values/struct-fields ladybug-type)]
+ (str "{"
+ (str/join ", "
+ (for [[field field-type] fields
+ :let [fv (get v field)]]
+ ;; Backticked for the same reason as in the DDL: a field
+ ;; named `column` is a keyword and will not parse bare.
+ ;; A bare NULL is typed STRING, which changes the struct's
+ ;; type as surely as omitting the field would, so absent
+ ;; fields get a NULL cast to their declared type.
+ (str "`" field "`: "
+ (if (nil? fv)
+ (str "cast(NULL, '" field-type "')")
+ (format-typed-value field-type fv)))))
+ "}")))
+
+(defn format-typed-value
+ "Cypher literal for `v` in a column of `ladybug-type`.
+
+ Recursive over the type language, because the types are: a
+ `MAP(UUID, STRUCT(…))` needs its keys, its fields and each field's own type
+ honoured. `app.graph.schema.values/coerce` shapes the value first — turning a
+ matrix record into six doubles, a hex colour into a packed integer — so this
+ function only has to escape plain data.
+
+ `map-key-fn` renders the keys of a `MAP(STRING, …)`; the caller supplies it
+ because the right form is a property of the column, not of this function
+ (`app.graph.schema.contract/map-key-fn`)."
+ ([ladybug-type v] (format-typed-value ladybug-type v nil))
+ ([ladybug-type v map-key-fn]
+ (let [v (values/coerce ladybug-type v)]
+ (cond
+ (nil? v)
+ "NULL"
+
+ (list-type? ladybug-type)
+ (format-typed-list ladybug-type v)
+
+ (map-type? ladybug-type)
+ (let [[key-type value-type] (values/map-types ladybug-type)
+ entries (seq v)
+ format-key (if (and map-key-fn (= "STRING" key-type))
+ #(format-string (map-key-fn (key %)))
+ #(format-typed-value key-type (key %)))]
+ (str "map([" (str/join ", " (map format-key entries))
+ "], ["
+ (str/join ", " (map #(format-typed-value value-type (val %)) entries))
+ "])"))
+
+ (struct-type? ladybug-type)
+ (format-struct ladybug-type v)
+
+ (= ladybug-type "JSON")
+ (format-json v)
+
+ ;; Coerce string ids from transit edge-cases into UUID literals.
+ (= ladybug-type "UUID")
+ (format-uuid v)
+
+ (= ladybug-type "TIMESTAMP")
+ (format-timestamp v)
+
+ :else
+ (format-value v)))))
+
+(defn- ensure-semicolon
+ [statement]
+ (let [s (str/trim (str statement))]
+ (if (str/ends-with? s ";") s (str s ";"))))
+
+(defn- value->clj
+ [^Value value]
+ (when-not (.isNull value)
+ (let [v (try
+ (.getValue value)
+ (catch Exception _
+ ;; LIST/STRUCT values are not supported by the binding's
+ ;; getValue (\"value_get_value\"); fall back to the textual
+ ;; representation so console queries do not crash.
+ (.toString value)))]
+ (cond
+ (instance? Long v) v
+ (instance? Integer v) (long v)
+ (instance? Double v) v
+ :else v))))
+
+(defn- check-success!
+ [^QueryResult result statement]
+ (when-not (.isSuccess result)
+ (let [err (.getErrorMessage result)]
+ (ex/raise :type :internal
+ :code :ladybug-query-failed
+ :hint (str "Ladybug query failed: " err)
+ :statement statement
+ :err err))))
+
+(defn- query-columns
+ [^QueryResult result]
+ (let [ncols (.getNumColumns result)]
+ (vec (for [i (range ncols)]
+ (.getColumnName result (long i))))))
+
+(defn- query-row
+ [^FlatTuple tuple ncols]
+ (vec (for [i (range ncols)]
+ (with-open [^Value value (.getValue tuple (long i))]
+ (value->clj value)))))
+
+(def ^:private default-query-max-rows 200)
+
+(defn- read-query-rows
+ [^QueryResult result ncols max-rows]
+ (loop [rows [] n 0]
+ (if (and (< n max-rows) (.hasNext result))
+ (let [row (with-open [^FlatTuple tuple (.getNext result)]
+ (query-row tuple ncols))]
+ (recur (conj rows row) (inc n)))
+ rows)))
+
+(defn query-on-connection!
+ "Execute a Cypher query on `conn` and return tabular results.
+
+ Returns `{:columns [...] :rows [[...] ...] :truncated? bool}`."
+ [^Connection conn statement & {:keys [max-rows]
+ :or {max-rows default-query-max-rows}}]
+ (let [cypher (ensure-semicolon statement)]
+ (with-open [^QueryResult result (.query conn cypher)]
+ (check-success! result cypher)
+ (let [ncols (long (.getNumColumns result))
+ columns (query-columns result)
+ rows (read-query-rows result ncols max-rows)
+ total (long (.getNumTuples result))]
+ {:columns columns
+ :rows rows
+ :truncated? (and (pos? total) (> total (count rows)))}))))
+
+(def ^:private default-query-timeout-ms
+ "0 disables query timeout (recommended for bulk COPY ingest)."
+ 0)
+
+(defn- scalar-value
+ [^Connection conn statement]
+ (let [cypher (ensure-semicolon statement)]
+ (with-open [^QueryResult result (.query conn cypher)]
+ (check-success! result cypher)
+ (when (.hasNext result)
+ (with-open [^FlatTuple tuple (.getNext result)]
+ (with-open [^Value value (.getValue tuple 0)]
+ (value->clj value)))))))
+
+(defn- extension-statement-ok?
+ [err-msg]
+ (let [err (str/lower-case (or err-msg ""))]
+ (or (str/includes? err "already loaded")
+ (str/includes? err "already installed"))))
+
+(defn- run-extension-statement!
+ [^Connection conn statement]
+ (let [cypher (ensure-semicolon statement)]
+ (with-open [^QueryResult result (.query conn cypher)]
+ (when-not (.isSuccess result)
+ (let [err (.getErrorMessage result)]
+ (when-not (extension-statement-ok? err)
+ (check-success! result cypher)))))))
+
+(defn ensure-extensions!
+ "Install and load Ladybug extensions required by graph ingest and sync."
+ [^Connection conn]
+ (run-extension-statement! conn "INSTALL json;")
+ (run-extension-statement! conn "LOAD json;"))
+
+(defn- run-statements!
+ [^Connection conn statements]
+ (doseq [statement statements]
+ (let [cypher (ensure-semicolon statement)]
+ (with-open [^QueryResult result (.query conn cypher)]
+ (check-success! result cypher)))))
+
+(defn- ensure-db-path!
+ [db-path]
+ (when-not (memory-db-path? db-path)
+ (fs/create-dir (fs/parent db-path))))
+
+(defn with-connection!
+ "Open a Ladybug connection for `db-path` and invoke `(f conn)`.
+
+ Options:
+ - `:query-timeout-ms` query timeout in milliseconds (default 0, disabled)
+
+ For `:memory:`, the database only lives for the duration of this call;
+ all reads and writes must happen inside `f`."
+ [db-path f & {:keys [query-timeout-ms]
+ :or {query-timeout-ms default-query-timeout-ms}}]
+ (ensure-db-path! db-path)
+ (let [^Database db (if (memory-db-path? db-path)
+ (Database.)
+ (Database. (str db-path)))]
+ (try
+ (let [^Connection conn (Connection. db)]
+ (try
+ (.setQueryTimeout conn (long query-timeout-ms))
+ (ensure-extensions! conn)
+ (f conn)
+ (finally
+ (.close conn))))
+ (finally
+ (.close db)))))
+
+(defn exec-on-connection!
+ "Execute Cypher statements on an open Ladybug connection."
+ [^Connection conn statements]
+ (assert (sequential? statements) "statements should be a sequential collection")
+ (run-statements! conn statements))
+
+;; --- prepared statements
+
+(defn- ->param-value
+ "Clojure scalar → `Value` for prepared-statement binding.
+
+ This is the only `Value` constructor on the write path, so every parameter
+ is wrapped here. Parameters are scalars: the `Value` constructor takes no
+ list or map, so `MAP`, `STRUCT` and `T[]` columns stay literal-rendered
+ (`format-typed-value`) and the `:else` raise below means a caller tried to
+ bind one."
+ ^Value [v]
+ (cond
+ (nil? v) (Value/createNull) ; no explicit type needed
+ (uuid? v) (Value. ^Object v) ; native UUID
+ (string? v) (Value. ^Object v)
+ (boolean? v) (Value. ^Object v)
+ (integer? v) (Value. ^Object (long v))
+ (number? v) (Value. ^Object (double v))
+ (keyword? v) (Value. ^Object (name v))
+
+ (instance? java.time.Instant v) ; native TIMESTAMP
+ (Value. ^Object v)
+
+ (instance? java.util.Date v)
+ (Value. ^Object (.toInstant ^java.util.Date v))
+
+ :else
+ (ex/raise :type :internal
+ :code :ladybug-unsupported-param
+ :hint (str "cannot bind a " (type v) " as a Ladybug parameter; "
+ "compound columns must be literal-rendered")
+ :value v)))
+
+(defn- as-statement
+ "Normalize a statement to `{:cypher … :params …}`.
+
+ A bare string binds nothing, so the sync builders can convert to bound
+ parameters one family at a time."
+ [stmt]
+ (if (map? stmt)
+ (update stmt :params #(or % {}))
+ {:cypher stmt :params {}}))
+
+(defn prepare-on-connection!
+ "Parse and bind `statement` on `conn` without executing it.
+
+ The returned `PreparedStatement` is a JNI resource: the caller closes it."
+ ^PreparedStatement [^Connection conn statement]
+ (let [cypher (ensure-semicolon statement)
+ ps (.prepare conn cypher)]
+ (when-not (.isSuccess ps)
+ (let [err (.getErrorMessage ps)]
+ (.close ps)
+ (ex/raise :type :internal
+ :code :ladybug-prepare-failed
+ :hint (str "Ladybug prepare failed: " err)
+ :statement cypher
+ :err err)))
+ ps))
+
+(defn execute-prepared!
+ "Bind `params` into `ps` and execute it on `conn`.
+
+ `params` keys are parameter names without the `$` (keyword or string);
+ values are scalars. Every bound `Value` is closed, including the ones built
+ before a later parameter is rejected."
+ [^Connection conn ^PreparedStatement ps params]
+ (let [vmap (java.util.HashMap.)]
+ (try
+ (doseq [[k v] params]
+ (.put vmap (name k) (->param-value v)))
+ (with-open [^QueryResult result (.execute conn ps vmap)]
+ (check-success! result ""))
+ (finally
+ (run! #(.close ^Value %) (.values vmap))))))
+
+(defn exec-prepared-on-connection!
+ "Prepare all statements, then execute all of them.
+
+ A parse or bind failure in *any* statement aborts the batch before the first
+ mutation runs — the bind-level batch gate. Statements are
+ `{:cypher … :params {…}}` maps or bare strings."
+ [^Connection conn stmts]
+ (assert (sequential? stmts) "statements should be a sequential collection")
+ (let [prepared (volatile! [])]
+ (try
+ (doseq [stmt stmts]
+ (let [{:keys [cypher params]} (as-statement stmt)]
+ (vswap! prepared conj {:ps (prepare-on-connection! conn cypher)
+ :params params})))
+ (doseq [{:keys [ps params]} @prepared]
+ (execute-prepared! conn ps params))
+ (finally
+ (run! #(.close ^PreparedStatement (:ps %)) @prepared)))))
+
+(defn validate-on-connection!
+ "Binder gate: parse and semantic-check `statement` against the live schema,
+ without executing it.
+
+ Returns `{:ok? … :error … :read-only? …}`. Unlike `prepare-on-connection!`
+ a failure is a return value rather than a raise: the callers are gates (the
+ CI binder gate, the console read-only gate) that report it. `:read-only?` is
+ the engine's own read/write analysis."
+ [^Connection conn statement]
+ (with-open [^PreparedStatement ps (.prepare conn (ensure-semicolon statement))]
+ (let [ok? (.isSuccess ps)]
+ {:ok? ok?
+ :error (when-not ok? (.getErrorMessage ps))
+ :read-only? (when ok? (.isReadOnly ps))})))
+
+(defn query-scalar-on-connection!
+ "Execute a query expected to return a single scalar value on `conn`."
+ [^Connection conn statement]
+ (scalar-value conn statement))
+
+(defn exec!
+ "Execute Cypher statements against a Ladybug database.
+
+ `db-path` is either `:memory:` or a filesystem path to a `.lbug` database."
+ [db-path statements]
+ (with-connection! db-path
+ (fn [conn]
+ (exec-on-connection! conn statements))))
+
+(defn query-scalar!
+ "Execute a query expected to return a single scalar value."
+ [db-path statement]
+ (with-connection! db-path
+ (fn [conn]
+ (query-scalar-on-connection! conn statement))))
+
+(defn smoke-test!
+ "Run a minimal CREATE + count against Ladybug."
+ [& {:keys [db-path] :or {db-path ":memory:"}}]
+ (when-not (memory-db-path? db-path)
+ (reset-db-path! db-path))
+ (with-connection! db-path
+ (fn [^Connection conn]
+ (run-statements! conn
+ ["CREATE NODE TABLE Person(name STRING, age INT64, PRIMARY KEY(name));"
+ "CREATE (:Person {name: 'Alice', age: 25});"
+ "CREATE (:Person {name: 'Bob', age: 30});"])
+ {:db-path db-path
+ :person-count (scalar-value conn
+ "MATCH (a:Person) RETURN count(a) AS c;")})))
diff --git a/backend/src/app/graph/meta.clj b/backend/src/app/graph/meta.clj
new file mode 100644
index 0000000000..129babd1fa
--- /dev/null
+++ b/backend/src/app/graph/meta.clj
@@ -0,0 +1,59 @@
+;; 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.graph.meta
+ "`GraphMeta`: the graph's own account of who built it and from what.
+
+ A projected graph is a cache of a file at a revision, built by a known
+ schema. The row records both, so a reader can decide whether to reuse the
+ database or rebuild it: a `schema_version` that no longer matches the
+ registry, or a `source_revn` behind the file's, means the cache is stale.
+
+ The row is written *last* in a build, so its presence also marks the build
+ complete.
+
+ Keyed by `source_file_id` rather than holding a single row: a closure graph
+ is a union of per-file builds, and each contributing file keeps its own
+ provenance."
+ (:require
+ [app.common.time :as ct]
+ [app.graph.ladybug :as ladybug]
+ [app.graph.schema.nodes :as nodes])
+ (:import
+ com.ladybugdb.Connection))
+
+(set! *warn-on-reflection* true)
+
+(def table
+ "GraphMeta")
+
+(def producer
+ "penpot")
+
+(def ddl
+ "DDL for the provenance table."
+ (str "CREATE NODE TABLE `" table "` ("
+ "`source_file_id` UUID, "
+ "`producer` STRING, "
+ "`producer_version` STRING, "
+ "`schema_version` STRING, "
+ "`source_revn` INT64, "
+ "`built_at` TIMESTAMP, "
+ "PRIMARY KEY (`source_file_id`));"))
+
+(defn write!
+ "Record what this build produced for `file-id`."
+ [^Connection conn {:keys [file-id revn]}]
+ (ladybug/exec-on-connection! conn [ddl])
+ (ladybug/exec-on-connection!
+ conn
+ [(str "MERGE (m:`" table "` {source_file_id: " (ladybug/format-uuid file-id) "}) "
+ "SET m.producer = " (ladybug/format-string producer) ", "
+ "m.producer_version = " (ladybug/format-string (or (System/getenv "PENPOT_BUILD") "devenv")) ", "
+ "m.schema_version = " (ladybug/format-string nodes/schema-version) ", "
+ "m.source_revn = " (ladybug/format-int (or revn 0)) ", "
+ "m.built_at = " (ladybug/format-timestamp (ct/now)) ";")]))
+
diff --git a/backend/src/app/graph/projection/document.clj b/backend/src/app/graph/projection/document.clj
new file mode 100644
index 0000000000..9d0eec6851
--- /dev/null
+++ b/backend/src/app/graph/projection/document.clj
@@ -0,0 +1,214 @@
+;; 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.graph.projection.document
+ "Project a Penpot file-data map into Ladybug nodes and structural edges.
+
+ Projects Document, Page, Component, the full shape tree (skipping the root
+ frame), and `IsChildOf` edges from shapes/pages/components to their parent.
+
+ Two denormalizations happen here rather than in a later pass, because the
+ walk already has both answers in hand and a post-ingest statement would have
+ to rediscover them:
+
+ - `page-id` on every shape, from the page the walk is currently in;
+ - `component-id` propagated from an instance head down to its descendants,
+ from the head context the walk carries."
+ (:require
+ [app.common.logging :as l]
+ [app.common.uuid :as uuid]
+ [app.graph.schema.nodes :as nodes]))
+
+(def root-frame-id
+ uuid/zero)
+
+(defn- document-attrs
+ "The Document node's attrs: the file row, minus its data blob.
+
+ `:options` is lifted out of the blob before it goes: it is file-level
+ configuration a consumer wants without opening `:data`."
+ [file data]
+ (-> file
+ (assoc :id (or (:id data) (:id file)))
+ (cond-> (:options data) (assoc :options (:options data)))
+ (dissoc :data)))
+
+(defn- page-attrs
+ [page index]
+ (-> page
+ (dissoc :objects)
+ (cond-> (some? index) (assoc :index (long index)))))
+
+(defn- component-attrs
+ [component]
+ (-> component
+ (dissoc :objects)
+ ;; schema:component requires :path; some legacy rows omit it
+ (update :path #(or % ""))))
+
+(defn- shape-table
+ [shape]
+ (nodes/table-for-type (:type shape)))
+
+(defn denormalized-shape
+ "`shape` with `page-id` set and an inherited `component-id` filled in.
+
+ A shape that carries its own `component-id` keeps it; `component-ctx` only
+ fills the gap for descendants (see `descend-component-ctx`)."
+ [shape page-id component-ctx]
+ (cond-> (assoc shape :page-id page-id)
+ (and (uuid? component-ctx) (nil? (:component-id shape)))
+ (assoc :component-id component-ctx)))
+
+(defn- shape-node-attrs
+ [table shape page-id component-ctx]
+ (nodes/project-attrs table (denormalized-shape shape page-id component-ctx)))
+
+(defn descend-component-ctx
+ "The component context to pass to `shape`'s children.
+
+ Inheritance stops at the nearest ancestor Frame carrying a `component-id`,
+ and any intermediate shape that carries one is a barrier:
+
+ - a Frame with its own `component-id` becomes the new context (it is an
+ instance head, and its descendants belong to *it*, not to an outer head);
+ - any other shape carrying a `component-id` blocks inheritance below it
+ without being able to supply one, since only Frames are heads;
+ - otherwise the context passes through unchanged."
+ [table shape ctx]
+ (let [own (:component-id shape)]
+ (cond
+ (and (some? own) (= table "Frame")) own
+ (some? own) ::blocked
+ :else ctx)))
+
+(defn- container-table?
+ [table]
+ (contains? nodes/container-tables table))
+
+(defn- child-shape-ids
+ "Child ids in Penpot z-order (reversed from the stored :shapes list)."
+ [parent]
+ (when-let [shapes (:shapes parent)]
+ (vec (reverse shapes))))
+
+(defn- initial-acc
+ []
+ {:nodes {}
+ :edges []
+ :stats {:documents 0 :pages 0 :components 0 :shapes 0}})
+
+(declare project-shape-ids)
+
+(defn- project-shape
+ [objects acc table shape parent-table parent-id position page-id component-ctx]
+ (let [shape-id (:id shape)
+ acc' (-> acc
+ (update-in [:nodes table] (fnil conj [])
+ (shape-node-attrs table shape page-id component-ctx))
+ (update :edges conj {:from-table table
+ :from-id shape-id
+ :to-table parent-table
+ :to-id parent-id
+ :position position})
+ (update-in [:stats :shapes] inc))]
+ (if-let [child-ids (when (container-table? table)
+ (child-shape-ids shape))]
+ (project-shape-ids objects acc' table shape-id child-ids page-id
+ (descend-component-ctx table shape component-ctx))
+ acc')))
+
+(defn- project-shape-ids
+ [objects acc parent-table parent-id child-ids page-id component-ctx]
+ (reduce
+ (fn [acc [position shape-id]]
+ (if-let [shape (get objects shape-id)]
+ (if-let [table (shape-table shape)]
+ (project-shape objects acc table shape parent-table parent-id position
+ page-id component-ctx)
+ (do
+ (l/wrn :hint "unsupported shape type for graph slice"
+ :shape-id (str shape-id)
+ :type (:type shape))
+ acc))
+ (do
+ (l/wrn :hint "missing shape in page objects"
+ :shape-id (str shape-id))
+ acc)))
+ acc
+ (map-indexed vector child-ids)))
+
+(defn- project-page
+ [acc doc-id page position]
+ (let [page-id (:id page)
+ objects (:objects page)
+ root (get objects root-frame-id)
+ page-node (nodes/project-attrs "Page" (page-attrs page position))
+ acc' (-> acc
+ (update-in [:nodes "Page"] (fnil conj []) page-node)
+ (update :edges conj {:from-table "Page"
+ :from-id page-id
+ :to-table "Document"
+ :to-id doc-id
+ :position position})
+ (update-in [:stats :pages] inc))]
+ (if-let [top-level-ids (child-shape-ids root)]
+ (project-shape-ids objects acc' "Page" page-id top-level-ids page-id nil)
+ acc')))
+
+(defn- project-component
+ [acc doc-id component position]
+ (if (:deleted component)
+ acc
+ (let [comp-id (:id component)
+ node (nodes/project-attrs "Component" (component-attrs component))]
+ (-> acc
+ (update-in [:nodes "Component"] (fnil conj []) node)
+ (update :edges conj {:from-table "Component"
+ :from-id comp-id
+ :to-table "Document"
+ :to-id doc-id
+ :position position})
+ (update-in [:stats :components] inc)))))
+
+(defn- project-components
+ [acc doc-id components]
+ (reduce (fn [acc [position [_id component]]]
+ (project-component acc doc-id component position))
+ acc
+ (map-indexed vector components)))
+
+(defn projection-data
+ "Build node/edge rows for projecting `data` into Ladybug.
+
+ Returns `{:nodes {table [attrs ...]} :edges [...] :stats {...}}`."
+ [data file]
+ (let [doc-id (or (:id data) (:id file))
+ doc-node (nodes/project-attrs "Document" (document-attrs file data))
+ ;; `:pages` is the tab order the user sees, and `Page.index` and the
+ ;; page's `IsChildOf.position` are that order. Child shapes are
+ ;; reversed on the way in (`child-shape-ids`) because their stored
+ ;; list runs bottom to top; pages have no such second ordering.
+ pages (seq (:pages data))
+ comps (seq (:components data))
+ acc0 (-> (initial-acc)
+ (update-in [:nodes "Document"] (fnil conj []) doc-node)
+ (assoc-in [:stats :documents] 1))
+ acc (cond-> acc0
+ (seq comps)
+ (project-components doc-id comps))
+ acc (if (empty? pages)
+ acc
+ (reduce (fn [acc [position page-id]]
+ (if-let [page (get-in data [:pages-index page-id])]
+ (project-page acc doc-id page position)
+ (do
+ (l/wrn :hint "missing page in pages-index"
+ :page-id (str page-id))
+ acc)))
+ acc
+ (map-indexed vector pages)))]
+ (select-keys acc [:nodes :edges :stats])))
diff --git a/backend/src/app/graph/projection/transforms.clj b/backend/src/app/graph/projection/transforms.clj
new file mode 100644
index 0000000000..dc87392983
--- /dev/null
+++ b/backend/src/app/graph/projection/transforms.clj
@@ -0,0 +1,149 @@
+;; 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.graph.projection.transforms
+ "Derived graph links: edges a reader could compute from the projected
+ columns, materialized once at build time so a query does not have to.
+
+ Each entry in `registry` names the transform, the relationship it produces,
+ and the function that produces it, so adding one is a single entry and
+ nothing else has to be told about it."
+ (:require
+ [app.common.logging :as l]
+ [app.graph.ladybug :as ladybug]
+ [app.graph.schema.nodes :as nodes])
+ (:import
+ com.ladybugdb.Connection))
+
+(set! *warn-on-reflection* true)
+
+(defn- run-scalar!
+ [^Connection conn statement]
+ (or (ladybug/query-scalar-on-connection! conn statement) 0))
+
+(defn- link-component-instances!
+ "`IsInstanceOf` from Frame instance heads to their Component.
+
+ Every head is linked, the main instance and any copy root alike.
+
+ `component-file` is what makes a head a head here, not `component-id` alone.
+ `app.common.types.component/instance-of?` requires both, and the projection
+ denormalizes `component-id` down the shape tree
+ (`app.graph.projection.document`), so on its own it no longer distinguishes a
+ head from a shape that merely lives inside one. `component-file` is not
+ denormalized and remains the head marker Penpot itself uses."
+ [^Connection conn]
+ (run-scalar! conn
+ (str "MATCH (f:Frame), (c:Component) "
+ "WHERE f.component_id = c.id "
+ "AND f.component_file IS NOT NULL "
+ "AND NOT COALESCE(c.deleted, false) "
+ "MERGE (f)-[:IsInstanceOf]->(c) "
+ "RETURN count(*);")))
+
+(defn- shape-pair-statements
+ "One statement per (from, to) shape-table pair.
+
+ Ladybug cannot create a relationship bound by multiple node labels in a
+ single `MERGE`, a constraint inherited from Kùzu, which it forks (upstream
+ issue kuzudb/kuzu#5841). The loop over label pairs is that dialect
+ constraint, not a modelling choice."
+ [f]
+ (for [from nodes/shape-tables
+ to nodes/shape-tables]
+ (f from to)))
+
+(defn- link-shape-refs!
+ "`RefersTo` from an instance shape to its homologue in the main instance,
+ driven by `shape-ref`."
+ [^Connection conn]
+ (reduce
+ (fn [total statement] (+ total (run-scalar! conn statement)))
+ 0
+ (shape-pair-statements
+ (fn [from to]
+ (str "MATCH (s:" (nodes/match-label from) "), (t:" (nodes/match-label to) ") "
+ "WHERE s.shape_ref = t.id "
+ "MERGE (s)-[:RefersTo]->(t) "
+ "RETURN count(*);")))))
+
+(def ^:private swap-slot-prefix "swap-slot-")
+
+(def ^:private slot-uuid-expr
+ ;; Ladybug `substring` is 1-indexed; 36 = RFC 4122 UUID text length.
+ (str "substring(touched_key, " (inc (count swap-slot-prefix)) ", 36)"))
+
+(defn- link-swap-slots!
+ "`FillsSwapSlot` from a swapped-in shape to the slot it replaces.
+
+ Penpot records a component sub-shape swap as a `swap-slot-` entry in
+ the *replacing* shape's `touched` set, where `` names the replaced
+ slot shape in the main instance. The entries are then stripped from
+ `touched`, as `app.common.types.component/normal-touched-groups` does, so a
+ reader of `touched` sees design edits rather than swap bookkeeping.
+
+ Stripping makes this the one transform that writes a column another
+ transform could read. Anything reading `touched` has to run before it."
+ [^Connection conn]
+ (let [linked
+ (reduce
+ (fn [total statement] (+ total (run-scalar! conn statement)))
+ 0
+ (shape-pair-statements
+ (fn [from to]
+ (str "MATCH (s:" (nodes/match-label from) ") "
+ "WHERE size(s.touched) > 0 "
+ "UNWIND s.touched AS touched_key "
+ "WITH s, touched_key "
+ "WHERE STARTS_WITH(touched_key, '" swap-slot-prefix "') "
+ "WITH s, CAST(" slot-uuid-expr ", 'UUID') AS slot_id "
+ "MATCH (t:" (nodes/match-label to) ") "
+ "WHERE t.id = slot_id AND s.id <> t.id "
+ "MERGE (s)-[r:FillsSwapSlot {slot_id: slot_id}]->(t) "
+ "RETURN count(r);"))))]
+ ;; Strip unconditionally: an entry may name a slot that was garbage
+ ;; collected, so "no edge created" does not mean "nothing to strip".
+ (doseq [table nodes/shape-tables]
+ (ladybug/exec-on-connection!
+ conn
+ [(str "MATCH (s:" (nodes/match-label table) ") "
+ "WHERE size(s.touched) > 0 "
+ "SET s.touched = list_filter(s.touched, x -> "
+ "NOT STARTS_WITH(x, '" swap-slot-prefix "'));")]))
+ linked))
+
+(def registry
+ "Every transform this backend applies.
+
+ `:id` names the transform in the ingest report and the log. `:rel` names
+ the relationship it produces. The three registered here read disjoint
+ columns, so the vector order is not load-bearing. The one ordering
+ constraint that exists is stated on `link-swap-slots!`."
+ [{:id "link-component-instances" :rel :IsInstanceOf :run link-component-instances!}
+ {:id "link-shape-refs" :rel :RefersTo :run link-shape-refs!}
+ {:id "link-swap-slots" :rel :FillsSwapSlot :run link-swap-slots!}])
+
+(defn apply-transforms!
+ "Apply every registered transform to an already loaded graph.
+
+ Returns `{:ids [...] :counts {...} :transforms n}`, where `:ids` names what
+ ran and `:counts` gives the edges each one produced."
+ [_system ^Connection conn _data _file]
+ (reduce
+ (fn [acc {:keys [id rel run]}]
+ (let [n (run conn)]
+ (l/inf :hint "graph transform" :transform id :edges n)
+ (-> acc
+ (update :ids conj id)
+ (update :counts assoc rel n)
+ (assoc rel n))))
+ {:ids [] :counts {} :transforms (count registry)}
+ registry))
+
+(defn transform-ids
+ "Ids of every transform in the registry."
+ []
+ (mapv :id registry))
diff --git a/backend/src/app/graph/report.clj b/backend/src/app/graph/report.clj
new file mode 100644
index 0000000000..f026ff3a52
--- /dev/null
+++ b/backend/src/app/graph/report.clj
@@ -0,0 +1,65 @@
+;; 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.graph.report
+ (:require
+ [clojure.core :as c]
+ [clojure.string :as str]))
+
+(defn- println!
+ [& lines]
+ (doseq [line lines]
+ (println line)))
+
+(defn- section-title
+ [title]
+ (println! (str "\n" title)
+ (str (apply str (repeat (count title) "─")))))
+
+(defn- kv-line
+ [k v]
+ (format " %-14s %s" (str k ":") v))
+
+(defn- print-node-counts
+ [nodes]
+ (doseq [[table count] (sort-by first nodes)
+ :when (pos? (long count))]
+ (println! (kv-line table count))))
+
+(defn print-ingest!
+ "Pretty-print the result map returned by `app.graph.ingest/ingest-file!`."
+ [{:keys [file-id revn name db-path schema-version projection transforms stats]}]
+ (section-title "Graph ingest")
+ (println! (kv-line "File" (str name " (" file-id ")"))
+ (kv-line "Revision" revn)
+ (kv-line "Schema" schema-version)
+ (kv-line "Database" db-path))
+
+ (when-let [pstats (:stats projection)]
+ (section-title "Projection")
+ (doseq [[k v] (sort-by key pstats)]
+ (println! (kv-line (c/name k) v))))
+
+ (section-title "Transforms")
+ (println! (kv-line "Applied" (or (:transforms transforms) 0)))
+ (doseq [[rel count] (sort-by key (:counts transforms))]
+ (println! (kv-line (c/name rel) count)))
+ (when-let [ids (seq (:ids transforms))]
+ (println! (kv-line "Recorded" (str/join ", " ids))))
+
+ (when stats
+ (section-title "Graph counts")
+ (when-let [nodes (:nodes stats)]
+ (println! " Nodes")
+ (print-node-counts nodes))
+ (when-let [edges (:edges stats)]
+ (println! " Edges")
+ (doseq [[rel count] (sort-by key edges)
+ :when (pos? (long count))]
+ (println! (kv-line (c/name rel) count)))))
+
+ (println!)
+ nil)
diff --git a/backend/src/app/graph/schema.clj b/backend/src/app/graph/schema.clj
new file mode 100644
index 0000000000..0aace14bea
--- /dev/null
+++ b/backend/src/app/graph/schema.clj
@@ -0,0 +1,30 @@
+;; 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.graph.schema
+ "Ladybug DDL facade for the graph-backed Penpot vertical slice.
+
+ Node metadata and DDL generation live in `app.graph.schema.nodes`."
+ (:require
+ [app.graph.schema.nodes :as nodes]))
+
+(def schema-version
+ nodes/schema-version)
+
+(def container-node-tables
+ nodes/container-tables)
+
+(def shape-node-tables
+ nodes/shape-tables)
+
+(def node-tables
+ (mapv (fn [{:keys [table schema]}]
+ {:name table :schema schema})
+ nodes/node-types))
+
+(defn ddl-statements
+ []
+ (nodes/ddl-statements))
diff --git a/backend/src/app/graph/schema/contract.clj b/backend/src/app/graph/schema/contract.clj
new file mode 100644
index 0000000000..684a12dd2e
--- /dev/null
+++ b/backend/src/app/graph/schema/contract.clj
@@ -0,0 +1,150 @@
+;; 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.graph.schema.contract
+ "Deliberate choices in Penpot's graph schema, recorded as data.
+
+ Penpot must pick a spelling and a type for every graph column. A Ladybug
+ column gets both once, at table creation, and neither widens afterwards. The
+ choices are therefore worth making deliberately and worth recording.
+
+ Three of them live here:
+
+ - `column-name` maps a Penpot key to its column. The rule is snake_case of
+ the key, and `renames` records every exception.
+ - `dropped-keys` and `per-table-dropped` name Penpot keys that deliberately
+ get no column.
+ - `type-overrides` pins the Ladybug type where the Malli-derived one
+ (`app.graph.schema.types`) is coarser than the column deserves.
+
+ Each entry carries its reason. A divergence from the default rule is then a
+ diff to review rather than a silent rename."
+ (:require
+ [app.common.json :as json]
+ [clojure.string :as str]))
+
+(def ^:private renames
+ "Penpot key to column name, where the column is not snake_case of the key.
+
+ Keyed by the Penpot key alone: no shape type gives one of these a second
+ meaning, so a per-table map would only add ceremony."
+ {;; `bool` collides with the Ladybug type name, so the column is named after
+ ;; the table (`Boolean`) rather than after Penpot's `:bool` shape type.
+ :bool-type "boolean_type"
+
+ ;; The column records what the file saved, which can lag what the shape
+ ;; tree implies. The `saved_` prefix marks it as the stored value rather
+ ;; than a derivation.
+ :component-root "saved_component_root"
+
+ ;; The value is a list, so the plural is accurate.
+ :shadow "shadows"
+
+ ;; The column spells the revision number out.
+ :revn "revision"})
+
+(def dropped-keys
+ "Penpot keys projected by the Malli registry that get no column.
+
+ Dropping is right only when the column would be dead weight for every reader
+ of the graph. A key a reader might learn from belongs in `unprojected-keys`
+ instead."
+ {:deleted-at
+ "Only non-nil for a soft-deleted file, and a deleted file is never ingested."
+
+ :pixel-grid-color
+ "Viewer chrome: the color of the editor's pixel grid, not design content."
+
+ :pixel-grid-opacity
+ "Viewer chrome, as above."})
+
+(def unprojected-keys
+ "Penpot keys that should become graph columns and do not have one yet.
+
+ Distinct from `dropped-keys` on purpose: these are a debt the projection
+ owes, not a decision to discard data. Keeping the two apart means a new
+ upstream attribute cannot be quietly buried in the drop list."
+ {:background-blur
+ "Landed upstream behind a default-on flag. No column for it yet."})
+
+(def ^:private per-table-dropped
+ "Keys dropped only on certain tables.
+
+ `:grids` is the standing case: Penpot's shape schema admits it on every
+ shape, but only a Frame ever carries one. Emitting an always-null column on
+ ten other tables would widen every multi-table scan for nothing."
+ {:grids #{"Boolean" "Circle" "Group" "Image" "Path" "Rectangle" "SVGRaw" "Text"}})
+
+(def type-overrides
+ "Ladybug column type per column name, where the derived type is too coarse.
+
+ `app.graph.schema.types` derives a type from the Malli schema, which is the
+ right default but coarser than the column deserves in places: a Malli `:map`
+ becomes `JSON`, where a native Ladybug MAP or a fixed-size array lets a
+ consumer read a tensor row without parsing.
+
+ Only load-bearing divergences are pinned here, in the order they became
+ load-bearing."
+ {;; Must be a native MAP: a JSON blob cannot be indexed by key in Cypher, so
+ ;; `map_keys` and `map_extract` cannot reach a single token at all.
+ "applied_tokens" "MAP(STRING, STRING)"
+
+ ;; `grc/schema:rect` is an inline `:and` over a map, not the registered
+ ;; `::grc/rect`, so `app.graph.schema.types` cannot recognize it by type.
+ ;; Four doubles rather than the eight-field struct: `x1`/`y1`/`x2`/`y2` are
+ ;; derivable from `x`/`y`/`width`/`height`, and a fixed-size array is a
+ ;; tensor row a consumer reads without parsing.
+ "selrect" "DOUBLE[4]"
+
+ ;; The SVG provenance attributes are typed `:map` in the shape schema on
+ ;; purpose. Legacy files hold them as plain maps rather than as
+ ;; `::grc/rect` and `::gmt/matrix` records, and a tighter *schema* would
+ ;; reject those files
+ ;; (`app.common.types.shape/schema:shape-generic-attrs`). A tighter
+ ;; *column* is free: `app.graph.schema.values/coerce` reads either form.
+ "svg_viewbox" "DOUBLE[4]"
+ "svg_transform" "DOUBLE[6]"
+
+ ;; `:fills` is an `:or` over the packed `app.common.types.fills` value and
+ ;; a plain vector of fill maps, so the schema alone cannot say it is a
+ ;; collection. It always is one, and a fill has enough optional shape
+ ;; (solid, gradient, image) that JSON per element is the honest element
+ ;; type.
+ "fills" "JSON[]"})
+
+(def ^:private map-key-fns
+ "How to render the *keys* of a MAP column, per column.
+
+ A column name is schema, so it is snake_case. The keys inside a MAP are
+ values, so they keep the spelling their producer used. `applied_tokens` is
+ keyed by shape attribute in the camelCase form
+ `app.common.json/write-camel-key` produces: `strokeWidth`, not
+ `stroke-width`."
+ {"applied_tokens" json/write-camel-key})
+
+(defn map-key-fn
+ "Key renderer for a MAP column. `name` unless the column says otherwise."
+ [column]
+ (get map-key-fns column name))
+
+(defn column-name
+ "The graph column name for Penpot key `k`.
+
+ Default: snake_case of the key. `renames` overrides."
+ [k]
+ (or (get renames k)
+ (str/replace (name k) "-" "_")))
+
+(defn drop-key?
+ "Should key `k` be omitted from `table`'s columns?"
+ [table k]
+ (or (contains? dropped-keys k)
+ (contains? (get per-table-dropped k #{}) table)))
+
+(defn ladybug-type
+ "The pinned Ladybug type for `column`, or `fallback` when nothing is pinned."
+ [column fallback]
+ (get type-overrides column fallback))
diff --git a/backend/src/app/graph/schema/nodes.clj b/backend/src/app/graph/schema/nodes.clj
new file mode 100644
index 0000000000..c81802415d
--- /dev/null
+++ b/backend/src/app/graph/schema/nodes.clj
@@ -0,0 +1,343 @@
+;; 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.graph.schema.nodes
+ "Single source of truth for graph node tables.
+
+ Each registry entry declares Penpot Malli sources plus projection
+ options (`:drop`, optional `:extra`). Derived artifacts — Ladybug
+ DDL, Arrow fields, validation, type dispatch — all flow from that.
+
+ This registry is the single source of the graph schema. A Ladybug column
+ gets its name and its type once, at table creation, and there is no
+ widening afterwards. Every divergence between a Penpot key and its column
+ is recorded in `app.graph.schema.contract`."
+ (:require
+ [app.common.exceptions :as ex]
+ [app.common.schema :as sm]
+ [app.common.time :as ct]
+ [app.common.types.component :as ctk]
+ [app.common.types.file :as ctf]
+ [app.common.types.page :as ctp]
+ [app.graph.ladybug :as ladybug]
+ [app.graph.schema.contract :as contract]
+ [app.graph.schema.projection :as projection]
+ [app.graph.schema.types :as types]
+ [clojure.string :as str]))
+
+(def schema-version
+ "penpot-graph-slice-4")
+
+(def ^:private document-projection
+ {:source ctf/schema:file
+ :drop [:data]
+ ;; Attributes a file map carries that `ctf/schema:file` does not declare.
+ ;;
+ ;; They belong here rather than in that schema, even though the graph wants
+ ;; them, because `schema:file` is on the *write* path too:
+ ;; `app.binfile.common/update-file!` derives its UPDATE columns from a file
+ ;; map's keys, so declaring `:backend` there made it try to write a `backend`
+ ;; column, which the `file` table does not have — it is synthesized on read.
+ ;; A projection `:extra` is local to the graph and cannot reach a write.
+ ;;
+ ;; `:options` is lifted out of `:data` before the blob is dropped
+ ;; (`app.graph.projection.document/document-attrs`); the rest come off the file
+ ;; map as `get-file` returns it.
+ :extra [:map
+ [:options {:optional true} [:maybe :map]]
+ [:backend {:optional true} [:maybe :string]]
+ [:comment-thread-seqn {:optional true} [:maybe :int]]
+ [:ignore-sync-until {:optional true} [:maybe ::ct/inst]]]})
+
+(def ^:private page-projection
+ {:source ctp/schema:page
+ :drop [:objects]})
+
+(def ^:private component-projection
+ {:source ctk/schema:component
+ :drop [:objects]
+ ;; Soft-delete flag used at runtime; not in schema:component.
+ :extra [:map
+ [:deleted {:optional true} :boolean]
+ [:annotation {:optional true} :string]]})
+
+(def ^:private shape-projection
+ {:drop [:type]})
+
+(def ^:private shape-node-types
+ [{:table "Frame" :penpot-type :frame :container? true}
+ {:table "Group" :penpot-type :group :container? true}
+ {:table "Boolean" :penpot-type :bool :container? true}
+ {:table "SVGRaw" :penpot-type :svg-raw :container? true}
+ {:table "Rectangle" :penpot-type :rect}
+ {:table "Circle" :penpot-type :circle}
+ {:table "Path" :penpot-type :path}
+ {:table "Text" :penpot-type :text}
+ {:table "Image" :penpot-type :image}])
+
+(defn- resolve-schema
+ [{:keys [schema source drop extra penpot-type]}]
+ (or schema
+ (when penpot-type
+ (projection/project-shape-schema penpot-type
+ {:drop drop
+ :extra extra}))
+ (projection/project-schema source
+ {:drop drop
+ :extra extra})))
+
+(defn- shape-node-entry
+ [{:keys [table penpot-type container?] :as entry}]
+ (let [projection (-> shape-projection
+ (merge (:projection entry))
+ (assoc :penpot-type penpot-type))]
+ {:table table
+ :pk :id
+ :penpot-type penpot-type
+ :container? container?
+ :projection projection
+ :schema (resolve-schema projection)}))
+
+(def node-types
+ "Ordered node registry."
+ (into [{:table "Document"
+ :pk :id
+ :projection document-projection
+ :schema (resolve-schema document-projection)}
+ {:table "Page"
+ :pk :id
+ :projection page-projection
+ :schema (resolve-schema page-projection)}
+ {:table "Component"
+ :pk :id
+ :projection component-projection
+ :schema (resolve-schema component-projection)}]
+ (map shape-node-entry shape-node-types)))
+
+(def ^:private by-table
+ (into {} (map (juxt :table identity) node-types)))
+
+(def ^:private by-penpot-type
+ (into {} (keep (fn [{:keys [penpot-type table]}]
+ (when penpot-type [penpot-type table]))
+ node-types)))
+
+(def container-tables
+ (into #{} (comp (filter :container?) (map :table)) node-types))
+
+(def shape-tables
+ (into [] (comp (filter :penpot-type) (map :table)) node-types))
+
+(defn table-for-type
+ "Map a Penpot shape `:type` keyword to a Ladybug node table name."
+ [penpot-type]
+ (get by-penpot-type (keyword penpot-type)))
+
+(defn node-entry
+ [table]
+ (get by-table table))
+
+(defn projection-for
+ "Return the projection options map for `table`."
+ [table]
+ (:projection (node-entry table)))
+
+(defn- entry-child-schema
+ "Return the value schema from a Malli map entry (`[k s]` or `[k props s]`)."
+ [entry]
+ (if (> (count entry) 2)
+ (nth entry 2)
+ (nth entry 1)))
+
+(defn column-name
+ "Graph column name for projected key `k` on `table`."
+ [_table k]
+ (contract/column-name k))
+
+(defn column-ladybug-type
+ "Ladybug column type for projected key `k` on `table`."
+ [table k]
+ (some (fn [entry]
+ (when (= k (first entry))
+ (contract/ladybug-type (column-name table k)
+ (types/ladybug-type (entry-child-schema entry)))))
+ (projection/schema-map-entries (:schema (node-entry table)))))
+
+(defn column-keys
+ "Projected column keys for `table`, in registry order.
+
+ Keys the contract drops on this table are omitted, so the column order, the
+ Arrow batch, and the DDL cannot disagree about what exists."
+ [table]
+ (into []
+ (comp (map first)
+ (remove #(contract/drop-key? table %)))
+ (projection/schema-map-entries (:schema (node-entry table)))))
+
+(defn columns
+ "Projected column names for `table`, in registry order."
+ [table]
+ (mapv #(column-name table %) (column-keys table)))
+
+(def ^:private validate-node-fn
+ (memoize
+ (fn [table]
+ (let [{:keys [schema]} (node-entry table)]
+ (sm/check-fn schema
+ :type :validation
+ :code (keyword "graph-node-projection" (str/lower-case table))
+ :hint (str "invalid graph node projection for " table))))))
+
+(defn- projection-error-hint
+ [table explain]
+ (str "invalid graph node projection for " table
+ (when explain
+ (str "\n" (sm/humanize-explain explain)))))
+
+(defn validate-node
+ "Validate and return projected node attrs for `table`."
+ [table value]
+ (let [{:keys [schema]} (node-entry table)]
+ (try
+ ((validate-node-fn table) value)
+ (catch clojure.lang.ExceptionInfo e
+ (let [data (ex-data e)
+ explain (or (::sm/explain data)
+ (sm/explain schema value))]
+ (ex/raise :type :validation
+ :code (keyword "graph-node-projection" (str/lower-case table))
+ :hint (projection-error-hint table explain)
+ :table table
+ ::sm/explain explain
+ :cause e))))))
+
+(defn- get-projected-attr
+ "The attribute under `k`, keyword or string key.
+
+ `if-some`, not `or`: `false` and `0` are values, and falling through on them
+ is how `opacity 0` became `nil` and then the column default."
+ [attrs k]
+ (if-some [v (get attrs k)]
+ v
+ (when (keyword? k) (get attrs (name k)))))
+
+(defn- raise-empty-projection!
+ [table attrs]
+ (ex/raise :type :validation
+ :code (keyword "graph-node-projection" (str/lower-case table))
+ :hint (str "empty graph node projection for " table
+ "; columns=" (count (column-keys table))
+ " shape-keys=" (vec (keys attrs)))))
+
+(defn project-attrs
+ "Select and validate the projected columns for `table` from `attrs`."
+ [table attrs]
+ ;; `some?`, not truthiness: `false` and `0` are values. Dropping them sent
+ ;; `opacity 0` to the column default of 1.0 — a fully transparent shape
+ ;; projected as opaque.
+ (let [projected (into {}
+ (keep (fn [k]
+ (let [v (get-projected-attr attrs k)]
+ (when (some? v) [k v])))
+ (column-keys table)))]
+ (when (empty? projected)
+ (raise-empty-projection! table attrs))
+ (validate-node table projected)))
+
+(defn match-label
+ "Cypher node label for MATCH; backtick-wrapped when required by Ladybug."
+ [table]
+ (if (#{"Group" "Boolean"} table)
+ (str "`" table "`")
+ table))
+
+(defn cypher-property-key
+ "Backtick-wrapped column name for inline Cypher literals."
+ [table k]
+ (str "`" (column-name table k) "`"))
+
+(defn column-map-key-fn
+ "How a MAP column of `table` renders its keys.
+
+ A MAP's keys are values, not schema, so they keep the spelling their consumer
+ parsed — `applied_tokens` is keyed in camelCase. Both writers need this, so it
+ lives next to the column's type rather than in either of them."
+ [table k]
+ (contract/map-key-fn (column-name table k)))
+
+(defn format-column-value
+ "Cypher literal for `v` in column `k` of `table`.
+
+ The single place that knows both the column's Ladybug type and the contract
+ detail that a MAP column may render its keys differently from `name` — used
+ by the bulk loader's post-COPY fixups and by the incremental sync alike, so
+ the two cannot disagree about a value's shape."
+ [table k v]
+ (ladybug/format-typed-value (column-ladybug-type table k)
+ v
+ (column-map-key-fn table k)))
+
+(defn- create-node-table-ddl
+ [{:keys [table pk]}]
+ (let [cols (for [k (column-keys table)]
+ (str "`" (column-name table k) "` " (column-ladybug-type table k)))]
+ (str "CREATE NODE TABLE `" table "` ("
+ (str/join ", " (concat cols
+ [(str "PRIMARY KEY (`" (column-name table pk) "`)")]))
+ ");")))
+
+(defn is-child-of-ddl
+ []
+ (str "CREATE REL TABLE `IsChildOf` ("
+ "FROM `Page` TO `Document`, "
+ "FROM `Component` TO `Document`, "
+ (str/join ", "
+ (concat
+ (map (fn [shape]
+ (str "FROM `" shape "` TO `Page`"))
+ shape-tables)
+ (for [shape shape-tables
+ container container-tables]
+ (str "FROM `" shape "` TO `" container "`"))))
+ ", `position` INT64);"))
+
+(defn is-instance-of-ddl
+ "Frame instance heads → Component."
+ []
+ "CREATE REL TABLE `IsInstanceOf` (FROM `Frame` TO `Component`);")
+
+(defn- shape-to-shape-rel-ddl
+ "A rel table over the full shape × shape product.
+
+ Created up-front rather than on demand: the bulk loader must never race on
+ lazy table creation, and a consumer can then tell \"this producer cannot
+ emit that pair\" from \"this document happens to have none\"."
+ [rel props]
+ (str "CREATE REL TABLE `" rel "` ("
+ (str/join ", " (for [from shape-tables
+ to shape-tables]
+ (str "FROM `" from "` TO `" to "`")))
+ (when (seq props) (str ", " (str/join ", " props)))
+ ");"))
+
+(defn refers-to-ddl
+ "Instance shape → its homologue in the component main instance, resolved
+ from `shape-ref`."
+ []
+ (shape-to-shape-rel-ddl "RefersTo" nil))
+
+(defn fills-swap-slot-ddl
+ "Swapped-in shape → the slot shape it replaces."
+ []
+ (shape-to-shape-rel-ddl "FillsSwapSlot" ["`slot_id` UUID"]))
+
+(defn ddl-statements
+ []
+ (-> (mapv create-node-table-ddl node-types)
+ (conj (is-child-of-ddl))
+ (conj (is-instance-of-ddl))
+ (conj (refers-to-ddl))
+ (conj (fills-swap-slot-ddl))))
\ No newline at end of file
diff --git a/backend/src/app/graph/schema/projection.clj b/backend/src/app/graph/schema/projection.clj
new file mode 100644
index 0000000000..4d0e969329
--- /dev/null
+++ b/backend/src/app/graph/schema/projection.clj
@@ -0,0 +1,85 @@
+;; 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.graph.schema.projection
+ "Derive Ladybug node column schemas from Penpot Malli sources.
+
+ Start from the canonical schema and remove the keys that must not become
+ graph columns."
+ (:require
+ [app.common.exceptions :as ex]
+ [app.common.schema :as sm]
+ [app.common.types.shape :as cts]
+ [malli.core :as m]))
+
+(def ^:private malli-opts sm/default-options)
+
+(defn- coerce-schema
+ "Normalize Malli sources to a compiled schema, unwrapping `:val` nodes."
+ [schema]
+ (loop [s (cond
+ (sm/schema? schema) schema
+ :else (sm/schema schema))]
+ (if (= :malli.core/val (sm/type s))
+ (recur (first (sm/children s)))
+ s)))
+
+(defn- unsupported-projection-schema!
+ [schema]
+ (ex/raise :type :internal
+ :code :unsupported-projection-schema
+ :hint (str "unsupported projection schema type: "
+ (sm/type (coerce-schema schema)))))
+
+(defn schema-map-entries
+ "Map entries for `schema`, flattening `:merge` composites."
+ [schema]
+ (let [s (coerce-schema schema)]
+ (or (seq (sm/entries s))
+ (unsupported-projection-schema! schema))))
+
+(defn- select-projected-keys
+ "Project `schema` to a flat map schema, optionally dropping keys."
+ [schema drop-keys]
+ (let [s (coerce-schema schema)
+ keys (if (seq drop-keys)
+ (remove (set drop-keys) (sm/keys s))
+ (sm/keys s))]
+ (sm/select-keys s (vec keys))))
+
+(defn shape-type-schema
+ "Return the compiled Penpot Malli branch for shape type `penpot-type`.
+
+ `m/entries` on the shape `:multi` yields MapEntries whose values are
+ compiled branch schemas (wrapped in `:val`). `m/children` returns raw
+ entry forms and must not be used here."
+ [penpot-type]
+ (let [kw (keyword penpot-type)
+ multi (sm/schema cts/schema:shape-attrs)]
+ (or (some (fn [entry]
+ (when (= kw (key entry))
+ (val entry)))
+ (m/entries multi malli-opts))
+ (ex/raise :type :validation
+ :code :unknown-shape-type
+ :hint (str "unknown penpot shape type: " kw)))))
+
+(defn project-schema
+ "Build a graph node schema from canonical Malli `source`.
+
+ Options:
+ - `:drop` - keys removed from the source
+ - `:extra` - optional extra `[:map ...]` merged on top"
+ [source {:keys [drop extra]}]
+ (let [projected (select-projected-keys source drop)]
+ (if extra
+ (sm/merge projected (coerce-schema extra))
+ projected)))
+
+(defn project-shape-schema
+ "Project `:drop` from the Penpot schema for `penpot-type`."
+ [penpot-type opts]
+ (project-schema (shape-type-schema penpot-type) opts))
diff --git a/backend/src/app/graph/schema/types.clj b/backend/src/app/graph/schema/types.clj
new file mode 100644
index 0000000000..a905d92dcc
--- /dev/null
+++ b/backend/src/app/graph/schema/types.clj
@@ -0,0 +1,174 @@
+;; 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.graph.schema.types
+ "Map Malli schemas to Ladybug column types.
+
+ Ladybug is schema-first and strongly typed: every property key gets its type
+ at table-creation time, and there is no widening later. That makes this
+ mapping the whole of the graph's typing, and it is worth being tight — a
+ column typed `DOUBLE[4]` is four numbers a consumer reads as a tensor row,
+ where the same value as `JSON` is text somebody has to parse and trust. So
+ JSON is the fallback of last resort, taken only where the Malli schema
+ genuinely admits shapes no single column can hold.
+
+ Three groups, in the order the mapping tries them:
+
+ 1. **Scalars** (`base-type->ladybug`) — the leaf Malli types.
+ 2. **Registered composites** (`custom-type->ladybug`) — Penpot's own value
+ types whose *layout* is fixed even though Malli only sees a map or a
+ string: a matrix is six doubles, a point two, a rect four, a hex colour
+ one packed integer. These are named explicitly because the tight encoding
+ is a modelling decision, not something derivable from the schema.
+ 3. **Structure** — collections become `T[]`, `:map-of` becomes `MAP(k, v)`,
+ and a closed map of scalars becomes a `STRUCT`. Anything that could be
+ more than one shape (a `:multi`, an `:or`, an optional-keyed map) becomes
+ `JSON`, because a Ladybug column cannot be two types.
+
+ Every encoding here has a matching value formatter in `app.graph.ladybug`.
+ The two must move together: a column type with no case there falls back to
+ guessing the literal from the runtime value."
+ (:require
+ [app.common.logging :as l]
+ [app.common.schema :as sm]
+ [app.common.time :as ct]
+ [clojure.string :as str]
+ [malli.core :as m]))
+
+(def ^:private malli-opts sm/default-options)
+
+(def ^:private base-type->ladybug
+ {::sm/uuid "UUID"
+ ::sm/safe-number "DOUBLE"
+ ::sm/safe-double "DOUBLE"
+ ::sm/safe-int "INT64"
+ ::sm/number "DOUBLE"
+ ::sm/boolean "BOOLEAN"
+ ::sm/int "INT64"
+ ::ct/inst "TIMESTAMP"
+ :uuid "UUID"
+ :string "STRING"
+ :int "INT64"
+ :double "DOUBLE"
+ :float "DOUBLE"
+ :boolean "BOOLEAN"
+ :keyword "STRING"
+ :inst "TIMESTAMP"})
+
+(def ^:private custom-type->ladybug
+ "Penpot value types with a fixed layout Malli does not express.
+
+ Fixed-size arrays are the point of each: they are dense, they need no
+ parsing, and a consumer can read a whole column as a tensor.
+
+ - `::gmt/matrix` — the affine transform, `[a b c d e f]`.
+ - `::gpt/point` — `[x y]`.
+ - `::grc/rect` — `[x y width height]`. `x1`/`y1`/`x2`/`y2` are dropped: they
+ are derivable from those four, and carrying them would double the column.
+ - `::clr/hex-color` — `#RRGGBB` packed as `0xRRGGBBAA`, so colours compare
+ and group without string handling."
+ {:app.common.geom.matrix/matrix "DOUBLE[6]"
+ :app.common.geom.point/point "DOUBLE[2]"
+ :app.common.geom.rect/rect "DOUBLE[4]"
+ :app.common.types.color/hex-color "UINT32"})
+
+(def ^:private collection-types
+ #{:vector :sequential :set ::sm/vec ::sm/set ::sm/coll})
+
+(def ^:private string-collection-types
+ "Registered collection schemas whose element type is not in `children`."
+ {::sm/set-of-strings "STRING[]"
+ ::sm/set-of-keywords "STRING[]"
+ ::sm/set-of-uuid "UUID[]"
+ ::sm/vec-of-uuid "UUID[]"})
+
+(defn- normalize-schema
+ "Resolve refs, but stop at a schema this namespace maps explicitly.
+
+ Order matters: `::grc/rect` derefs to an `:and` over a map, and following
+ that would lose the fixed-size-array encoding."
+ [schema]
+ (let [s (sm/schema schema)]
+ (if (and (m/-ref-schema? s)
+ (not (contains? custom-type->ladybug (m/type s)))
+ (not (contains? string-collection-types (m/type s))))
+ (recur (m/deref s malli-opts))
+ s)))
+
+(declare ladybug-type)
+
+(defn- entry-child
+ "The value schema of a Malli map entry (`[k s]` or `[k props s]`)."
+ [entry]
+ (if (> (count entry) 2) (nth entry 2) (nth entry 1)))
+
+(defn- entry-optional?
+ [entry]
+ (and (> (count entry) 2)
+ (:optional (nth entry 1))))
+
+(defn- struct-type
+ "`STRUCT(...)` for a closed map of scalars, or nil when JSON is the honest answer.
+
+ A struct is a fixed layout: every field present, every field a single type.
+ An optional key would make the column's shape depend on the row, and a nested
+ collection or map makes it recursive — Ladybug allows nesting, but a consumer
+ reading such a column gains nothing over JSON, so the line is drawn at
+ scalars."
+ [s]
+ (let [entries (m/entries s malli-opts)]
+ (when (and (seq entries)
+ (not-any? entry-optional? entries))
+ (let [fields (for [entry entries
+ :let [t (ladybug-type (entry-child entry))]]
+ (when (and t
+ (not= "JSON" t)
+ (not (str/includes? t "(")))
+ ;; snake_case like a column name, and always
+ ;; backtick-quoted: a grid cell has a field called
+ ;; `column`, which is a Ladybug keyword, and an unquoted
+ ;; one fails to parse in the DDL *and* in every literal.
+ ;; The catalog reports them unquoted.
+ (str "`" (str/replace (name (key entry)) "-" "_") "` " t)))]
+ (when (every? some? fields)
+ (str "STRUCT(" (str/join ", " fields) ")"))))))
+
+(defn ladybug-type
+ "Return the Ladybug column type for a Malli child schema."
+ [schema]
+ (let [s (normalize-schema schema)
+ t (m/type s)]
+ (or (base-type->ladybug t)
+ (custom-type->ladybug t)
+ (string-collection-types t)
+ (when (contains? collection-types t)
+ (when-let [child (first (m/children s malli-opts))]
+ (str (ladybug-type child) "[]")))
+ (case t
+ (:maybe :and) (ladybug-type (first (m/children s malli-opts)))
+
+ ;; `::sm/one-of` is how Penpot spells a closed set of keywords —
+ ;; `:blend-mode`, `:grow-type`, every `:layout-*`. One keyword, one
+ ;; string.
+ (:enum ::sm/one-of) "STRING"
+
+ :map-of
+ (let [[key-schema value-schema] (m/children s malli-opts)]
+ (str "MAP(" (ladybug-type key-schema) ", "
+ (ladybug-type value-schema) ")"))
+
+ :map (or (struct-type s) "JSON")
+
+ ;; A schema we do not recognize. If it has no children it is a leaf —
+ ;; one of Penpot's registered keyword or enum schemas, say — and a
+ ;; string holds it exactly. If it has children it is a composite whose
+ ;; shape we cannot pin down, and JSON is the honest answer.
+ (if (empty? (m/children s malli-opts))
+ "STRING"
+ (do
+ (l/wrn :hint "unmapped composite malli type, defaulting to JSON"
+ :malli-type t)
+ "JSON"))))))
diff --git a/backend/src/app/graph/schema/values.clj b/backend/src/app/graph/schema/values.clj
new file mode 100644
index 0000000000..e93b74f499
--- /dev/null
+++ b/backend/src/app/graph/schema/values.clj
@@ -0,0 +1,202 @@
+;; 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.graph.schema.values
+ "Shape a Penpot value into the plain data its Ladybug column type wants.
+
+ Ladybug is strongly typed, and `app.graph.schema.types` maps Penpot's Malli
+ schemas onto types as tight as it can — a matrix is `DOUBLE[6]`, a rect
+ `DOUBLE[4]`, a colour `UINT32`, a closed map a `STRUCT`. A tight column is
+ only worth having if the writer actually fills it in that shape, which is
+ what this namespace does: it turns records and maps into the numbers, vectors
+ and plain maps the type names.
+
+ It deliberately stops there. Serialization belongs to the writer — Cypher
+ literals in `app.graph.ladybug`, Arrow vectors in `app.graph.arrow` — so that
+ shaping a value and writing it are separate concerns and each has one home.
+
+ The type language is the Ladybug one, read recursively: `T[]`, `T[n]`,
+ `MAP(k, v)`, `STRUCT(name t, …)`. Anything else is passed through."
+ (:require
+ [app.common.geom.matrix :as gmt]
+ [app.common.geom.point :as gpt]
+ [app.common.types.color :as clr]
+ [clojure.string :as str]))
+
+(defn- split-args
+ "Split a comma-separated type argument list, respecting nesting.
+
+ `\"UUID, STRUCT(a INT64, b INT64)\"` → `[\"UUID\" \"STRUCT(a INT64, b INT64)\"]`."
+ [s]
+ (loop [chars (seq s) depth 0 current (StringBuilder.) out []]
+ (if-let [c (first chars)]
+ (cond
+ (and (= c \,) (zero? depth))
+ (recur (rest chars) depth (StringBuilder.) (conj out (str/trim (str current))))
+
+ (or (= c \() (= c \[))
+ (recur (rest chars) (inc depth) (.append current c) out)
+
+ (or (= c \)) (= c \]))
+ (recur (rest chars) (dec depth) (.append current c) out)
+
+ :else
+ (recur (rest chars) depth (.append current c) out))
+ (let [last-arg (str/trim (str current))]
+ (cond-> out (seq last-arg) (conj last-arg))))))
+
+(defn- parse-list
+ "`[element-type]` when `t` is a list or fixed-size array type, else nil.
+
+ `DOUBLE[]` and `DOUBLE[4]` are both lists of doubles as far as shaping goes;
+ the size only matters to the DDL."
+ [t]
+ (when-let [[_ element] (re-matches #"(.+?)\[\d*\]$" t)]
+ [element]))
+
+(defn- parse-map
+ "`[key-type value-type]` when `t` is a MAP type, else nil."
+ [t]
+ (when-let [[_ args] (re-matches #"MAP\((.*)\)$" t)]
+ (let [[k v] (split-args args)]
+ (when (and k v) [k v]))))
+
+(defn- parse-struct
+ "`[[field-name field-type] …]` when `t` is a STRUCT type, else nil.
+
+ Field names arrive backtick-quoted (see `app.graph.schema.types`). The
+ quoting is syntax, so it is stripped by default and re-applied by the writer —
+ except for the Arrow writer, which needs it kept (`keep-quotes?`)."
+ [t keep-quotes?]
+ (when-let [[_ args] (re-matches #"STRUCT\((.*)\)$" t)]
+ (for [arg (split-args args)
+ :let [idx (str/index-of arg " ")]
+ :when idx]
+ [(cond-> (subs arg 0 idx) (not keep-quotes?) (str/replace "`" ""))
+ (str/trim (subs arg (inc idx)))])))
+
+(def ^:private struct-field-keys
+ "Field name → the Penpot keys that may hold it.
+
+ A STRUCT field name is the snake_case of the Penpot key, but a value arrives
+ with its original key, and some arrive from JSON with the string form. Both
+ are tried before giving up."
+ (memoize
+ (fn [field]
+ [(keyword (str/replace field "_" "-"))
+ (keyword field)
+ field
+ (str/replace field "_" "-")])))
+
+(defn- struct-field
+ [value field]
+ (some (fn [k] (when (contains? value k) (get value k)))
+ (struct-field-keys field)))
+
+(defn- fixed-vector
+ "`v` as a plain vector of numbers, for a `DOUBLE[n]` column.
+
+ Records come first because they are what a realized snapshot holds; the map
+ forms are what a JSON round-trip leaves behind."
+ [v]
+ (cond
+ (gmt/matrix? v) [(:a v) (:b v) (:c v) (:d v) (:e v) (:f v)]
+ (gpt/point? v) [(:x v) (:y v)]
+
+ ;; A rect: four of the eight fields, the rest being derivable.
+ (and (map? v) (contains? v :width) (contains? v :height))
+ [(:x v) (:y v) (:width v) (:height v)]
+
+ (and (map? v) (contains? v :x) (contains? v :y))
+ [(:x v) (:y v)]
+
+ (and (map? v) (contains? v :a) (contains? v :f))
+ [(:a v) (:b v) (:c v) (:d v) (:e v) (:f v)]
+
+ (sequential? v) (vec v)
+ :else nil))
+
+(defn- packed-color
+ "`#RRGGBB` as the packed integer `0xRRGGBBAA`.
+
+ Alpha defaults to opaque: the column holds a colour, and any opacity Penpot
+ keeps alongside it is a separate attribute."
+ [v]
+ (cond
+ (integer? v) v
+ (and (string? v) (clr/valid-hex-color? v))
+ (let [rgb (Long/parseLong (subs v 1) 16)]
+ (bit-or (bit-shift-left rgb 8) 0xFF))
+ :else nil))
+
+(def struct-fields
+ "`[[field-name field-type] …]` for a STRUCT type, memoized.
+
+ Public because the writers need the same field list to emit a literal."
+ (memoize (fn [ladybug-type] (vec (parse-struct ladybug-type false)))))
+
+(def struct-fields-quoted
+ "`struct-fields` with the DDL's backticks intact.
+
+ Only the Arrow writer wants this: Ladybug names a staged struct's fields from
+ the Arrow child names and quotes none of them, so a field whose name is a
+ reserved word — a layout grid cell's `column` — has to arrive already quoted
+ or `createArrowTable` fails outright."
+ (memoize (fn [ladybug-type] (vec (parse-struct ladybug-type true)))))
+
+(def map-types
+ "`[key-type value-type]` for a MAP type, memoized."
+ (memoize (fn [ladybug-type] (parse-map ladybug-type))))
+
+(def list-element
+ "Element type of a `T[]` / `T[n]` column, memoized; nil when not a list."
+ (memoize (fn [ladybug-type] (first (parse-list ladybug-type)))))
+
+(declare coerce)
+
+(defn- coerce-struct
+ [fields v]
+ (when (map? v)
+ (into {}
+ (keep (fn [[field field-type]]
+ (when-some [fv (struct-field v field)]
+ [field (coerce field-type fv)])))
+ fields)))
+
+(defn coerce
+ "`v` as the plain data a column of `ladybug-type` holds.
+
+ Returns `nil` when the value cannot be shaped that way, which callers treat
+ as \"write NULL\" — a wrong shape in a strongly typed column fails the whole
+ load, so declining is better than guessing."
+ [ladybug-type v]
+ (cond
+ (nil? v) nil
+ (not (string? ladybug-type)) v
+
+ (= "UINT32" ladybug-type) (packed-color v)
+
+ ;; Fixed-size numeric arrays are records: matrix, point, rect.
+ (re-matches #"DOUBLE\[\d+\]" ladybug-type) (fixed-vector v)
+
+ :else
+ (if-let [[element] (parse-list ladybug-type)]
+ (when (or (sequential? v) (set? v))
+ ;; A set has no order, so its column would otherwise vary between
+ ;; builds of the same file. Sorting makes it deterministic — which is
+ ;; what lets two builds be diffed at all, and what a stable golden
+ ;; needs. Sequential values keep their order: for `shapes` and
+ ;; `points`, the order *is* the content.
+ (let [elements (mapv #(coerce element %) v)]
+ (if (set? v) (vec (sort-by str elements)) elements)))
+ (if-let [[key-type value-type] (parse-map ladybug-type)]
+ (when (map? v)
+ (into {}
+ (map (fn [[k mv]] [(coerce key-type k) (coerce value-type mv)]))
+ v))
+ (if-let [fields (seq (parse-struct ladybug-type false))]
+ (coerce-struct fields v)
+ v)))))
diff --git a/backend/src/app/graph/stats.clj b/backend/src/app/graph/stats.clj
new file mode 100644
index 0000000000..06a4eb2330
--- /dev/null
+++ b/backend/src/app/graph/stats.clj
@@ -0,0 +1,48 @@
+;; 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.graph.stats
+ (:require
+ [app.graph.ladybug :as ladybug]
+ [app.graph.schema.nodes :as nodes]))
+
+(defn- count-on-connection
+ [conn statement]
+ (or (ladybug/query-scalar-on-connection! conn statement) 0))
+
+(defn- rel-table-names
+ "Relationship tables present in the open database.
+
+ Read from the catalog so a newly ported transform's edges are counted
+ without this namespace being told about it."
+ [conn]
+ (->> (ladybug/query-on-connection!
+ conn "CALL show_tables() WHERE type = 'REL' RETURN name;" :max-rows 1000)
+ :rows
+ (map first)))
+
+(defn summarize-connection
+ "Return node/edge counts using an open Ladybug connection."
+ [conn]
+ {:nodes (into {}
+ (map (fn [table]
+ [table (count-on-connection
+ conn
+ (str "MATCH (n:" (nodes/match-label table) ") "
+ "RETURN count(n) AS " table "_c;"))])
+ (map :table nodes/node-types)))
+ :edges (into {}
+ (map (fn [rel]
+ [(keyword rel)
+ (count-on-connection
+ conn
+ (str "MATCH ()-[e:`" rel "`]->() RETURN count(e) AS c;"))]))
+ (rel-table-names conn))})
+
+(defn summarize
+ "Return node/edge counts from the graph database."
+ [db-path]
+ (ladybug/with-connection! db-path summarize-connection))
diff --git a/backend/src/app/graph/sync.clj b/backend/src/app/graph/sync.clj
new file mode 100644
index 0000000000..cb32fe2d47
--- /dev/null
+++ b/backend/src/app/graph/sync.clj
@@ -0,0 +1,900 @@
+;; 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.graph.sync
+ "Incremental Ladybug graph updates from Penpot file-change events."
+ (:require
+ [app.common.logging :as l]
+ [app.common.uuid :as uuid]
+ [app.graph.ladybug :as ladybug]
+ [app.graph.projection.document :as projection.document]
+ [app.graph.schema.nodes :as nodes]
+ [clojure.string :as str])
+ (:import
+ com.ladybugdb.Connection))
+
+(set! *warn-on-reflection* true)
+
+(def ^:private supported-change-types
+ #{:add-obj :mod-obj :del-obj
+ :add-page :del-page :mod-page :mov-objects
+ :add-component :mod-component :del-component
+ :restore-component :purge-component})
+
+(defn- shape-table
+ [shape]
+ (nodes/table-for-type (:type shape)))
+
+(defn- build-parent-map
+ [edges]
+ (into {}
+ (map (fn [{:keys [from-id to-id to-table]}]
+ [from-id {:parent-id to-id :parent-table to-table}]))
+ edges))
+
+(defn- build-children-map
+ [edges]
+ (reduce (fn [acc {:keys [from-id to-id]}]
+ (update acc to-id (fnil conj #{}) from-id))
+ {}
+ edges))
+
+(defn- resolve-page-id
+ [shape-id parents pages]
+ (loop [id shape-id]
+ (cond
+ (contains? pages id) id
+ (get parents id) (recur (:parent-id (parents id)))
+ :else nil)))
+
+(defn- node-attrs-id
+ [attrs]
+ (cond
+ (map? attrs) (or (:id attrs) (get attrs "id"))
+ (and (vector? attrs) (= 2 (count attrs)))
+ (let [[k v] attrs]
+ (when (or (= k :id) (= k "id")) v))))
+
+(defn- table-rows
+ "Normalize a projection table value to a vector of attribute maps."
+ [nodes table]
+ (let [rows (or (get nodes table) (get nodes (keyword table)))]
+ (cond
+ (nil? rows) []
+ (map? rows) [rows]
+ (sequential? rows) (vec rows)
+ :else [])))
+
+(defn- document-id-from-nodes
+ [nodes file-id]
+ (or (some node-attrs-id (table-rows nodes "Document"))
+ file-id))
+
+(defn- page-index-entry
+ [attrs]
+ (let [id (node-attrs-id attrs)]
+ [id {:id id
+ :name (:name attrs)
+ :index (long (:index attrs 0))}]))
+
+(defn- index-pages
+ [nodes]
+ (into {} (map page-index-entry (table-rows nodes "Page"))))
+
+(defn- component-index-entry
+ [attrs]
+ (let [id (node-attrs-id attrs)]
+ [id {:id id
+ :name (:name attrs)
+ :deleted (boolean (:deleted attrs))}]))
+
+(defn- index-components
+ [nodes]
+ (into {} (map component-index-entry (table-rows nodes "Component"))))
+
+(defn- shape-index-table?
+ [table]
+ (not (contains? #{"Document" "Page" "Component"
+ :Document :Page :Component}
+ table)))
+
+(defn- shape-index-entry
+ [table attrs parents pages edges]
+ (let [shape-id (node-attrs-id attrs)
+ {:keys [parent-id parent-table]} (parents shape-id)
+ edge (first (filter #(= shape-id (:from-id %)) edges))]
+ [shape-id {:id shape-id
+ :name (:name attrs)
+ :table table
+ :parent-id parent-id
+ :parent-table parent-table
+ :position (long (:position edge 0))
+ :frame-id (:frame-id attrs)
+ ;; The projection already denormalized these; re-deriving
+ ;; page-id from the parent chain would only be a second way to
+ ;; get the same answer. `:component-ctx` is what later
+ ;; `:add-obj` children inherit — it is the shape's effective
+ ;; component-id, which loses the barrier case of a *non-Frame*
+ ;; carrying its own `component-id` (indistinguishable once
+ ;; denormalized). Cold projection keeps the distinction. Only a
+ ;; graph synced across such a shape can drift, and a Reload
+ ;; rebuilds it.
+ :component-ctx (:component-id attrs)
+ :page-id (or (:page-id attrs)
+ (resolve-page-id shape-id parents pages))}]))
+
+(defn- index-shapes
+ [nodes edges parents pages]
+ (reduce
+ (fn [acc [table _]]
+ (into acc (map #(shape-index-entry table % parents pages edges)
+ (table-rows nodes table))))
+ {}
+ (filter (fn [[table _]] (shape-index-table? table)) nodes)))
+
+(defn build-index
+ "Build a sync index from a full graph projection."
+ [file-id revn {:keys [nodes edges]}]
+ (let [doc-id (document-id-from-nodes nodes file-id)
+ pages (index-pages nodes)
+ components (index-components nodes)
+ parents (build-parent-map edges)
+ children-index (build-children-map edges)
+ shapes (index-shapes nodes edges parents pages)]
+ {:file-id file-id
+ :doc-id doc-id
+ :revn (long revn)
+ :pages pages
+ :components components
+ :shapes shapes
+ :children children-index}))
+
+
+(defn- format-node-value
+ [table k v]
+ (nodes/format-column-value table k v))
+
+(defn- create-node-statement
+ [table attrs]
+ (let [label (nodes/match-label table)
+ pairs (for [k (nodes/column-keys table)
+ :let [v (get attrs k)]
+ :when (some? v)]
+ (str (nodes/cypher-property-key table k) ": "
+ (format-node-value table k v)))]
+ (str "CREATE (:" label " {" (str/join ", " pairs) "});")))
+
+(defn- delete-node-statement
+ [table shape-id]
+ (str "MATCH (n:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) "
+ "DETACH DELETE n;"))
+
+(defn- create-edge-statement
+ [{:keys [from-table from-id to-table to-id position]}]
+ (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "}), "
+ "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) "
+ "CREATE (s)-[:IsChildOf {position: " (ladybug/format-int position) "}]->(p);"))
+
+(defn- create-instance-of-statement
+ "Link a Frame instance head to its Component.
+
+ No-op when the Component is absent (e.g. library component not ingested)."
+ [frame-id component-id]
+ (str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "}), "
+ "(c:Component {id: " (ladybug/format-uuid component-id) "}) "
+ "WHERE NOT COALESCE(c.deleted, false) "
+ "MERGE (f)-[:IsInstanceOf]->(c);"))
+
+(defn- delete-instance-of-statement
+ [frame-id]
+ (str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "})"
+ "-[r:IsInstanceOf]->(:Component) "
+ "DELETE r;"))
+
+(defn- instance-of-statements
+ "Cypher to (re)link `IsInstanceOf` after add/mod of a Frame's component-id."
+ [table shape-id component-id]
+ (when (= table "Frame")
+ (cond-> [(delete-instance-of-statement shape-id)]
+ (some? component-id)
+ (conj (create-instance-of-statement shape-id component-id)))))
+
+(defn- delete-edge-statement
+ [{:keys [from-table from-id to-table to-id]}]
+ (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})"
+ "-[r:IsChildOf]->"
+ "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) "
+ "DELETE r;"))
+
+(defn- set-edge-position-statement
+ [{:keys [from-table from-id to-table to-id position]}]
+ (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})"
+ "-[r:IsChildOf]->"
+ "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) "
+ "SET r.position = " (ladybug/format-int position) ";"))
+
+(defn- set-node-attr-statement
+ [table shape-id attr value]
+ (str "MATCH (s:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) "
+ "SET s." (nodes/cypher-property-key table attr) " = "
+ (format-node-value table attr value) ";"))
+
+(defn- set-page-name-statement
+ [page-id name]
+ (str "MATCH (p:Page {id: " (ladybug/format-uuid page-id) "}) "
+ "SET p.name = " (ladybug/format-string name) ";"))
+
+(defn- remove-node-attr-statement
+ "Clear a property. Ladybug has no Neo4j-style REMOVE; SET to NULL."
+ [table shape-id attr]
+ (str "MATCH (s:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) "
+ "SET s." (nodes/cypher-property-key table attr) " = NULL;"))
+
+(defn- index-add-component!
+ [index {:keys [id name doc-id]}]
+ (-> index
+ (assoc-in [:components id] {:id id :name name :deleted false})
+ (update :children update doc-id (fnil conj #{}) id)))
+
+(defn- index-remove-component!
+ [index component-id]
+ (let [doc-id (:doc-id index)]
+ (-> index
+ (update :components dissoc component-id)
+ (update :children update doc-id #(disj (or % #{}) component-id)))))
+
+
+(defn- set-document-revision-statement
+ "Set the Document's revision number.
+
+ `app.graph.schema.contract` names the column `revision`, not `revn`. The name
+ is produced by `nodes/cypher-property-key`, so this statement and the DDL
+ cannot disagree."
+ [doc-id revn]
+ (str "MATCH (d:Document {id: " (ladybug/format-uuid doc-id) "}) "
+ "SET d." (nodes/cypher-property-key "Document" :revn) " = "
+ (ladybug/format-int revn) ";"))
+
+(defn- resolve-parent-for-add
+ [index {:keys [parent-id frame-id page-id]}]
+ (let [pid (or parent-id frame-id)]
+ (if (or (nil? pid) (uuid/zero? pid))
+ (when page-id
+ {:parent-id page-id :parent-table "Page"})
+ (if-let [shape (get-in index [:shapes pid])]
+ {:parent-id pid :parent-table (:table shape)}
+ (when (get-in index [:pages pid])
+ {:parent-id pid :parent-table "Page"})))))
+
+(defn- index-add-shape!
+ [index {:keys [id name table parent-id parent-table position page-id
+ frame-id component-ctx]}]
+ (-> index
+ (assoc-in [:shapes id]
+ {:id id
+ :name name
+ :table table
+ :parent-id parent-id
+ :parent-table parent-table
+ :position position
+ :frame-id frame-id
+ :component-ctx component-ctx
+ :page-id page-id})
+ (update :children update parent-id (fnil conj #{}) id)))
+
+(defn- index-remove-shape!
+ [index shape-id]
+ (if-let [shape (get-in index [:shapes shape-id])]
+ (-> index
+ (update :shapes dissoc shape-id)
+ (update :children update (:parent-id shape)
+ #(disj (or % #{}) shape-id))
+ (update :children dissoc shape-id))
+ index))
+
+(defn- index-add-page!
+ [index {:keys [id name doc-id] page-index :index}]
+ (-> index
+ (assoc-in [:pages id] {:id id :name name :index page-index})
+ (update :children update doc-id (fnil conj #{}) id)))
+
+(defn- index-move-shape!
+ [index shape-id {:keys [parent-id parent-table position page-id frame-id]}]
+ (let [old-parent (get-in index [:shapes shape-id :parent-id])]
+ (-> index
+ (assoc-in [:shapes shape-id :parent-id] parent-id)
+ (assoc-in [:shapes shape-id :parent-table] parent-table)
+ (assoc-in [:shapes shape-id :position] position)
+ (assoc-in [:shapes shape-id :frame-id] frame-id)
+ (cond-> page-id (assoc-in [:shapes shape-id :page-id] page-id))
+ (update :children update old-parent #(disj (or % #{}) shape-id))
+ (update :children update parent-id (fnil conj #{}) shape-id))))
+
+;; --- the columns that restate parenthood
+;;
+;; A shape carries `parent_id` and `frame_id`, and a container carries the
+;; ordered `shapes` list. All three restate what `IsChildOf` already says, and
+;; the cold projection writes them from the file, so this path has to keep
+;; them in step or a synced graph stops matching a rebuilt one.
+
+(defn- shape-parent-id
+ "The `parent_id` a shape's own column holds.
+
+ A top-level shape's parent in the file is the page's root frame, which the
+ graph does not materialize, so `IsChildOf` points at the Page while the
+ column holds `uuid/zero`."
+ [parent-id parent-table]
+ (if (= "Page" parent-table) uuid/zero parent-id))
+
+(defn- frame-id-under
+ "The `frame_id` a shape gets when its parent is `parent-id`.
+
+ Penpot's rule, from `app.common.files.changes` `:mov-objects`: the parent
+ itself when the parent is a Frame, the parent's own frame otherwise."
+ [index parent-id parent-table]
+ (cond
+ (= "Page" parent-table) uuid/zero
+ (= "Frame" parent-table) parent-id
+ :else (get-in index [:shapes parent-id :frame-id] uuid/zero)))
+
+(defn- frame-id-updates
+ "`[shape-id frame-id]` for a moved shape and everything that follows it.
+
+ A Frame keeps its descendants pointing at itself, so the walk stops there.
+ Any other shape carries its subtree onto the new frame."
+ [index shape-id frame-id]
+ (into [[shape-id frame-id]]
+ (when (not= "Frame" (get-in index [:shapes shape-id :table]))
+ (mapcat #(frame-id-updates index % frame-id)
+ (get-in index [:children shape-id] #{})))))
+
+(defn- child-shapes-value
+ "A container's stored `shapes` list, rebuilt from the index.
+
+ `IsChildOf.position` counts from the last entry of that list
+ (`app.graph.projection.document/child-shape-ids` reverses it), so reversing the
+ children ordered by position gives the list back."
+ [index parent-id]
+ (->> (get-in index [:children parent-id] #{})
+ (sort-by #(get-in index [:shapes % :position] 0))
+ reverse
+ vec))
+
+(defn- insert-position
+ "The graph position the lowest of `k` shapes takes when they are inserted
+ into a parent that already holds `n-before` children.
+
+ A container's stored `:shapes` list runs bottom to top, and the graph
+ numbers children in Penpot z-order, so the two run opposite ways. An append
+ to the stored list, which is what `:add-obj` does without an `:index`, is
+ therefore position 0 and pushes every sibling up by one. The block occupies
+ the result and the `k - 1` positions above it, the first shape highest."
+ [n-before {:keys [index]} after-position]
+ (cond
+ (some? after-position) (long after-position)
+ (some? index) (max 0 (- n-before (long index)))
+ :else 0))
+
+(defn- renumber-siblings
+ "Shift `parent-id`'s children at or above `from` by `delta`.
+
+ Returns `[index statements]`. `except` names children the caller is placing
+ itself."
+ [index parent-id parent-table from delta except]
+ (reduce
+ (fn [[idx stmts] child-id]
+ (let [pos (get-in idx [:shapes child-id :position])]
+ (if (and (some? pos) (not (contains? except child-id)) (>= (long pos) (long from)))
+ (let [pos' (+ (long pos) (long delta))]
+ [(assoc-in idx [:shapes child-id :position] pos')
+ (conj stmts (set-edge-position-statement
+ {:from-table (get-in idx [:shapes child-id :table])
+ :from-id child-id
+ :to-table parent-table
+ :to-id parent-id
+ :position pos'}))])
+ [idx stmts])))
+ [index []]
+ (vec (get-in index [:children parent-id] #{}))))
+
+(defn- set-children-statements
+ "Refresh the `shapes` column of every container in `parent-ids`.
+
+ A Page has no such column: its top-level shapes hang off a root frame the
+ graph never materializes."
+ [index parent-ids]
+ (into []
+ (comp (distinct)
+ (keep (fn [parent-id]
+ (let [table (get-in index [:shapes parent-id :table])]
+ (when (contains? nodes/container-tables table)
+ (set-node-attr-statement
+ table parent-id :shapes
+ (child-shapes-value index parent-id)))))))
+ parent-ids))
+
+(defn- mov-object-ids
+ [shapes]
+ (let [coll (cond
+ (nil? shapes) []
+ (sequential? shapes) shapes
+ (uuid? shapes) [shapes]
+ (map? shapes) (if-let [id (or (:id shapes) (get shapes "id"))]
+ [id]
+ [])
+ :else [])]
+ (into []
+ (keep (fn [shape]
+ (when shape
+ (if (uuid? shape) shape (:id shape)))))
+ coll)))
+
+(defn- detach-shape
+ "Take `shape-id` out of its current parent and close the gap it leaves.
+
+ Returns `[index statements]`. The edge itself is left alone: the caller
+ either replaces it or deletes it."
+ [index shape-id]
+ (let [{:keys [parent-id parent-table position]} (get-in index [:shapes shape-id])
+ index (update-in index [:children parent-id] #(disj (or % #{}) shape-id))
+ [index stmts] (renumber-siblings index parent-id parent-table
+ (inc (long (or position 0))) -1 #{})]
+ [(assoc-in index [:shapes shape-id :position] nil) stmts]))
+
+(defn- apply-mov-objects
+ [index {:keys [shapes page-id] :as change}]
+ (let [shape-ids (mov-object-ids shapes)
+ parent (resolve-parent-for-add index
+ (assoc change
+ :frame-id (:parent-id change)
+ :page-id page-id))]
+ (cond
+ (empty? shape-ids)
+ {:index index :statements [] :applied? true}
+
+ (not parent)
+ {:index index :statements [] :applied? false :reason :missing-parent}
+
+ :else
+ (let [parent-id (:parent-id parent)
+ parent-table (:parent-table parent)
+ page-id' (or page-id
+ (when (= parent-table "Page") parent-id)
+ (get-in index [:shapes (first shape-ids) :page-id]))
+ known (filterv #(get-in index [:shapes %]) shape-ids)
+ old-parents (mapv #(get-in index [:shapes % :parent-id]) known)
+ ;; Penpot removes the shapes from wherever they were, then inserts
+ ;; the block into the target, so the target's width is measured
+ ;; after the removals.
+ [index detach-stmts]
+ (reduce (fn [[idx stmts] shape-id]
+ (let [[idx' s] (detach-shape idx shape-id)]
+ [idx' (into stmts s)]))
+ [index []]
+ known)
+ n-before (count (get-in index [:children parent-id] #{}))
+ after-pos (get-in index [:shapes (:after-shape change) :position])
+ lowest (insert-position n-before change after-pos)
+ k (count known)
+ [index shift-stmts]
+ (renumber-siblings index parent-id parent-table lowest k #{})]
+ (loop [index index
+ statements (into detach-stmts shift-stmts)
+ entries (map-indexed vector known)]
+ (if-let [[offset shape-id] (first entries)]
+ (let [shape (get-in index [:shapes shape-id])
+ position (+ lowest (- k 1 (long offset)))
+ frame-id (frame-id-under index parent-id parent-table)
+ frame-writes (frame-id-updates index shape-id frame-id)
+ edge {:from-table (:table shape)
+ :from-id shape-id
+ :to-table parent-table
+ :to-id parent-id
+ :position position}
+ moved? (not= parent-id (:parent-id shape))
+ statements (-> statements
+ (cond-> moved?
+ (conj (delete-edge-statement
+ {:from-table (:table shape)
+ :from-id shape-id
+ :to-table (:parent-table shape)
+ :to-id (:parent-id shape)})))
+ (conj (if moved?
+ (create-edge-statement edge)
+ (set-edge-position-statement edge))))
+ ;; The shape's own columns restate the edge, and the frame
+ ;; follows the whole subtree the shape carries with it.
+ statements (if-not moved?
+ statements
+ (into (conj statements
+ (set-node-attr-statement
+ (:table shape) shape-id :parent-id
+ (shape-parent-id parent-id parent-table)))
+ (map (fn [[sid fid]]
+ (set-node-attr-statement
+ (get-in index [:shapes sid :table])
+ sid :frame-id fid)))
+ frame-writes))
+ index (index-move-shape! index shape-id
+ {:parent-id parent-id
+ :parent-table parent-table
+ :position position
+ :frame-id frame-id
+ :page-id page-id'})
+ index (reduce (fn [idx [sid fid]]
+ (assoc-in idx [:shapes sid :frame-id] fid))
+ index
+ frame-writes)]
+ (recur index statements (rest entries)))
+ {:index index
+ :statements (into statements
+ (set-children-statements index (conj old-parents parent-id)))
+ :applied? true}))))))
+
+(defn- index-remove-page!
+ [index page-id]
+ (let [doc-id (:doc-id index)]
+ (-> index
+ (update :pages dissoc page-id)
+ (update :children update doc-id #(disj (or % #{}) page-id))
+ (update :children dissoc page-id))))
+
+(defn- mod-attrs-for-table
+ [table]
+ (disj (set (nodes/column-keys table)) :id))
+
+(defn- apply-add-obj
+ [index change]
+ (let [{:keys [id obj page-id]} change
+ table (shape-table obj)]
+ (if-not table
+ {:index index :statements [] :applied? false :reason :unsupported-shape-type}
+ (let [parent (resolve-parent-for-add index change)]
+ (if-not parent
+ {:index index :statements [] :applied? false :reason :missing-parent}
+ (let [parent-id (:parent-id parent)
+ parent-table (:parent-table parent)
+ n-before (count (get-in index [:children parent-id] #{}))
+ position (insert-position n-before change nil)
+ [index shift-stmts]
+ (renumber-siblings index parent-id parent-table position 1 #{})
+ ;; The same denormalizations the cold projection performs, so
+ ;; a live-synced graph and a rebuilt one carry equal columns.
+ resolved-page-id
+ (or page-id
+ (when (= parent-table "Page") parent-id)
+ (get-in index [:shapes parent-id :page-id]))
+ parent-ctx (get-in index [:shapes parent-id :component-ctx])
+ shape (projection.document/denormalized-shape
+ (assoc obj :id id) resolved-page-id parent-ctx)
+ attrs (nodes/project-attrs table shape)
+ edge {:from-table table
+ :from-id id
+ :to-table parent-table
+ :to-id parent-id
+ :position position}
+ stmts (-> shift-stmts
+ (conj (create-node-statement table attrs))
+ (conj (create-edge-statement edge))
+ (into (instance-of-statements table id (:component-id attrs))))
+ index' (index-add-shape! index
+ {:id id
+ :name (:name attrs)
+ :table table
+ :parent-id parent-id
+ :parent-table parent-table
+ :position position
+ :frame-id (:frame-id attrs)
+ :component-ctx (projection.document/descend-component-ctx
+ table shape parent-ctx)
+ :page-id resolved-page-id})]
+ {:index index'
+ :statements (into stmts (set-children-statements index' [parent-id]))
+ :applied? true}))))))
+
+(defn- apply-mod-obj
+ [index {:keys [id operations]}]
+ (if-let [shape (get-in index [:shapes id])]
+ (let [table (:table shape)
+ syncable (mod-attrs-for-table table)
+ set-ops (filter #(and (= :set (:type %))
+ (contains? syncable (:attr %)))
+ operations)]
+ (if (empty? set-ops)
+ {:index index :statements [] :applied? false :reason :unsupported-operations}
+ (let [updates (into {} (map (juxt :attr :val) set-ops))
+ statements
+ (into (vec (for [[attr value] updates]
+ (set-node-attr-statement table id attr value)))
+ ;; Relink when component-id is among the synced attrs.
+ (when (contains? updates :component-id)
+ (instance-of-statements table id (:component-id updates))))
+ index' (reduce (fn [idx [attr value]]
+ (assoc-in idx [:shapes id attr] value))
+ index
+ updates)]
+ {:index index'
+ :statements statements
+ :applied? true})))
+ {:index index :statements [] :applied? false :reason :missing-shape}))
+
+(defn- delete-order-deepest-first
+ [children root-id]
+ (letfn [(post-order [id]
+ (into (mapcat post-order (get children id #{}))
+ [id]))]
+ (post-order root-id)))
+
+(defn- apply-del-obj
+ [index {:keys [id]}]
+ (if-let [root (get-in index [:shapes id])]
+ (let [to-delete (delete-order-deepest-first (:children index) id)
+ statements
+ (vec (mapcat (fn [shape-id]
+ (let [{:keys [table parent-id parent-table]}
+ (get-in index [:shapes shape-id])]
+ [(delete-edge-statement
+ {:from-table table
+ :from-id shape-id
+ :to-table parent-table
+ :to-id parent-id})
+ (delete-node-statement table shape-id)]))
+ to-delete))
+ index' (reduce index-remove-shape! index to-delete)
+ ;; Only the deleted subtree's own parent survives to be renumbered:
+ ;; every other parent in `to-delete` goes with it.
+ [index' shift-stmts]
+ (renumber-siblings index' (:parent-id root) (:parent-table root)
+ (inc (long (or (:position root) 0))) -1 #{})]
+ {:index index'
+ :statements (-> statements
+ (into shift-stmts)
+ (into (set-children-statements index' [(:parent-id root)])))
+ :applied? true})
+ ;; Penpot emits one :del-obj per selected shape; an earlier change in the
+ ;; same batch may have already removed this node (e.g. parent + child).
+ {:index index :statements [] :applied? true}))
+
+(defn- apply-add-page
+ [index {:keys [id name page]}]
+ (let [page-id (or id (:id page))
+ page (or page {:id page-id :name name})
+ page (nodes/project-attrs "Page" {:id page-id
+ :name (or (:name page) "Page")
+ :index (count (:pages index))})
+ doc-id (:doc-id index)
+ position (count (:pages index))
+ edge {:from-table "Page"
+ :from-id page-id
+ :to-table "Document"
+ :to-id doc-id
+ :position position}]
+ {:index (index-add-page! index
+ {:id page-id
+ :name (:name page)
+ :index (:index page)
+ :doc-id doc-id})
+ :statements [(create-node-statement "Page" page)
+ (create-edge-statement edge)]
+ :applied? true}))
+
+(defn- apply-del-page
+ [index {:keys [id]}]
+ (if (get-in index [:pages id])
+ (let [shape-ids (into #{}
+ (comp (filter #(= id (get-in index [:shapes % :page-id])))
+ (filter #(= "Page" (get-in index [:shapes % :parent-table]))))
+ (keys (:shapes index)))
+ del-shapes
+ (reduce (fn [acc shape-id]
+ (let [result (apply-del-obj acc {:type :del-obj :id shape-id})]
+ (if (:applied? result)
+ (-> acc
+ (assoc :index (:index result))
+ (update :statements into (:statements result)))
+ acc)))
+ {:index index :statements []}
+ shape-ids)
+ statements
+ (conj (:statements del-shapes)
+ (delete-edge-statement {:from-table "Page"
+ :from-id id
+ :to-table "Document"
+ :to-id (:doc-id index)})
+ (delete-node-statement "Page" id))]
+ {:index (-> (:index del-shapes) (index-remove-page! id))
+ :statements statements
+ :applied? true})
+ {:index index :statements [] :applied? false :reason :missing-page}))
+
+(defn- apply-mod-page
+ [index {:keys [id name]}]
+ (if (and (string? name) (get-in index [:pages id]))
+ {:index (assoc-in index [:pages id :name] name)
+ :statements [(set-page-name-statement id name)]
+ :applied? true}
+ {:index index :statements [] :applied? false :reason :unsupported-page-change}))
+
+(defn- component-syncable-attrs
+ "Projected Component columns that sync may SET (everything but :id)."
+ []
+ (disj (set (nodes/column-keys "Component")) :id))
+
+(defn- component-attrs-from-change
+ "Build CREATE attrs for `:add-component` (objects are not projected)."
+ [{:keys [id name path main-instance-id main-instance-page
+ annotation variant-id variant-properties]}]
+ (cond-> {:id id
+ :name (or name "Component")
+ :path (or path "")
+ :main-instance-id main-instance-id
+ :main-instance-page main-instance-page}
+ (some? annotation) (assoc :annotation annotation)
+ (some? variant-id) (assoc :variant-id variant-id)
+ (seq variant-properties) (assoc :variant-properties variant-properties)))
+
+(defn- apply-add-component
+ [index {:keys [id] :as change}]
+ (if (get-in index [:components id])
+ {:index index :statements [] :applied? true}
+ (let [doc-id (:doc-id index)
+ position (count (:components index))
+ attrs (nodes/project-attrs "Component" (component-attrs-from-change change))
+ edge {:from-table "Component"
+ :from-id id
+ :to-table "Document"
+ :to-id doc-id
+ :position position}]
+ {:index (index-add-component! index
+ {:id id
+ :name (:name attrs)
+ :doc-id doc-id})
+ :statements [(create-node-statement "Component" attrs)
+ (create-edge-statement edge)]
+ :applied? true})))
+
+(defn- apply-mod-component
+ "Update projected Component attrs from a `:mod-component` change.
+
+ Nil optional values clear the property (Penpot dissocs them). `:objects`
+ is never projected — shape trees live on pages."
+ [index {:keys [id] :as change}]
+ (let [syncable (component-syncable-attrs)
+ sets (into {}
+ (keep (fn [[k v]]
+ (when (and (contains? syncable k) (some? v))
+ [k v])))
+ (dissoc change :type :id :objects))
+ removes (into []
+ (keep (fn [[k v]]
+ (when (and (contains? syncable k) (nil? v))
+ k)))
+ (dissoc change :type :id :objects))
+ stmts (into (mapv (fn [[k v]]
+ (set-node-attr-statement "Component" id k v))
+ sets)
+ (map #(remove-node-attr-statement "Component" id %) removes))
+ index' (if (get-in index [:components id])
+ (cond-> index
+ (contains? sets :name)
+ (assoc-in [:components id :name] (:name sets)))
+ (assoc-in index [:components id]
+ {:id id
+ :name (:name sets)
+ :deleted false}))]
+ (if (empty? stmts)
+ {:index index :statements [] :applied? true}
+ {:index index' :statements stmts :applied? true})))
+
+(defn- apply-del-component
+ [index {:keys [id skip-undelete?]}]
+ (cond
+ (not (get-in index [:components id]))
+ {:index index :statements [] :applied? true}
+
+ skip-undelete?
+ {:index (index-remove-component! index id)
+ :statements [(delete-edge-statement {:from-table "Component"
+ :from-id id
+ :to-table "Document"
+ :to-id (:doc-id index)})
+ (delete-node-statement "Component" id)]
+ :applied? true}
+
+ :else
+ {:index (assoc-in index [:components id :deleted] true)
+ :statements [(set-node-attr-statement "Component" id :deleted true)]
+ :applied? true}))
+
+(defn- apply-restore-component
+ [index {:keys [id page-id]}]
+ (let [stmts (cond-> [(set-node-attr-statement "Component" id :deleted false)]
+ page-id
+ (conj (set-node-attr-statement "Component" id :main-instance-page page-id)))
+ index (if (get-in index [:components id])
+ (-> index
+ (assoc-in [:components id :deleted] false)
+ (cond-> page-id
+ (assoc-in [:components id :main-instance-page] page-id)))
+ (assoc-in index [:components id]
+ {:id id :name nil :deleted false}))]
+ {:index index :statements stmts :applied? true}))
+
+(defn- apply-purge-component
+ [index {:keys [id]}]
+ (if-not (get-in index [:components id])
+ ;; Still attempt delete in case the node exists but was not indexed.
+ {:index index
+ :statements [(delete-edge-statement {:from-table "Component"
+ :from-id id
+ :to-table "Document"
+ :to-id (:doc-id index)})
+ (delete-node-statement "Component" id)]
+ :applied? true}
+ {:index (index-remove-component! index id)
+ :statements [(delete-edge-statement {:from-table "Component"
+ :from-id id
+ :to-table "Document"
+ :to-id (:doc-id index)})
+ (delete-node-statement "Component" id)]
+ :applied? true}))
+
+(defn- apply-change
+ [index change]
+ (case (:type change)
+ :add-obj (apply-add-obj index change)
+ :mod-obj (apply-mod-obj index change)
+ :del-obj (apply-del-obj index change)
+ :add-page (apply-add-page index change)
+ :del-page (apply-del-page index change)
+ :mod-page (apply-mod-page index change)
+ :mov-objects (apply-mov-objects index change)
+ :add-component (apply-add-component index change)
+ :mod-component (apply-mod-component index change)
+ :del-component (apply-del-component index change)
+ :restore-component (apply-restore-component index change)
+ :purge-component (apply-purge-component index change)
+ {:index index :statements [] :applied? false :reason :unsupported-type}))
+
+(defn apply-changes!
+ "Apply Penpot `changes` to an open Ladybug `conn` and return the updated index.
+
+ Returns `{:index ... :revn ... :applied [...] :skipped [...]}`."
+ [^Connection conn index changes revn]
+ (when (> (long revn) (:revn index))
+ (l/wrn :hint "graph sync revn gap"
+ :file-id (str (:file-id index))
+ :index-revn (:revn index)
+ :change-revn revn))
+ (loop [index index
+ applied []
+ skipped []
+ stmts []
+ changes (seq changes)]
+ (if-let [change (first changes)]
+ (let [{:keys [index statements applied? reason]}
+ (apply-change index change)]
+ (recur index
+ (cond-> applied applied? (conj (:type change)))
+ (cond-> skipped (not applied?) (conj {:type (:type change) :reason reason}))
+ (cond-> stmts applied? (into statements))
+ (rest changes)))
+ (let [final-stmts (cond-> stmts
+ (and (seq applied) (:doc-id index))
+ (conj (set-document-revision-statement (:doc-id index) revn)))
+ index' (if (seq applied)
+ (assoc index :revn (long revn))
+ index)]
+ (when (seq final-stmts)
+ (ladybug/exec-on-connection! conn final-stmts))
+ {:index index'
+ :revn (if (seq applied) (long revn) (:revn index'))
+ :applied applied
+ :skipped skipped}))))
+
+(defn supported-change?
+ [change]
+ (contains? supported-change-types (:type change)))
diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj
index e991fd9849..d3496a58e5 100644
--- a/backend/src/app/http.clj
+++ b/backend/src/app/http.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http
(:require
diff --git a/backend/src/app/http/access_token.clj b/backend/src/app/http/access_token.clj
index 02d877b1df..57f19fdda8 100644
--- a/backend/src/app/http/access_token.clj
+++ b/backend/src/app/http/access_token.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.access-token
(:require
diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj
index 1458b06d27..5c35e9dbf4 100644
--- a/backend/src/app/http/assets.clj
+++ b/backend/src/app/http/assets.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.assets
"Assets related handlers."
@@ -14,6 +14,7 @@
[app.db :as db]
[app.http.access-token :as actoken]
[app.http.session :as session]
+ [app.rpc.permissions :as perms]
[app.storage :as sto]
[integrant.core :as ig]
[yetti.response :as-alias yres]))
@@ -40,20 +41,38 @@
(ex/raise :type :not-found
:hint "object not found")))
+(defn- get-share-id
+ "Extract and validate the optional `share-id` query param. Returns a UUID
+ or `nil` for missing/malformed values."
+ [{:keys [query-params]}]
+ (some-> query-params :share-id d/parse-uuid))
+
(defn- get-file-media-object
[pool id]
- (db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
+ (db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
(defn- serve-object-from-s3
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
(let [sig-max-age (or signature-max-age default-signature-max-age)
cch-max-age (or cache-max-age default-cache-max-age)
- {:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
+ bucket (-> obj meta :bucket)
+ public? (contains? public-buckets bucket)
+ ;; The disposition is also signed into the presigned url: this
+ ;; response is a redirect, so the header below applies to the
+ ;; redirect itself and not to the bytes the client then fetches
+ ;; from the object store.
+ {:keys [host port] :as url} (sto/get-object-url storage obj
+ (cond-> {:max-age sig-max-age}
+ (not public?)
+ (assoc :content-disposition "attachment")))
+ headers (cond-> {"location" (str url)
+ "x-host" (cond-> host port (str ":" port))
+ "x-mtype" (-> obj meta :content-type)
+ "cache-control" (str "max-age=" (inst-ms cch-max-age))}
+ (not public?)
+ (assoc "content-disposition" "attachment"))]
{::yres/status 307
- ::yres/headers {"location" (str url)
- "x-host" (cond-> host port (str ":" port))
- "x-mtype" (-> obj meta :content-type)
- "cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
+ ::yres/headers headers}))
(defn- serve-object-from-fs
[{:keys [::path ::cache-max-age]} obj]
@@ -61,9 +80,12 @@
purl (u/join (u/uri path)
(sto/object->relative-path obj))
mdata (meta obj)
- headers {"x-accel-redirect" (:path purl)
- "content-type" (:content-type mdata)
- "cache-control" (str "max-age=" (inst-ms cch-max-age))}]
+ bucket (:bucket mdata)
+ headers (cond-> {"x-accel-redirect" (:path purl)
+ "content-type" (:content-type mdata)
+ "cache-control" (str "max-age=" (inst-ms cch-max-age))}
+ (not (contains? public-buckets bucket))
+ (assoc "content-disposition" "attachment"))]
{::yres/status 204
::yres/headers headers}))
@@ -81,17 +103,32 @@
(let [bucket (-> obj meta :bucket)]
(not (contains? public-buckets bucket))))
+(defn- request-profile-id
+ "Extract the authenticated profile-id from the request."
+ [request]
+ (or (::session/profile-id request)
+ (::actoken/profile-id request)))
+
(defn- authenticated?
"Check if the request has an authenticated profile, either via session
or access token."
[request]
- (or (some? (::session/profile-id request))
- (some? (::actoken/profile-id request))))
+ (some? (request-profile-id request)))
+
+(defn- tempfile-owner-match?
+ "Check if the request's profile-id matches the tempfile's stored owner.
+ Returns true if no profile-id was stored (legacy objects)."
+ [obj request]
+ (let [stored-profile-id (:profile-id (meta obj))
+ request-profile-id (request-profile-id request)]
+ (or (nil? stored-profile-id)
+ (= stored-profile-id request-profile-id))))
(defn objects-handler
"Handler that serves storage objects by id.
For non-public buckets (e.g. profile), requires authentication
- via session cookie or access token."
+ via session cookie or access token.
+ For tempfile bucket, also requires ownership (profile-id match)."
[{:keys [::sto/storage] :as cfg} request]
(let [id (get-id request)
obj (sto/get-object storage id)]
@@ -103,19 +140,32 @@
(not (authenticated? request)))
{::yres/status 401}
+ (and (= (-> obj meta :bucket) sto/tempfile-bucket)
+ (not (tempfile-owner-match? obj request)))
+ {::yres/status 404}
+
:else
(serve-object cfg obj))))
(defn- generic-handler
"A generic handler helper/common code for file-media based handlers."
[{:keys [::sto/storage] :as cfg} request kf]
- (let [pool (::db/pool storage)
- id (get-id request)
- mobj (get-file-media-object pool id)
- sobj (sto/get-object storage (kf mobj))]
- (if sobj
- (serve-object cfg sobj)
- {::yres/status 404})))
+ (let [pool (::db/pool storage)
+ id (get-id request)
+ mobj (get-file-media-object pool id)]
+ (if (nil? mobj)
+ {::yres/status 404}
+ (let [file-id (:file-id mobj)
+ profile-id (or (::session/profile-id request)
+ (::actoken/profile-id request))
+ share-id (get-share-id request)
+ perms (perms/get-file-read-permissions pool profile-id file-id share-id)]
+ (if-not (:can-read perms)
+ {::yres/status 404}
+ (let [sobj (sto/get-object storage (kf mobj))]
+ (if sobj
+ (serve-object cfg sobj)
+ {::yres/status 404})))))))
(defn file-objects-handler
"Handler that serves storage objects by file media id."
diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj
index 3dddc1045d..a3c2f03ec7 100644
--- a/backend/src/app/http/awsns.clj
+++ b/backend/src/app/http/awsns.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.awsns
"AWS SNS webhook handler for bounces."
diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj
index bba77f9aa0..db5ec6ffbe 100644
--- a/backend/src/app/http/client.clj
+++ b/backend/src/app/http/client.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.client
"Http client abstraction layer.
diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj
index 26cac774f6..edcef0bb50 100644
--- a/backend/src/app/http/debug.clj
+++ b/backend/src/app/http/debug.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.debug
(:refer-clojure :exclude [error-handler])
@@ -13,6 +13,10 @@
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
+ [app.common.files.changes :as cfc]
+ [app.common.files.repair :as cfr]
+ [app.common.files.validate :as cfv]
+ [app.common.json :as json]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.time :as ct]
@@ -22,17 +26,20 @@
[app.db :as db]
[app.features.file-migrations :as feat.fmig]
[app.http.session :as session]
+ [app.redis :as rds]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.files-create :refer [create-file]]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
[app.setup :as-alias setup]
[app.setup.clock :as clock]
+ [app.srepl.helpers :as h]
[app.srepl.main :as srepl]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
[app.util.template :as tmpl]
[cuerdas.core :as str]
+ [datoteka.fs :as fs]
[datoteka.io :as io]
[emoji.core :as emj]
[integrant.core :as ig]
@@ -47,21 +54,66 @@
;; INDEX
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+(def ^:private max-export-jobs 200)
+
+(defn- scan-export-job-keys
+ "Note: no index for now, get them all and filter"
+ [conn pattern]
+ (loop [cursor "0"
+ found []]
+ (let [[cursor keys] (rds/scan conn cursor pattern max-export-jobs)
+ found (into found keys)]
+ (if (or (nil? cursor)
+ (= "0" cursor)
+ (>= (count found) max-export-jobs))
+ (into [] (take max-export-jobs) found)
+ (recur cursor found)))))
+
+(defn- get-export-jobs
+ [cfg job-id]
+ (let [filtered? (not (str/empty-or-nil? job-id))
+ job-uuid (when filtered? (parse-uuid job-id))]
+ (if (and filtered? (nil? job-uuid))
+ []
+ (try
+ (let [pattern (str "penpot.exporter." (cf/get :tenant) ".job." (or job-uuid "*"))]
+ (->> (rds/run! cfg (fn [{:keys [::rds/conn]}]
+ (->> (scan-export-job-keys conn pattern)
+ (mapv (fn [key] (rds/hget conn key "data"))))))
+ (keep (fn [blob]
+ (try
+ (t/decode-str blob)
+ (catch Throwable _ nil))))
+ (sort-by :created-at #(compare %2 %1))
+ ;; The exporter stores instants as epoch millis.
+ (map (fn [{:keys [created-at ended-at] :as job}]
+ (-> job
+ (assoc :created-at (some-> created-at ct/inst (ct/format-inst :rfc1123)))
+ (assoc :ended-at (some-> ended-at ct/inst (ct/format-inst :rfc1123))))))
+ (vec)))
+ (catch Throwable cause
+ (l/warn :hint "unable to read export jobs" :cause cause)
+ [])))))
+
(defn index-handler
[cfg request]
(let [profile-id (::session/profile-id request)
offset (clock/get-offset profile-id)
- profile (profile/get-profile cfg profile-id)]
+ profile (profile/get-profile cfg profile-id)
+ job-filter (some-> request :params :job-id str/trim)]
{::yres/status 200
::yres/headers {"content-type" "text/html"}
::yres/body (-> (io/resource "app/templates/debug.tmpl")
(tmpl/render {:version (:full cf/version)
:profile profile
+ :graph-enabled (contains? cf/flags :graph)
:current-clock ct/*clock*
:current-offset (if offset
(ct/format-duration offset)
"NO OFFSET")
:current-time (ct/format-inst (ct/now) :http)
+ :export-jobs (get-export-jobs cfg job-filter)
+ :export-job-filter job-filter
:supported-features cfeat/supported-features}))}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -130,7 +182,7 @@
:hint "invalid button"))
(ex/raise :type :not-found
- :code :enpty-data
+ :code :empty-data
:hint "empty response"))))
(defn- is-file-exists?
@@ -314,11 +366,17 @@
(if clone?
(let [profile (profile/get-profile pool profile-id)
project-id (:default-project-id profile)
+ team (teams/get-team pool
+ :profile-id profile-id
+ :project-id project-id)
cfg (assoc cfg
::bfc/overwrite false
::bfc/profile-id profile-id
::bfc/project-id project-id
- ::bfc/input path)]
+ ::bfc/team-id (:id team)
+ ::bfc/input path
+ ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
+ ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
(bf.v3/import-files! cfg)
{::yres/status 200
::yres/headers {"content-type" "text/plain"}
@@ -330,6 +388,226 @@
"content-disposition" (str "attachmen; filename=" (first file-ids) ".penpot")}}))))
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; GRAPH (flag: :graph)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+;; `app.graph.*` resolves at call time, never at the top of this namespace.
+;; `app.graph.ladybug` imports `com.ladybugdb.*`, so requiring it links the
+;; Ladybug native library into the JVM, and this namespace loads on every
+;; backend boot. The routes below are registered only under the `:graph` flag,
+;; so with the flag off nothing resolves and no native code loads.
+
+(defn- graph-export-file
+ "Path of a freshly projected graph for `file-id`."
+ [cfg file-id]
+ (let [ingest-file! (requiring-resolve 'app.graph.ingest/ingest-file!)
+ {:keys [db-path]} (ingest-file! cfg file-id :skip-stats? true)]
+ (when-not (fs/exists? db-path)
+ (ex/raise :type :internal
+ :code :graph-file-not-found
+ :hint "graph database file missing after ingest"
+ :file-id (str file-id)
+ :db-path db-path))
+ db-path))
+
+(defn- graph-export-session
+ "Path of a snapshot of the caller's live in-memory graph for `file-id`."
+ [profile-id file-id]
+ (let [session-info (requiring-resolve 'app.graph.debug/session-info)
+ export-session-database! (requiring-resolve 'app.graph.debug/export-session-database!)
+ info (session-info profile-id)]
+ (when-not info
+ (ex/raise :type :not-found
+ :code :graph-session-not-loaded
+ :hint "no in-memory graph is loaded; load one first, or use source=file"))
+ (when-not (= file-id (:file-id info))
+ (ex/raise :type :validation
+ :code :graph-session-file-mismatch
+ :hint "the loaded session holds a different file"
+ :requested (str file-id)
+ :loaded (str (:file-id info))))
+ (export-session-database! profile-id)))
+
+(defn graph-export-handler
+ "Stream a Ladybug `.lbug` database for a file.
+
+ `source=file` (default) projects the file afresh from the database — the
+ reproducible artifact. `source=session` snapshots the caller's live
+ in-memory console graph instead, which live-sync may have moved away from a
+ fresh projection; taking that away to query it elsewhere is the whole point
+ of asking for it. Synchronous on each request."
+ [cfg {:keys [params] :as request}]
+ (let [file-id (some-> params :file-id parse-uuid)
+ source (or (some-> params :source str/lower) "file")]
+ (when-not file-id
+ (ex/raise :type :validation
+ :code :missing-arguments
+ :hint "missing file-id"))
+ (when-not (contains? #{"file" "session"} source)
+ (ex/raise :type :validation
+ :code :invalid-arguments
+ :hint "source must be 'file' or 'session'"
+ :source source))
+
+ (let [session? (= "session" source)
+ db-path (if session?
+ (graph-export-session (::session/profile-id request) file-id)
+ (graph-export-file cfg file-id))]
+ {::yres/status 200
+ ;; A session export is a temp file this request owns; deleting it on
+ ;; close would race the streaming body, so it is left for the OS temp
+ ;; sweep. A file export is the canonical per-file database and is meant
+ ;; to persist.
+ ::yres/body (io/input-stream db-path)
+ ::yres/headers {"content-type" "application/octet-stream"
+ "content-disposition"
+ (str "attachment; filename=" file-id
+ (when session? "-session") ".lbug")}})))
+
+(defn- graph-console-response
+ [data]
+ {::yres/status 200
+ ::yres/headers {"content-type" "text/html; charset=utf-8"
+ "x-robots-tag" "noindex"}
+ ::yres/body (-> (io/resource "app/templates/graph-console.tmpl")
+ (tmpl/render (assoc data :version (:full cf/version))))})
+
+(defn graph-console-handler
+ [_cfg {:keys [::session/profile-id]}]
+ (let [console-context (requiring-resolve 'app.graph.debug/console-context)]
+ (graph-console-response (console-context profile-id))))
+
+(defn graph-load-handler
+ [cfg {:keys [params ::session/profile-id]}]
+ (let [file-id (some-> (:file-id params) parse-uuid)
+ load-session! (requiring-resolve 'app.graph.debug/load-session!)]
+ (when-not file-id
+ (ex/raise :type :validation
+ :code :missing-arguments
+ :hint "missing file-id"))
+ (load-session! cfg profile-id file-id)
+ {::yres/status 302
+ ::yres/headers {"location" "/dbg/graph"}}))
+
+(defn graph-unload-handler
+ [_cfg {:keys [::session/profile-id]}]
+ ((requiring-resolve 'app.graph.debug/unload-session!) profile-id)
+ {::yres/status 302
+ ::yres/headers {"location" "/dbg/graph"}})
+
+(defn graph-reload-handler
+ "Re-ingest the currently loaded file into the in-memory graph session."
+ [cfg {:keys [::session/profile-id]}]
+ (let [session-info (requiring-resolve 'app.graph.debug/session-info)
+ load-session! (requiring-resolve 'app.graph.debug/load-session!)]
+ (if-let [file-id (some-> (session-info profile-id) :file-id)]
+ (do
+ (load-session! cfg profile-id file-id)
+ {::yres/status 302
+ ::yres/headers {"location" "/dbg/graph"}})
+ (ex/raise :type :not-found
+ :code :graph-session-not-loaded
+ :hint "load a file graph before reloading"))))
+
+(defn graph-sync-status-handler
+ [_cfg {:keys [::session/profile-id]}]
+ (if-let [status ((requiring-resolve 'app.graph.debug/sync-status) profile-id)]
+ {::yres/status 200
+ ::yres/headers {"content-type" "application/json; charset=utf-8"}
+ ::yres/body (t/encode-str status {:type :json-verbose})}
+ {::yres/status 404
+ ::yres/headers {"content-type" "application/json; charset=utf-8"}
+ ::yres/body (t/encode-str {:error "no-session"} {:type :json-verbose})}))
+
+(defn graph-data-handler
+ "Export the in-memory session graph as plain JSON (not transit) for the
+ G6 graph view embedded in the console page."
+ [_cfg {:keys [::session/profile-id]}]
+ (if-let [data ((requiring-resolve 'app.graph.debug/export-graph-data!) profile-id)]
+ {::yres/status 200
+ ::yres/headers {"content-type" "application/json; charset=utf-8"}
+ ::yres/body (json/encode data)}
+ {::yres/status 404
+ ::yres/headers {"content-type" "application/json; charset=utf-8"}
+ ::yres/body (json/encode {:error "no-session"})}))
+
+(def ^:private sql:graph-files
+ "select t.id as team_id, t.name as team_name,
+ p.id as project_id, p.name as project_name,
+ f.id as file_id, f.name as file_name
+ from team as t
+ join team_profile_rel as tpr on (tpr.team_id = t.id)
+ join project as p on (p.team_id = t.id)
+ join file as f on (f.project_id = p.id)
+ where tpr.profile_id = ?
+ and t.deleted_at is null
+ and p.deleted_at is null
+ and f.deleted_at is null
+ order by t.name, p.name, f.name
+ limit 500")
+
+(defn- graph-files-tree
+ [rows]
+ (->> (group-by (juxt :team-id :team-name) rows)
+ (mapv (fn [[[team-id team-name] team-rows]]
+ {:id (str team-id)
+ :name team-name
+ :projects
+ (->> (group-by (juxt :project-id :project-name) team-rows)
+ (mapv (fn [[[project-id project-name] project-rows]]
+ {:id (str project-id)
+ :name project-name
+ :files (mapv (fn [{:keys [file-id file-name]}]
+ {:id (str file-id) :name file-name})
+ project-rows)}))
+ (sort-by :name)
+ (vec))}))
+ (sort-by :name)
+ (vec)))
+
+(defn graph-files-handler
+ "List teams -> projects -> files reachable by the current profile, as
+ plain JSON for the graph console file tree."
+ [{:keys [::db/pool]} {:keys [::session/profile-id]}]
+ (let [rows (db/exec! pool [sql:graph-files profile-id])]
+ {::yres/status 200
+ ::yres/headers {"content-type" "application/json; charset=utf-8"}
+ ::yres/body (json/encode {:teams (graph-files-tree rows)})}))
+
+(defn- json-request?
+ [request]
+ (some-> request
+ (yreq/get-header "accept")
+ (str/includes? "application/json")))
+
+(defn graph-query-handler
+ [_cfg {:keys [params ::session/profile-id] :as request}]
+ (let [query (:query params)
+ query-session! (requiring-resolve 'app.graph.debug/query-session!)
+ console-context (requiring-resolve 'app.graph.debug/console-context)]
+ (try
+ (let [result (query-session! profile-id query)]
+ (if (json-request? request)
+ {::yres/status 200
+ ::yres/headers {"content-type" "application/json; charset=utf-8"}
+ ::yres/body (t/encode-str {:query query
+ :query-result result}
+ {:type :json-verbose})}
+ (graph-console-response (console-context profile-id
+ :query query
+ :query-result result))))
+ (catch Throwable e
+ (let [error (or (:hint (ex-data e)) (ex-message e))]
+ (if (json-request? request)
+ {::yres/status 200
+ ::yres/headers {"content-type" "application/json; charset=utf-8"}
+ ::yres/body (t/encode-str {:query query :error error}
+ {:type :json-verbose})}
+ (graph-console-response (console-context profile-id
+ :query query
+ :error error))))))))
+
(defn import-handler
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
(when-not (contains? params :file)
@@ -354,7 +632,10 @@
::bfc/profile-id profile-id
::bfc/project-id project-id
::bfc/input path
- ::bfc/features (cfeat/get-team-enabled-features cf/flags team))]
+ ::bfc/team-id (:id team)
+ ::bfc/features (cfeat/get-team-enabled-features cf/flags team)
+ ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
+ ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
(if (= format :binfile-v3)
(bf.v3/import-files! cfg)
@@ -484,6 +765,89 @@
{::yres/status 302
::yres/headers {"location" "/dbg"}}))))
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; VALIDATE / REPAIR
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn- validate-file
+ [cfg {:keys [params] :as request}]
+ (let [file-id (some-> params :file-id parse-uuid)]
+
+ (when-not file-id
+ (ex/raise :type :validation
+ :code :missing-arguments))
+
+ (db/tx-run! (assoc cfg ::db/rollback true)
+ (fn [cfg]
+ (let [file (bfc/get-file cfg file-id)
+ libs (bfc/get-resolved-file-libraries cfg file-id)]
+ (if file
+ (let [errors (cfv/validate-file file libs)]
+ {::yres/status 200
+ ::yres/headers {"content-type" "text/plain"}
+ ::yres/body (if (empty? errors)
+ "NO VALIDATION ERRORS FOUND"
+ (pp/pprint-str errors))})
+ (ex/raise :type :not-found
+ :code :empty-data
+ :hint "empty response")))))))
+
+(defn- repair-file
+ [cfg {:keys [params] :as request}]
+ (let [file-id (some-> params :file-id parse-uuid)
+ skip-snapshot? (contains? params :skip-snapshot)
+ profile-id (:app.http.session/profile-id request)]
+
+ (when-not file-id
+ (ex/raise :type :validation
+ :code :missing-arguments))
+
+ (let [output (StringBuilder.)
+
+ repair-file
+ (fn [file libs _]
+ (let [errors (cfv/validate-file file libs)]
+ (.append output (if (empty? errors)
+ "NO VALIDATION ERRORS FOUND\n"
+ (str "VALIDATION ERRORS FOUND:\n"
+ (pp/pprint-str errors) "\n")))
+ (if (empty? errors)
+ file
+ (let [changes (cfr/repair-file file libs errors)]
+ (-> file
+ (update :revn inc)
+ (update :data cfc/process-changes changes))))))]
+
+ (add-watch l/log-record ::repair-watcher
+ (fn [_ _ _ record]
+ (when (= "app.common.files.repair" (::l/logger record))
+ (let [props (::l/props record)
+ hint (get props :hint "")
+ args (dissoc props :hint)
+ message (str hint " "
+ (when-not (empty? args)
+ args)
+ "\n")]
+ (.append output message)))))
+ (try
+ (db/tx-run! cfg
+ h/process-file!
+ file-id
+ repair-file
+ {::h/with-libraries? true
+ ::h/validate? false
+ ::h/profile-id profile-id
+ ::h/snapshot-label (when-not skip-snapshot? "repair")})
+
+ (.append output "\nREPAIR FINISHED")
+
+ {::yres/status 200
+ ::yres/headers {"content-type" "text/plain"}
+ ::yres/body (.toString output)}
+
+ (finally
+ (remove-watch l/log-record ::repair-watcher))))))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; OTHER SMALL VIEWS/HANDLERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -539,7 +903,12 @@
(letfn [(handle-error [cause]
(when-let [data (ex-data cause)]
(when (= :validation (:type data))
- (str "Error: " (or (:hint data) (ex-message cause)) "\n"))))]
+ (let [hint (or (:hint data) (ex-message cause))
+ explain (ex/explain data)]
+ (str "Error: " hint
+ (when (and explain (not (str/includes? hint explain)))
+ (str "\n" explain))
+ "\n")))))]
{:name ::errors
:compile
(fn [& _params]
@@ -559,24 +928,51 @@
(assert (db/pool? (::db/pool params)) "expected a valid database pool")
(assert (session/manager? (::session/manager params)) "expected a valid session manager"))
+(defn- graph-action-routes
+ [cfg]
+ [["/graph-export" {:handler (partial graph-export-handler cfg)}]
+ ["/graph-load" {:handler (partial graph-load-handler cfg)}]
+ ["/graph-query" {:handler (partial graph-query-handler cfg)}]
+ ["/graph-unload" {:handler (partial graph-unload-handler cfg)}]
+ ["/graph-reload" {:handler (partial graph-reload-handler cfg)}]
+ ["/graph-sync-status" {:handler (partial graph-sync-status-handler cfg)}]
+ ["/graph-data" {:handler (partial graph-data-handler cfg)}]
+ ["/graph-files" {:handler (partial graph-files-handler cfg)}]])
+
(defmethod ig/init-key ::routes
[_ {:keys [::db/pool] :as cfg}]
- [["/readyz" {:handler (partial health-handler cfg)}]
- ["/dbg" {:middleware [[session/authz cfg]
- [with-authorization pool]]}
- ["" {:handler (partial index-handler cfg)}]
- ["/health" {:handler (partial health-handler cfg)}]
- ["/changelog" {:handler (partial changelog-handler cfg)}]
- ["/error/:id" {:handler (partial error-handler cfg)}]
- ["/error" {:handler (partial error-list-handler cfg)}]
- ["/actions" {:middleware [[errors]]}
- ["/set-virtual-clock"
- {:handler (partial set-virtual-clock cfg)}]
- ["/resend-email-verification"
- {:handler (partial resend-email-notification cfg)}]
- ["/handle-team-features"
- {:handler (partial handle-team-features cfg)}]
- ["/file-export" {:handler (partial export-handler cfg)}]
- ["/file-import" {:handler (partial import-handler cfg)}]
- ["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])
+ ;; The graph routes are registered only under the `:graph` flag. Left
+ ;; unregistered they 404, and nothing ever resolves `app.graph.*`. The `/dbg`
+ ;; admin gate is unchanged: it covers the graph routes exactly as before.
+ (let [graph? (contains? cf/flags :graph)
+ actions (cond-> ["/actions" {:middleware [[errors]]}
+ ["/set-virtual-clock"
+ {:handler (partial set-virtual-clock cfg)}]
+ ["/resend-email-verification"
+ {:handler (partial resend-email-notification cfg)}]
+ ["/handle-team-features"
+ {:handler (partial handle-team-features cfg)}]
+ ["/file-export" {:handler (partial export-handler cfg)}]
+ ["/file-import" {:handler (partial import-handler cfg)}]
+ ["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]
+ ["/file-validate" {:handler (partial validate-file cfg)}]
+ ["/file-repair" {:handler (partial repair-file cfg)}]]
+ graph? (into (graph-action-routes cfg)))
+ dbg (cond-> ["/dbg" {:middleware [[session/authz cfg]
+ [with-authorization pool]]}
+ ["" {:handler (partial index-handler cfg)}]
+ ["/health" {:handler (partial health-handler cfg)}]
+ ["/changelog" {:handler (partial changelog-handler cfg)}]
+ ["/error/:id" {:handler (partial error-handler cfg)}]
+ ["/error" {:handler (partial error-list-handler cfg)}]
+ actions]
+ graph? (conj ["/graph" {:handler (partial graph-console-handler cfg)}]))]
+ (when graph?
+ ;; With the flag on, the Ladybug native library belongs to this process,
+ ;; so load it here. A missing or unusable library then fails the boot
+ ;; instead of the first console request.
+ (require 'app.graph.debug 'app.graph.ingest))
+
+ [["/readyz" {:handler (partial health-handler cfg)}]
+ dbg]))
diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj
index f1eaea621c..4f713a5f6b 100644
--- a/backend/src/app/http/errors.clj
+++ b/backend/src/app/http/errors.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.errors
"A errors handling for the http server."
@@ -34,6 +34,12 @@
(assoc :request/auth-data (dissoc auth :token))
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
+(defn- strip-internal-fields
+ "Remove fields that leak internal implementation details from error
+ response data. Full context is preserved in server-side logs."
+ [data]
+ (dissoc data :state :path :context))
+
(defmulti handle-error
(fn [cause _ _]
(-> cause ex-data :type)))
@@ -136,6 +142,7 @@
(l/error :hint "assertion error" :cause cause)
{::yres/status 500
::yres/body (-> data
+ (strip-internal-fields)
(assoc :type :server-error)
(assoc :code :assertion))})))))
@@ -161,9 +168,9 @@
(l/error :hint "internal error" :cause cause)
{::yres/status 500
::yres/body (-> data
+ (strip-internal-fields)
(assoc :type :server-error)
- (update :code #(or % :unhandled))
- (assoc :hint (ex-message error)))})))
+ (update :code #(or % :unhandled)))})))
(defmethod handle-error :default
[error request parent-cause]
@@ -178,6 +185,20 @@
(handle-exception (:handling edata) request error)
(handle-exception error request parent-cause))))
+(defn- pgsql-state->message
+ "Map PostgreSQL SQLSTATE codes to safe, client-facing messages.
+ Returns a user-friendly string that conveys the nature of the error
+ without exposing table names, constraint names, or other internals."
+ [state]
+ (case state
+ "23505" "A conflicting entry already exists"
+ "23503" "The referenced item does not exist"
+ "23502" "A required field is missing"
+ "23514" "The value violates a data integrity constraint"
+ "57014" "The operation took too long and was cancelled"
+ "25P03" "The transaction was idle too long and was cancelled"
+ "A database error occurred"))
+
(defmethod handle-exception org.postgresql.util.PSQLException
[error request parent-cause]
(let [state (.getSQLState ^java.sql.SQLException error)
@@ -190,20 +211,19 @@
{::yres/status 504
::yres/body {:type :server-error
:code :statement-timeout
- :hint (ex-message error)}}
+ :hint (pgsql-state->message state)}}
(= state "25P03")
{::yres/status 504
::yres/body {:type :server-error
:code :idle-in-transaction-timeout
- :hint (ex-message error)}}
+ :hint (pgsql-state->message state)}}
:else
{::yres/status 500
::yres/body {:type :server-error
- :code :unexpected
- :hint (ex-message error)
- :state state}}))))
+ :code :database-error
+ :hint (pgsql-state->message state)}}))))
(defmethod handle-exception :default
[error request parent-cause]
@@ -216,17 +236,16 @@
(l/error :hint "unexpected error" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
- :code :unexpected
- :hint (ex-message error)}})
+ :code :unexpected}})
:else
(binding [l/*context* (request->context request)]
(l/error :hint "unhandled error" :cause cause)
{::yres/status 500
::yres/body (-> edata
+ (strip-internal-fields)
(assoc :type :server-error)
- (update :code #(or % :unhandled))
- (assoc :hint (ex-message error)))}))))
+ (update :code #(or % :unhandled)))}))))
(defmethod handle-exception java.io.IOException
[cause request _]
@@ -234,9 +253,7 @@
(l/wrn :hint "io exception" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
- :code :io-exception
- :hint (ex-message cause)
- :path (:path request)}}))
+ :code :io-exception}}))
(defmethod handle-exception java.util.concurrent.CompletionException
[cause request _]
diff --git a/backend/src/app/http/management.clj b/backend/src/app/http/management.clj
index 507a518e5c..50ce8506ad 100644
--- a/backend/src/app/http/management.clj
+++ b/backend/src/app/http/management.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.management
"Internal mangement HTTP API"
@@ -209,7 +209,7 @@
[:enum
"customer_service"
"low_quality"
- "missing_feature"
+ "missing_features"
"other"
"switched_service"
"too_complex"
diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj
index fa2faa8a55..22c1cc29d9 100644
--- a/backend/src/app/http/middleware.clj
+++ b/backend/src/app/http/middleware.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.middleware
(:require
@@ -24,7 +24,8 @@
(:import
io.undertow.server.RequestTooBigException
java.io.InputStream
- java.io.OutputStream))
+ java.io.OutputStream
+ java.security.MessageDigest))
(set! *warn-on-reflection* true)
@@ -82,18 +83,18 @@
(instance? IllegalArgumentException cause)
(ex/raise :type :validation
:code :malformed-json
- :hint (ex-message cause)
+ :hint "invalid JSON in request body"
:cause cause)
(instance? RequestTooBigException cause)
(ex/raise :type :validation
:code :request-body-too-large
- :hint (ex-message cause))
+ :hint "request body exceeds size limit")
(instance? java.io.EOFException cause)
(ex/raise :type :validation
:code :malformed-json
- :hint (ex-message cause)
+ :hint "unexpected end of request body"
:cause cause)
(instance? RuntimeException cause)
@@ -329,6 +330,11 @@
{:name ::auth
:compile (constantly wrap-auth)})
+(defn- constant-time-eq?
+ "Compare strings in constant time to prevent timing attacks."
+ [^String a ^String b]
+ (MessageDigest/isEqual (.getBytes a "UTF-8") (.getBytes b "UTF-8")))
+
(defn- wrap-shared-key-auth
[handler keys]
(if (seq keys)
@@ -338,7 +344,7 @@
(let [key-id (-> key-id str/lower keyword)]
(if (and (string? key)
(contains? keys key-id)
- (= key (get keys key-id)))
+ (constant-time-eq? key (get keys key-id)))
(-> request
(assoc ::http/auth-key-id key-id)
(handler))
diff --git a/backend/src/app/http/security.clj b/backend/src/app/http/security.clj
index af4f875b10..5529421cb1 100644
--- a/backend/src/app/http/security.clj
+++ b/backend/src/app/http/security.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.security
"Additional security layer middlewares"
diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj
index 614942c072..914dfc169c 100644
--- a/backend/src/app/http/session.clj
+++ b/backend/src/app/http/session.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.session
(:refer-clojure :exclude [read])
@@ -204,7 +204,7 @@
[{:keys [::manager]}]
(assert (manager? manager) "expected valid session manager")
(fn [request response]
- (some->> (get request ::id) (delete-session manager))
+ (some->> (get request ::session) :id (delete-session manager))
(clear-session-cookie response)))
(defn decode-token
@@ -226,6 +226,14 @@
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
(db/get-update-count))))
+(defn invalidate-all
+ "Delete all sessions for a given profile. Used when a profile is deleted
+ to ensure immediate access revocation across all devices."
+ [cfg profile-id]
+ (let [sql "delete from http_session_v2 where profile_id = ?"]
+ (-> (db/exec-one! cfg [sql profile-id])
+ (db/get-update-count))))
+
(def ^:private sql:clear-organization-sso-sessions
(str "UPDATE http_session_v2 "
"SET props = props #- ARRAY['~:sso', ?]::text[] "
diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj
index 8d6290571e..0fc7c385bd 100644
--- a/backend/src/app/http/sse.clj
+++ b/backend/src/app/http/sse.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.sse
"SSE (server sent events) helpers"
diff --git a/backend/src/app/http/websocket.clj b/backend/src/app/http/websocket.clj
index 9a3972a26a..ffbaad8a2c 100644
--- a/backend/src/app/http/websocket.clj
+++ b/backend/src/app/http/websocket.clj
@@ -2,11 +2,12 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.http.websocket
"A penpot notification service for file cooperative edition."
(:require
+ [app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
@@ -133,15 +134,15 @@
(mbus/pub! msgbus :topic topic :message msg))))
(defmethod handle-message :subscribe-team
- [cfg {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
+ [{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
- (teams/check-read-permissions! cfg profile-id team-id)
+ (teams/check-read-permissions! pool profile-id team-id)
(let [prev-subs (get @state ::team-subscription)
channel (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
(sp/pipe channel output-ch false)
- (mbus/sub! (::mbus/msgbus cfg) :topic team-id :chan channel)
+ (mbus/sub! msgbus :topic team-id :chan channel)
(let [subs {:team-id team-id :channel channel :topic team-id}]
(swap! state assoc ::team-subscription subs))
@@ -149,13 +150,14 @@
;; Close previous subscription if exists
(when-let [ch (:channel prev-subs)]
(sp/close! ch)
- (mbus/purge! (::mbus/msgbus cfg) [ch]))))
+ (mbus/purge! msgbus [ch]))))
(defmethod handle-message :subscribe-file
- [cfg {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
+ [{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
- (files/check-read-permissions! cfg profile-id file-id)
+ (bfc/check-file-exists pool file-id)
+ (files/check-read-permissions! pool profile-id file-id)
(let [psub (::file-subscription @state)
fch (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
@@ -166,7 +168,7 @@
;; Close previous subscription if exists
(when-let [ch (:channel psub)]
(sp/close! ch)
- (mbus/purge! (::mbus/msgbus cfg) [ch]))
+ (mbus/purge! msgbus [ch]))
(sp/go-loop []
(when-let [{:keys [type] :as message} (sp/take! fch)]
@@ -178,20 +180,20 @@
:file-id file-id
:session-id session-id
:profile-id profile-id}]
- (mbus/pub! (::mbus/msgbus cfg)
+ (mbus/pub! msgbus
:topic file-id
:message message)))
(recur)))
;; Subscribe to file topic
- (mbus/sub! (::mbus/msgbus cfg) :topic file-id :chan fch)
+ (mbus/sub! msgbus :topic file-id :chan fch)
;; Notifify the rest of participants of the new connection.
(let [message {:type :join-file
:file-id file-id
:session-id session-id
:profile-id profile-id}]
- (mbus/pub! (::mbus/msgbus cfg) :topic file-id :message message))))
+ (mbus/pub! msgbus :topic file-id :message message))))
(defmethod handle-message :unsubscribe-file
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::session-id ::profile-id]} {:keys [file-id] :as params}]
diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj
index f68209255b..86778ab55f 100644
--- a/backend/src/app/loggers/audit.clj
+++ b/backend/src/app/loggers/audit.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.loggers.audit
"Services related to the user activity (audit log)."
@@ -36,6 +36,16 @@
(def ^:private filter-auth-events
#{"login-with-oidc" "login-with-password" "register-profile" "update-profile"})
+(def ^:private organization-sso-failure-reasons
+ #{"access-denied"
+ "provider-unavailable"
+ "invalid-configuration"
+ "provider-error"
+ "token-exchange-failed"
+ "user-info-failed"
+ "incomplete-user-info"
+ "unexpected-error"})
+
(def ^:private safe-backend-context-keys
#{:version
:initiator
@@ -297,6 +307,14 @@
(defn filter-telemetry-props
[{:keys [source name props type] :as params}]
(cond
+ (and (= source "backend")
+ (= name "organization-sso-auth-failed"))
+ (let [props' (into {} xf:filter-telemetry-props props)
+ props' (cond-> props'
+ (contains? organization-sso-failure-reasons (:failure-reason props))
+ (assoc :failure-reason (:failure-reason props)))]
+ (assoc params :props props'))
+
(or (and (= source "frontend")
(= type "identify"))
(and (= source "backend")
diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj
index e577351e31..1c5f953d44 100644
--- a/backend/src/app/loggers/audit/archive_task.clj
+++ b/backend/src/app/loggers/audit/archive_task.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.loggers.audit.archive-task
(:require
diff --git a/backend/src/app/loggers/audit/gc_task.clj b/backend/src/app/loggers/audit/gc_task.clj
index 24af10cfed..6fbd1710b4 100644
--- a/backend/src/app/loggers/audit/gc_task.clj
+++ b/backend/src/app/loggers/audit/gc_task.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.loggers.audit.gc-task
(:require
diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj
index 9f9c1bebaa..cd17dcd51d 100644
--- a/backend/src/app/loggers/database.clj
+++ b/backend/src/app/loggers/database.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.loggers.database
"A specific logger impl that persists errors on the database."
diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj
index e3089f1f03..71a61afb37 100644
--- a/backend/src/app/loggers/mattermost.clj
+++ b/backend/src/app/loggers/mattermost.clj
@@ -2,11 +2,12 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.loggers.mattermost
"A mattermost integration for error reporting."
(:require
+ [app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
@@ -25,7 +26,7 @@
(defn- send-mattermost-notification!
[cfg {:keys [id] :as report}]
(let [type (get report :type)
- text (str "#" type " | " (get report :hint) "\n"
+ text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n"
(when id
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
@@ -38,7 +39,7 @@
"- tenant: #" (:tenant report) "\n"
"- origin: #" (:origin report) "\n"
(when-let [href (get report :href)]
- (str "- href: `" href "`\n"))
+ (str "- href: `" (d/escape-markdown href) "`\n"))
(when-let [version (get report :frontend-version)]
(str "- frontend-version: `" version "`\n"))
(when-let [version (get report :backend-version)]
diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj
index a57fd6bca2..83df9825cc 100644
--- a/backend/src/app/loggers/webhooks.clj
+++ b/backend/src/app/loggers/webhooks.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.loggers.webhooks
"A mattermost integration for error reporting."
diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj
index 9f90d0b72f..b6f70d953c 100644
--- a/backend/src/app/main.clj
+++ b/backend/src/app/main.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main
(:require
@@ -37,6 +37,7 @@
[app.storage.fs :as-alias sto.fs]
[app.storage.gc-deleted :as-alias sto.gc-deleted]
[app.storage.gc-touched :as-alias sto.gc-touched]
+ [app.storage.pending-gc :as-alias sto.pending-gc]
[app.storage.s3 :as-alias sto.s3]
[app.system :as sys]
[app.util.cron]
@@ -199,6 +200,10 @@
::sto.gc-touched/handler
{::db/pool (ig/ref ::db/pool)}
+ ::sto.pending-gc/handler
+ {::db/pool (ig/ref ::db/pool)
+ ::sto/storage (ig/ref ::sto/storage)}
+
::http.client/client
{}
@@ -283,7 +288,9 @@
::http.debug/routes
{::db/pool (ig/ref ::db/pool)
+ ::rds/pool (ig/ref ::rds/pool)
::session/manager (ig/ref ::session/manager)
+ ::mbus/msgbus (ig/ref ::mbus/msgbus)
::sto/storage (ig/ref ::sto/storage)
::setup/props (ig/ref ::setup/props)}
@@ -335,6 +342,7 @@
::rpc/rlimit (ig/ref ::rpc/rlimit)
::setup/templates (ig/ref ::setup/templates)
::setup/props (ig/ref ::setup/props)
+ ::setup/shared-keys (ig/ref ::setup/shared-keys)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)
@@ -385,12 +393,15 @@
:upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler)
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
+ :storage-pending-gc (ig/ref ::sto.pending-gc/handler)
:session-gc (ig/ref ::session.tasks/gc)
:audit-log-archive (ig/ref :app.loggers.audit.archive-task/handler)
:audit-log-gc (ig/ref :app.loggers.audit.gc-task/handler)
:delete-object
(ig/ref :app.tasks.delete-object/handler)
+ :demo-purge
+ (ig/ref :app.tasks.demo-purge/handler)
:process-webhook-event
(ig/ref ::webhooks/process-event-handler)
:run-webhook
@@ -428,6 +439,9 @@
:app.tasks.delete-object/handler
{::db/pool (ig/ref ::db/pool)}
+ :app.tasks.demo-purge/handler
+ {::db/pool (ig/ref ::db/pool)}
+
:app.tasks.file-gc/handler
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
@@ -467,10 +481,11 @@
::migrations (ig/ref :app.migrations/migrations)}
::setup/shared-keys
- {::setup/props (ig/ref ::setup/props)
- :nexus (cf/get :nexus-shared-key)
- :admin-console (cf/get :admin-console-shared-key)
- :exporter (cf/get :exporter-shared-key)}
+ {::setup/props (ig/ref ::setup/props)
+ :nexus (cf/get :nexus-shared-key)
+ :admin-console (cf/get :admin-console-shared-key)
+ :exporter (cf/get :exporter-shared-key)
+ :media-processor (cf/get :media-processor-shared-key)}
::setup/clock
{}
@@ -543,6 +558,9 @@
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
:task :storage-gc-touched}
+ {:cron #penpot/cron "0 0 0 * * ?" ;; daily
+ :task :storage-pending-gc}
+
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
:task :tasks-gc}
diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj
index 30527857ad..c00ac00b24 100644
--- a/backend/src/app/media.clj
+++ b/backend/src/app/media.clj
@@ -2,319 +2,40 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.media
- "Media & Font postprocessing."
+ "Media & Font postprocessing.
+
+ This namespace is the dispatch layer only. Processing implementations
+ live in two separate namespaces, each owning their own defmulti:
+
+ app.media.local — shell/ImageMagick/FontForge implementations
+ app.media.remote — HTTP delegation to media-processor service
+
+ Validation and schemas live in app.media.validation (leaf namespace,
+ no circular dep). When adding a new :cmd type, add defmethods in
+ BOTH local and remote."
(:require
[app.common.data :as d]
- [app.common.data.macros :as dm]
[app.common.exceptions :as ex]
- [app.common.logging :as l]
- [app.common.media :as cm]
- [app.common.schema :as sm]
- [app.common.schema.openapi :as-alias oapi]
- [app.common.time :as ct]
[app.config :as cf]
[app.db :as-alias db]
[app.http.client :as http]
+ [app.media.local :as media.local]
+ [app.media.remote :as media.remote]
[app.media.sanitize :as sanitize]
+ [app.media.validation :as validation]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
- [app.util.shell :as shell]
- [buddy.core.bytes :as bb]
- [buddy.core.codecs :as bc]
- [clojure.string]
- [clojure.xml :as xml]
[cuerdas.core :as str]
- [datoteka.fs :as fs]
- [datoteka.io :as io])
- (:import
- clojure.lang.XMLHandler
- java.io.InputStream
- javax.xml.parsers.SAXParserFactory
- javax.xml.XMLConstants
- org.apache.commons.io.IOUtils))
-
-(def schema:upload
- [:map {:title "Upload"}
- [:filename :string]
- [:size ::sm/int]
- [:path ::fs/path]
- [:mtype {:optional true} :string]
- [:headers {:optional true}
- [:map-of :string :string]]])
-
-(def ^:private schema:input
- [:map {:title "Input"}
- [:path ::fs/path]
- [:mtype {:optional true} ::sm/text]])
-
-(def check-input
- (sm/check-fn schema:input))
-
-(defn validate-media-type!
- ([upload] (validate-media-type! upload cm/image-types))
- ([upload allowed]
- (when-not (contains? allowed (:mtype upload))
- (ex/raise :type :validation
- :code :media-type-not-allowed
- :hint "Seems like you are uploading an invalid media object"))
-
- upload))
-
-(defn validate-media-size!
- [upload]
- (let [max-size (cf/get :media-max-file-size)]
- (when (> (:size upload) max-size)
- (ex/raise :type :restriction
- :code :media-max-file-size-reached
- :hint (str/ffmt "the uploaded file size % is greater than the maximum %"
- (:size upload)
- max-size)))
- upload))
-
-(defn validate-font-size!
- "Validates that the font file `upload` does not exceed the configured
- `:font-max-file-size` limit. Accepts the same map shape as
- `validate-media-size!` — requires a `:size` key in bytes."
- [upload]
- (let [max-size (cf/get :font-max-file-size)]
- (when (> (:size upload) max-size)
- (ex/raise :type :restriction
- :code :font-max-file-size-reached
- :hint (str/ffmt "the uploaded font size % is greater than the maximum %"
- (:size upload)
- max-size)))
- upload))
-
-(defmulti process (fn [_system params] (:cmd params)))
-
-(defmethod process :default
- [_system {:keys [cmd] :as params}]
- (ex/raise :type :internal
- :code :not-implemented
- :hint (str/fmt "No impl found for process cmd: %s" cmd)))
+ [datoteka.io :as io]))
(defn run
[system params]
- (process system params))
-
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;; SVG PARSING
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-
-(defn- secure-parser-factory
- [^InputStream input ^XMLHandler handler]
- (.. (doto (SAXParserFactory/newInstance)
- (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
- (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
- (newSAXParser)
- (parse input handler)))
-
-(defn- strip-doctype
- [data]
- (cond-> data
- (str/includes? data "]*>" "")))
-
-(defn- parse-svg
- [text]
- (let [text (strip-doctype text)]
- (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
- (xml/parse istream secure-parser-factory))))
-
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;; IMAGE THUMBNAILS
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-
-(def ^:private schema:thumbnail-params
- [:map {:title "ThumbnailParams"}
- [:input schema:input]
- [:format [:enum :jpeg :webp :png]]
- [:quality [:int {:min 1 :max 100}]]
- [:width :int]
- [:height :int]])
-
-(def ^:private check-thumbnail-params
- (sm/check-fn schema:thumbnail-params))
-
-;; Related info on how thumbnails generation
-;; http://www.imagemagick.org/Usage/thumbnails/
-
-(def ^:private imagemagick-default-env
- "Default environment variables for ImageMagick resource limits.
- These are the soft ceiling — policy.xml is the hard ceiling."
- {"MAGICK_THREAD_LIMIT" "2"
- "MAGICK_MEMORY_LIMIT" "256MiB"
- "MAGICK_MAP_LIMIT" "512MiB"
- "MAGICK_AREA_LIMIT" "128MP"
- "MAGICK_DISK_LIMIT" "1GiB"
- "MAGICK_TIME_LIMIT" "30"})
-
-(defn- get-imagemagick-env
- "Returns environment variables for ImageMagick commands.
- Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
- []
- (let [thread (cf/get :imagemagick-thread-limit)
- memory (cf/get :imagemagick-memory-limit)
- map-l (cf/get :imagemagick-map-limit)
- area (cf/get :imagemagick-area-limit)
- disk (cf/get :imagemagick-disk-limit)
- time (cf/get :imagemagick-time-limit)
- width (cf/get :imagemagick-width-limit)
- height (cf/get :imagemagick-height-limit)]
- (cond-> imagemagick-default-env
- thread (assoc "MAGICK_THREAD_LIMIT" thread)
- memory (assoc "MAGICK_MEMORY_LIMIT" memory)
- map-l (assoc "MAGICK_MAP_LIMIT" map-l)
- area (assoc "MAGICK_AREA_LIMIT" area)
- disk (assoc "MAGICK_DISK_LIMIT" disk)
- time (assoc "MAGICK_TIME_LIMIT" time)
- width (assoc "MAGICK_WIDTH_LIMIT" width)
- height (assoc "MAGICK_HEIGHT_LIMIT" height))))
-
-(defn- exec-magick!
- "Execute an ImageMagick command with resource limits.
- `args` is a vector of string arguments to pass to `magick`."
- [system args]
- (let [cmd (into ["magick"] args)
- result (shell/exec! system
- :cmd cmd
- :env (get-imagemagick-env)
- :timeout 60)]
- (when (not= 0 (:exit result))
- (ex/raise :type :validation
- :code :invalid-image
- :hint (str "ImageMagick command failed: " (:err result))
- :cmd cmd
- :exit (:exit result)))
- result))
-
-(defn- generic-process
- [system {:keys [input format convert-args] :as params}]
- (let [{:keys [path mtype]} input
- format (or format (cm/mtype->format mtype))
- ext (cm/format->extension format)
- tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
- args (into [(str path)] (conj (vec convert-args) (str tmp)))]
- (exec-magick! system args)
- (assoc params
- :format format
- :mtype (cm/format->mtype format)
- :size (fs/size tmp)
- :data tmp)))
-
-(defmethod process :generic-thumbnail
- [system params]
- (let [{:keys [quality width height] :as params}
- (check-thumbnail-params params)]
- (generic-process system
- (assoc params
- :convert-args ["-auto-orient" "-strip"
- "-thumbnail" (str width "x" height ">")
- "-quality" (str quality)]))))
-
-(defmethod process :profile-thumbnail
- [system params]
- (let [{:keys [quality width height] :as params}
- (check-thumbnail-params params)]
- (generic-process system
- (assoc params
- :convert-args ["-auto-orient" "-strip"
- "-thumbnail" (str width "x" height "^")
- "-gravity" "center"
- "-extent" (str width "x" height)
- "-quality" (str quality)]))))
-
-(defn get-basic-info-from-svg
- [{:keys [tag attrs] :as data}]
- (when (not= tag :svg)
- (ex/raise :type :validation
- :code :unable-to-parse-svg
- :hint "uploaded svg has invalid content"))
- (reduce (fn [default f]
- (if-let [res (f attrs)]
- (reduced res)
- default))
- {:width 100 :height 100}
- [(fn parse-width-and-height
- [{:keys [width height]}]
- (when (and (string? width)
- (string? height))
- (let [width (d/parse-double width)
- height (d/parse-double height)]
- (when (and width height)
- {:width (int width)
- :height (int height)}))))
- (fn parse-viewbox
- [{:keys [viewBox]}]
- (let [[x y width height] (->> (str/split viewBox #"\s+" 4)
- (map d/parse-double))]
- (when (and x y width height)
- {:width (int width)
- :height (int height)})))]))
-
-(defn- get-dimensions-with-orientation [system ^String path]
- ;; Image magick doesn't give info about exif rotation so we use the identify command
- ;; If we are processing an animated gif we use the first frame with -scene 0
- (let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
- orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
- (when (= 0 (:exit dim-result))
- (let [[w h] (-> (:out dim-result)
- str/trim
- (clojure.string/split #"\s+")
- (->> (mapv #(Integer/parseInt %))))
- orientation-exit (:exit orient-result)
- orientation (-> orient-result :out str/trim)]
- (if (= 0 orientation-exit)
- (case orientation
- ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
- {:width w :height h}) ; Normal or unknown orientation
- {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
-
-(defmethod process :info
- [system {:keys [input] :as params}]
- (let [{:keys [path mtype] :as input} (check-input input)]
- (if (= mtype "image/svg+xml")
- (let [info (some-> path slurp parse-svg get-basic-info-from-svg)]
- (when-not info
- (ex/raise :type :validation
- :code :invalid-svg-file
- :hint "uploaded svg does not provides dimensions"))
- (merge input info {:ts (ct/now) :size (fs/size path)}))
-
- (let [path-str (str path)
- identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
- ;; identify prints one line per frame (animated GIFs, etc.); we take the first one
- mtype' (if (zero? (:exit identify-res))
- (-> identify-res
- :out
- str/trim
- (str/split #"\s+" 2)
- first
- str/lower)
- (ex/raise :type :validation
- :code :invalid-image
- :hint "invalid image"))
- {:keys [width height]}
- (or (get-dimensions-with-orientation system path-str)
- (do
- (l/warn "Failed to read image dimensions with orientation" {:path path})
- (ex/raise :type :validation
- :code :invalid-image
- :hint "invalid image")))]
- (when (and (string? mtype)
- (not= (str/lower mtype) mtype'))
- (ex/raise :type :validation
- :code :media-type-mismatch
- :hint (str "Seems like you are uploading a file whose content does not match the extension."
- "Expected: " mtype ". Got: " mtype')))
- (assoc input
- :width width
- :height height
- :size (fs/size path)
- :ts (ct/now))))))
+ (if (contains? cf/flags :remote-media-processing)
+ (media.remote/process system params)
+ (media.local/process system params)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMAGE HELPERS
@@ -338,8 +59,8 @@
:hint "seems like the url points to resource with unknown size"))
(-> {:size size :mtype mtype}
- (validate-media-type!)
- (validate-media-size!))))]
+ (validation/validate-media-type!)
+ (validation/validate-media-size!))))]
(let [{:keys [body] :as response}
(try
@@ -367,188 +88,24 @@
(ex/raise :type :validation
:code :unable-to-download-image
:hint (str/ffmt "unable to download image from '%': I/O error" uri)
- :cause cause)))
+ :cause cause)))]
- {:keys [size mtype]} (parse-and-validate response)
- path (tmp/tempfile :prefix "penpot.media.download.")
- written (io/write* path body :size size)]
+ (if body
+ (with-open [body body]
+ (let [{:keys [size mtype]} (parse-and-validate response)
+ path (tmp/tempfile :prefix "penpot.media.download.")
+ written (io/write* path body :size size)]
- (when (not= written size)
- (ex/raise :type :internal
- :code :mismatch-write-size
- :hint "unexpected state: unable to write to file"))
+ (when (not= written size)
+ (ex/raise :type :internal
+ :code :mismatch-write-size
+ :hint "unexpected state: unable to write to file"))
- ;; Sanitize: strip trailing data after image EOF markers
- (let [new-size (sanitize/truncate-after-eof path mtype)]
- {:path path
- :mtype mtype
- :size new-size}))))
+ ;; Sanitize: strip trailing data after image EOF markers
+ (let [new-size (sanitize/truncate-after-eof path mtype)]
+ {:path path
+ :mtype mtype
+ :size new-size})))
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;; FONTS
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-
-(defn- get-font-prlimit
- "Returns resource limits for font processing tools, read from config."
- []
- {:mem (cf/get :font-process-mem)
- :cpu (cf/get :font-process-cpu)})
-
-(defn- get-font-timeout
- "Returns the wall-clock timeout for font processing, read from config."
- []
- (cf/get :font-process-timeout))
-
-(defn- exec-font!
- "Execute a font processing command with resource limits.
- `args` is a vector of string arguments."
- [system args]
- (shell/exec! system
- :cmd args
- :prlimit (get-font-prlimit)
- :timeout (get-font-timeout)))
-
-(defmethod process :generate-fonts
- [system {:keys [input] :as params}]
- (letfn [(ttf->otf [data]
- (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
- foutput (fs/path (str finput ".otf"))]
- (try
- (io/write* finput data)
- (let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
- (str/fmt "Open('%s'); Generate('%s')"
- (str finput)
- (str foutput))])]
- (when (zero? (:exit res))
- foutput))
- (finally
- (fs/delete finput)))))
-
- (otf->ttf [data]
- (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
- foutput (fs/path (str finput ".ttf"))]
- (try
- (io/write* finput data)
- (let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
- (str/fmt "Open('%s'); Generate('%s')"
- (str finput)
- (str foutput))])]
- (when (zero? (:exit res))
- foutput))
- (finally
- (fs/delete finput)))))
-
- (ttf-or-otf->woff [data]
- (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
- foutput (fs/path (str finput ".woff"))]
- (try
- (io/write* finput data)
- (let [res (exec-font! system ["sfnt2woff" (str finput)])]
- (when (zero? (:exit res))
- foutput))
- (finally
- (fs/delete finput)))))
-
- (woff->sfnt [data]
- (let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
- (try
- (io/write* finput data)
- (let [res (shell/exec! system
- :cmd ["woff2sfnt" (str finput)]
- :out-enc :bytes
- :prlimit (get-font-prlimit)
- :timeout (get-font-timeout))]
- (when (zero? (:exit res))
- (:out res)))
- (finally
- (fs/delete finput)))))
-
- (woff2->sfnt [data]
- ;; woff2_decompress outputs to same directory with .ttf extension
- (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
- foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
- (try
- (io/write* finput data)
- (let [res (exec-font! system ["woff2_decompress" (str finput)])]
- (if (zero? (:exit res))
- foutput
- (do
- (when (fs/exists? foutput)
- (fs/delete foutput))
- nil)))
- (finally
- (fs/delete finput)))))
-
- ;; Documented here:
- ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
- (get-sfnt-type [data]
- (let [buff (bb/slice data 0 4)
- type (bc/bytes->hex buff)]
- (case type
- "4f54544f" :otf
- "00010000" :ttf
- (ex/raise :type :internal
- :code :unexpected-data
- :hint "unexpected font data"))))
-
- (gen-if-nil [val factory]
- (if (nil? val)
- (factory)
- val))]
-
- (let [current (into #{} (keys input))]
- (cond
- (contains? current "font/ttf")
- (let [data (get input "font/ttf")]
- (-> input
- (update "font/otf" gen-if-nil #(ttf->otf data))
- (update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
-
- (contains? current "font/otf")
- (let [data (get input "font/otf")]
- (-> input
- (update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
- (assoc "font/ttf" (otf->ttf data))))
-
- (contains? current "font/woff")
- (let [data (get input "font/woff")
- sfnt (woff->sfnt data)]
- (when-not sfnt
- (ex/raise :type :validation
- :code :invalid-woff-file
- :hint "invalid woff file"))
- (let [stype (get-sfnt-type sfnt)]
- (cond-> input
- true
- (-> (assoc "font/woff" data))
-
- (= stype :otf)
- (-> (assoc "font/otf" sfnt)
- (assoc "font/ttf" (otf->ttf sfnt)))
-
- (= stype :ttf)
- (-> (assoc "font/otf" (ttf->otf sfnt))
- (assoc "font/ttf" sfnt)))))
-
- (contains? current "font/woff2")
- (let [data (get input "font/woff2")
- foutput (woff2->sfnt data)]
- (when-not foutput
- (ex/raise :type :validation
- :code :invalid-woff2-file
- :hint "invalid woff2 file"))
- (try
- (let [sfnt (io/read* foutput)
- type (get-sfnt-type sfnt)]
- (cond-> input
- (= type :otf)
- (-> (assoc "font/otf" sfnt)
- (assoc "font/ttf" (otf->ttf sfnt))
- (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
-
- (= type :ttf)
- (-> (assoc "font/ttf" sfnt)
- (assoc "font/otf" (ttf->otf sfnt))
- (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
- (finally
- (fs/delete foutput))))))))
+ ;; No body - validation will raise appropriate error
+ (parse-and-validate response)))))
diff --git a/backend/src/app/media/local.clj b/backend/src/app/media/local.clj
new file mode 100644
index 0000000000..f3811e83aa
--- /dev/null
+++ b/backend/src/app/media/local.clj
@@ -0,0 +1,366 @@
+;; 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 SUBSIDIARY SL
+
+(ns app.media.local
+ "Local media processing via ImageMagick and FontForge shell commands."
+ (:require
+ [app.common.exceptions :as ex]
+ [app.common.logging :as l]
+ [app.common.media :as cm]
+ [app.common.schema :as sm]
+ [app.common.time :as ct]
+ [app.config :as cf]
+ [app.media.svg :as svg]
+ [app.media.validation :as validation]
+ [app.storage.tmp :as tmp]
+ [app.util.shell :as shell]
+ [buddy.core.bytes :as bb]
+ [buddy.core.codecs :as bc]
+ [clojure.string]
+ [cuerdas.core :as str]
+ [datoteka.fs :as fs]
+ [datoteka.io :as io]))
+
+(defmulti process (fn [_system params] (:cmd params)))
+
+(defmethod process :default
+ [_system {:keys [cmd] :as params}]
+ (ex/raise :type :internal
+ :code :not-implemented
+ :hint (str/fmt "No impl found for local process cmd: %s" cmd)))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; IMAGE THUMBNAILS
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private schema:thumbnail-params
+ [:map {:title "ThumbnailParams"}
+ [:input validation/schema:input]
+ [:format [:enum :jpeg :webp :png]]
+ [:quality [:int {:min 1 :max 100}]]
+ [:width :int]
+ [:height :int]])
+
+(def ^:private check-thumbnail-params
+ (sm/check-fn schema:thumbnail-params))
+
+;; Related info on how thumbnails generation
+;; http://www.imagemagick.org/Usage/thumbnails/
+
+(def ^:private imagemagick-default-env
+ "Default environment variables for ImageMagick resource limits.
+ These are the soft ceiling — policy.xml is the hard ceiling."
+ {"MAGICK_THREAD_LIMIT" "2"
+ "MAGICK_MEMORY_LIMIT" "256MiB"
+ "MAGICK_MAP_LIMIT" "512MiB"
+ "MAGICK_AREA_LIMIT" "128MP"
+ "MAGICK_DISK_LIMIT" "1GiB"
+ "MAGICK_TIME_LIMIT" "30"})
+
+(defn- get-imagemagick-env
+ "Returns environment variables for ImageMagick commands.
+ Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
+ []
+ (let [thread (cf/get :imagemagick-thread-limit)
+ memory (cf/get :imagemagick-memory-limit)
+ map-l (cf/get :imagemagick-map-limit)
+ area (cf/get :imagemagick-area-limit)
+ disk (cf/get :imagemagick-disk-limit)
+ time (cf/get :imagemagick-time-limit)
+ width (cf/get :imagemagick-width-limit)
+ height (cf/get :imagemagick-height-limit)]
+ (cond-> imagemagick-default-env
+ thread (assoc "MAGICK_THREAD_LIMIT" thread)
+ memory (assoc "MAGICK_MEMORY_LIMIT" memory)
+ map-l (assoc "MAGICK_MAP_LIMIT" map-l)
+ area (assoc "MAGICK_AREA_LIMIT" area)
+ disk (assoc "MAGICK_DISK_LIMIT" disk)
+ time (assoc "MAGICK_TIME_LIMIT" time)
+ width (assoc "MAGICK_WIDTH_LIMIT" width)
+ height (assoc "MAGICK_HEIGHT_LIMIT" height))))
+
+(defn- exec-magick!
+ "Execute an ImageMagick command with resource limits.
+ `args` is a vector of string arguments to pass to `magick`."
+ [system args]
+ (let [cmd (into ["magick"] args)
+ result (shell/exec! system
+ :cmd cmd
+ :env (get-imagemagick-env)
+ :timeout 60)]
+ (when (not= 0 (:exit result))
+ (ex/raise :type :validation
+ :code :invalid-image
+ :hint (str "ImageMagick command failed: " (:err result))
+ :cmd cmd
+ :exit (:exit result)))
+ result))
+
+(defn- generic-process
+ [system {:keys [input format convert-args] :as params}]
+ (let [{:keys [path mtype]} input
+ format (or format (cm/mtype->format mtype))
+ ext (cm/format->extension format)
+ tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
+ args (into [(str path)] (conj (vec convert-args) (str tmp)))]
+ (exec-magick! system args)
+ (assoc params
+ :format format
+ :mtype (cm/format->mtype format)
+ :size (fs/size tmp)
+ :data tmp)))
+
+(defmethod process :generic-thumbnail
+ [system params]
+ (let [{:keys [quality width height] :as params}
+ (check-thumbnail-params params)]
+ (generic-process system
+ (assoc params
+ :convert-args ["-auto-orient" "-strip"
+ "-thumbnail" (str width "x" height ">")
+ "-quality" (str quality)]))))
+
+(defmethod process :profile-thumbnail
+ [system params]
+ (let [{:keys [quality width height] :as params}
+ (check-thumbnail-params params)]
+ (generic-process system
+ (assoc params
+ :convert-args ["-auto-orient" "-strip"
+ "-thumbnail" (str width "x" height "^")
+ "-gravity" "center"
+ "-extent" (str width "x" height)
+ "-quality" (str quality)]))))
+
+(defn- get-dimensions-with-orientation [system ^String path]
+ ;; Image magick doesn't give info about exif rotation so we use the identify command
+ ;; If we are processing an animated gif we use the first frame with -scene 0
+ (let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
+ orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
+ (when (= 0 (:exit dim-result))
+ (let [[w h] (-> (:out dim-result)
+ str/trim
+ (clojure.string/split #"\s+")
+ (->> (mapv #(Integer/parseInt %))))
+ orientation-exit (:exit orient-result)
+ orientation (-> orient-result :out str/trim)]
+ (if (= 0 orientation-exit)
+ (case orientation
+ ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
+ {:width w :height h}) ; Normal or unknown orientation
+ {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
+
+(defmethod process :info
+ [system {:keys [input] :as params}]
+ (let [{:keys [path mtype] :as input} (validation/check-input input)]
+ (if (= mtype "image/svg+xml")
+ (let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
+ (when-not info
+ (ex/raise :type :validation
+ :code :invalid-svg-file
+ :hint "uploaded svg does not provides dimensions"))
+ (merge input info {:ts (ct/now) :size (fs/size path)}))
+
+ (let [path-str (str path)
+ identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
+ ;; identify prints one line per frame (animated GIFs, etc.); we take the first one
+ mtype' (if (zero? (:exit identify-res))
+ (-> identify-res
+ :out
+ str/trim
+ (str/split #"\s+" 2)
+ first
+ str/lower)
+ (ex/raise :type :validation
+ :code :invalid-image
+ :hint "invalid image"))
+ {:keys [width height]}
+ (or (get-dimensions-with-orientation system path-str)
+ (do
+ (l/warn "Failed to read image dimensions with orientation" {:path path})
+ (ex/raise :type :validation
+ :code :invalid-image
+ :hint "invalid image")))]
+ (when (and (string? mtype)
+ (not= (str/lower mtype) mtype'))
+ (ex/raise :type :validation
+ :code :media-type-mismatch
+ :hint (str "Seems like you are uploading a file whose content does not match the extension."
+ "Expected: " mtype ". Got: " mtype')))
+ (assoc input
+ :width width
+ :height height
+ :size (fs/size path)
+ :ts (ct/now))))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; FONTS
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn- get-font-prlimit
+ "Returns resource limits for font processing tools, read from config."
+ []
+ {:mem (cf/get :font-process-mem)
+ :cpu (cf/get :font-process-cpu)})
+
+(defn- get-font-timeout
+ "Returns the wall-clock timeout for font processing, read from config."
+ []
+ (cf/get :font-process-timeout))
+
+(defn- exec-font!
+ "Execute a font processing command with resource limits.
+ `args` is a vector of string arguments."
+ [system args]
+ (shell/exec! system
+ :cmd args
+ :prlimit (get-font-prlimit)
+ :timeout (get-font-timeout)))
+
+(defmethod process :generate-fonts
+ [system {:keys [input] :as params}]
+ (letfn [(ttf->otf [data]
+ (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
+ foutput (fs/path (str finput ".otf"))]
+ (try
+ (io/write* finput data)
+ (let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
+ (str/fmt "Open('%s'); Generate('%s')"
+ (str finput)
+ (str foutput))])]
+ (when (zero? (:exit res))
+ foutput))
+ (finally
+ (fs/delete finput)))))
+
+ (otf->ttf [data]
+ (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
+ foutput (fs/path (str finput ".ttf"))]
+ (try
+ (io/write* finput data)
+ (let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
+ (str/fmt "Open('%s'); Generate('%s')"
+ (str finput)
+ (str foutput))])]
+ (when (zero? (:exit res))
+ foutput))
+ (finally
+ (fs/delete finput)))))
+
+ (ttf-or-otf->woff [data]
+ (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
+ foutput (fs/path (str finput ".woff"))]
+ (try
+ (io/write* finput data)
+ (let [res (exec-font! system ["sfnt2woff" (str finput)])]
+ (when (zero? (:exit res))
+ foutput))
+ (finally
+ (fs/delete finput)))))
+
+ (woff->sfnt [data]
+ (let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
+ (try
+ (io/write* finput data)
+ (let [res (shell/exec! system
+ :cmd ["woff2sfnt" (str finput)]
+ :out-enc :bytes
+ :prlimit (get-font-prlimit)
+ :timeout (get-font-timeout))]
+ (when (zero? (:exit res))
+ (:out res)))
+ (finally
+ (fs/delete finput)))))
+
+ (woff2->sfnt [data]
+ ;; woff2_decompress outputs to same directory with .ttf extension
+ (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
+ foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
+ (try
+ (io/write* finput data)
+ (let [res (exec-font! system ["woff2_decompress" (str finput)])]
+ (if (zero? (:exit res))
+ foutput
+ (do
+ (when (fs/exists? foutput)
+ (fs/delete foutput))
+ nil)))
+ (finally
+ (fs/delete finput)))))
+
+ ;; Documented here:
+ ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
+ (get-sfnt-type [data]
+ (let [buff (bb/slice data 0 4)
+ type (bc/bytes->hex buff)]
+ (case type
+ "4f54544f" :otf
+ "00010000" :ttf
+ (ex/raise :type :internal
+ :code :unexpected-data
+ :hint "unexpected font data"))))
+
+ (gen-if-nil [val factory]
+ (if (nil? val)
+ (factory)
+ val))]
+
+ (let [current (into #{} (keys input))]
+ (cond
+ (contains? current "font/ttf")
+ (let [data (get input "font/ttf")]
+ (-> input
+ (update "font/otf" gen-if-nil #(ttf->otf data))
+ (update "font/woff" gen-if-nil #(ttf-or-otf->woff data))))
+
+ (contains? current "font/otf")
+ (let [data (get input "font/otf")]
+ (-> input
+ (update "font/woff" gen-if-nil #(ttf-or-otf->woff data))
+ (assoc "font/ttf" (otf->ttf data))))
+
+ (contains? current "font/woff")
+ (let [data (get input "font/woff")
+ sfnt (woff->sfnt data)]
+ (when-not sfnt
+ (ex/raise :type :validation
+ :code :invalid-woff-file
+ :hint "invalid woff file"))
+ (let [stype (get-sfnt-type sfnt)]
+ (cond-> input
+ true
+ (-> (assoc "font/woff" data))
+
+ (= stype :otf)
+ (-> (assoc "font/otf" sfnt)
+ (assoc "font/ttf" (otf->ttf sfnt)))
+
+ (= stype :ttf)
+ (-> (assoc "font/otf" (ttf->otf sfnt))
+ (assoc "font/ttf" sfnt)))))
+
+ (contains? current "font/woff2")
+ (let [data (get input "font/woff2")
+ foutput (woff2->sfnt data)]
+ (when-not foutput
+ (ex/raise :type :validation
+ :code :invalid-woff2-file
+ :hint "invalid woff2 file"))
+ (try
+ (let [sfnt (io/read* foutput)
+ type (get-sfnt-type sfnt)]
+ (cond-> input
+ (= type :otf)
+ (-> (assoc "font/otf" sfnt)
+ (assoc "font/ttf" (otf->ttf sfnt))
+ (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
+
+ (= type :ttf)
+ (-> (assoc "font/ttf" sfnt)
+ (assoc "font/otf" (ttf->otf sfnt))
+ (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
+ (finally
+ (fs/delete foutput))))))))
diff --git a/backend/src/app/media/remote.clj b/backend/src/app/media/remote.clj
new file mode 100644
index 0000000000..1004035703
--- /dev/null
+++ b/backend/src/app/media/remote.clj
@@ -0,0 +1,264 @@
+;; 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 SUBSIDIARY SL
+
+(ns app.media.remote
+ "Remote media processing via the media-processor HTTP service."
+ (:require
+ [app.common.exceptions :as ex]
+ [app.common.media :as cm]
+ [app.common.time :as ct]
+ [app.common.uri :as uri]
+ [app.config :as cf]
+ [app.http.client :as http]
+ [app.media.svg :as svg]
+ [app.media.validation :as validation]
+ [app.setup :as-alias setup]
+ [app.storage.tmp :as tmp]
+ [app.util.json :as json]
+ [cuerdas.core :as str]
+ [datoteka.fs :as fs]
+ [datoteka.io :as io])
+ (:import
+ java.io.ByteArrayInputStream
+ java.io.InputStream
+ java.io.SequenceInputStream
+ java.net.ConnectException
+ java.net.http.HttpTimeoutException
+ java.util.Collections))
+
+(defn- service-base-url
+ "Returns the base URL of the media-processor service."
+ []
+ (or (cf/get :media-processing-service-uri)
+ (ex/raise :type :internal
+ :code :media-processor-not-configured
+ :hint "PENPOT_MEDIA_PROCESSING_SERVICE_URI is not configured")))
+
+(defn- service-timeout
+ "Returns the HTTP timeout (ms) for media-processor requests."
+ []
+ (or (cf/get :media-processing-service-timeout)
+ 120000))
+
+(defn- get-shared-key
+ "Returns the shared key for authenticating with the media-processor."
+ [system]
+ (-> system ::setup/shared-keys :media-processor))
+
+(defn- parse-json-response
+ "Parse a JSON response body."
+ [body]
+ (json/read! body))
+
+(defn- translate-error
+ "Translate a media-processor error response into a Penpot exception."
+ [status body]
+ (let [code (or (:code body) "media-processor-error")
+ hint (or (:hint body) "media-processor request failed")]
+ (case status
+ 400 {:type :validation :code (keyword code) :hint hint}
+ 403 {:type :authorization :code :forbidden :hint hint}
+ 413 {:type :restriction :code (keyword code) :hint hint}
+ 504 {:type :internal :code :media-processor-timeout :hint hint}
+ {:type :internal :code (keyword code) :hint hint})))
+
+(defn service-request
+ "Make an HTTP request to the media-processor service."
+ [system {:keys [method uri body headers timeout]}]
+ (let [client (::http/client system)
+ timeout (or timeout (service-timeout))]
+ (try
+ (let [resp (http/req client
+ {:method method
+ :uri uri
+ :body body
+ :headers headers}
+ {:response-type :input-stream
+ :skip-ssrf-check? true
+ :timeout timeout})
+ status (:status resp)]
+ (when (not (<= 200 status 299))
+ (let [body (:body resp)]
+ (try
+ (let [parsed (try (parse-json-response body) (catch Exception _ nil))
+ err (translate-error status parsed)]
+ (ex/raise :type (:type err) :code (:code err) :hint (:hint err)))
+ (finally
+ (.close body)))))
+ resp)
+ (catch ConnectException _cause
+ (ex/raise :type :internal
+ :code :media-processor-unavailable
+ :hint "Cannot connect to media-processor service"))
+ (catch HttpTimeoutException _cause
+ (ex/raise :type :internal
+ :code :media-processor-timeout
+ :hint "media-processor service request timed out")))))
+
+(defn- multipart-boundary
+ []
+ (str "----PenpotBoundary" (System/currentTimeMillis)))
+
+(defn- build-multipart-stream
+ "Build a streaming multipart/form-data body with a single file field.
+ Returns an InputStream that lazily reads from the file on demand."
+ [^String boundary mtype ^InputStream file-stream]
+ (let [header (.getBytes (str "--" boundary "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
+ "Content-Type: " mtype "\r\n"
+ "\r\n")
+ "UTF-8")
+ footer (.getBytes (str "\r\n--" boundary "--\r\n")
+ "UTF-8")
+ parts (Collections/enumeration
+ [(ByteArrayInputStream. header)
+ file-stream
+ (ByteArrayInputStream. footer)])]
+ (SequenceInputStream. parts)))
+
+(defn- service-multipart-request
+ "Send a multipart request to the media-processor service.
+ Accepts a file from disk via :path. The file stream is closed
+ after the HTTP request completes (success or failure)."
+ [system {:keys [endpoint path mtype query timeout]}]
+ (let [shared-key (get-shared-key system)
+ boundary (multipart-boundary)
+ ctype (or mtype "application/octet-stream")
+ base-url (service-base-url)
+ request-uri (cond-> (uri/join base-url endpoint)
+ (seq query)
+ (str "?" (uri/map->query-string query)))]
+ (with-open [file-stream (io/input-stream path)]
+ (let [body (build-multipart-stream boundary ctype file-stream)]
+ (service-request system
+ {:method :post
+ :uri request-uri
+ :body body
+ :headers {"Content-Type" (str "multipart/form-data; boundary=" boundary)
+ "x-shared-key" shared-key}
+ :timeout timeout})))))
+
+(def ^:private known-font-types
+ "Priority-ordered list of font mime-types the system knows how to convert.
+ Order matters: when a font upload contains multiple variants, the first
+ match becomes the conversion source (ttf preferred for best coverage)."
+ ["font/ttf" "font/otf" "font/woff" "font/woff2"])
+
+(defn- font-convert
+ "Convert a font to the given target mime-type via the media-processor service.
+ Accepts source font data as a filesystem Path. Returns a tempfile Path."
+ [system source-mtype target-mtype data]
+ (let [resp (service-multipart-request system {:endpoint "api/font/convert"
+ :path data
+ :mtype source-mtype
+ :query {:target-type target-mtype}
+ :timeout 180000})
+ ext (cm/mtype->extension target-mtype)
+ tmp (tmp/tempfile :prefix "penpot.font." :suffix ext)
+ body (:body resp)]
+ (try
+ (io/write* tmp body)
+ (finally
+ (.close body)))
+ tmp))
+
+(defn- font-missing-variants
+ "Return the set of target mime-types that should be generated for the given
+ source mime-type (excluding font/woff2, which is never generated)."
+ [source-mtype]
+ (case source-mtype
+ "font/ttf" #{"font/otf" "font/woff"}
+ "font/otf" #{"font/ttf" "font/woff"}
+ "font/woff" #{"font/ttf" "font/otf"}
+ "font/woff2" #{"font/ttf" "font/otf" "font/woff"}))
+
+(defmulti process (fn [_system params] (:cmd params)))
+
+(defmethod process :info
+ [system {:keys [input]}]
+ (let [{:keys [path mtype]} (validation/check-input input)]
+ (if (= mtype "image/svg+xml")
+ ;; SVG: parse locally (Sharp doesn't support SVG)
+ (let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)]
+ (when-not info
+ (ex/raise :type :validation
+ :code :invalid-svg-file
+ :hint "uploaded svg does not provide dimensions"))
+ (merge input info {:ts (ct/now) :size (fs/size path)}))
+ ;; Raster: delegate to media-processor
+ (let [resp (service-multipart-request system {:endpoint "api/image/info"
+ :path path
+ :mtype mtype})
+ body (:body resp)]
+ (try
+ (let [info (parse-json-response body)
+ detected-mtype (:mtype info)]
+ (when (and (string? mtype)
+ (string? detected-mtype)
+ (not= (str/lower mtype) (str/lower detected-mtype)))
+ (ex/raise :type :validation
+ :code :media-type-mismatch
+ :hint (str "File content does not match the declared type. "
+ "Expected: " mtype ". Got: " detected-mtype)))
+ (assoc input
+ :width (:width info)
+ :height (:height info)
+ :size (fs/size path)
+ :ts (ct/now)))
+ (finally
+ (.close body)))))))
+
+(defn- thumbnail-request
+ "Shared implementation for generic-thumbnail and profile-thumbnail."
+ [system params mode]
+ (let [{:keys [input format quality width height]} params
+ {:keys [path mtype]} (validation/check-input input)
+ fmt (name (or format (cm/mtype->format mtype) :jpeg))
+ resp (service-multipart-request system {:endpoint "api/image/thumbnail"
+ :path path
+ :mtype mtype
+ :query {:width width
+ :height height
+ :quality quality
+ :format fmt
+ :mode mode}})
+ out-format (or format (cm/mtype->format mtype) :jpeg)
+ ext (cm/format->extension out-format)
+ tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
+ body (:body resp)]
+ (try
+ (io/write* tmp body)
+ (finally
+ (.close body)))
+ (assoc params
+ :format out-format
+ :mtype (cm/format->mtype out-format)
+ :size (fs/size tmp)
+ :data tmp)))
+
+(defmethod process :generic-thumbnail
+ [system params]
+ (thumbnail-request system params "fit"))
+
+(defmethod process :profile-thumbnail
+ [system params]
+ (thumbnail-request system params "crop"))
+
+(defmethod process :generate-fonts
+ [system {:keys [input]}]
+ (let [source-mtype (or (some #(when (contains? input %) %) known-font-types)
+ (ex/raise :type :validation
+ :code :invalid-font
+ :hint "No recognized font variant in input"))
+ data (get input source-mtype)
+ present (set (keys input))
+ targets (remove present (font-missing-variants source-mtype))]
+ (reduce (fn [acc target-mtype]
+ (assoc acc target-mtype
+ (font-convert system source-mtype target-mtype data)))
+ input
+ targets)))
+
diff --git a/backend/src/app/media/sanitize.clj b/backend/src/app/media/sanitize.clj
index f67414501c..6f0d9465d5 100644
--- a/backend/src/app/media/sanitize.clj
+++ b/backend/src/app/media/sanitize.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.media.sanitize
"Image EOF truncation helpers — strips trailing data after image EOF
diff --git a/backend/src/app/media/svg.clj b/backend/src/app/media/svg.clj
new file mode 100644
index 0000000000..287322d460
--- /dev/null
+++ b/backend/src/app/media/svg.clj
@@ -0,0 +1,130 @@
+;; 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 SUBSIDIARY SL
+
+(ns app.media.svg
+ "SVG parsing, sanitization, and info extraction.
+ Centralizes all SVG-related security concerns."
+ (:require
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.common.exceptions :as ex]
+ [app.common.logging :as l]
+ [clojure.xml :as xml]
+ [cuerdas.core :as str])
+ (:import
+ clojure.lang.XMLHandler
+ java.io.InputStream
+ javax.xml.parsers.SAXParserFactory
+ javax.xml.XMLConstants
+ org.apache.commons.io.IOUtils))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; SVG PARSING
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn- secure-parser-factory
+ [^InputStream input ^XMLHandler handler]
+ (.. (doto (SAXParserFactory/newInstance)
+ (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true)
+ (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true))
+ (newSAXParser)
+ (parse input handler)))
+
+(defn- strip-doctype
+ [data]
+ (cond-> data
+ (str/includes? data "]*>" "")))
+
+(defn parse-svg
+ [text]
+ (let [text (strip-doctype text)]
+ (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
+ (xml/parse istream secure-parser-factory))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; SVG SANITIZATION
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private dangerous-attrs-pattern #"(?i)^on\w+$")
+(def ^:private javascript-href-pattern #"(?i)^javascript:")
+
+(defn- sanitize-svg-element
+ "Recursively sanitize an SVG element by removing dangerous tags and attributes."
+ [{:keys [tag attrs content] :as element}]
+ (when (and (map? element) tag)
+ (let [dangerous-tags #{:script :foreignObject :set :animate :animateTransform :animateColor :animateMotion}]
+ (when-not (contains? dangerous-tags tag)
+ (let [clean-attrs (->> attrs
+ (remove (fn [[k v]]
+ (or (re-matches dangerous-attrs-pattern (name k))
+ (and (#{:href :xlink:href} k)
+ (string? v)
+ (re-find javascript-href-pattern (str/trim v))))))
+ (into {}))
+ clean-content (when content
+ (->> content
+ (filter #(or (string? %) (map? %)))
+ (map (fn [child]
+ (if (map? child)
+ (sanitize-svg-element child)
+ child)))
+ (filter some?)
+ vec))]
+ (cond-> {:tag tag :attrs clean-attrs}
+ (seq clean-content) (assoc :content clean-content)))))))
+
+(defn sanitize-svg
+ "Sanitize SVG content by removing dangerous elements and attributes.
+ Removes "
+ result (svg/sanitize-svg svg)]
+ (t/is (not (clojure.string/includes? result ""
+ result (svg/sanitize-svg svg)]
+ (t/is (not (clojure.string/includes? result "foreignObject")))
+ (t/is (not (clojure.string/includes? result ""
+ result (svg/sanitize-svg svg)]
+ (t/is (not (clojure.string/includes? result ""
:font-weight 400 :font-style "normal"
- :data {"font/ttf" data}}
+ :uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation)))
;; name with ' should fail
- (let [params {::th/type :create-font-variant
+ (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
+ params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil'name"
:font-weight 400 :font-style "normal"
- :data {"font/ttf" data}}
+ :uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; name with } should fail
- (let [params {::th/type :create-font-variant
+ (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
+ params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil}name"
:font-weight 400 :font-style "normal"
- :data {"font/ttf" data}}
+ :uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; valid name should succeed
- (let [params {::th/type :create-font-variant
+ (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
+ params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id (uuid/custom 10 101)
:font-family "Source Sans Pro"
:font-weight 400 :font-style "normal"
- :data {"font/ttf" data}}
+ :uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (th/success? out))))))
@@ -887,12 +578,13 @@
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))]
;; Create a valid font first
- (let [params {::th/type :create-font-variant
+ (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024))
+ params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "ValidFont"
:font-weight 400 :font-style "normal"
- :data {"font/ttf" data}}
+ :uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (th/success? out)))
@@ -922,3 +614,65 @@
:name "Valid Font Name"}
out (th/command! params)]
(t/is (th/success? out))))))
+
+(t/deftest create-font-variant-rejects-foreign-font-id
+ ;; N2-07: A user with edit permissions on their own team must not be
+ ;; able to create a font variant using a font-id that already belongs
+ ;; to another team (BOLA / CWE-639).
+ (let [prof1 (th/create-profile* 1 {:is-active true})
+ prof2 (th/create-profile* 2 {:is-active true})
+ team1 (:default-team-id prof1)
+ team2 (:default-team-id prof2)
+ font-id (uuid/custom 10 999)
+ data (-> (io/resource "backend_tests/test_files/font-1.ttf")
+ (io/read*))]
+
+ ;; prof1 creates a font variant in team1 with font-id
+ (let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024))
+ params {::th/type :create-font-variant
+ ::rpc/profile-id (:id prof1)
+ :team-id team1
+ :font-id font-id
+ :font-family "SharedFont"
+ :font-weight 400
+ :font-style "normal"
+ :uploads {"font/ttf" session-id}}
+ out (th/command! params)]
+ (t/is (nil? (:error out))))
+
+ ;; prof2 tries to create a variant using the same font-id but
+ ;; in team2, which must be rejected because font-id belongs to team1
+ (let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024))
+ params {::th/type :create-font-variant
+ ::rpc/profile-id (:id prof2)
+ :team-id team2
+ :font-id font-id
+ :font-family "SharedFont"
+ :font-weight 700
+ :font-style "normal"
+ :uploads {"font/ttf" session-id}}
+ out (th/command! params)]
+ (t/is (some? (:error out)))
+ (t/is (= :not-found (-> out :error ex-data :type)))
+ (t/is (= :object-not-found (-> out :error ex-data :code))))))
+
+(t/deftest get-font-variants-nonexistent-file
+ (let [prof (th/create-profile* 1 {:is-active true})
+ out (th/command! {::th/type :get-font-variants
+ ::rpc/profile-id (:id prof)
+ :file-id (uuid/random)})
+ err (:error out)]
+ (t/is (th/ex-info? err))
+ (t/is (th/ex-of-type? err :not-found))))
+
+(t/deftest get-font-variants-no-permission
+ (let [owner (th/create-profile* 1 {:is-active true})
+ other (th/create-profile* 2 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id owner)
+ :project-id (:default-project-id owner)})
+ out (th/command! {::th/type :get-font-variants
+ ::rpc/profile-id (:id other)
+ :file-id (:id file)})
+ err (:error out)]
+ (t/is (th/ex-info? err))
+ (t/is (th/ex-of-type? err :not-found))))
diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj
index 769b5ec535..7857bdc1a5 100644
--- a/backend/test/backend_tests/rpc_management_nitrate_test.clj
+++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-management-nitrate-test
(:require
@@ -50,45 +50,138 @@
(t/is (= :authentication-required (th/ex-code (:error out))))))
(t/deftest create-and-update-organization-invitations-audit-props
+ (let [owner-id-ref (atom nil)]
+ (with-mocks [email-mock {:target 'app.email/send! :return nil}
+ audit-mock {:target 'app.loggers.audit/submit :return nil}
+ nitrate-mock {:target 'app.nitrate/call
+ :return (fn [_cfg method params]
+ (when (= method :get-organization-summary)
+ {:id (:organization-id params)
+ :name "Acme"
+ :owner-id @owner-id-ref
+ :teams []}))}]
+ (binding [cf/flags (conj cf/flags :email-verification)]
+ (let [owner (th/create-profile* 101 {:is-active true})
+ invitee (th/create-profile* 102 {:is-active true})
+ organization {:id (uuid/random)
+ :name "Acme"
+ :initials "AC"
+ :logo nil
+ :avatar-bg-url nil}
+ _ (reset! owner-id-ref (:id owner))
+ params {::th/type :invite-to-organization
+ ::rpc/profile-id (:id owner)
+ :email (:email invitee)
+ :organization organization}
+ create-out (th/management-command! params)
+ update-out (th/management-command! params)
+ external-out (th/management-command! (assoc params :email "external@example.com"))
+ events (mapv second (:call-args-list @audit-mock))
+ create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
+ update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
+ external-event
+ (first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
+ (t/is (th/success? create-out))
+ (t/is (th/success? update-out))
+ (t/is (th/success? external-out))
+
+ (doseq [event [create-event update-event]]
+ (t/is (not (contains? (:props event) :event-origin)))
+ (t/is (= (str (:id owner))
+ (get-in event [:props :user-who-send-invitation])))
+ (t/is (= (:id organization)
+ (get-in event [:props :organization-id])))
+ (t/is (= (:email invitee)
+ (get-in event [:props :member-email])))
+ (t/is (= (:id invitee)
+ (get-in event [:props :member-id]))))
+
+ (t/is (not (contains? (:props external-event) :member-id))))))))
+
+(t/deftest invite-to-organization-rejects-non-owner
+ (let [organization-summary-ref (atom nil)]
+ (with-mocks [email-mock {:target 'app.email/send! :return nil}
+ nitrate-mock {:target 'app.nitrate/call
+ :return (fn [_cfg method _params]
+ (when (= method :get-organization-summary)
+ @organization-summary-ref))}]
+ (let [owner (th/create-profile* 103 {:is-active true})
+ attacker (th/create-profile* 104 {:is-active true})
+ organization-id (uuid/random)
+ organization {:id organization-id
+ :name "Trusted Organization"
+ :initials "TO"
+ :logo nil
+ :avatar-bg-url nil}
+ _ (reset! organization-summary-ref
+ {:id organization-id
+ :name "Trusted Organization"
+ :owner-id (:id owner)
+ :teams []})
+ out (th/management-command! {::th/type :invite-to-organization
+ ::rpc/profile-id (:id attacker)
+ :email "victim@example.com"
+ :organization organization})]
+ (t/is (not (th/success? out)))
+ (t/is (= :not-found (th/ex-type (:error out))))
+ (t/is (= :object-not-found (th/ex-code (:error out))))
+ (t/is (not (:called? @email-mock)))))))
+
+(t/deftest invite-to-organization-rejects-unknown-organization
(with-mocks [email-mock {:target 'app.email/send! :return nil}
- audit-mock {:target 'app.loggers.audit/submit :return nil}
nitrate-mock {:target 'app.nitrate/call :return nil}]
- (binding [cf/flags (conj cf/flags :email-verification)]
- (let [owner (th/create-profile* 101 {:is-active true})
- invitee (th/create-profile* 102 {:is-active true})
- organization {:id (uuid/random)
- :name "Acme"
- :initials "AC"
- :logo nil
- :avatar-bg-url nil}
- params {::th/type :invite-to-organization
- ::rpc/profile-id (:id owner)
- :email (:email invitee)
- :organization organization}
- create-out (th/management-command! params)
- update-out (th/management-command! params)
- external-out (th/management-command! (assoc params :email "external@example.com"))
- events (mapv second (:call-args-list @audit-mock))
- create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
- update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
- external-event
- (first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
- (t/is (th/success? create-out))
- (t/is (th/success? update-out))
- (t/is (th/success? external-out))
+ (let [profile (th/create-profile* 105 {:is-active true})
+ organization-id (uuid/random)
+ out (th/management-command! {::th/type :invite-to-organization
+ ::rpc/profile-id (:id profile)
+ :email "victim@example.com"
+ :organization {:id organization-id
+ :name "Fabricated Organization"
+ :initials "FO"
+ :logo "https://evil.example/logo.png"
+ :avatar-bg-url nil}})]
+ (t/is (not (th/success? out)))
+ (t/is (= :not-found (th/ex-type (:error out))))
+ (t/is (= :object-not-found (th/ex-code (:error out))))
+ (t/is (not (:called? @email-mock))))))
- (doseq [event [create-event update-event]]
- (t/is (not (contains? (:props event) :event-origin)))
- (t/is (= (str (:id owner))
- (get-in event [:props :user-who-send-invitation])))
- (t/is (= (:id organization)
- (get-in event [:props :organization-id])))
- (t/is (= (:email invitee)
- (get-in event [:props :member-email])))
- (t/is (= (:id invitee)
- (get-in event [:props :member-id]))))
-
- (t/is (not (contains? (:props external-event) :member-id)))))))
+(t/deftest invite-to-organization-uses-authoritative-branding
+ (let [organization-summary-ref (atom nil)]
+ (with-mocks [email-mock {:target 'app.email/send! :return nil}
+ nitrate-mock {:target 'app.nitrate/call
+ :return (fn [_cfg method _params]
+ (when (= method :get-organization-summary)
+ @organization-summary-ref))}]
+ (binding [cf/flags (conj cf/flags :email-verification)]
+ (let [owner (th/create-profile* 106 {:is-active true})
+ organization-id (uuid/random)
+ logo-id (uuid/random)
+ _ (reset! organization-summary-ref
+ {:id organization-id
+ :name "Trusted Organization"
+ :owner-id (:id owner)
+ :logo-id logo-id
+ :avatar-bg-url "https://trusted.example/avatar.svg"
+ :sso-active true
+ :teams []})
+ out (th/management-command! {::th/type :invite-to-organization
+ ::rpc/profile-id (:id owner)
+ :email "victim@example.com"
+ :organization {:id organization-id
+ :name "Fabricated Bank"
+ :initials "FB"
+ :logo "https://evil.example/logo.png"
+ :avatar-bg-url "https://evil.example/avatar.svg"
+ :sso-active false}})
+ email-params (first (:call-args @email-mock))
+ organization (:organization email-params)]
+ (t/is (th/success? out))
+ (t/is (= "Trusted Organization" (:name organization)))
+ (t/is (= "" (:initials organization)))
+ (t/is (str/ends-with? (str (:logo organization))
+ (str "/assets/by-id/" logo-id)))
+ (t/is (nil? (:avatar-bg-url organization)))
+ (t/is (true? (:sso-active organization))))))))
(t/deftest get-penpot-version
(let [out (th/management-command! {::th/type :get-penpot-version})
@@ -101,6 +194,15 @@
(string? (get version k)))))
(t/is (= cf/version version))))
+(t/deftest get-air-gapped
+ (let [out (th/management-command! {::th/type :get-air-gapped})]
+ (t/is (th/success? out))
+ (t/is (false? (-> out :result :air-gapped))))
+ (binding [cf/flags (conj cf/flags :air-gapped-conf)]
+ (let [out (th/management-command! {::th/type :get-air-gapped})]
+ (t/is (th/success? out))
+ (t/is (true? (-> out :result :air-gapped))))))
+
(t/deftest get-teams-returns-only-owned-non-default-non-deleted
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(let [profile (th/create-profile* 1 {:is-active true})
@@ -175,7 +277,7 @@
new-team (th/db-get :team {:id new-team-id})]
(t/is (th/success? out))
(t/is (= 1 (count (set/difference after-teams before-teams))))
- (t/is (= "Your Penpot" (:name new-team)))
+ (t/is (= "Personal Projects" (:name new-team)))
(t/is (true? (:is-default new-team))))))
(t/deftest get-managed-profiles-returns-unique-members-for-owned-teams
diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj
index 601e8b3d35..1148fde042 100644
--- a/backend/test/backend_tests/rpc_management_test.clj
+++ b/backend/test/backend_tests/rpc_management_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-management-test
(:require
@@ -11,6 +11,7 @@
[app.common.pprint :as pp]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
+ [app.config :as cf]
[app.db :as db]
[app.http :as http]
[app.rpc :as-alias rpc]
@@ -19,6 +20,7 @@
[backend-tests.storage-test :refer [configure-storage-backend]]
[buddy.core.bytes :as b]
[clojure.test :as t]
+ [cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io]))
@@ -50,13 +52,34 @@
:path path
:mtype "image/png"
:size 7}}
- out1 (th/management-command! params)
- out2 (th/management-command! params)]
+ config (assoc cf/config :public-uri "https://example.com/penpot")
+ out1 (binding [cf/config config]
+ (th/management-command! params))
+ out2 (binding [cf/config config]
+ (th/management-command! params))]
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
+ (t/is (str/starts-with? (str (get-in out1 [:result :uri]))
+ "https://example.com/penpot/assets/by-id/"))
(t/is (not= (get-in out1 [:result :id])
(get-in out2 [:result :id])))))
+(t/deftest upload-tempfile-rejects-html-content-type
+ ;; N2-13: upload-tempfile must reject non-allowed content types
+ (let [profile (th/create-profile* 1 {:is-active true})
+ path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-")
+ _ (io/write* path "")
+ params {::th/type :upload-tempfile
+ ::rpc/profile-id (:id profile)
+ :content {:filename "evil.html"
+ :path path
+ :mtype "text/html"
+ :size 27}}
+ out (th/management-command! params)]
+ (t/is (some? (:error out)))
+ (t/is (= :validation (th/ex-type (:error out))))
+ (t/is (= :media-type-not-allowed (th/ex-code (:error out))))))
+
(t/deftest duplicate-file
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj
index ff38aee470..75db78c06b 100644
--- a/backend/test/backend_tests/rpc_media_test.clj
+++ b/backend/test/backend_tests/rpc_media_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-media-test
(:require
@@ -380,8 +380,41 @@
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err))))))))
-;; --------------------------------------------------------------------
-;; Helpers for chunked-upload tests
+
+(t/deftest download-image-closes-stream
+ (t/testing "response body stream is closed on success"
+ (let [closed? (atom false)
+ ;; Minimal valid PNG (1x1 pixel, red)
+ png-data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A 0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE 0x00 0x00 0x00 0x0C 0x49 0x44 0x41 0x54 0x08 0xD7 0x63 0xF8 0xCF 0xC0 0x00 0x00 0x00 0x02 0x00 0x01 0xE2 0x21 0xBC 0x33 0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82])
+ body (proxy [java.io.ByteArrayInputStream] [png-data]
+ (close [] (reset! closed? true)))]
+ (with-mocks [http-mock {:target 'app.http.client/req-with-redirects
+ :return {:status 200
+ :headers {"content-type" "image/png"
+ "content-length" (str (alength png-data))}
+ :body body}}]
+ (let [cfg {::http/client :mock-client}
+ result (media/download-image cfg "https://example.com/image.png")]
+ (t/is (some? result))
+ (t/is @closed? "body stream should be closed after successful download")))))
+
+ (t/testing "response body stream is closed on validation error"
+ (let [closed? (atom false)
+ body (proxy [java.io.ByteArrayInputStream] [(byte-array 100)]
+ (close [] (reset! closed? true)))]
+ (with-mocks [http-mock {:target 'app.http.client/req-with-redirects
+ :return {:status 404
+ :headers {"content-type" "text/html"
+ "content-length" "100"}
+ :body body}}]
+ (let [cfg {::http/client :mock-client}
+ err (try
+ (media/download-image cfg "https://example.com/not-found.png")
+ nil
+ (catch clojure.lang.ExceptionInfo e e))]
+ (t/is (some? err))
+ (t/is (= :unable-to-download-image (:code (ex-data err))))
+ (t/is @closed? "body stream should be closed even on validation error"))))))
;; --------------------------------------------------------------------
(defn- split-file-into-chunks
@@ -548,6 +581,41 @@
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))))
+(t/deftest chunked-upload-other-profile-cannot-assemble
+ ;; assemble-chunks must scope the session lookup to the requesting
+ ;; profile so that a different profile cannot assemble chunks from
+ ;; a session they do not own (BOLA / CWE-639).
+ (let [prof1 (th/create-profile* 1)
+ prof2 (th/create-profile* 2)
+ session-id (create-session! prof1 1)
+ source-path (th/tempfile "backend_tests/test_files/sample.jpg")
+ mfile {:filename "sample.jpg"
+ :path source-path
+ :mtype "image/jpeg"
+ :size 312043}]
+
+ ;; prof1 uploads a chunk into their own session
+ (let [out (th/command! {::th/type :upload-chunk
+ ::rpc/profile-id (:id prof1)
+ :session-id session-id
+ :index 0
+ :content mfile})]
+ (t/is (nil? (:error out))))
+
+ ;; prof2 tries to assemble prof1's session via create-font-variant
+ ;; (which calls assemble-chunks without ownership check)
+ (let [out (th/command! {::th/type :create-font-variant
+ ::rpc/profile-id (:id prof2)
+ :team-id (:default-team-id prof2)
+ :font-id (uuid/next)
+ :font-family "TestFont"
+ :font-weight 400
+ :font-style "normal"
+ :uploads {"font/ttf" session-id}})]
+ (t/is (some? (:error out)))
+ (t/is (= :not-found (-> out :error ex-data :type)))
+ (t/is (= :object-not-found (-> out :error ex-data :code))))))
+
(t/deftest chunked-upload-invalid-media-type
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
@@ -650,6 +718,24 @@
(t/is (= :max-quote-reached (-> out :error ex-data :code)))
(t/is (= "upload-chunks-per-session" (-> out :error ex-data :target))))))
+(t/deftest chunked-upload-invalid-total-chunks
+ ;; total-chunks must be at least 1; zero and negative values are rejected
+ ;; with a :validation error.
+ (let [prof (th/create-profile* 1)]
+ ;; zero total-chunks
+ (let [out (th/command! {::th/type :create-upload-session
+ ::rpc/profile-id (:id prof)
+ :total-chunks 0})]
+ (t/is (some? (:error out)))
+ (t/is (= :validation (-> out :error ex-data :type))))
+
+ ;; negative total-chunks
+ (let [out (th/command! {::th/type :create-upload-session
+ ::rpc/profile-id (:id prof)
+ :total-chunks -1})]
+ (t/is (some? (:error out)))
+ (t/is (= :validation (-> out :error ex-data :type))))))
+
(t/deftest chunked-upload-invalid-chunk-index
;; Both a negative index and an index >= total-chunks must be
;; rejected with a :validation / :invalid-chunk-index error.
@@ -701,3 +787,98 @@
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :max-quote-reached (-> out :error ex-data :code)))))))
+
+;; --- Clone File Media Object BOLA tests ---
+
+(defn- create-storage-object!
+ [content content-type]
+ (let [storage (:app.storage/storage th/*system*)]
+ (sto/put-object! storage {::sto/content (sto/content content)
+ :content-type content-type})))
+
+(t/deftest clone-file-media-object-success
+ (let [prof1 (th/create-profile* 1)
+ _ (th/create-project* 1 {:profile-id (:id prof1)
+ :team-id (:default-team-id prof1)})
+ file1 (th/create-file* 1 {:profile-id (:id prof1)
+ :project-id (:default-project-id prof1)
+ :is-shared false})
+ sobj (create-storage-object! "image-content" "image/png")
+ mobj (th/create-file-media-object* {:file-id (:id file1)
+ :name "test-media"
+ :width 100
+ :height 100
+ :mtype "image/png"
+ :media-id (:id sobj)})
+ file2 (th/create-file* 2 {:profile-id (:id prof1)
+ :project-id (:default-project-id prof1)
+ :is-shared false})
+ params {::th/type :clone-file-media-object
+ ::rpc/profile-id (:id prof1)
+ :file-id (:id file2)
+ :is-local true
+ :id (:id mobj)}
+ out (th/command! params)]
+
+ (t/is (nil? (:error out)))
+ (let [result (:result out)]
+ (t/is (= (:id file2) (:file-id result)))
+ (t/is (= (:name mobj) (:name result)))
+ (t/is (= (:media-id mobj) (:media-id result)))
+ (t/is (uuid? (:id result)))
+ (t/is (not= (:id mobj) (:id result))))))
+
+(t/deftest clone-file-media-object-no-read-access
+ (let [prof1 (th/create-profile* 1)
+ _ (th/create-project* 1 {:profile-id (:id prof1)
+ :team-id (:default-team-id prof1)})
+ file1 (th/create-file* 1 {:profile-id (:id prof1)
+ :project-id (:default-project-id prof1)
+ :is-shared false})
+ sobj (create-storage-object! "private-content" "image/png")
+ mobj (th/create-file-media-object* {:file-id (:id file1)
+ :name "private-media"
+ :width 100
+ :height 100
+ :mtype "image/png"
+ :media-id (:id sobj)})
+
+ prof2 (th/create-profile* 2)
+ _ (th/create-project* 2 {:profile-id (:id prof2)
+ :team-id (:default-team-id prof2)})
+ file2 (th/create-file* 2 {:profile-id (:id prof2)
+ :project-id (:default-project-id prof2)
+ :is-shared false})
+
+ params {::th/type :clone-file-media-object
+ ::rpc/profile-id (:id prof2)
+ :file-id (:id file2)
+ :is-local true
+ :id (:id mobj)}
+ out (th/command! params)]
+
+ (let [error (:error out)
+ error-data (ex-data error)]
+ (t/is (th/ex-info? error))
+ (t/is (= :not-found (:type error-data)))
+ (t/is (= :object-not-found (:code error-data))))))
+
+(t/deftest clone-file-media-object-source-not-found
+ (let [prof (th/create-profile* 1)
+ _ (th/create-project* 1 {:profile-id (:id prof)
+ :team-id (:default-team-id prof)})
+ file (th/create-file* 1 {:profile-id (:id prof)
+ :project-id (:default-project-id prof)
+ :is-shared false})
+ params {::th/type :clone-file-media-object
+ ::rpc/profile-id (:id prof)
+ :file-id (:id file)
+ :is-local true
+ :id (uuid/random)}
+ out (th/command! params)]
+
+ (let [error (:error out)
+ error-data (ex-data error)]
+ (t/is (th/ex-info? error))
+ (t/is (= :not-found (:type error-data)))
+ (t/is (= :object-not-found (:code error-data))))))
diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj
index 5d973d94bd..936e354910 100644
--- a/backend/test/backend_tests/rpc_nitrate_test.clj
+++ b/backend/test/backend_tests/rpc_nitrate_test.clj
@@ -2,11 +2,12 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-nitrate-test
(:require
[app.auth.oidc :as oidc]
+ [app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.time :as ct]
[app.common.uuid :as uuid]
@@ -14,15 +15,17 @@
[app.db :as-alias db]
[app.email :as eml]
[app.http :as-alias http]
+ [app.http.errors :as http-errors]
[app.nitrate :as nitrate]
- [app.rpc :as-alias rpc]
+ [app.rpc :as rpc]
[app.rpc.commands.nitrate]
[app.rpc.commands.teams :as teams]
[app.rpc.helpers :as rph]
[backend-tests.helpers :as th]
[buddy.core.codecs :as bc]
[clojure.test :as t]
- [cuerdas.core :as str]))
+ [cuerdas.core :as str]
+ [yetti.response :as-alias yres]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@@ -86,6 +89,31 @@
nil)))
+(defn- unauthorized-sso-mock
+ "Creates a mock for nitrate/sso-session-authorized? that reports an active
+ SSO the session does not satisfy. Pass nil to leave the organization out of
+ the nitrate payload."
+ [organization-id]
+ (fn [_cfg _organization-id _team-id _request]
+ {:authorized false
+ :sso (cond-> {:active true
+ :issuer "https://idp.example.com"}
+ (some? organization-id)
+ (assoc :organization-id organization-id))}))
+
+(defn- sso-gate-error
+ "Builds the SSO gate around a handler that must never be reached, and
+ returns the exception it raises for `params`."
+ [mdata params cfg]
+ (let [handler (fn [_cfg _params] ::handler-called)
+ wrapped (binding [cf/flags (conj cf/flags :admin-console)]
+ (#'rpc/wrap-nitrate-sso nil handler mdata))]
+ (try
+ (wrapped cfg (with-meta params {::http/request {}}))
+ nil
+ (catch Throwable cause
+ cause))))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -111,13 +139,17 @@
(constantly "https://idp.example.com/authorize")]
(let [out (th/command! params)]
(t/is (th/success? out))
- (t/is (= {:authorized true} (:result out))))))))
+ ;; The reason tells the client this is a permission problem, not a
+ ;; usable SSO session.
+ (t/is (= {:authorized true :reason :no-team-access} (:result out))))))))
(t/deftest check-nitrate-sso-keeps-gate-for-team-member
(let [team-owner (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id team-owner)})
organization-id (uuid/random)
redirect-uri "https://idp.example.com/authorize"
+ redirect-options (atom nil)
+ started-event (atom nil)
params (with-meta
{::th/type :check-nitrate-sso
::rpc/profile-id (:id team-owner)
@@ -131,12 +163,50 @@
organization-id
(:id team-owner))
oidc/build-organization-sso-auth-redirect-uri
- (constantly redirect-uri)]
+ (fn [_cfg _sso & options]
+ (reset! redirect-options (apply hash-map options))
+ redirect-uri)
+ oidc/submit-organization-sso-auth-started-event
+ (fn [_cfg _request profile-id received-organization-id]
+ (reset! started-event {:profile-id profile-id
+ :organization-id received-organization-id}))]
(let [out (th/command! params)]
(t/is (th/success? out))
(t/is (= {:authorized false
:redirect-uri redirect-uri}
- (:result out))))))))
+ (:result out)))
+ (t/is (= #{:dest-url :organization-id} (set (keys @redirect-options))))
+ (t/is (= "https://penpot.example.com/#/workspace" (str (:dest-url @redirect-options))))
+ (t/is (nil? (:organization-id @redirect-options)))
+ (t/is (= {:profile-id (:id team-owner)
+ :organization-id organization-id}
+ @started-event)))))))
+
+(t/deftest check-nitrate-sso-reports-redirect-failure
+ (let [profile (th/create-profile* 1 {:is-active true})
+ organization-id (uuid/random)
+ cause (ex-info "provider unavailable" {:response-status-code 503})
+ reported (atom nil)
+ params (with-meta
+ {::th/type :check-nitrate-sso
+ ::rpc/profile-id (:id profile)
+ :organization-id organization-id
+ :url "https://penpot.example.com/#/workspace"}
+ {::http/request {}})]
+ (binding [cf/flags (conj cf/flags :admin-console)]
+ (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)
+ oidc/build-organization-sso-auth-redirect-uri (fn [& _] (throw cause))
+ oidc/submit-organization-sso-auth-failed-event
+ (fn [_cfg _request profile-id received-organization-id received-cause]
+ (reset! reported {:profile-id profile-id
+ :organization-id received-organization-id
+ :cause received-cause}))]
+ (let [out (th/command! params)]
+ (t/is (not (th/success? out)))
+ (t/is (= {:profile-id (:id profile)
+ :organization-id organization-id
+ :cause cause}
+ @reported)))))))
(t/deftest check-nitrate-sso-keeps-gate-for-non-member-organization-owner
(let [team-owner (th/create-profile* 1 {:is-active true})
@@ -168,6 +238,91 @@
:redirect-uri redirect-uri}
(:result out))))))))
+(t/deftest check-nitrate-sso-reports-a-satisfied-gate-for-a-valid-session
+ (let [team-owner (th/create-profile* 1 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id team-owner)})
+ organization-id (uuid/random)
+ params (with-meta
+ {::th/type :check-nitrate-sso
+ ::rpc/profile-id (:id team-owner)
+ :team-id (:id team)
+ :url "https://penpot.example.com/#/workspace"}
+ {::http/request {}})]
+ (binding [cf/flags (conj cf/flags :admin-console)]
+ (with-redefs [nitrate/sso-session-authorized?
+ (fn [_cfg _organization-id _team-id _request]
+ {:authorized true
+ :sso {:active true
+ :issuer "https://idp.example.com"
+ :organization-id organization-id}})]
+ (let [out (th/command! params)]
+ (t/is (th/success? out))
+ (t/is (= {:authorized true :reason :sso-satisfied} (:result out))))))))
+
+(t/deftest nitrate-sso-required-error-resolves-the-team-from-the-file
+ (t/testing "the workspace path, where the file id arrives as :id, still reports the team"
+ (let [profile (th/create-profile* 1 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id (:default-project-id profile)})
+ organization-id (uuid/random)]
+ (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)]
+ (let [data (ex-data (sso-gate-error {::rpc/id-type :file}
+ {::rpc/profile-id (:id profile)
+ :id (:id file)}
+ th/*system*))]
+ (t/is (= :authentication (:type data)))
+ (t/is (= :nitrate-sso-required (:code data)))
+ (t/is (= organization-id (:organization-id data)))
+ (t/is (= (:default-team-id profile) (:team-id data))))))))
+
+(t/deftest nitrate-sso-required-error-keeps-the-team-known-by-the-request
+ (t/testing "an explicit team-id is not dropped by an explicit organization-id"
+ (let [profile-id (uuid/random)
+ team-id (uuid/random)
+ organization-id (uuid/random)]
+ ;; The nitrate payload carries no organization-id here, so the one from
+ ;; the request params is the only one left to report.
+ (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)]
+ (let [data (ex-data (sso-gate-error {}
+ {::rpc/profile-id profile-id
+ :team-id team-id
+ :organization-id organization-id}
+ {}))]
+ (t/is (= organization-id (:organization-id data)))
+ (t/is (= team-id (:team-id data))))))))
+
+(t/deftest nitrate-sso-required-error-resolves-the-team-with-a-known-organization
+ (t/testing "knowing the organization does not stop the team lookup"
+ (let [profile (th/create-profile* 1 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id (:default-project-id profile)})
+ organization-id (uuid/random)]
+ (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)]
+ (let [data (ex-data (sso-gate-error {}
+ {::rpc/profile-id (:id profile)
+ :organization-id organization-id
+ :file-id (:id file)}
+ th/*system*))]
+ (t/is (= organization-id (:organization-id data)))
+ (t/is (= (:default-team-id profile) (:team-id data))))))))
+
+(t/deftest nitrate-sso-required-error-reaches-the-client-in-the-401-body
+ (t/testing "the ids survive the http error response, not only the exception"
+ (let [profile-id (uuid/random)
+ team-id (uuid/random)
+ organization-id (uuid/random)]
+ (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)]
+ (let [cause (sso-gate-error {}
+ {::rpc/profile-id profile-id
+ :team-id team-id}
+ {})
+ response (http-errors/handle cause {})
+ body (::yres/body response)]
+ (t/is (= 401 (::yres/status response)))
+ (t/is (= :nitrate-sso-required (:code body)))
+ (t/is (= organization-id (:organization-id body)))
+ (t/is (= team-id (:team-id body))))))))
+
(t/deftest leave-organization-happy-path-no-extra-teams
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
@@ -1013,12 +1168,68 @@
@set-team-params))
(let [emails (->> @sent (map :to) set)]
- (t/is (= 2 (count @sent)))
- (t/is (= #{"member302@example.com" "external301@example.com"} emails))
+ (t/is (= 1 (count @sent)))
+ (t/is (= #{"member302@example.com"} emails))
(doseq [email-params @sent]
(t/is (= organization-name (:organization-name email-params)))
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
+(t/deftest add-team-to-organization-deletes-external-invitations-for-unregistered-users
+ (let [owner (th/create-profile* 305 {:is-active true
+ :fullname "Owner"
+ :email "owner305@example.com"})
+ member (th/create-profile* 306 {:is-active true
+ :fullname "Member"
+ :email "member306@example.com"})
+ team (th/create-team* 305 {:profile-id (:id owner)})
+ _ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id member)
+ :role :editor})
+ organization-id (uuid/random)
+ organization-summary {:id organization-id
+ :name "Test Org"
+ :owner-id (:id owner)
+ :teams []}
+ organization-perms {:owner-id (:id owner)
+ :permissions {:create-teams "any"
+ :move-teams "always"
+ :new-team-members "members"}}]
+
+ (th/db-insert! :team-invitation
+ {:id (uuid/random)
+ :team-id (:id team)
+ :org-id nil
+ :email-to "unregistered@example.com"
+ :created-by (:id owner)
+ :role "editor"
+ :valid-until (ct/in-future "48h")})
+ (th/db-insert! :team-invitation
+ {:id (uuid/random)
+ :team-id (:id team)
+ :org-id nil
+ :email-to "unregistered2@example.com"
+ :created-by (:id owner)
+ :role "editor"
+ :valid-until (ct/in-future "48h")})
+
+ (with-redefs [cf/flags (conj cf/flags :admin-console)
+ nitrate/call (add-team-to-organization-nitrate-mock
+ {:organization-id organization-id
+ :organization-summary organization-summary
+ :organization-perms organization-perms
+ :owner-id (:id owner)
+ :team-id (:id team)
+ :sso-active? false})
+ teams/initialize-user-in-organization (fn [& _] nil)]
+ (let [out (th/command! {::th/type :add-team-to-organization
+ ::rpc/profile-id (:id owner)
+ :team-id (:id team)
+ :organization-id organization-id})]
+ (t/is (th/success? out))))
+
+ (let [remaining (th/db-query :team-invitation {:team-id (:id team)})]
+ (t/is (empty? remaining) "Both external invitations should be deleted"))))
+
(t/deftest create-team-in-organization-passes-association-to-nitrate
(let [organization-id (uuid/random)
team {:id (uuid/random)
@@ -1123,3 +1334,45 @@
::rpc/profile-id (:id profile)})]
(t/is (not (th/success? out)))
(t/is (th/ex-of-code? (:error out) :nitrate-identity-unavailable))))))
+
+(t/deftest redeem-nitrate-activation-code-used
+ (let [profile (th/create-profile* 1 {:is-active true})]
+ (with-redefs [cf/flags (conj cf/flags :admin-console)
+ nitrate/call (fn [_cfg method _params]
+ (t/is (= :redeem-activation-code method))
+ (ex/raise :type :nitrate-http-error
+ :status 409
+ :hint "activation code already used"))]
+ (let [out (th/command! {::th/type :redeem-nitrate-activation-code
+ ::rpc/profile-id (:id profile)
+ :activation-code "already-used-code"})]
+ (t/is (not (th/success? out)))
+ (t/is (th/ex-of-code? (:error out) :used-activation-code))))))
+
+(t/deftest redeem-nitrate-activation-code-expired
+ (let [profile (th/create-profile* 1 {:is-active true})]
+ (with-redefs [cf/flags (conj cf/flags :admin-console)
+ nitrate/call (fn [_cfg method _params]
+ (t/is (= :redeem-activation-code method))
+ (ex/raise :type :nitrate-http-error
+ :status 410
+ :hint "activation code expired"))]
+ (let [out (th/command! {::th/type :redeem-nitrate-activation-code
+ ::rpc/profile-id (:id profile)
+ :activation-code "expired-code"})]
+ (t/is (not (th/success? out)))
+ (t/is (th/ex-of-code? (:error out) :expired-activation-code))))))
+
+(t/deftest redeem-nitrate-activation-code-invalid
+ (let [profile (th/create-profile* 1 {:is-active true})]
+ (with-redefs [cf/flags (conj cf/flags :admin-console)
+ nitrate/call (fn [_cfg method _params]
+ (t/is (= :redeem-activation-code method))
+ (ex/raise :type :nitrate-http-error
+ :status 422
+ :hint "invalid activation code"))]
+ (let [out (th/command! {::th/type :redeem-nitrate-activation-code
+ ::rpc/profile-id (:id profile)
+ :activation-code "invalid-code"})]
+ (t/is (not (th/success? out)))
+ (t/is (th/ex-of-code? (:error out) :invalid-activation-code))))))
diff --git a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj
index 8168242f16..13c7f8120f 100644
--- a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj
+++ b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-organization-owner-permissions-test
(:require
@@ -50,7 +50,16 @@
:organization (organization-data organization-id organization-owner-id)}
{:id (:team-id params)
:is-your-penpot false
- :organization nil})))]
+ :organization nil})
+
+ :get-teams-organizations
+ (->> (:team-ids params)
+ (keep (fn [candidate-team-id]
+ (when (= team-id candidate-team-id)
+ {:id team-id
+ :is-your-penpot false
+ :organization (organization-data organization-id organization-owner-id)})))
+ vec)))]
(f)))
(defn- with-captured-messages
diff --git a/backend/test/backend_tests/rpc_plugins_test.clj b/backend/test/backend_tests/rpc_plugins_test.clj
new file mode 100644
index 0000000000..9850d90ba9
--- /dev/null
+++ b/backend/test/backend_tests/rpc_plugins_test.clj
@@ -0,0 +1,162 @@
+;; 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 backend-tests.rpc-plugins-test
+ (:require
+ [app.common.uuid :as uuid]
+ [app.rpc :as-alias rpc]
+ [app.rpc.commands.profile :as profile]
+ [backend-tests.helpers :as th]
+ [clojure.test :as t]))
+
+(t/use-fixtures :once th/state-init)
+(t/use-fixtures :each th/database-reset)
+
+(def ^:private plugin-id-1 (str (uuid/next)))
+(def ^:private plugin-id-2 (str (uuid/next)))
+
+(def ^:private valid-plugin
+ {:plugin-id plugin-id-1
+ :name "Test Plugin"
+ :description "A test plugin"
+ :host "https://example.com"
+ :code "(function() { console.log('hello'); })()"
+ :icon "icon.svg"
+ :permissions #{"content:read" "content:write"}})
+
+(t/deftest add-profile-plugin-accepts-valid-permissions
+ (let [profile (th/create-profile* 1)
+ data {::th/type :add-profile-plugin
+ ::rpc/profile-id (:id profile)
+ :plugin valid-plugin}
+ out (th/command! data)]
+
+ (t/is (nil? (:error out)))
+ (t/is (some? (:result out)))
+
+ (let [saved (th/db-get :profile {:id (:id profile)})
+ props (profile/decode-row saved)
+ plugins (get-in props [:props :plugins])]
+ (t/is (= [plugin-id-1] (:ids plugins)))
+ (t/is (= valid-plugin (get-in plugins [:data plugin-id-1]))))))
+
+(t/deftest add-profile-plugin-rejects-invalid-permissions
+ (let [profile (th/create-profile* 1)
+ plugin (assoc valid-plugin :permissions #{"content:read" "admin:delete"})
+ data {::th/type :add-profile-plugin
+ ::rpc/profile-id (:id profile)
+ :plugin plugin}
+ out (th/command! data)]
+
+ ;; Schema validation catches invalid permissions before custom validation
+ (t/is (th/ex-info? (:error out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :params-validation))
+
+ (let [saved (th/db-get :profile {:id (:id profile)})
+ props (profile/decode-row saved)
+ plugins (get-in props [:props :plugins])]
+ (t/is (nil? plugins) "No plugins should be persisted when validation fails"))))
+
+(t/deftest add-profile-plugin-updates-existing-plugin
+ (let [profile (th/create-profile* 1)
+ data1 {::th/type :add-profile-plugin
+ ::rpc/profile-id (:id profile)
+ :plugin valid-plugin}
+ _ (th/command! data1)
+
+ updated-plugin (assoc valid-plugin :name "Updated Plugin")
+ data2 {::th/type :add-profile-plugin
+ ::rpc/profile-id (:id profile)
+ :plugin updated-plugin}
+ out (th/command! data2)]
+
+ (t/is (nil? (:error out)))
+
+ (let [saved (th/db-get :profile {:id (:id profile)})
+ props (profile/decode-row saved)
+ plugins (get-in props [:props :plugins])]
+ (t/is (= 1 (count (:ids plugins))) "Should still have only one plugin")
+ (t/is (= "Updated Plugin" (get-in plugins [:data plugin-id-1 :name]))))))
+
+(t/deftest remove-profile-plugin-removes-plugin
+ (let [profile (th/create-profile* 1)
+ data1 {::th/type :add-profile-plugin
+ ::rpc/profile-id (:id profile)
+ :plugin valid-plugin}
+ _ (th/command! data1)
+
+ data2 {::th/type :remove-profile-plugin
+ ::rpc/profile-id (:id profile)
+ :plugin-id (uuid/uuid plugin-id-1)}
+ out (th/command! data2)]
+
+ (t/is (nil? (:error out)))
+
+ (let [saved (th/db-get :profile {:id (:id profile)})
+ props (profile/decode-row saved)
+ plugins (get-in props [:props :plugins])]
+ (t/is (= [] (:ids plugins)))
+ (t/is (empty? (:data plugins))))))
+
+(t/deftest remove-profile-plugin-handles-nonexistent-plugin
+ (let [profile (th/create-profile* 1)
+ data {::th/type :remove-profile-plugin
+ ::rpc/profile-id (:id profile)
+ :plugin-id (uuid/next)}
+ out (th/command! data)]
+
+ (t/is (nil? (:error out)))
+
+ (let [saved (th/db-get :profile {:id (:id profile)})
+ props (profile/decode-row saved)
+ plugins (get-in props [:props :plugins])]
+ (t/is (or (nil? plugins)
+ (and (empty? (:ids plugins))
+ (empty? (:data plugins))))
+ "Plugins should be nil or empty when no plugins exist"))))
+
+(t/deftest add-profile-plugin-multiple-plugins
+ (let [profile (th/create-profile* 1)
+ plugin1 valid-plugin
+ plugin2 (assoc valid-plugin
+ :plugin-id plugin-id-2
+ :name "Second Plugin")
+
+ data1 {::th/type :add-profile-plugin
+ ::rpc/profile-id (:id profile)
+ :plugin plugin1}
+ _ (th/command! data1)
+
+ data2 {::th/type :add-profile-plugin
+ ::rpc/profile-id (:id profile)
+ :plugin plugin2}
+ _ (th/command! data2)]
+
+ (let [saved (th/db-get :profile {:id (:id profile)})
+ props (profile/decode-row saved)
+ plugins (get-in props [:props :plugins])]
+ (t/is (= 2 (count (:ids plugins))))
+ (t/is (contains? (set (:ids plugins)) plugin-id-1))
+ (t/is (contains? (set (:ids plugins)) plugin-id-2))
+ (t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name])))
+ (t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name]))))))
+
+(t/deftest update-profile-props-rejects-plugins
+ (let [profile (th/create-profile* 1)
+ data {::th/type :update-profile-props
+ ::rpc/profile-id (:id profile)
+ :props {:plugins {:ids ["test"] :data {"test" valid-plugin}}}}
+ out (th/command! data)]
+
+ (t/is (th/ex-info? (:error out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :params-validation))
+
+ (let [saved (th/db-get :profile {:id (:id profile)})
+ props (profile/decode-row saved)]
+ (t/is (nil? (get-in props [:props :plugins]))
+ ":plugins must not be writable via update-profile-props"))))
diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj
index ffbb55ca7c..95d9d079c5 100644
--- a/backend/test/backend_tests/rpc_profile_test.clj
+++ b/backend/test/backend_tests/rpc_profile_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-profile-test
(:require
@@ -42,7 +42,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)]
#_(th/print-result! out)
@@ -56,7 +56,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
- :password "123123"}
+ :password "Test123!"}
out (th/command! data)]
;; (th/print-result! out)
(let [error (:error out)]
@@ -69,7 +69,7 @@
(let [profile (th/create-profile* 1 {:is-active true})
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
- :password "123123"}
+ :password "Test123!"}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
@@ -125,6 +125,17 @@
(t/is (= "en" (:lang result)))
(t/is (= "dark" (:theme result))))))
+ (t/testing "update profile preserves omitted optional fields"
+ (let [data {::th/type :update-profile
+ ::rpc/profile-id (:id profile)
+ :fullname "Updated Name"}
+ out (th/command! data)]
+
+ (t/is (nil? (:error out)))
+ (t/is (= "Updated Name" (get-in out [:result :fullname])))
+ (t/is (= "en" (get-in out [:result :lang])))
+ (t/is (= "dark" (get-in out [:result :theme])))))
+
(t/testing "update photo"
(let [data {::th/type :update-profile-photo
::rpc/profile-id (:id profile)
@@ -388,6 +399,63 @@
(let [result (th/run-task! :objects-gc {:min-age 0})]
(t/is (= 10 (:processed result))))))
+(t/deftest profile-deletion-invalidates-all-sessions
+ (let [prof (th/create-profile* 1)
+
+ ;; Insert 3 sessions for this profile directly into the database
+ session-ids (doall
+ (for [i (range 3)]
+ (let [sid (uuid/random)]
+ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
+ sid (:id prof) (str "user-agent-" i)])
+ sid)))]
+
+ ;; Verify sessions exist
+ (let [count-before (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
+ (t/is (= 3 count-before)))
+
+ ;; Request profile to be deleted
+ (let [params {::th/type :delete-profile
+ ::rpc/profile-id (:id prof)}
+ out (th/command! params)]
+ (t/is (nil? (:error out))))
+
+ ;; Verify ALL sessions were invalidated (not just one)
+ (let [count-after (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
+ (t/is (= 0 count-after)))))
+
+(t/deftest profile-deletion-via-gc-cascades
+ (let [prof (th/create-profile* 1)
+ file (th/create-file* 1 {:profile-id (:id prof)
+ :project-id (:default-project-id prof)
+ :is-shared false})
+ team-id (:default-team-id prof)
+ project-id (:default-project-id prof)
+ file-id (:id file)
+
+ deleted-at (ct/minus (ct/now) (ct/duration {:days 1}))]
+
+ (th/db-update! :profile
+ {:deleted-at deleted-at}
+ {:id (:id prof)})
+
+ (let [team-before (th/db-get :team {:id team-id} {::db/remove-deleted false})]
+ (t/is (nil? (:deleted-at team-before))))
+
+ (let [result (th/run-task! :objects-gc {:min-age 0})]
+ (t/is (pos? (:processed result))))
+
+ (let [profile-after (th/db-get :profile {:id (:id prof)} {::db/remove-deleted false})]
+ (t/is (nil? profile-after)))
+
+ (let [team-after (th/db-get :team {:id team-id} {::db/remove-deleted false})]
+ (t/is (nil? team-after)))
+
+ (let [project-after (th/db-get :project {:id project-id} {::db/remove-deleted false})]
+ (t/is (nil? project-after)))
+
+ (let [file-after (th/db-get :file {:id file-id} {::db/remove-deleted false})]
+ (t/is (nil? file-after)))))
(t/deftest email-blacklist-1
(t/is (false? (email.blacklist/enabled? th/*system*)))
@@ -403,7 +471,7 @@
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
- :password "foobar"
+ :password "Foobar12!"
:utm_campaign "utma"
:mtm_campaign "mtma"}
out (th/command! data)
@@ -444,7 +512,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -463,7 +531,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -498,7 +566,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -521,7 +589,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -547,7 +615,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -576,7 +644,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -614,7 +682,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
- :password "foobar"}
+ :password "Foobar12!"}
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
@@ -659,7 +727,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
- :password "foobar"}
+ :password "Foobar12!"}
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
@@ -692,7 +760,7 @@
:invitation-token itoken
:email "user@example.com"
:fullname "foobar"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -712,7 +780,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -733,7 +801,7 @@
:invitation-token itoken
:email "user@example.com"
:fullname "foobar"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -754,7 +822,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -767,7 +835,7 @@
(let [data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -780,7 +848,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email (:email profile)
- :password "foobar"}
+ :password "Foobar12!"}
out (th/command! data)]
;; (th/print-result! out)
(t/is (th/success? out))
@@ -793,7 +861,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
- :password "foobar"}]
+ :password "Foobar12!"}]
(th/create-global-complaint-for pool {:type :bounce :email "user@example.com"})
@@ -808,7 +876,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
- :password "foobar"}]
+ :password "Foobar12!"}]
(th/create-global-complaint-for pool {:type :complaint :email "user@example.com"})
@@ -1131,8 +1199,8 @@
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
- :old-password "123123"
- :password "foobarfoobar"}
+ :old-password "Test123!"
+ :password "Foobar12!"}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))))
@@ -1143,7 +1211,7 @@
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "badpassword"
- :password "foobarfoobar"}
+ :password "Foobar12!"}
{:keys [result error] :as out} (th/command! data)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
@@ -1154,7 +1222,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
- :old-password "123123"
+ :old-password "Test123!"
:password "profile1.test@nodomain.com"}
{:keys [result error] :as out} (th/command! data)]
(t/is (th/ex-info? error))
@@ -1271,3 +1339,49 @@
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))
+
+
+(t/deftest prepare-register-profile-password-too-short
+ (let [data {::th/type :prepare-register-profile
+ :email "user@example.com"
+ :fullname "foobar"
+ :password "123"}
+ out (th/command! data)]
+ (t/is (th/ex-info? (:error out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :weak-password))))
+
+
+(t/deftest prepare-register-profile-weak-password
+ (let [data {::th/type :prepare-register-profile
+ :email "user@example.com"
+ :fullname "foobar"
+ :password "password123"}
+ out (th/command! data)]
+ (t/is (th/ex-info? (:error out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :weak-password))))
+
+
+(t/deftest update-profile-password-too-short
+ (let [profile (th/create-profile* 1)
+ data {::th/type :update-profile-password
+ ::rpc/profile-id (:id profile)
+ :old-password "Test123!"
+ :password "123"}
+ out (th/command! data)]
+ (t/is (th/ex-info? (:error out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :weak-password))))
+
+
+(t/deftest update-profile-password-weak-password
+ (let [profile (th/create-profile* 1)
+ data {::th/type :update-profile-password
+ ::rpc/profile-id (:id profile)
+ :old-password "Test123!"
+ :password "qwerty"}
+ out (th/command! data)]
+ (t/is (th/ex-info? (:error out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :weak-password))))
diff --git a/backend/test/backend_tests/rpc_project_test.clj b/backend/test/backend_tests/rpc_project_test.clj
index 96376a42b1..f1443ce931 100644
--- a/backend/test/backend_tests/rpc_project_test.clj
+++ b/backend/test/backend_tests/rpc_project_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-project-test
(:require
@@ -241,3 +241,24 @@
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))))
+
+(t/deftest get-project-nonexistent
+ (let [prof (th/create-profile* 1 {:is-active true})
+ out (th/command! {::th/type :get-project
+ ::rpc/profile-id (:id prof)
+ :id (uuid/random)})
+ err (:error out)]
+ (t/is (th/ex-info? err))
+ (t/is (th/ex-of-type? err :not-found))))
+
+(t/deftest get-project-no-permission
+ (let [owner (th/create-profile* 1 {:is-active true})
+ other (th/create-profile* 2 {:is-active true})
+ proj (th/create-project* 1 {:profile-id (:id owner)
+ :team-id (:default-team-id owner)})
+ out (th/command! {::th/type :get-project
+ ::rpc/profile-id (:id other)
+ :id (:id proj)})
+ err (:error out)]
+ (t/is (th/ex-info? err))
+ (t/is (th/ex-of-type? err :not-found))))
diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj
index 94db804e17..c691ea678b 100644
--- a/backend/test/backend_tests/rpc_quotes_test.clj
+++ b/backend/test/backend_tests/rpc_quotes_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-quotes-test
(:require
@@ -338,3 +338,137 @@
(check-ok! 4)
(check-ko! 5))))
+
+(t/deftest media-storage-bytes-per-team-quote
+ (with-mocks [mock {:target 'app.config/get
+ :return (th/config-get-mock
+ {:quotes-media-storage-bytes-per-team 1000})}]
+
+ (let [profile-1 (th/create-profile* 1)
+ profile-2 (th/create-profile* 2)
+ team-id (:default-team-id profile-1)
+ data {::quotes/id ::quotes/media-storage-bytes-per-team
+ ::quotes/profile-id (:id profile-1)
+ ::quotes/team-id team-id
+ ::quotes/incr 500}
+
+ check-ok! (fn [msg]
+ (quotes/check! th/*system* data)
+ (t/is (true? true) msg))
+ check-ko! (fn [msg]
+ (try
+ (quotes/check! th/*system* data)
+ (t/is false (str msg " — expected exception but none thrown"))
+ (catch Exception e
+ (let [ed (ex-data e)]
+ (t/is (= :restriction (:type ed)))
+ (t/is (= :max-quote-reached (:code ed)))
+ (t/is (= "media-storage-bytes-per-team" (:target ed)))))))]
+
+ ;; Under default limit (1000) with incr=500 and no existing storage — ok
+ (check-ok! "first check under limit")
+
+ ;; Insert a quote row for another profile on the same team — does not help
+ (th/db-insert! :usage-quote
+ {:profile-id (:id profile-2)
+ :target "media-storage-bytes-per-team"
+ :quote 100})
+
+ ;; Insert a team+profile quote that is still too low
+ (th/db-insert! :usage-quote
+ {:team-id team-id
+ :profile-id (:id profile-2)
+ :target "media-storage-bytes-per-team"
+ :quote 200})
+
+ ;; Insert a team-level quote (no profile) that is still too low
+ (th/db-insert! :usage-quote
+ {:team-id team-id
+ :target "media-storage-bytes-per-team"
+ :quote 400})
+
+ ;; total=0, incr=500, best quote=400 → 0+500 > 400 → blocked
+ (check-ko! "blocked by team-level quote")
+
+ ;; Insert a team+profile quote that allows it
+ (th/db-insert! :usage-quote
+ {:team-id team-id
+ :profile-id (:id profile-1)
+ :target "media-storage-bytes-per-team"
+ :quote 1000})
+
+ ;; total=0, incr=500, best quote=1000 → 0+500 <= 1000 → ok
+ (check-ok! "allowed by team+profile quote"))))
+
+(t/deftest media-storage-bytes-quote-deduped
+ (with-mocks [mock {:target 'app.config/get
+ :return (th/config-get-mock
+ {:quotes-media-storage-bytes-per-team 1100})}]
+
+ (let [prof (th/create-profile* 1)
+ team-id (:default-team-id prof)
+ proj (th/create-project* 1 {:profile-id (:id prof)
+ :team-id team-id})
+ file1 (th/create-file* 1 {:profile-id (:id prof)
+ :project-id (:id proj)
+ :is-shared false})
+ file2 (th/create-file* 2 {:profile-id (:id prof)
+ :project-id (:id proj)
+ :is-shared false})
+
+ ;; One physical storage object of 500 bytes
+ so-id (uuid/random)
+ _ (th/db-insert! :storage-object {:id so-id
+ :size 500
+ :backend "test"})
+
+ ;; Two file_media_object rows pointing at the SAME storage object
+ ;; (simulates the deduplication path: same content uploaded twice)
+ _ (th/create-file-media-object*
+ {:file-id (:id file1) :media-id so-id
+ :name "icon" :mtype "image/svg+xml"})
+ _ (th/create-file-media-object*
+ {:file-id (:id file2) :media-id so-id
+ :name "icon" :mtype "image/svg+xml"})
+
+ data {::quotes/id ::quotes/media-storage-bytes-per-team
+ ::quotes/profile-id (:id prof)
+ ::quotes/team-id team-id
+ ::quotes/incr 200}]
+
+ ;; Physical size is 500. With UNION (correct), total=500, 500+200=700 ≤ 1100 → ok.
+ ;; With UNION ALL (buggy), total=1000, 1000+200=1200 > 1100 → rejected.
+ (quotes/check! th/*system* data)
+ (t/is (true? true) "deduped storage counted once, under quota"))))
+
+(t/deftest media-upload-enforces-storage-quote
+ (with-mocks [mock {:target 'app.config/get
+ :return (th/config-get-mock
+ {:quotes-media-storage-bytes-per-team 100})}]
+
+ (let [prof (th/create-profile* 1)
+ proj (th/create-project* 1 {:profile-id (:id prof)
+ :team-id (:default-team-id prof)})
+ file (th/create-file* 1 {:profile-id (:id prof)
+ :project-id (:id proj)
+ :is-shared false})
+ mfile {:filename "sample.jpg"
+ :path (th/tempfile "backend_tests/test_files/sample.jpg")
+ :mtype "image/jpeg"
+ :size 312043}
+
+ params {::th/type :upload-file-media-object
+ ::rpc/profile-id (:id prof)
+ :file-id (:id file)
+ :is-local true
+ :name "testfile"
+ :content mfile}
+
+ out (th/command! params)]
+
+ ;; 312043 bytes > 100 byte limit → should be rejected
+ (t/is (not (th/success? out)))
+ (let [error (:error out)]
+ (t/is (= :restriction (th/ex-type error)))
+ (t/is (= :max-quote-reached (th/ex-code error)))
+ (t/is (= "media-storage-bytes-per-team" (:target (ex-data error))))))))
diff --git a/backend/test/backend_tests/rpc_rlimit_test.clj b/backend/test/backend_tests/rpc_rlimit_test.clj
new file mode 100644
index 0000000000..a10c6363af
--- /dev/null
+++ b/backend/test/backend_tests/rpc_rlimit_test.clj
@@ -0,0 +1,28 @@
+;; 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 SUBSIDIARY SL
+
+(ns backend-tests.rpc-rlimit-test
+ (:require
+ [app.common.time :as ct]
+ [app.redis :as rds]
+ [app.rpc.rlimit :as rlimit]
+ [clojure.test :as t]))
+
+(t/deftest bucket-reset-supports-fractional-milliseconds
+ (let [now (ct/inst 0)
+ limit {::rlimit/name :test
+ ::rlimit/strategy :bucket
+ ::rlimit/key "test"
+ ::rlimit/method "main.test"
+ ::rlimit/capacity 5
+ ::rlimit/rate 3
+ ::rlimit/interval (ct/duration 1000)
+ ::rlimit/params [1 3 5]
+ ::rlimit/opts "5/3/1s"}]
+ (with-redefs [rds/eval (fn [_ _] [true 4])]
+ (let [result (rlimit/process-limit nil "profile" now limit)]
+ (t/is (= (ct/inst 334)
+ (:app.rpc.rlimit.result/reset result)))))))
diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj
index 7c2b5d0552..0fcb427ad2 100644
--- a/backend/test/backend_tests/rpc_team_test.clj
+++ b/backend/test/backend_tests/rpc_team_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-team-test
(:require
@@ -154,10 +154,14 @@
(get-in % [:props :member-email])))
events))]
(doseq [event [create-organization update-organization]]
+ (t/is (= (str (:id owner))
+ (get-in event [:props :user-who-send-invitation])))
(t/is (true? (get-in event [:props :team-belongs-to-organization])))
(t/is (true? (get-in event [:props :adds-invitee-to-organization])))
(t/is (true? (get-in event [:props :invitee-already-organization-member]))))
+ (t/is (= (str (:id owner))
+ (get-in create-plain [:props :user-who-send-invitation])))
(t/is (false? (get-in create-plain [:props :team-belongs-to-organization])))
(t/is (false? (get-in create-plain [:props :adds-invitee-to-organization])))
(t/is (false? (get-in create-plain [:props :invitee-already-organization-member])))))))
@@ -357,6 +361,28 @@
(t/is (= (:id profile2) (:member-id claims))))))))
+(t/deftest get-team-invitation-token-requires-edition-permissions
+ (let [profile1 (th/create-profile* 1 {:is-active true})
+ profile2 (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id profile1)})
+ pool (:app.db/pool th/*system*)]
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id profile2)
+ :role :viewer})
+ (db/insert! pool :team-invitation
+ {:team-id (:id team)
+ :email-to "victim@example.com"
+ :role "editor"
+ :valid-until (ct/in-future "48h")})
+ (let [data {::th/type :get-team-invitation-token
+ ::rpc/profile-id (:id profile2)
+ :team-id (:id team)
+ :email "victim@example.com"}
+ out (th/command! data)]
+ (t/is (not (th/success? out)))
+ (t/is (= :not-found (-> out :error ex-data :type))))))
+
+
(t/deftest accept-invitation-tokens
(let [profile1 (th/create-profile* 1 {:is-active true})
profile2 (th/create-profile* 2 {:is-active true})
@@ -499,6 +525,9 @@
(let [event (organization-event)]
(t/is (= organization-id (get-in event [:props :organization-id])))
+ (t/is (= (:id invitee) (get-in event [:props :user-id])))
+ (t/is (= (:id inviter)
+ (get-in event [:props :user-who-send-invitation])))
(t/is (not (contains? (:props event) :organization-member-add-source)))
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
(t/is (not (contains? (:props event) :organization-member-count-before)))
@@ -508,6 +537,10 @@
(:origin @frontend-event)))
(t/is (= organization-id
(get-in @frontend-event [:props :organization-id])))
+ (t/is (= (:id invitee)
+ (get-in @frontend-event [:props :user-id])))
+ (t/is (= (:id inviter)
+ (get-in @frontend-event [:props :user-who-send-invitation])))
(t/is (= "direct-organization-invitation"
(get-in @frontend-event [:props :organization-member-add-source])))
(t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add])))
@@ -548,6 +581,9 @@
(t/is (some #(= "accept-team-invitation-from" (:name %)) events))
(t/is (= (:id team) (get-in event [:props :team-id])))
(t/is (= organization-id (get-in event [:props :organization-id])))
+ (t/is (= (:id invitee) (get-in event [:props :user-id])))
+ (t/is (= (:id inviter)
+ (get-in event [:props :user-who-send-invitation])))
(t/is (not (contains? (:props event) :organization-member-add-source)))
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
(t/is (not (contains? (:props event) :organization-member-count-before)))
@@ -556,6 +592,10 @@
(t/is (= (:id team) (get-in @frontend-event [:props :team-id])))
(t/is (= organization-id
(get-in @frontend-event [:props :organization-id])))
+ (t/is (= (:id invitee)
+ (get-in @frontend-event [:props :user-id])))
+ (t/is (= (:id inviter)
+ (get-in @frontend-event [:props :user-who-send-invitation])))
(t/is (= "team-invitation"
(get-in @frontend-event [:props :organization-member-add-source])))
(t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add])))
@@ -719,6 +759,67 @@
(t/is (not= (:default-team-id profile1) (:id item1))))))
+(t/deftest get-teams-fetches-organizations-in-one-batch
+ (let [profile (th/create-profile* 1 {:is-active true})
+ organization-team (th/create-team* 1 {:profile-id (:id profile)})
+ plain-team (th/create-team* 2 {:profile-id (:id profile)})
+ expired-team (th/create-team* 3 {:profile-id (:id profile)})
+ organization-id (uuid/random)
+ calls (atom [])
+ organization {:id organization-id
+ :name "Acme"
+ :slug "acme"
+ :owner-id (:id profile)
+ :avatar-bg-url "https://example.com/avatar.svg"}
+ nitrate-call (fn [_cfg method params]
+ (swap! calls conj [method params])
+ [{:id (:id organization-team)
+ :is-your-penpot false
+ :organization organization}
+ {:id (:id expired-team)
+ :is-your-penpot false
+ :organization (assoc organization :expired-license true)}])
+ params {::th/type :get-teams
+ ::rpc/profile-id (:id profile)}]
+ (with-redefs [cf/flags (conj cf/flags :admin-console)
+ nitrate/call nitrate-call]
+ (let [out (th/command! params)
+ teams (:result out)]
+ (t/is (th/success? out))
+ (t/is (= 1 (count @calls)))
+ (t/is (= :get-teams-organizations (ffirst @calls)))
+ (t/is (= #{(:default-team-id profile)
+ (:id organization-team)
+ (:id plain-team)
+ (:id expired-team)}
+ (-> @calls first second :team-ids set)))
+ (t/is (= #{(:default-team-id profile)
+ (:id organization-team)
+ (:id plain-team)}
+ (into #{} (map :id) teams)))
+ (t/is (= organization
+ (->> teams
+ (filter #(= (:id organization-team) (:id %)))
+ first
+ :organization)))))))
+
+
+(t/deftest get-teams-rejects-invalid-organization-batch-response
+ (let [profile (th/create-profile* 1 {:is-active true})
+ calls (atom [])
+ params {::th/type :get-teams
+ ::rpc/profile-id (:id profile)}]
+ (with-redefs [cf/flags (conj cf/flags :admin-console)
+ nitrate/call (fn [_cfg method call-params]
+ (swap! calls conj [method call-params])
+ nil)]
+ (let [out (th/command! params)]
+ (t/is (not (th/success? out)))
+ (t/is (= :nitrate-unavailable (th/ex-type (:error out))))
+ (t/is (= 1 (count @calls)))
+ (t/is (= :get-teams-organizations (ffirst @calls)))))))
+
+
(t/deftest team-deletion-1
(let [profile1 (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)})
@@ -1015,6 +1116,46 @@
out (th/command! data)]
(t/is (th/success? out)))))
+(t/deftest create-team-invitations-email-cooldown
+ (with-mocks [mock {:target 'app.email/send! :return nil}]
+ (let [profile1 (th/create-profile* 1 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id profile1)})
+
+ data {::th/type :create-team-invitations
+ ::rpc/profile-id (:id profile1)
+ :team-id (:id team)
+ :role :editor
+ :emails ["cooldown-test@example.com"]}]
+
+ ;; First invitation sends email
+ (let [out (th/command! data)]
+ (t/is (th/success? out))
+ (t/is (= 1 (:call-count @mock))))
+
+ ;; Resending immediately should NOT send email (cooldown active)
+ (th/reset-mock! mock)
+ (let [out (th/command! data)]
+ (t/is (th/success? out))
+ (t/is (= 0 (:call-count @mock))))
+
+ ;; Resending to a different email should send email
+ (th/reset-mock! mock)
+ (let [data (assoc data :emails ["different@example.com"])
+ out (th/command! data)]
+ (t/is (th/success? out))
+ (t/is (= 1 (:call-count @mock))))
+
+ ;; After cooldown expires, resending should send email
+ (th/reset-mock! mock)
+ (th/db-update! :team-invitation
+ {:updated-at (ct/in-past "10m")}
+ {:team-id (:id team)
+ :email-to "cooldown-test@example.com"})
+ (let [data (assoc data :emails ["cooldown-test@example.com"])
+ out (th/command! data)]
+ (t/is (th/success? out))
+ (t/is (= 1 (:call-count @mock)))))))
+
(t/deftest update-team-with-invalid-name
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile)})]
@@ -1056,3 +1197,232 @@
:name "My Valid Team"}
out (th/command! data)]
(t/is (th/success? out)))))
+
+(t/deftest create-team-in-organization-regression
+ (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
+ (let [owner (th/create-profile* 401 {:is-active true})
+ non-member (th/create-profile* 402 {:is-active true})
+ organization-id (uuid/random)
+ params {::th/type :create-team
+ ::rpc/profile-id (:id owner)
+ :name "Test Team"
+ :organization-id organization-id}
+
+ nitrate-call-fn
+ (fn [_cfg method p]
+ (case method
+ :get-organization-membership
+ (if (= (:profile-id p) (:id non-member))
+ {:organization-id organization-id :is-member false}
+ {:organization-id organization-id :is-member true})
+
+ :get-organization-permissions
+ {:owner-id (:id owner)
+ :permissions {:create-teams "any"}}
+
+ :set-team-organization
+ (let [team-id (:team-id p)]
+ {:id team-id
+ :name "Test Team"
+ :organization-id organization-id
+ :default-project-id (uuid/random)})
+
+ nil))]
+
+ ;; Non-member should be denied with :user-doesnt-belong-organization
+ (with-redefs [cf/flags (conj cf/flags :admin-console)
+ nitrate/call nitrate-call-fn]
+ (let [out (th/command! (assoc params ::rpc/profile-id (:id non-member)))]
+ (t/is (not (th/success? out)))
+ (let [edata (-> out :error ex-data)]
+ (t/is (= :validation (:type edata)))
+ (t/is (= :user-doesnt-belong-organization (:code edata))))))
+
+ ;; Authorized member should succeed
+ (th/reset-mock! audit-mock)
+ (with-redefs [cf/flags (conj cf/flags :admin-console)
+ nitrate/call nitrate-call-fn]
+ (let [out (th/command! params)]
+ (t/is (th/success? out))
+ (let [team (:result out)]
+ (t/is (uuid? (:id team)))
+ (t/is (= "Test Team" (:name team)))))))))
+
+;; --- T7-F-01: Role ceiling in team invitations ---
+
+(t/deftest admin-cannot-create-invitation-with-owner-role
+ (with-mocks [mock {:target 'app.email/send! :return nil}]
+ (let [owner (th/create-profile* 1 {:is-active true})
+ admin (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})]
+
+ ;; Add admin as team member with :admin role
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id admin)
+ :role :admin})
+
+ ;; Admin tries to create invitation with :owner role (emails+role format)
+ ;; This should FAIL with :cant-promote-to-owner
+ (let [data {::th/type :create-team-invitations
+ ::rpc/profile-id (:id admin)
+ :team-id (:id team)
+ :role :owner
+ :emails ["invitee@example.com"]}
+ out (th/command! data)]
+ (t/is (not (th/success? out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner))
+ (t/is (= 0 (:call-count @mock)))))))
+
+(t/deftest admin-cannot-create-invitation-with-owner-role-invitations-format
+ (with-mocks [mock {:target 'app.email/send! :return nil}]
+ (let [owner (th/create-profile* 1 {:is-active true})
+ admin (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})]
+
+ ;; Add admin as team member with :admin role
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id admin)
+ :role :admin})
+
+ ;; Admin tries to create invitation with :owner role (invitations format)
+ ;; This should FAIL with :cant-promote-to-owner
+ (let [data {::th/type :create-team-invitations
+ ::rpc/profile-id (:id admin)
+ :team-id (:id team)
+ :invitations [{:email "invitee@example.com" :role :owner}]}
+ out (th/command! data)]
+ (t/is (not (th/success? out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner))
+ (t/is (= 0 (:call-count @mock)))))))
+
+(t/deftest admin-cannot-update-invitation-role-to-owner
+ (with-mocks [mock {:target 'app.email/send! :return nil}]
+ (let [owner (th/create-profile* 1 {:is-active true})
+ admin (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})]
+
+ ;; Add admin as team member with :admin role
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id admin)
+ :role :admin})
+
+ ;; Owner creates an invitation with :editor role
+ (let [data {::th/type :create-team-invitations
+ ::rpc/profile-id (:id owner)
+ :team-id (:id team)
+ :role :editor
+ :emails ["invitee@example.com"]}
+ out (th/command! data)]
+ (t/is (th/success? out)))
+
+ (th/reset-mock! mock)
+
+ ;; Admin tries to update invitation role to :owner
+ ;; This should FAIL with :cant-promote-to-owner
+ (let [data {::th/type :update-team-invitation-role
+ ::rpc/profile-id (:id admin)
+ :team-id (:id team)
+ :email "invitee@example.com"
+ :role :owner}
+ out (th/command! data)]
+ (t/is (not (th/success? out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner))))))
+
+(t/deftest owner-can-create-invitation-with-owner-role
+ (with-mocks [mock {:target 'app.email/send! :return nil}]
+ (let [owner (th/create-profile* 1 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})]
+
+ ;; Owner creates invitation with :owner role
+ ;; This should SUCCEED (owner has full privileges)
+ (let [data {::th/type :create-team-invitations
+ ::rpc/profile-id (:id owner)
+ :team-id (:id team)
+ :role :owner
+ :emails ["invitee@example.com"]}
+ out (th/command! data)]
+ (t/is (th/success? out))
+ (t/is (= 1 (:call-count @mock)))))))
+
+(t/deftest admin-cannot-remove-team-owner
+ (let [owner (th/create-profile* 1 {:is-active true})
+ admin (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})]
+
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id admin)
+ :role :admin})
+
+ (let [out (th/command! {::th/type :delete-team-member
+ ::rpc/profile-id (:id admin)
+ :team-id (:id team)
+ :member-id (:id owner)})]
+ (t/is (not (th/success? out)))
+ (t/is (th/ex-of-type? (:error out) :validation))
+ (t/is (th/ex-of-code? (:error out) :cant-remove-owner)))))
+
+(t/deftest owner-can-remove-another-owner
+ (let [owner1 (th/create-profile* 1 {:is-active true})
+ owner2 (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner1)})]
+
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id owner2)
+ :role :owner})
+
+ (let [out (th/command! {::th/type :delete-team-member
+ ::rpc/profile-id (:id owner1)
+ :team-id (:id team)
+ :member-id (:id owner2)})]
+ (t/is (th/success? out)))))
+
+(t/deftest owner-can-remove-admin
+ (let [owner (th/create-profile* 1 {:is-active true})
+ admin (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})]
+
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id admin)
+ :role :admin})
+
+ (let [out (th/command! {::th/type :delete-team-member
+ ::rpc/profile-id (:id owner)
+ :team-id (:id team)
+ :member-id (:id admin)})]
+ (t/is (th/success? out)))))
+
+(t/deftest admin-can-remove-admin
+ (let [owner (th/create-profile* 1 {:is-active true})
+ admin1 (th/create-profile* 2 {:is-active true})
+ admin2 (th/create-profile* 3 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})]
+
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id admin1)
+ :role :admin})
+
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id admin2)
+ :role :admin})
+
+ (let [out (th/command! {::th/type :delete-team-member
+ ::rpc/profile-id (:id admin1)
+ :team-id (:id team)
+ :member-id (:id admin2)})]
+ (t/is (th/success? out)))))
+
+(t/deftest delete-nonexistent-member-returns-not-found
+ (let [owner (th/create-profile* 1 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})
+ fake-id (uuid/next)]
+
+ (let [out (th/command! {::th/type :delete-team-member
+ ::rpc/profile-id (:id owner)
+ :team-id (:id team)
+ :member-id fake-id})]
+ (t/is (not (th/success? out)))
+ (t/is (th/ex-of-type? (:error out) :not-found))
+ (t/is (th/ex-of-code? (:error out) :member-does-not-exist)))))
diff --git a/backend/test/backend_tests/rpc_viewer_test.clj b/backend/test/backend_tests/rpc_viewer_test.clj
index 14040aeacb..cbf9e4493a 100644
--- a/backend/test/backend_tests/rpc_viewer_test.clj
+++ b/backend/test/backend_tests/rpc_viewer_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-viewer-test
(:require
@@ -128,3 +128,80 @@
(let [result (:result out)]
(t/is (contains? result :file))
(t/is (contains? result :project)))))))
+
+(t/deftest share-link-token-disclosure
+ (let [owner (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id owner)
+
+ file (th/create-file* 1 {:profile-id (:id owner)
+ :project-id proj-id
+ :is-shared false})
+
+ page-a (get-in file [:data :pages 0])
+ page-b (uuid/random)
+
+ ;; Add a second page to the file
+ _ (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id owner)
+ :id (:id file)
+ :session-id (uuid/random)
+ :revn 0
+ :vern 0
+ :changes [{:type :add-page
+ :id page-b
+ :page {:id page-b
+ :name "Page B"
+ :options {}
+ :objects {}}}]})
+
+ ;; Create Link A: restrictive (no pages, team-only comments/inspect)
+ link-a (th/command! {::th/type :create-share-link
+ ::rpc/profile-id (:id owner)
+ :file-id (:id file)
+ :pages #{}
+ :who-comment "team"
+ :who-inspect "team"})
+ link-a-id (get-in link-a [:result :id])
+
+ ;; Create Link B: permissive (all pages, all can comment/inspect)
+ link-b (th/command! {::th/type :create-share-link
+ ::rpc/profile-id (:id owner)
+ :file-id (:id file)
+ :pages #{page-a page-b}
+ :who-comment "all"
+ :who-inspect "all"})
+ link-b-id (get-in link-b [:result :id])]
+
+ (t/testing "restrictive share-link holder cannot see other share-link tokens"
+ (let [out (th/command! {::th/type :get-view-only-bundle
+ :share-id link-a-id
+ :file-id (:id file)})
+ err (:error out)
+ result (:result out)
+ share-links (:share-links result)]
+
+ ;; Should not error
+ (t/is (nil? err))
+
+ ;; Should only see the share-link used for authentication
+ (t/is (= 1 (count share-links)))
+ (t/is (= link-a-id (:id (first share-links))))
+
+ ;; Should NOT see Link B's token
+ (t/is (not (some #(= link-b-id (:id %)) share-links)))))
+
+ (t/testing "team member still sees all share-links"
+ (let [out (th/command! {::th/type :get-view-only-bundle
+ ::rpc/profile-id (:id owner)
+ :file-id (:id file)})
+ err (:error out)
+ result (:result out)
+ share-links (:share-links result)]
+
+ ;; Should not error
+ (t/is (nil? err))
+
+ ;; Team member should see both share-links
+ (t/is (= 2 (count share-links)))
+ (t/is (some #(= link-a-id (:id %)) share-links))
+ (t/is (some #(= link-b-id (:id %)) share-links))))))
diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj
index 3b39c8b52d..5b15d46111 100644
--- a/backend/test/backend_tests/rpc_webhooks_test.clj
+++ b/backend/test/backend_tests/rpc_webhooks_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-webhooks-test
(:require
@@ -155,8 +155,7 @@
:return {:status 200}}]
(let [owner (th/create-profile* 1 {:is-active true})
viewer (th/create-profile* 2 {:is-active true})
- team (th/create-team* 1 {:profile-id (:id owner)})
- whook (volatile! nil)]
+ team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id viewer)
:role :viewer})
@@ -164,52 +163,15 @@
(let [roles (th/db-query :team-profile-rel {:team-id (:id team)})]
(t/is (= 2 (count roles))))
- (t/testing "viewer creates a webhook"
+ (t/testing "viewer cannot create a webhook (requires editor role)"
(let [viewers-webhook (create-webhook-params (:id viewer) (:id team))
out (th/command! viewers-webhook)]
- (t/is (nil? (:error out)))
- (t/is (= 1 (:call-count @http-mock)))
-
- (let [result (:result out)]
- (check-webhook-format result)
- (t/is (= (:uri viewers-webhook) (:uri result)))
- (t/is (= (:team-id viewers-webhook) (:team-id result)))
- (t/is (= (::rpc/profile-id viewers-webhook) (:profile-id result)))
- (t/is (= (:mtype viewers-webhook) (:mtype result)))
- (vreset! whook result))))
-
- (th/reset-mock! http-mock)
-
- (t/testing "viewer updates it's own webhook (success)"
- (let [params {::th/type :update-webhook
- ::rpc/profile-id (:id viewer)
- :id (:id @whook)
- :uri (:uri @whook)
- :mtype "application/transit+json"
- :is-active false}
- out (th/command! params)
- result (:result out)]
-
- (t/is (nil? (:error out)))
(t/is (= 0 (:call-count @http-mock)))
- (check-webhook-format result)
- (t/is (= (:is-active params) (:is-active result)))
- (t/is (= (:team-id @whook) (:team-id result)))
- (t/is (= (:mtype params) (:mtype result)))
- (vreset! whook result)))
-
- (th/reset-mock! http-mock)
-
- (t/testing "viewer deletes it's own webhook (success)"
- (let [params {::th/type :delete-webhook
- ::rpc/profile-id (:id viewer)
- :id (:id @whook)}
- out (th/command! params)]
- (t/is (= 0 (:call-count @http-mock)))
- (t/is (nil? (:error out)))
- (t/is (nil? (:result out)))
- (let [rows (th/db-exec! ["select * from webhook"])]
- (t/is (= 0 (count rows))))))
+ (let [error (:error out)
+ error-data (ex-data error)]
+ (t/is (th/ex-info? error))
+ (t/is (= (:type error-data) :not-found))
+ (t/is (= (:code error-data) :object-not-found)))))
(th/reset-mock! http-mock))))
@@ -268,6 +230,26 @@
(t/is (= (:type error-data) :not-found))
(t/is (= (:code error-data) :object-not-found)))))))
+(t/deftest webhooks-viewer-cannot-create
+ (with-mocks [http-mock {:target 'app.http.client/req
+ :return {:status 200}}]
+ (let [owner (th/create-profile* 1 {:is-active true})
+ viewer (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})]
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id viewer)
+ :role :viewer})
+
+ (t/testing "viewer cannot create a webhook on the team"
+ (let [params (create-webhook-params (:id viewer) (:id team))
+ out (th/command! params)]
+ (t/is (= 0 (:call-count @http-mock)))
+ (let [error (:error out)
+ error-data (ex-data error)]
+ (t/is (th/ex-info? error))
+ (t/is (= (:type error-data) :not-found))
+ (t/is (= (:code error-data) :object-not-found))))))))
+
(t/deftest webhooks-quotes
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
@@ -304,3 +286,91 @@
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :restriction))
(t/is (= (:code error-data) :webhooks-quote-reached))))))
+
+(t/deftest removed-user-cannot-edit-webhook
+ (with-mocks [http-mock {:target 'app.http.client/req
+ :return {:status 200}}]
+
+ (let [owner (th/create-profile* 1 {:is-active true})
+ editor (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})]
+
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id editor)
+ :role :editor})
+
+ (let [params {::th/type :create-webhook
+ ::rpc/profile-id (:id editor)
+ :team-id (:id team)
+ :uri (u/uri "http://example.com")
+ :mtype "application/json"}
+ out (th/command! params)]
+
+ (t/is (nil? (:error out)))
+ (let [whook (:result out)]
+
+ (th/reset-mock! http-mock)
+
+ (t/testing "owner can edit editor's webhook (team owns it)"
+ (let [params {::th/type :update-webhook
+ ::rpc/profile-id (:id owner)
+ :id (:id whook)
+ :uri (u/uri "http://example.com/updated")
+ :mtype "application/transit+json"
+ :is-active true}
+ out (th/command! params)]
+ (t/is (nil? (:error out)))
+ (t/is (= 1 (:call-count @http-mock)))))
+
+ (th/reset-mock! http-mock)
+
+ (t/testing "remove editor from team"
+ (let [params {::th/type :delete-team-member
+ ::rpc/profile-id (:id owner)
+ :team-id (:id team)
+ :member-id (:id editor)}
+ out (th/command! params)]
+ (t/is (nil? (:error out)))))
+
+ (th/reset-mock! http-mock)
+
+ (t/testing "removed editor cannot update webhook"
+ (let [params {::th/type :update-webhook
+ ::rpc/profile-id (:id editor)
+ :id (:id whook)
+ :uri (u/uri "http://example.com/evil")
+ :mtype "application/transit+json"
+ :is-active true}
+ out (th/command! params)]
+ (t/is (= 0 (:call-count @http-mock)))
+ (let [error (:error out)
+ error-data (ex-data error)]
+ (t/is (th/ex-info? error))
+ (t/is (= (:type error-data) :not-found))
+ (t/is (= (:code error-data) :object-not-found)))))
+
+ (th/reset-mock! http-mock)
+
+ (t/testing "removed editor cannot delete webhook"
+ (let [params {::th/type :delete-webhook
+ ::rpc/profile-id (:id editor)
+ :id (:id whook)}
+ out (th/command! params)]
+ (t/is (= 0 (:call-count @http-mock)))
+ (let [error (:error out)
+ error-data (ex-data error)]
+ (t/is (th/ex-info? error))
+ (t/is (= (:type error-data) :not-found))
+ (t/is (= (:code error-data) :object-not-found)))))
+
+ (th/reset-mock! http-mock)
+
+ (t/testing "owner can still delete editor's webhook"
+ (let [params {::th/type :delete-webhook
+ ::rpc/profile-id (:id owner)
+ :id (:id whook)}
+ out (th/command! params)]
+ (t/is (nil? (:error out)))
+ (t/is (nil? (:result out)))
+ (let [rows (th/db-exec! ["select * from webhook"])]
+ (t/is (= 0 (count rows)))))))))))
diff --git a/backend/test/backend_tests/shell_test.clj b/backend/test/backend_tests/shell_test.clj
index c9d1932c44..df8a4336b1 100644
--- a/backend/test/backend_tests/shell_test.clj
+++ b/backend/test/backend_tests/shell_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.shell-test
(:require
diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj
index 348a978fc2..cc34773e67 100644
--- a/backend/test/backend_tests/storage_test.clj
+++ b/backend/test/backend_tests/storage_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.storage-test
(:require
@@ -12,12 +12,23 @@
[app.db :as db]
[app.rpc :as-alias rpc]
[app.storage :as sto]
+ [app.storage.fs :as-alias sto.fs]
+ [app.storage.impl :as impl]
+ [app.storage.s3 :as-alias sto.s3]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io]
- [mockery.core :refer [with-mocks]]))
+ [mockery.core :refer [with-mocks]]
+ [promesa.core :as p])
+ (:import
+ (software.amazon.awssdk.services.s3
+ S3AsyncClient)
+ (software.amazon.awssdk.services.s3.model
+ NoSuchKeyException)
+ (software.amazon.awssdk.services.s3.presigner
+ S3Presigner)))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each (th/serial
@@ -199,6 +210,25 @@
(let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])]
(t/is (= 0 (:count res)))))))
+(defn- upload-font-chunked!
+ "Splits `font-bytes` into a single chunk, creates an upload session,
+ uploads the chunk, and returns the session-id UUID."
+ [prof ^bytes font-bytes mtype]
+ (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")
+ _ (io/write* tmp font-bytes)
+ mfile {:filename "chunk" :path tmp :mtype mtype :size (alength font-bytes)}
+ session-id (-> (th/command! {::th/type :create-upload-session
+ ::rpc/profile-id (:id prof)
+ :total-chunks 1})
+ :result :session-id)
+ out (th/command! {::th/type :upload-chunk
+ ::rpc/profile-id (:id prof)
+ :session-id session-id
+ :index 0
+ :content mfile})]
+ (assert (nil? (:error out)))
+ session-id))
+
(t/deftest touched-gc-task-2
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
@@ -229,6 +259,8 @@
:name "testfile"
:content mfile}
+ session-id (upload-font-chunked! prof ttfdata "font/ttf")
+
params2 {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id
@@ -236,7 +268,7 @@
:font-family "somefont"
:font-weight 400
:font-style "normal"
- :data {"font/ttf" ttfdata}}
+ :uploads {"font/ttf" session-id}}
out1 (th/command! params1)
out2 (th/command! params2)]
@@ -250,7 +282,7 @@
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 5 (:freeze res)))
- (t/is (= 0 (:delete res)))
+ (t/is (= 1 (:delete res)))
(let [result-1 (:result out1)
result-2 (:result out2)]
@@ -271,7 +303,7 @@
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 2 (:freeze res)))
- (t/is (= 3 (:delete res))))
+ (t/is (= 4 (:delete res))))
;; now check that there are no touched objects
(let [res (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])]
@@ -279,7 +311,7 @@
;; now check that all objects are marked to be deleted
(let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])]
- (t/is (= 3 (:count res))))))))
+ (t/is (= 4 (:count res))))))))
(t/deftest touched-gc-task-3
(let [storage (-> (:app.storage/storage th/*system*)
@@ -347,27 +379,498 @@
now (ct/now)
object1 (sto/put-object! storage {::sto/content content1
- ::sto/touched-at (ct/plus now {:minutes 1})
+ ::sto/touched-at (ct/plus now {:hours 1})
:bucket "tempfile"
:content-type "text/plain"})]
-
+ ;; not eligible while the touched-at is in the future
(binding [ct/*clock* (ct/fixed-clock now)]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res)))))
-
- (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 3}))]
+ ;; still not eligible: touched-at (now+1h) is beyond the threshold
+ (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 2}))]
(let [res (th/run-task! :storage-gc-touched {})]
+ (t/is (= 0 (:freeze res)))
+ (t/is (= 0 (:delete res)))))
+
+ ;; eligible: marked for deletion immediately, without any extra delay
+ (let [clock (ct/plus now {:hours 3})]
+ (binding [ct/*clock* (ct/fixed-clock clock)]
+ (let [res (th/run-task! :storage-gc-touched {})]
+ (t/is (= 0 (:freeze res)))
+ (t/is (= 1 (:delete res)))))
+
+ (let [row (th/db-exec-one! ["select deleted_at from storage_object where id = ?" (:id object1)])]
+ (t/is (ct/is-before-or-equal? (:deleted-at row) (ct/plus clock {:seconds 1})))))
+
+ ;; removed on the next deleted gc run
+ (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 4}))]
+ (let [res (th/run-task! :storage-gc-deleted {})]
+ (t/is (= 1 (:deleted res)))))))
+
+(t/deftest touched-gc-task-skip-delay
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (sto/content "content1")
+ now (ct/now)
+
+ object1 (sto/put-object! storage {::sto/content content
+ ::sto/touched-at now
+ :bucket "tempfile"
+ :content-type "text/plain"})]
+
+ ;; too recent: not processed without skip-delay
+ (binding [ct/*clock* (ct/fixed-clock now)]
+ (let [res (th/run-task! :storage-gc-touched {})]
+ (t/is (= 0 (:freeze res)))
+ (t/is (= 0 (:delete res)))))
+
+ ;; processed immediately with skip-delay
+ (binding [ct/*clock* (ct/fixed-clock now)]
+ (let [res (th/run-task! :storage-gc-touched {:skip-delay true})]
(t/is (= 0 (:freeze res)))
(t/is (= 1 (:delete res)))))
+ ;; and marked for deletion without any additional delay
+ (let [row (th/db-exec-one! ["select deleted_at from storage_object where id = ?" (:id object1)])]
+ (t/is (ct/is-before-or-equal? (:deleted-at row) (ct/plus now {:seconds 1}))))))
- (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 1}))]
+(t/deftest storage-gc-deleted-immediate
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (sto/content "content1")
+ object (sto/put-object! storage {::sto/content content
+ :content-type "text/plain"})]
+
+ ;; mark as deleted right now
+ (th/db-exec! ["update storage_object set deleted_at = ?" (ct/now)])
+
+ ;; the deleted gc removes it on the next run
+ (let [res (th/run-task! :storage-gc-deleted {})]
+ (t/is (= 1 (:deleted res))))))
+
+(t/deftest objects-gc-task-skip-delay
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ prof (th/create-profile* 1)
+ proj (th/create-project* 1 {:profile-id (:id prof)
+ :team-id (:default-team-id prof)})
+ file (th/create-file* 1 {:profile-id (:id prof)
+ :project-id (:default-project-id prof)
+ :is-shared false})
+ mfile {:filename "sample.jpg"
+ :path (th/tempfile "backend_tests/test_files/sample.jpg")
+ :mtype "image/jpeg"
+ :size 312043}
+ params {::th/type :upload-file-media-object
+ ::rpc/profile-id (:id prof)
+ :file-id (:id file)
+ :is-local true
+ :name "testfile"
+ :content mfile}
+ out1 (th/command! params)
+ out2 (th/command! params)]
+
+ (t/is (nil? (:error out1)))
+ (t/is (nil? (:error out2)))
+
+ (let [result-1 (:result out1)
+ result-2 (:result out2)]
+
+ ;; mark as deleted but in the future (not yet eligible)
+ (th/db-update! :file-media-object
+ {:deleted-at (ct/in-future {:days 1})}
+ {:id (:id result-1)})
+
+ ;; without skip-delay the future deleted row is not processed
+ (let [res (th/run-task! :objects-gc {})]
+ (t/is (= 0 (:processed res))))
+
+ ;; with skip-delay it is processed immediately
+ (let [res (th/run-task! :objects-gc {:skip-delay true})]
+ (t/is (= 1 (:processed res)))))))
+
+(t/deftest put-object-write-failure-leaves-pending-row
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ ;; Point the fs backend at a path that is actually a file so the
+ ;; blob write fails.
+ blocked (fs/path "/tmp/penpot" (str "blocked-" (uuid/next)))
+ _ (spit (str blocked) "x")]
+ (try
+ (let [broken (assoc-in storage [::sto/backends :fs ::sto.fs/directory] (str blocked))
+ content (sto/content "content")
+ ex (try
+ (sto/put-object! broken {::sto/content content
+ :content-type "text/plain"})
+ nil
+ (catch Throwable cause cause))]
+ (t/is (some? ex))
+
+ ;; the pending row stays behind and is reclaimed asynchronously
+ ;; by the :storage-pending-gc task
+ (let [rows (th/db-query :storage-object {:status "pending"})]
+ (t/is (= 1 (count rows)))
+
+ (th/db-update! :storage-object
+ {:created-at (ct/in-past {:days 2})}
+ {:id (:id (first rows))})
+
+ (let [res (th/run-task! :storage-pending-gc {})]
+ (t/is (= 1 (:processed res))))
+
+ (let [row (th/db-exec-one! ["select count(*) from storage_object"])]
+ (t/is (= 0 (:count row))))))
+ (finally
+ (fs/delete blocked)))))
+
+(t/deftest pending-gc-reclaims-unpromoted-object
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (sto/content "content")
+ object (sto/put-object! storage {::sto/content content
+ :content-type "text/plain"})
+ path (sto/get-object-path storage object)]
+
+ ;; valid objects are never reclaimed
+ (let [res (th/run-task! :storage-pending-gc {})]
+ (t/is (= 0 (:processed res))))
+
+ ;; simulate a crash: the object was created but never promoted
+ (th/db-update! :storage-object {:status "pending"
+ :created-at (ct/in-past {:days 2})}
+ {:id (:id object)})
+
+ (t/is (fs/exists? path))
+
+ (let [res (th/run-task! :storage-pending-gc {})]
+ (t/is (= 1 (:processed res))))
+
+ ;; both the row and the orphaned blob are removed
+ (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])]
+ (t/is (= 0 (:count row))))
+ (t/is (not (fs/exists? path)))))
+
+(t/deftest pending-objects-excluded-from-gc-touched
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (sto/content "content")
+ object (sto/put-object! storage {::sto/content content
+ ::sto/touched-at (ct/now)
+ :content-type "text/plain"})]
+
+ ;; mark it pending and touched in the past
+ (th/db-update! :storage-object {:status "pending"
+ :touched-at (ct/in-past {:days 1})}
+ {:id (:id object)})
+
+ (binding [ct/*clock* (ct/fixed-clock (ct/now))]
+ (let [res (th/run-task! :storage-gc-touched {})]
+ (t/is (= 0 (:freeze res)))
+ (t/is (= 0 (:delete res)))))
+
+ ;; still present and not marked as deleted
+ (let [row (th/db-exec-one! ["select * from storage_object where id = ?" (:id object)])]
+ (t/is (some? row))
+ (t/is (nil? (:deleted-at row))))))
+
+(t/deftest pending-objects-excluded-from-get
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (sto/content "content")
+ object (sto/put-object! storage {::sto/content content
+ :content-type "text/plain"})]
+
+ (t/is (some? (sto/get-object storage (:id object))))
+
+ (th/db-update! :storage-object {:status "pending"} {:id (:id object)})
+
+ (t/is (nil? (sto/get-object storage (:id object))))))
+
+(t/deftest pending-objects-excluded-from-dedup
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (-> (sto/content "content")
+ (sto/wrap-with-hash "same-hash"))
+ object1 (sto/put-object! storage {::sto/content content
+ ::sto/deduplicate? true
+ :bucket "file-media-object"
+ :content-type "text/plain"})]
+
+ ;; mark the only matching row as pending
+ (th/db-update! :storage-object {:status "pending"} {:id (:id object1)})
+
+ (let [object2 (sto/put-object! storage {::sto/content content
+ ::sto/deduplicate? true
+ :bucket "file-media-object"
+ :content-type "text/plain"})]
+ (t/is (not= (:id object1) (:id object2))))))
+
+(t/deftest dedup-reuses-existing-blob
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (-> (sto/content "content")
+ (sto/wrap-with-hash "same-hash"))
+ object1 (sto/put-object! storage {::sto/content content
+ ::sto/deduplicate? true
+ :bucket "file-media-object"
+ :content-type "text/plain"})
+ object2 (sto/put-object! storage {::sto/content content
+ ::sto/deduplicate? true
+ :bucket "file-media-object"
+ :content-type "text/plain"})]
+ (t/is (= (:id object1) (:id object2)))
+ (let [row (th/db-exec-one! ["select count(*) from storage_object"])]
+ (t/is (= 1 (:count row))))))
+
+(t/deftest dedup-repairs-stale-object
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (-> (sto/content "content")
+ (sto/wrap-with-hash "same-hash"))
+ object1 (sto/put-object! storage {::sto/content content
+ ::sto/deduplicate? true
+ :bucket "file-media-object"
+ :content-type "text/plain"})]
+
+ ;; remove the physical blob to simulate a stale/broken object
+ (let [path (sto/get-object-path storage object1)]
+ (fs/delete path))
+
+ ;; re-uploading identical content repairs the same reference in place
+ (let [object2 (sto/put-object! storage {::sto/content content
+ ::sto/deduplicate? true
+ :bucket "file-media-object"
+ :content-type "text/plain"})]
+ (t/is (= (:id object1) (:id object2)))
+
+ ;; the row stays live: no tombstone and no extra row
+ (let [row (th/db-exec-one! ["select status, deleted_at from storage_object where id = ?" (:id object1)])]
+ (t/is (= "valid" (:status row)))
+ (t/is (nil? (:deleted-at row))))
+
+ (let [row (th/db-exec-one! ["select count(*) from storage_object"])]
+ (t/is (= 1 (:count row))))
+
+ ;; the repaired blob is readable again under the original id
+ (t/is (= "content" (slurp (sto/get-object-data storage object2)))))))
+
+(t/deftest gc-deleted-removes-broken-object
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (sto/content "content")
+ object (sto/put-object! storage {::sto/content content
+ :content-type "text/plain"})]
+
+ ;; mark as deleted and remove the physical blob
+ (th/db-update! :storage-object {:deleted-at (ct/in-past {:minutes 1})}
+ {:id (:id object)})
+ (let [path (sto/get-object-path storage object)]
+ (fs/delete path))
+
+ ;; the deleted gc removes the row without error even though the blob is
+ ;; missing (the physical deletion is best-effort)
+ (let [res (th/run-task! :storage-gc-deleted {})]
+ (t/is (= 1 (:deleted res))))
+
+ (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])]
+ (t/is (= 0 (:count row))))))
+
+(t/deftest pending-objects-excluded-from-gc-deleted
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (sto/content "content")
+ object (sto/put-object! storage {::sto/content content
+ :content-type "text/plain"})]
+ ;; mark as pending + deleted in the past
+ (th/db-update! :storage-object {:status "pending"
+ :deleted-at (ct/in-past {:minutes 1})}
+ {:id (:id object)})
+ ;; gc-deleted skips it because status != 'valid'
+ (let [res (th/run-task! :storage-gc-deleted {})]
+ (t/is (= 0 (:deleted res))))
+ ;; row still exists (with deleted_at set — we set it above)
+ (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?"
+ (:id object)])]
+ (t/is (= 1 (:count row))))))
+
+(t/deftest gc-deleted-gives-up-after-max-attempts
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (sto/content "content")
+ object (sto/put-object! storage {::sto/content content
+ :content-type "text/plain"})]
+
+ (th/db-update! :storage-object {:deleted-at (ct/in-past {:minutes 1})
+ :deletion_attempts 6}
+ {:id (:id object)})
+
+ (with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk
+ :return (fn [_ ids] (set ids))}]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 0 (:deleted res)))))
- (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 2}))]
- (let [res (th/run-task! :storage-gc-deleted {})]
- (t/is (= 0 (:deleted res)))))))
+ (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])]
+ (t/is (= 0 (:count row))))))
+
+(t/deftest dedup-reuses-existing-blob-with-touch
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (-> (sto/content "content")
+ (sto/wrap-with-hash "same-hash"))
+ t0 (ct/now)
+ params {::sto/deduplicate? true
+ ::sto/touch true
+ :bucket "file-media-object"
+ :content-type "text/plain"}
+ object1 (binding [ct/*clock* (ct/fixed-clock t0)]
+ (sto/put-object! storage (assoc params ::sto/content content)))]
+
+ ;; a touched hit reuses the object and updates its touched_at
+ (let [object2 (binding [ct/*clock* (ct/fixed-clock (ct/plus t0 {:hours 1}))]
+ (sto/put-object! storage (assoc params ::sto/content content)))]
+ (t/is (= (:id object1) (:id object2)))
+
+ (let [row (th/db-exec-one! ["select touched_at from storage_object where id = ?" (:id object1)])]
+ (t/is (ct/is-after? (:touched-at row) t0))))
+
+ ;; with the blob removed, the touched hit repairs the stale row in
+ ;; place: the same id is kept, the row is not deleted and touched_at
+ ;; is left untouched (the touch flag only applies to healthy hits)
+ (let [path (sto/get-object-path storage object1)]
+ (fs/delete path))
+
+ (let [object3 (binding [ct/*clock* (ct/fixed-clock (ct/plus t0 {:hours 2}))]
+ (sto/put-object! storage (assoc params ::sto/content content)))]
+ (t/is (= (:id object1) (:id object3)))
+
+ (let [row (th/db-exec-one! ["select deleted_at, touched_at from storage_object where id = ?" (:id object1)])]
+ (t/is (nil? (:deleted-at row)))
+ ;; the touch flag does not apply to repairs: touched_at was last
+ ;; set by the healthy hit and is not bumped by the repair
+ (t/is (ct/is-before? (:touched-at row) (ct/plus t0 {:hours 2})))))))
+
+(t/deftest put-object-repair-failure-leaves-row-intact
+ (let [storage (-> (:app.storage/storage th/*system*)
+ (configure-storage-backend))
+ content (-> (sto/content "content")
+ (sto/wrap-with-hash "same-hash"))
+ object (sto/put-object! storage {::sto/content content
+ ::sto/deduplicate? true
+ :bucket "file-media-object"
+ :content-type "text/plain"})
+ path (sto/get-object-path storage object)
+
+ ;; Point the fs backend at a path that is actually a file so the
+ ;; blob write fails.
+ blocked (fs/path "/tmp/penpot" (str "blocked-" (uuid/next)))
+ _ (spit (str blocked) "x")]
+ (try
+ ;; remove the physical blob to simulate a stale/broken object
+ (fs/delete path)
+
+ (let [broken (assoc-in storage [::sto/backends :fs ::sto.fs/directory] (str blocked))
+ ex (try
+ (sto/put-object! broken {::sto/content content
+ ::sto/deduplicate? true
+ :bucket "file-media-object"
+ :content-type "text/plain"})
+ nil
+ (catch Throwable cause cause))]
+ (t/is (some? ex))
+
+ ;; the failed repair leaves the original row exactly as it was:
+ ;; live and valid, so a later upload can retry the healing
+ (let [row (th/db-exec-one! ["select status, deleted_at from storage_object where id = ?" (:id object)])]
+ (t/is (= "valid" (:status row)))
+ (t/is (nil? (:deleted-at row))))
+
+ (let [row (th/db-exec-one! ["select count(*) from storage_object"])]
+ (t/is (= 1 (:count row)))))
+ (finally
+ (fs/delete blocked)))))
+
+(t/deftest upload-chunks-exclude-pending
+ (let [prof (th/create-profile* 1)
+ _ (th/create-project* 1 {:profile-id (:id prof)
+ :team-id (:default-team-id prof)})
+ file (th/create-file* 1 {:profile-id (:id prof)
+ :project-id (:default-project-id prof)
+ :is-shared false})
+ mfile {:filename "chunk"
+ :path (th/tempfile "backend_tests/test_files/sample.jpg")
+ :mtype "image/jpeg"
+ :size 312043}
+ session-id (-> (th/command! {::th/type :create-upload-session
+ ::rpc/profile-id (:id prof)
+ :total-chunks 1})
+ :result :session-id)
+ out (th/command! {::th/type :upload-chunk
+ ::rpc/profile-id (:id prof)
+ :session-id session-id
+ :index 0
+ :content mfile})]
+
+ (t/is (nil? (:error out)))
+
+ ;; mark all the chunks of this session as pending (simulates rows that
+ ;; were never promoted)
+ (th/db-exec! ["update storage_object set status = 'pending' where (metadata->>'~:upload-id') = ?"
+ (str session-id)])
+
+ ;; assembling fails because no chunk is visible anymore
+ (let [assemble-out (th/command! {::th/type :assemble-file-media-object
+ ::rpc/profile-id (:id prof)
+ :session-id session-id
+ :file-id (:id file)
+ :is-local true
+ :name "assembled-image"
+ :mtype "image/jpeg"})]
+ (t/is (some? (:error assemble-out))))))
+
+(defn- fake-s3-backend
+ []
+ {::sto/type :s3
+ ::sto.s3/client (reify S3AsyncClient)
+ ::sto.s3/presigner (reify S3Presigner)})
+
+(t/deftest s3-exists-object-returns-true-on-found
+ (with-mocks [mock {:target 'app.storage.s3/head-object
+ :return (p/resolved {})}]
+ (t/is (true? (impl/exists-object? (fake-s3-backend) {:id (uuid/next)})))
+ (t/is (= 1 (:call-count @mock)))))
+
+(t/deftest s3-exists-object-returns-false-on-missing-key
+ (with-mocks [mock {:target 'app.storage.s3/head-object
+ :return (p/rejected (-> (NoSuchKeyException/builder)
+ (.message "no key")
+ (.build)))}]
+ (t/is (false? (impl/exists-object? (fake-s3-backend) {:id (uuid/next)})))
+ ;; a missing key is a definitive answer: no retries
+ (t/is (= 1 (:call-count @mock)))))
+
+(t/deftest s3-exists-object-retries-transient-errors
+ (let [calls (atom 0)]
+ (with-mocks [_mock {:target 'app.storage.s3/head-object
+ :return (fn [& _]
+ (swap! calls inc)
+ (if (< @calls 3)
+ (p/rejected (RuntimeException. "boom"))
+ (p/resolved {})))}]
+ (t/is (true? (impl/exists-object? (fake-s3-backend) {:id (uuid/next)})))
+ (t/is (= 3 @calls)))))
+
+(t/deftest s3-exists-object-throws-after-retries-exhausted
+ (with-mocks [mock {:target 'app.storage.s3/head-object
+ :return (p/rejected (RuntimeException. "boom"))}]
+ ;; p/await returns the rejection wrapped in an ExecutionException
+ (let [ex (try
+ (impl/exists-object? (fake-s3-backend) {:id (uuid/next)})
+ nil
+ (catch Throwable cause cause))]
+ (t/is (some? ex))
+ (t/is (= "boom" (ex-message (ex-cause ex)))))
+ ;; one initial attempt plus max-retries
+ (t/is (= 4 (:call-count @mock)))))
diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj
index 07b8f7c7f6..e12af0553b 100644
--- a/backend/test/backend_tests/tasks_telemetry_test.clj
+++ b/backend/test/backend_tests/tasks_telemetry_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.tasks-telemetry-test
(:require
@@ -710,6 +710,19 @@
(t/is (not (contains? (:props result) :route)))
(t/is (not (contains? (:props result) :label)))))
+(t/deftest test-filter-telemetry-props-organization-sso-failure-keeps-reason
+ (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)
+ organization-id (uuid/next)
+ result (ftp {:source "backend"
+ :name "organization-sso-auth-failed"
+ :type "action"
+ :props {:organization-id organization-id
+ :failure-reason "access-denied"
+ :unsafe-label "should-be-stripped"}})]
+ (t/is (= {:organization-id organization-id
+ :failure-reason "access-denied"}
+ (:props result)))))
+
(t/deftest test-filter-telemetry-props-navigate-keeps-route-and-ids
;; Frontend navigate events keep specific routing keys: :route,
;; :file-id, :team-id, :page-id. These ids are strings because
diff --git a/backend/test/backend_tests/test_files/file-with-library.penpot b/backend/test/backend_tests/test_files/file-with-library.penpot
new file mode 100644
index 0000000000..c17bb86a3d
Binary files /dev/null and b/backend/test/backend_tests/test_files/file-with-library.penpot differ
diff --git a/backend/test/backend_tests/util_blob_test.clj b/backend/test/backend_tests/util_blob_test.clj
index f6e129f7ce..bdd502de51 100644
--- a/backend/test/backend_tests/util_blob_test.clj
+++ b/backend/test/backend_tests/util_blob_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.util-blob-test
(:require
diff --git a/backend/test/backend_tests/util_objects_map_test.clj b/backend/test/backend_tests/util_objects_map_test.clj
index 6cf9fab519..c73bf5fa86 100644
--- a/backend/test/backend_tests/util_objects_map_test.clj
+++ b/backend/test/backend_tests/util_objects_map_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.util-objects-map-test
(:require
diff --git a/backend/test/backend_tests/util_pointer_map_test.clj b/backend/test/backend_tests/util_pointer_map_test.clj
index 6d206d9f9a..ce39715c4b 100644
--- a/backend/test/backend_tests/util_pointer_map_test.clj
+++ b/backend/test/backend_tests/util_pointer_map_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.util-pointer-map-test
(:require
diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj
index 2dadb8282a..725a4c9a55 100644
--- a/backend/test/backend_tests/util_ssrf_test.clj
+++ b/backend/test/backend_tests/util_ssrf_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.util-ssrf-test
(:require
@@ -13,11 +13,25 @@
[clojure.test :as t]))
(t/deftest validate-url-allows-public-https
- (t/is (true? (ssrf/safe-url? "https://example.com/foo")))
- (t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))
+ (let [original ssrf/resolve-host]
+ (with-redefs [ssrf/resolve-host
+ (fn [hostname]
+ (if (= hostname "example.com")
+ (into-array java.net.InetAddress
+ [(java.net.InetAddress/getByName "93.184.216.34")])
+ (original hostname)))]
+ (t/is (true? (ssrf/safe-url? "https://example.com/foo")))
+ (t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))))
(t/deftest validate-url-allows-public-http
- (t/is (true? (ssrf/safe-url? "http://example.com/foo"))))
+ (let [original ssrf/resolve-host]
+ (with-redefs [ssrf/resolve-host
+ (fn [hostname]
+ (if (= hostname "example.com")
+ (into-array java.net.InetAddress
+ [(java.net.InetAddress/getByName "93.184.216.34")])
+ (original hostname)))]
+ (t/is (true? (ssrf/safe-url? "http://example.com/foo"))))))
(t/deftest validate-url-blocks-disallowed-schemes
(t/is (false? (ssrf/safe-url? "file:///etc/passwd")))
@@ -66,6 +80,24 @@
(t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo")))
(t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo"))))
+(t/deftest validate-url-blocks-nat64-encoded-metadata
+ ;; 64:ff9b::a9fe:a9fe embeds 169.254.169.254 (cloud metadata)
+ (t/is (false? (ssrf/safe-url? "http://[64:ff9b::a9fe:a9fe]/latest/meta-data/"))))
+
+(t/deftest validate-url-blocks-nat64-encoded-loopback
+ ;; 64:ff9b::7f00:0001 embeds 127.0.0.1
+ (t/is (false? (ssrf/safe-url? "http://[64:ff9b::7f00:1]/foo"))))
+
+(t/deftest validate-url-blocks-6to4-encoded-private
+ ;; 2002:a00:1:: embeds 10.0.0.1; 2002:c0a8:101:: embeds 192.168.1.1
+ (t/is (false? (ssrf/safe-url? "http://[2002:a00:1::1]/foo")))
+ (t/is (false? (ssrf/safe-url? "http://[2002:c0a8:101::1]/foo"))))
+
+(t/deftest validate-url-blocks-teredo-encoded-addresses
+ ;; Teredo server prefix 2001:0000::/32
+ (t/is (false? (ssrf/safe-url?
+ "http://[2001:0000:4136:e378:8000:63bf:3fff:fdd2]/foo"))))
+
(t/deftest validate-url-blocks-encoded-loopback
;; Decimal encoding of 127.0.0.1 = 2130706433
;; InetAddress normalizes this to 127.0.0.1
diff --git a/backend/test/e2e/asset-download.test.mjs b/backend/test/e2e/asset-download.test.mjs
new file mode 100644
index 0000000000..99bb0446ba
--- /dev/null
+++ b/backend/test/e2e/asset-download.test.mjs
@@ -0,0 +1,145 @@
+import { describe, it, before } from "node:test";
+import assert from "node:assert/strict";
+import {
+ setupTestProfile,
+ createAccessToken,
+} from "./helpers/auth.mjs";
+import { rpcPost, getAsset } from "./helpers/client.mjs";
+import { parseSSE, extractResult } from "./helpers/sse.mjs";
+
+async function createAndExport(cookie, projectId) {
+ const createRes = await rpcPost(
+ "create-file",
+ { name: "E2E Asset Test", projectId },
+ { cookieToken: cookie }
+ );
+ assert.equal(createRes.status, 200);
+ const fileId = createRes.body.id;
+
+ const exportRes = await rpcPost(
+ "export-binfile",
+ { fileId, includeLibraries: false, embedAssets: true },
+ { cookieToken: cookie }
+ );
+ assert.equal(exportRes.status, 200);
+ const assetUrl = extractResult(parseSSE(exportRes.body));
+ return assetUrl;
+}
+
+function extractAssetId(assetUrl) {
+ const match = assetUrl.match(/\/assets\/by-id\/([0-9a-f-]+)/);
+ return match ? match[1] : null;
+}
+
+describe("asset download", () => {
+ let profile, cookie, assetUrl, assetId;
+
+ before(async () => {
+ const setup = await setupTestProfile();
+ profile = setup.profile;
+ cookie = setup.cookie;
+
+ assetUrl = await createAndExport(cookie, profile.defaultProjectId);
+ assetId = extractAssetId(assetUrl);
+ assert.ok(assetId, `should extract asset id from URL: ${assetUrl}`);
+ });
+
+ it("asset download with cookie auth succeeds", async () => {
+ // In devenv, nginx's @handle_redirect intercepts the backend's 307 and
+ // proxies to S3 directly. The client sees 200 with file content, not 307.
+ const res = await getAsset(assetId, { cookieToken: cookie });
+ assert.equal(res.status, 200, `expected 200, got ${res.status}`);
+ assert.ok(
+ res.body.length > 0 || typeof res.body === "object",
+ "response should have content"
+ );
+ });
+
+ it("asset download with access token auth succeeds", async () => {
+ const tokenObj = await createAccessToken(cookie, "e2e-asset-test");
+ const accessToken = tokenObj.token;
+
+ const res = await getAsset(assetId, { accessToken });
+ assert.equal(res.status, 200, `expected 200, got ${res.status}`);
+ });
+
+ it("asset download without auth returns 401", async () => {
+ const res = await getAsset(assetId, {});
+ assert.equal(res.status, 401, `expected 401, got ${res.status}`);
+ });
+
+ it("asset download returns file content through nginx proxy", async () => {
+ // The full flow: backend returns 307 with S3 presigned URL,
+ // nginx intercepts and proxies to S3, client gets 200 with content.
+ const res = await getAsset(assetId, { cookieToken: cookie });
+ assert.equal(res.status, 200);
+ // Response should be a .penpot file (binary/zip content)
+ assert.ok(res.body, "response should have body");
+ });
+
+ it("follow S3 redirect WITH auth header (bug repro)", async () => {
+ // In devenv, nginx's @handle_redirect intercepts the 307 and proxies to
+ // S3 server-side, only forwarding the Host header from X-Host. The client's
+ // Authorization header is NOT forwarded to S3, so the request succeeds.
+ //
+ // In production (no nginx proxy), the backend returns 307 directly. The HTTP
+ // client follows the redirect and forwards the Authorization: Token header to
+ // S3, which conflicts with the presigned URL's X-Amz-* params and returns
+ // 400 InvalidArgument.
+ //
+ // This test documents the devenv behavior: nginx strips the auth header
+ // when proxying to S3, so the download succeeds.
+ const res = await getAsset(assetId, { cookieToken: cookie });
+ assert.equal(res.status, 200, "through nginx, download succeeds");
+ assert.ok(res.body, "should have file content");
+ });
+
+ it("full export-to-download flow works end-to-end", async () => {
+ const url = await createAndExport(cookie, profile.defaultProjectId);
+ const id = extractAssetId(url);
+ assert.ok(id);
+
+ const res = await getAsset(id, { cookieToken: cookie });
+ assert.equal(res.status, 200);
+ });
+
+ it("asset URL is accessible immediately after export", async () => {
+ const url = await createAndExport(cookie, profile.defaultProjectId);
+ const id = extractAssetId(url);
+ assert.ok(id);
+
+ const res = await getAsset(id, { cookieToken: cookie });
+ assert.equal(res.status, 200, "asset should be accessible right after export");
+ });
+
+ it("token-only: export then download asset with same token", async () => {
+ const tokenObj = await createAccessToken(cookie, "e2e-token-export-test");
+ const token = tokenObj.token;
+
+ const createRes = await rpcPost(
+ "create-file",
+ { name: "E2E Token Export Test", projectId: profile.defaultProjectId },
+ { accessToken: token }
+ );
+ assert.equal(createRes.status, 200);
+ const fileId = createRes.body.id;
+
+ const exportRes = await rpcPost(
+ "export-binfile",
+ { fileId, includeLibraries: false, embedAssets: true },
+ { accessToken: token }
+ );
+ assert.equal(exportRes.status, 200);
+
+ const events = parseSSE(exportRes.body);
+ const url = extractResult(events);
+ assert.ok(url, "should get an asset URL from export");
+
+ const id = extractAssetId(url);
+ assert.ok(id, `should extract asset id from URL: ${url}`);
+
+ const res = await getAsset(id, { accessToken: token });
+ assert.equal(res.status, 200, `expected 200, got ${res.status}`);
+ assert.ok(res.body, "response should have file content");
+ });
+});
diff --git a/backend/test/e2e/auth-flow.test.mjs b/backend/test/e2e/auth-flow.test.mjs
new file mode 100644
index 0000000000..7f9fbb34fd
--- /dev/null
+++ b/backend/test/e2e/auth-flow.test.mjs
@@ -0,0 +1,78 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import {
+ createDemoProfile,
+ login,
+ setupTestProfile,
+} from "./helpers/auth.mjs";
+import { rpcPost } from "./helpers/client.mjs";
+
+describe("auth flow", () => {
+ it("creates a demo profile", async () => {
+ const { email, password } = await createDemoProfile();
+ assert.match(email, /^demo-.*\.demo@example\.com$/);
+ assert.ok(password.length > 0);
+ });
+
+ it("logs in with valid credentials", async () => {
+ const { email, password } = await createDemoProfile();
+ const { profile, cookie } = await login(email, password);
+
+ assert.equal(profile.email, email);
+ assert.equal(profile.isDemo, true);
+ assert.ok(profile.id, "profile should have id");
+ assert.ok(profile.defaultProjectId, "profile should have defaultProjectId");
+ assert.ok(profile.defaultTeamId, "profile should have defaultTeamId");
+ assert.ok(cookie, "cookie should be set");
+ });
+
+ it("login sets session cookie", async () => {
+ const { email, password } = await createDemoProfile();
+ const { cookie } = await login(email, password);
+ assert.ok(cookie, "auth-token cookie should be extracted");
+ assert.ok(cookie.length > 10, "cookie should have meaningful length");
+ });
+
+ it("login fails with wrong password", async () => {
+ const { email } = await createDemoProfile();
+ try {
+ await login(email, "wrong-password");
+ assert.fail("should have thrown");
+ } catch (e) {
+ assert.ok(e.message.includes("Login failed"));
+ }
+ });
+
+ it("login fails with non-existent email", async () => {
+ try {
+ await login("nonexistent@example.com", "some-password");
+ assert.fail("should have thrown");
+ } catch (e) {
+ assert.ok(e.message.includes("Login failed"));
+ }
+ });
+
+ it("authenticated RPC with cookie", async () => {
+ const { profile, cookie } = await setupTestProfile();
+ const res = await rpcPost("get-profile", {}, { cookieToken: cookie });
+ assert.equal(res.status, 200);
+ assert.equal(res.body.id, profile.id);
+ assert.equal(res.body.email, profile.email);
+ });
+
+ it("unauthenticated RPC returns anonymous profile", async () => {
+ const res = await rpcPost("get-profile", {});
+ assert.equal(res.status, 200);
+ // Anonymous profile has uuid/zero as id
+ assert.equal(res.body.id, "00000000-0000-0000-0000-000000000000");
+ });
+
+ it("setupTestProfile returns all fields", async () => {
+ const { profile, cookie, email, password } = await setupTestProfile();
+ assert.ok(profile.id);
+ assert.ok(profile.defaultProjectId);
+ assert.ok(cookie);
+ assert.ok(email);
+ assert.ok(password);
+ });
+});
diff --git a/backend/test/e2e/config.mjs b/backend/test/e2e/config.mjs
new file mode 100644
index 0000000000..697f20f8c4
--- /dev/null
+++ b/backend/test/e2e/config.mjs
@@ -0,0 +1,7 @@
+const config = Object.freeze({
+ baseUrl: process.env.PENPOT_BASE_URL || "http://localhost:3450",
+ email: process.env.PENPOT_EMAIL || null,
+ password: process.env.PENPOT_PASSWORD || null,
+});
+
+export default config;
diff --git a/backend/test/e2e/export-binfile.test.mjs b/backend/test/e2e/export-binfile.test.mjs
new file mode 100644
index 0000000000..f6be598ec5
--- /dev/null
+++ b/backend/test/e2e/export-binfile.test.mjs
@@ -0,0 +1,87 @@
+import { describe, it, before } from "node:test";
+import assert from "node:assert/strict";
+import { setupTestProfile } from "./helpers/auth.mjs";
+import { rpcPost } from "./helpers/client.mjs";
+import { parseSSE, extractResult } from "./helpers/sse.mjs";
+
+async function createFile(cookie, projectId, name = "E2E Test File") {
+ const res = await rpcPost(
+ "create-file",
+ { name, projectId },
+ { cookieToken: cookie }
+ );
+ assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`);
+ return res.body;
+}
+
+async function exportFile(cookie, fileId) {
+ const res = await rpcPost(
+ "export-binfile",
+ {
+ fileId,
+ includeLibraries: false,
+ embedAssets: true,
+ },
+ { cookieToken: cookie }
+ );
+ assert.equal(res.status, 200, `export-binfile failed: ${JSON.stringify(res.body)}`);
+
+ const events = parseSSE(res.body);
+ const assetUrl = extractResult(events);
+ return assetUrl;
+}
+
+describe("export-binfile", () => {
+ let profile, cookie;
+
+ before(async () => {
+ const setup = await setupTestProfile();
+ profile = setup.profile;
+ cookie = setup.cookie;
+ });
+
+ it("creates a file via API", async () => {
+ const file = await createFile(cookie, profile.defaultProjectId);
+ assert.ok(file.id, "file should have an id");
+ assert.equal(file.name, "E2E Test File");
+ });
+
+ it("export returns an asset URL", async () => {
+ const file = await createFile(cookie, profile.defaultProjectId);
+ const assetUrl = await exportFile(cookie, file.id);
+ assert.ok(
+ typeof assetUrl === "string" && assetUrl.includes("/assets/by-id/"),
+ `asset URL should contain /assets/by-id/, got: ${assetUrl}`
+ );
+ assert.match(assetUrl, /\/assets\/by-id\/[0-9a-f-]+$/);
+ });
+
+ it("export with invalid file-id returns error", async () => {
+ const fakeId = "00000000-0000-0000-0000-000000000000";
+ const res = await rpcPost(
+ "export-binfile",
+ {
+ fileId: fakeId,
+ includeLibraries: false,
+ embedAssets: true,
+ },
+ { cookieToken: cookie }
+ );
+ assert.ok(
+ res.body.type || res.status !== 200,
+ "should return error for non-existent file"
+ );
+ });
+
+ it("export requires authentication", async () => {
+ const res = await rpcPost("export-binfile", {
+ fileId: "00000000-0000-0000-0000-000000000000",
+ includeLibraries: false,
+ embedAssets: true,
+ });
+ assert.ok(
+ res.body.type || res.status !== 200,
+ "should require authentication"
+ );
+ });
+});
diff --git a/backend/test/e2e/helpers/auth.mjs b/backend/test/e2e/helpers/auth.mjs
new file mode 100644
index 0000000000..07d34ef0f0
--- /dev/null
+++ b/backend/test/e2e/helpers/auth.mjs
@@ -0,0 +1,38 @@
+import { rpcPost, extractCookie } from "./client.mjs";
+
+export async function createDemoProfile() {
+ const res = await rpcPost("create-demo-profile", {});
+ if (res.body.type === "validation" || res.body.type === "restriction") {
+ throw new Error(
+ `Failed to create demo profile: ${res.body.code} - ${res.body.hint || ""}`
+ );
+ }
+ return { email: res.body.email, password: res.body.password };
+}
+
+export async function login(email, password) {
+ const res = await rpcPost("login-with-password", { email, password });
+ if (res.status !== 200 || res.body.type) {
+ throw new Error(
+ `Login failed: ${JSON.stringify(res.body)}`
+ );
+ }
+ const cookie = extractCookie(res.setCookie);
+ return { profile: res.body, cookie };
+}
+
+export async function createAccessToken(cookie, name = "e2e-test-token") {
+ const res = await rpcPost("create-access-token", { name }, { cookieToken: cookie });
+ if (res.status !== 200 || res.body.type) {
+ throw new Error(
+ `Create access token failed: ${JSON.stringify(res.body)}`
+ );
+ }
+ return res.body;
+}
+
+export async function setupTestProfile() {
+ const { email, password } = await createDemoProfile();
+ const { profile, cookie } = await login(email, password);
+ return { profile, cookie, email, password };
+}
diff --git a/backend/test/e2e/helpers/client.mjs b/backend/test/e2e/helpers/client.mjs
new file mode 100644
index 0000000000..1bfe8750d6
--- /dev/null
+++ b/backend/test/e2e/helpers/client.mjs
@@ -0,0 +1,86 @@
+import config from "../config.mjs";
+
+async function parseResponse(response) {
+ const contentType = response.headers.get("content-type") || "";
+ const setCookie = response.headers.get("set-cookie") || null;
+
+ let body;
+ if (contentType.includes("application/json")) {
+ body = await response.json();
+ } else {
+ body = await response.text();
+ }
+
+ return {
+ status: response.status,
+ headers: response.headers,
+ body,
+ setCookie,
+ };
+}
+
+export function extractCookie(setCookieHeader, name = "auth-token") {
+ if (!setCookieHeader) return null;
+ const match = setCookieHeader.match(new RegExp(`${name}=([^;]+)`));
+ return match ? match[1] : null;
+}
+
+export async function rpcPost(method, body = {}, { cookieToken, accessToken } = {}) {
+ const headers = {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ };
+ if (cookieToken) {
+ headers.Cookie = `auth-token=${cookieToken}`;
+ }
+ if (accessToken) {
+ headers.Authorization = `Token ${accessToken}`;
+ }
+
+ const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ });
+
+ return parseResponse(response);
+}
+
+export async function multipartPost(method, formData, { cookieToken } = {}) {
+ const headers = {
+ Accept: "application/json",
+ };
+ if (cookieToken) {
+ headers.Cookie = `auth-token=${cookieToken}`;
+ }
+
+ const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, {
+ method: "POST",
+ headers,
+ body: formData,
+ });
+
+ return parseResponse(response);
+}
+
+export async function getAsset(
+ id,
+ { cookieToken, accessToken, redirect = "manual" } = {}
+) {
+ const headers = { Accept: "application/json" };
+ if (cookieToken) {
+ headers.Cookie = `auth-token=${cookieToken}`;
+ }
+ if (accessToken) {
+ headers.Authorization = `Token ${accessToken}`;
+ }
+
+ const response = await fetch(`${config.baseUrl}/assets/by-id/${id}`, {
+ method: "GET",
+ headers,
+ redirect,
+ });
+
+ return parseResponse(response);
+}
+
diff --git a/backend/test/e2e/helpers/sse.mjs b/backend/test/e2e/helpers/sse.mjs
new file mode 100644
index 0000000000..02f3845d20
--- /dev/null
+++ b/backend/test/e2e/helpers/sse.mjs
@@ -0,0 +1,72 @@
+import { createParser } from "eventsource-parser";
+
+export function parseSSE(text) {
+ const events = [];
+ const parser = createParser({
+ onEvent(event) {
+ events.push({ event: event.event || "message", data: event.data });
+ },
+ });
+ parser.feed(text);
+ return events;
+}
+
+export function extractResult(events) {
+ const endEvent = events.find((e) => e.event === "end");
+ if (!endEvent) {
+ const errEvent = events.find((e) => e.event === "error");
+ if (errEvent) {
+ throw new Error(`SSE error: ${errEvent.data}`);
+ }
+ throw new Error(`No end event found in SSE stream. Events: ${JSON.stringify(events)}`);
+ }
+
+ const raw = JSON.parse(endEvent.data);
+
+ // Transit JSON verbose format:
+ // For URIs (e.g. asset URL): {"~#uri":"https://..."}
+ // For objects: {"~:key":"val",...} or ["^ ","~:key","val",...]
+ // For strings: plain string
+ if (raw && typeof raw === "object") {
+ // Tagged URI
+ if ("~#uri" in raw) {
+ return raw["~#uri"];
+ }
+ // Transit map with ~:value key
+ if ("~:value" in raw) {
+ const value = raw["~:value"];
+ if (Array.isArray(value)) {
+ return transitArrayToObj(value);
+ }
+ return value;
+ }
+ // Direct transit map (keys starting with ~:)
+ const firstKey = Object.keys(raw)[0];
+ if (firstKey && firstKey.startsWith("~:")) {
+ return transitMapToObj(raw);
+ }
+ }
+
+ return raw;
+}
+
+function transitArrayToObj(arr) {
+ // Transit verbose object: ["^ ","~:key1","val1","~:key2","val2",...]
+ const obj = {};
+ for (let i = 1; i < arr.length; i += 2) {
+ const key = arr[i].replace(/^~:/, "");
+ const val = arr[i + 1];
+ obj[key] = val;
+ }
+ return obj;
+}
+
+function transitMapToObj(map) {
+ // Transit verbose map: {"~:key1":"val1","~:key2":"val2",...}
+ const obj = {};
+ for (const [key, val] of Object.entries(map)) {
+ const cleanKey = key.replace(/^~:/, "");
+ obj[cleanKey] = val;
+ }
+ return obj;
+}
diff --git a/common/deps.edn b/common/deps.edn
index edffb487f0..c5cb94c494 100644
--- a/common/deps.edn
+++ b/common/deps.edn
@@ -17,14 +17,14 @@
org.slf4j/slf4j-api {:mvn/version "2.0.18"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.43"}
- selmer/selmer {:mvn/version "1.13.4"}
+ selmer/selmer {:mvn/version "1.13.5"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "1.0.0"
:exclusions [com.fasterxml.jackson.core/jackson-core
com.fasterxml.jackson.core/jackson-databind]}
- com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.1"}
- com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.1"}
+ com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.2"}
+ com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.2"}
metosin/malli {:mvn/version "0.20.1"}
@@ -55,12 +55,12 @@
io.aviso/pretty {:mvn/version "1.4.4"}
environ/environ {:mvn/version "1.2.0"}}
- :paths ["src" "vendor" "target/classes"]
+ :paths ["src" "vendor" "resources" "target/classes"]
:aliases
{:dev
{:extra-deps
{org.clojure/tools.namespace {:mvn/version "1.5.1"}
- thheller/shadow-cljs {:mvn/version "3.4.11"}
+ thheller/shadow-cljs {:mvn/version "3.5.0"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
com.bhauman/rebel-readline {:mvn/version "0.1.11"}
criterium/criterium {:mvn/version "0.4.6"}
diff --git a/common/dev/user.clj b/common/dev/user.clj
index cf6d6b5065..5a6a409341 100644
--- a/common/dev/user.clj
+++ b/common/dev/user.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns user
(:require
diff --git a/common/package.json b/common/package.json
index 09d48ef2c2..acc9432642 100644
--- a/common/package.json
+++ b/common/package.json
@@ -4,18 +4,18 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
- "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
+ "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"devDependencies": {
- "concurrently": "^10.0.4",
+ "concurrently": "^10.0.5",
"nodemon": "^3.1.14",
"prettier": "3.9.6",
"source-map-support": "^0.5.21",
- "ws": "^8.21.1"
+ "ws": "^8.21.3"
},
"dependencies": {
"date-fns": "^4.4.0"
diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml
index eeb471366f..77f173a685 100644
--- a/common/pnpm-lock.yaml
+++ b/common/pnpm-lock.yaml
@@ -1,3 +1,104 @@
+---
+lockfileVersion: '9.0'
+
+importers:
+
+ .:
+ configDependencies: {}
+ packageManagerDependencies:
+ pnpm:
+ specifier: 12.0.0
+ version: 12.0.0
+
+packages:
+
+ '@pnpm/exe.darwin-arm64@12.0.0':
+ resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@pnpm/exe.darwin-x64@12.0.0':
+ resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@pnpm/exe.linux-arm64-musl@12.0.0':
+ resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-arm64@12.0.0':
+ resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.linux-x64-musl@12.0.0':
+ resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-x64@12.0.0':
+ resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.win32-arm64@12.0.0':
+ resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@pnpm/exe.win32-x64@12.0.0':
+ resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ cpu: [x64]
+ os: [win32]
+
+ pnpm@12.0.0:
+ resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ engines: {node: '>=18.*'}
+ hasBin: true
+
+snapshots:
+
+ '@pnpm/exe.darwin-arm64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.darwin-x64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.linux-arm64-musl@12.0.0':
+ optional: true
+
+ '@pnpm/exe.linux-arm64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.linux-x64-musl@12.0.0':
+ optional: true
+
+ '@pnpm/exe.linux-x64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.win32-arm64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.win32-x64@12.0.0':
+ optional: true
+
+ pnpm@12.0.0:
+ optionalDependencies:
+ '@pnpm/exe.darwin-arm64': 12.0.0
+ '@pnpm/exe.darwin-x64': 12.0.0
+ '@pnpm/exe.linux-arm64': 12.0.0
+ '@pnpm/exe.linux-arm64-musl': 12.0.0
+ '@pnpm/exe.linux-x64': 12.0.0
+ '@pnpm/exe.linux-x64-musl': 12.0.0
+ '@pnpm/exe.win32-arm64': 12.0.0
+ '@pnpm/exe.win32-x64': 12.0.0
+
+---
lockfileVersion: '9.0'
settings:
@@ -13,8 +114,8 @@ importers:
version: 4.4.0
devDependencies:
concurrently:
- specifier: ^10.0.4
- version: 10.0.4
+ specifier: ^10.0.5
+ version: 10.0.5
nodemon:
specifier: ^3.1.14
version: 3.1.14
@@ -25,8 +126,8 @@ importers:
specifier: ^0.5.21
version: 0.5.21
ws:
- specifier: ^8.21.1
- version: 8.21.1
+ specifier: ^8.21.3
+ version: 8.21.3
packages:
@@ -50,9 +151,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
- brace-expansion@5.0.6:
- resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
- engines: {node: 18 || 20 || >=22}
+ brace-expansion@5.0.9:
+ resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
+ engines: {node: 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -73,8 +174,8 @@ packages:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
- concurrently@10.0.4:
- resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==}
+ concurrently@10.0.5:
+ resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==}
engines: {node: '>=22'}
hasBin: true
@@ -234,8 +335,8 @@ packages:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
- ws@8.21.1:
- resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
+ ws@8.21.3:
+ resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -273,7 +374,7 @@ snapshots:
binary-extensions@2.3.0: {}
- brace-expansion@5.0.6:
+ brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -303,7 +404,7 @@ snapshots:
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
- concurrently@10.0.4:
+ concurrently@10.0.5:
dependencies:
chalk: 5.6.2
rxjs: 7.8.2
@@ -357,7 +458,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
- brace-expansion: 5.0.6
+ brace-expansion: 5.0.9
ms@2.1.3: {}
@@ -439,7 +540,7 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
- ws@8.21.1: {}
+ ws@8.21.3: {}
y18n@5.0.8: {}
diff --git a/frontend/resources/fonts/gfonts.2025.11.28.json b/common/resources/fonts/gfonts.2025.11.28.json
similarity index 100%
rename from frontend/resources/fonts/gfonts.2025.11.28.json
rename to common/resources/fonts/gfonts.2025.11.28.json
diff --git a/common/src/app/common/UUIDv8.java b/common/src/app/common/UUIDv8.java
index 97485ce89c..0976f55e33 100644
--- a/common/src/app/common/UUIDv8.java
+++ b/common/src/app/common/UUIDv8.java
@@ -3,7 +3,7 @@
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
+ Copyright (c) KALEIDOS SUBSIDIARY SL
This file contains a UUIDv8 with conformance with
https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format
diff --git a/common/src/app/common/attrs.cljc b/common/src/app/common/attrs.cljc
index 57a06f7332..205b02ebd4 100644
--- a/common/src/app/common/attrs.cljc
+++ b/common/src/app/common/attrs.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.attrs
(:require
diff --git a/common/src/app/common/buffer.cljc b/common/src/app/common/buffer.cljc
index 16fb3be693..16798f2891 100644
--- a/common/src/app/common/buffer.cljc
+++ b/common/src/app/common/buffer.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.buffer
"A collection of helpers and macros for work with byte
diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc
index 22705c5980..8d0a4a486e 100644
--- a/common/src/app/common/colors.cljc
+++ b/common/src/app/common/colors.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.colors
(:refer-clojure :exclude [test])
diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc
index 7cbfdcc4f5..64da2a7d6a 100644
--- a/common/src/app/common/data.cljc
+++ b/common/src/app/common/data.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.data
"A collection of helpers for working with data structures and other
@@ -1173,6 +1173,15 @@
[key coll]
(sort-by key natural-compare coll))
+(defn normalize-string
+ "Normalizes a string by trimming leading/trailing whitespace.
+ Returns empty string for nil input. Non-string input is returned unchanged."
+ [s]
+ (cond
+ (nil? s) ""
+ (string? s) (str/trim s)
+ :else s))
+
(defn sanitize-string [s]
(if s
(-> s
@@ -1183,6 +1192,15 @@
str/trim)
""))
+(defn escape-markdown
+ "Escapes Markdown special characters by prefixing them with backslash.
+ Intended for user-controlled values embedded in Markdown messages
+ (e.g. Mattermost notifications)."
+ [s]
+ (if s
+ (str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" (fn [[_ c]] (str "\\" c)))
+ ""))
+
(defn get-initials
"Returns up to two uppercase initials extracted from a string.
Non-letter prefixes in each token are ignored."
diff --git a/common/src/app/common/data/macros.cljc b/common/src/app/common/data/macros.cljc
index 6902e3f2ac..4a6bd5b2a9 100644
--- a/common/src/app/common/data/macros.cljc
+++ b/common/src/app/common/data/macros.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.data.macros
"Data retrieval & manipulation specific macros."
diff --git a/common/src/app/common/data/undo_stack.cljc b/common/src/app/common/data/undo_stack.cljc
index 192d3a13b8..6ac0827a60 100644
--- a/common/src/app/common/data/undo_stack.cljc
+++ b/common/src/app/common/data/undo_stack.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.data.undo-stack
(:refer-clojure :exclude [peek])
diff --git a/common/src/app/common/debug.clj b/common/src/app/common/debug.clj
index 63d4455eda..8ca06064c0 100644
--- a/common/src/app/common/debug.clj
+++ b/common/src/app/common/debug.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.debug
(:require
diff --git a/common/src/app/common/encoding_impl.js b/common/src/app/common/encoding_impl.js
index 10cd3d1263..e83b566acd 100644
--- a/common/src/app/common/encoding_impl.js
+++ b/common/src/app/common/encoding_impl.js
@@ -3,7 +3,7 @@
* 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
+ * Copyright (c) KALEIDOS SUBSIDIARY SL
*/
"use strict";
diff --git a/common/src/app/common/exceptions.cljc b/common/src/app/common/exceptions.cljc
index 86785c58ff..7a4b6b1a1e 100644
--- a/common/src/app/common/exceptions.cljc
+++ b/common/src/app/common/exceptions.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.exceptions
"A helpers for work with exceptions."
diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc
index a5097a29a7..e1b12729e0 100644
--- a/common/src/app/common/features.cljc
+++ b/common/src/app/common/features.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.features
(:require
@@ -57,6 +57,7 @@
"text-editor/v2"
"text-editor-wasm/v1"
"render-wasm/v1"
+ "wasm-export/v1"
"variants/v1"})
;; A set of features enabled by default
@@ -82,7 +83,8 @@
"text-editor/v2"
"text-editor-wasm/v1"
"tokens/numeric-input"
- "render-wasm/v1"})
+ "render-wasm/v1"
+ "wasm-export/v1"})
;; Features that are mainly backend only or there are a proper
;; fallback when frontend reports no support for it
diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc
index c6bfd833e9..49d93407a0 100644
--- a/common/src/app/common/files/builder.cljc
+++ b/common/src/app/common/files/builder.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.builder
"Internal implementation of file builder. Mainly used as base impl
diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc
index 7c458aab3f..34148aa0b2 100644
--- a/common/src/app/common/files/changes.cljc
+++ b/common/src/app/common/files/changes.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.changes
(:require
diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc
index c0012dfce9..a0c733fecb 100644
--- a/common/src/app/common/files/changes_builder.cljc
+++ b/common/src/app/common/files/changes_builder.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.changes-builder
(:require
@@ -74,6 +74,12 @@
translation?
(assoc :translation? true)))
+(defn set-skip-component-sync?
+ [changes skip-component-sync?]
+ (cond-> changes
+ skip-component-sync?
+ (assoc :skip-component-sync? true)))
+
(defn with-page
[changes page]
(vary-meta changes assoc
diff --git a/common/src/app/common/files/comp_processors.cljc b/common/src/app/common/files/comp_processors.cljc
index 9c73a2bba3..b74fd0c4b0 100644
--- a/common/src/app/common/files/comp_processors.cljc
+++ b/common/src/app/common/files/comp_processors.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.comp-processors
"Repair, migration or transformation utilities for components."
diff --git a/common/src/app/common/files/defaults.cljc b/common/src/app/common/files/defaults.cljc
index 3f4520b5a4..480271c8c3 100644
--- a/common/src/app/common/files/defaults.cljc
+++ b/common/src/app/common/files/defaults.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.defaults)
diff --git a/common/src/app/common/files/focus.cljc b/common/src/app/common/files/focus.cljc
index b33be6e059..cb422630f0 100644
--- a/common/src/app/common/files/focus.cljc
+++ b/common/src/app/common/files/focus.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.focus
(:require
diff --git a/common/src/app/common/files/helpers.cljc b/common/src/app/common/files/helpers.cljc
index 3db1cdfba9..fcb7d6824d 100644
--- a/common/src/app/common/files/helpers.cljc
+++ b/common/src/app/common/files/helpers.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.helpers
(:require
diff --git a/common/src/app/common/files/indices.cljc b/common/src/app/common/files/indices.cljc
index 5e2f256a2e..a6e1a8a1df 100644
--- a/common/src/app/common/files/indices.cljc
+++ b/common/src/app/common/files/indices.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.indices
(:require
diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc
index 28174ba84f..de8d1b8f96 100644
--- a/common/src/app/common/files/migrations.cljc
+++ b/common/src/app/common/files/migrations.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.migrations
(:require
@@ -1978,6 +1978,26 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
+(defmethod migrate-data "0026-fix-svg-raw-shapes-uuids"
+ ;; Before the svg-raw schema declared :shapes as a vector of uuid,
+ ;; the JSON decoder had no type information for those child ids and
+ ;; left them as plain strings on any round trip, so they got
+ ;; persisted as strings. Once the schema was tightened, such files
+ ;; fail schema validation; this migration parses the strings back
+ ;; into uuid instances.
+ [data _]
+ (letfn [(update-object [object]
+ (cond-> object
+ (cfh/svg-raw-shape? object)
+ (d/update-when :shapes #(mapv uuid/coerce %))))
+
+ (update-container [container]
+ (d/update-when container :objects d/update-vals update-object))]
+
+ (-> data
+ (update :pages-index d/update-vals update-container)
+ (d/update-when :components d/update-vals update-container))))
+
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@@ -2060,4 +2080,5 @@
"0022-normalize-component-root-and-resync"
"0023-repair-token-themes-with-inexistent-sets"
"0024b-fix-stroke-cap-placement"
- "0025-repair-empty-text-content"]))
+ "0025-repair-empty-text-content"
+ "0026-fix-svg-raw-shapes-uuids"]))
diff --git a/common/src/app/common/files/page_diff.cljc b/common/src/app/common/files/page_diff.cljc
index f3535e9dae..32279b746e 100644
--- a/common/src/app/common/files/page_diff.cljc
+++ b/common/src/app/common/files/page_diff.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.page-diff
"Given a page in its old version and the new will retrieve a map with
diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc
index 6714bfce04..0bbec120bb 100644
--- a/common/src/app/common/files/repair.cljc
+++ b/common/src/app/common/files/repair.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.repair
(:require
@@ -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
diff --git a/common/src/app/common/files/shapes_builder.cljc b/common/src/app/common/files/shapes_builder.cljc
index d8b2c2fa64..9577fb528d 100644
--- a/common/src/app/common/files/shapes_builder.cljc
+++ b/common/src/app/common/files/shapes_builder.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.shapes-builder
"A SVG to Shapes builder."
diff --git a/common/src/app/common/files/shapes_helpers.cljc b/common/src/app/common/files/shapes_helpers.cljc
index 6ecbce515e..4540501938 100644
--- a/common/src/app/common/files/shapes_helpers.cljc
+++ b/common/src/app/common/files/shapes_helpers.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.shapes-helpers
(:require
diff --git a/common/src/app/common/files/stats.cljc b/common/src/app/common/files/stats.cljc
index 99a2315243..30763ed833 100644
--- a/common/src/app/common/files/stats.cljc
+++ b/common/src/app/common/files/stats.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.stats
"Pure helpers that compute aggregate statistics for a file data map.
diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc
index 95ff5da4df..89678417d0 100644
--- a/common/src/app/common/files/tokens.cljc
+++ b/common/src/app/common/files/tokens.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.tokens
(:require
@@ -166,7 +166,6 @@
(not (ctob/token-name-path-exists? token-name tokens-tree)))
new-tokens))))]])
(defn find-refs [value]
- (prn value)
(cond
(string? value)
(cto/find-token-value-references value)
diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc
index 82aeed3d9e..23a7b46957 100644
--- a/common/src/app/common/files/validate.cljc
+++ b/common/src/app/common/files/validate.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.validate
(:require
@@ -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]
diff --git a/common/src/app/common/files/variant.cljc b/common/src/app/common/files/variant.cljc
index 649e1d743c..989a80a58b 100644
--- a/common/src/app/common/files/variant.cljc
+++ b/common/src/app/common/files/variant.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.files.variant
(:require
[app.common.data.macros :as dm]
diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc
index ddfa667165..7ef1c0c6df 100644
--- a/common/src/app/common/flags.cljc
+++ b/common/src/app/common/flags.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.flags
"Flags parsing algorithm."
@@ -100,6 +100,10 @@
:backend-svgo
;; If enabled, it makes the Google Fonts available.
:google-fonts-provider
+ ;; Enables the Ladybug graph subsystem: the `/dbg` graph console and its
+ ;; actions. Off by default. With the flag off, `app.graph.*` never loads,
+ ;; so the Ladybug native library never enters the JVM.
+ :graph
;; Only for development.
:nrepl-server
;; Interactive repl. Only for development.
@@ -147,7 +151,6 @@
:render-switch
:hide-release-modal
:subscriptions
- :subscriptions-old
:inspect-styles
;; Enable performance logs in devconsole (disabled by default)
:perf-logs
@@ -178,7 +181,11 @@
:stroke-path
:stroke-per-side
- :custom-shortcuts})
+ ;; Exporter only: uses render-wasm for export instead of browser
+ ;; renderer.
+ :wasm-export
+ :custom-shortcuts
+ :remote-media-processing})
(def all-flags
(set/union email login varia))
diff --git a/frontend/src/app/main/fonts.clj b/common/src/app/common/fonts.clj
similarity index 95%
rename from frontend/src/app/main/fonts.clj
rename to common/src/app/common/fonts.clj
index c8abbd0478..8580abb8dd 100644
--- a/frontend/src/app/main/fonts.clj
+++ b/common/src/app/common/fonts.clj
@@ -2,10 +2,11 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
-(ns app.main.fonts
+(ns app.common.fonts
"A fonts loading macros."
+
(:require
[app.common.uuid :as uuid]
[clojure.data.json :as json]
@@ -47,6 +48,3 @@
(let [data (slurp (io/resource path))
data (json/read-str data)]
`~(mapv parse-gfont (get data "items"))))
-
-
-
diff --git a/frontend/src/app/render_wasm/fallback_fonts.cljs b/common/src/app/common/fonts.cljs
similarity index 60%
rename from frontend/src/app/render_wasm/fallback_fonts.cljs
rename to common/src/app/common/fonts.cljs
index 80f52be51c..237a6b362b 100644
--- a/frontend/src/app/render_wasm/fallback_fonts.cljs
+++ b/common/src/app/common/fonts.cljs
@@ -2,15 +2,161 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
-(ns app.render-wasm.fallback-fonts
- "Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
- which (google) fallback fonts cover them. Pure data + pure fns — no browser
- or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the
- headless exporter (`app.renderer.wasm`) compute the SAME fallback set from
- the same source. Anything a host must fetch/upload for text to render
- belongs here, not in host code.")
+(ns app.common.fonts
+ "Host-agnostic font knowledge shared by every renderer: the google catalog
+ baked at compile time from `common/resources/fonts/gfonts.*.json`, the
+ font-id/uuid mapping, weight/style variant resolution, and the noto fallback
+ fonts a text's scripts and emoji need. Also the one family bundled with the
+ frontend, which is not a google font but resolves by the same rules.
+
+ Pure data + pure fns — no browser or Node dependencies — so the workspace and
+ the headless exporter resolve the SAME fonts from the same source. Anything a
+ host must fetch or upload for text to render belongs here, not in host code."
+ (:require-macros [app.common.fonts :refer [preload-gfonts]])
+ (:require
+ [app.common.data :as d]
+ [app.common.uuid :as uuid]
+ [cuerdas.core :as str]))
+
+;; --- GOOGLE FONTS CATALOG
+
+(def catalog
+ (preload-gfonts "fonts/gfonts.2025.11.28.json"))
+
+(def ^:private by-id
+ (reduce (fn [m font] (assoc m (:id font) font)) {} catalog))
+
+(def ^:private by-uuid
+ (reduce (fn [m font] (assoc m (:uuid font) font)) {} catalog))
+
+(defn gfont-id->uuid
+ "Maps a `gfont-` id to its (compilation-stable) catalog uuid, or nil."
+ [gfont-id]
+ (:uuid (get by-id gfont-id)))
+
+;; --- font-id -> wasm uuid
+
+(def ^:private custom-prefix "custom-")
+(def ^:private gfont-prefix "gfont-")
+
+(defn font-id->backend
+ "Which source a content font-id comes from: `:google` for `gfont-`,
+ `:custom` for `custom-`, `:builtin` for everything else (bundled
+ families, but also unknown or malformed ids — the same bucket
+ `font-id->uuid` maps to `uuid/zero`)."
+ [font-id]
+ (cond
+ (not (string? font-id)) :builtin
+ (str/starts-with? font-id gfont-prefix) :google
+ (str/starts-with? font-id custom-prefix) :custom
+ :else :builtin))
+
+(defn font-id->uuid
+ "Maps a content font-id to the uuid WASM keys fonts by:
+
+ - `gfont-` -> the catalog uuid,
+ - `custom-` -> that uuid,
+ - anything else (builtin, unknown, malformed) -> `uuid/zero`, which WASM
+ resolves to the default font."
+
+ [font-id]
+ (case (font-id->backend font-id)
+ :google (or (gfont-id->uuid font-id) uuid/zero)
+ :custom (or (uuid/parse* (subs font-id (count custom-prefix))) uuid/zero)
+ uuid/zero))
+
+;; --- proxy urls
+
+(def ^:private gstatic-prefix
+ "https://fonts.gstatic.com/s")
+
+(defn gstatic->proxy-url
+ [s base]
+ (let [base (str/rtrim (str base) "/")]
+ (str/replace (str s) gstatic-prefix base)))
+
+;; --- variant resolution
+
+(defn closest-variant
+ [variants target-weight target-style]
+ (when-let [target-weight (d/parse-integer target-weight)]
+ (let [result
+ (reduce
+ (fn [closest-match variant]
+ (let [weight (d/parse-integer (:weight variant))
+ distance (abs (- target-weight weight))
+ matches-style? (= target-style (:style variant))
+ current {:variant variant
+ :weight weight
+ :distance distance}]
+ (cond
+ ;; Exact match found
+ (and (zero? distance)
+ (if target-style matches-style? true))
+ (reduced current)
+
+ (nil? closest-match) current
+
+ ;; Update best match if this variant is closer or equal distance but higher weight
+ (or (< distance (:distance closest-match))
+ (and (= distance (:distance closest-match))
+ (> weight (:weight closest-match))))
+ current
+
+ ;; Same weight as the `closest-match` but the style matches `target-style`
+ (and (= weight (:weight closest-match)) matches-style?)
+ current
+
+ :else
+ closest-match)))
+ nil
+ variants)]
+ (:variant result))))
+
+(defn resolve-ttf-url
+ [font-uuid weight style]
+ (when-let [font (get by-uuid font-uuid)]
+ (let [style (if (zero? style) "normal" "italic")
+ variants (:variants font)]
+ (:ttf-url (or (closest-variant variants weight style)
+ (first variants))))))
+
+;; --- BUILTIN FONTS
+;;
+;; Bundled with the frontend, served from `/fonts/`. Shared so the
+;; workspace and the exporter upload the same TTF for a given weight/style.
+
+(def local-fonts
+ [{:id "sourcesanspro"
+ :name "Source Sans Pro"
+ :family "sourcesanspro"
+ :variants
+ [{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"}
+ {:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"}
+ {:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"}
+ {:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"}
+ {:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"}
+ {:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"}
+ {:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"}
+ {:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"}
+ {:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"}
+ {:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"}
+ {:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"}
+ {:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}])
+
+(defn resolve-ttf-file
+ "Builtin TTF file name for `weight` and `style` (0 normal, 1 italic), by the
+ same nearest-weight rule as the google catalog."
+ [weight style]
+ (let [variants (:variants (first local-fonts))]
+ (:ttf-url (or (closest-variant variants weight (if (zero? style) "normal" "italic"))
+ (first variants)))))
+
+;; --- FALLBACK FONTS
+;;
+;; Which scripts/emoji a text uses and which (google) fallback fonts cover them.
(def ^:private emoji-pattern
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
diff --git a/common/src/app/common/fressian.clj b/common/src/app/common/fressian.clj
index b16d233b42..3d0f41eb2f 100644
--- a/common/src/app/common/fressian.clj
+++ b/common/src/app/common/fressian.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.fressian
(:require
@@ -31,6 +31,11 @@
([^String s, ^String encoding]
(.getBytes s encoding)))
+;; --- DEPTH TRACKING
+
+(def ^:dynamic *read-depth* 0)
+(def ^:const max-read-depth 128)
+
;; --- LOW LEVEL FRESSIAN API
(defn write-object!
@@ -41,7 +46,13 @@
(defn read-object!
[^Reader r]
- (.readObject r))
+ (when (>= *read-depth* max-read-depth)
+ (throw (ex-info "maximum Fressian read depth exceeded"
+ {:type :validation
+ :code :max-read-depth-reached
+ :hint "maximum Fressian read depth exceeded"})))
+ (binding [*read-depth* (inc *read-depth*)]
+ (.readObject r)))
(defn write-tag!
([^Writer w ^String n]
diff --git a/common/src/app/common/generic_pool.clj b/common/src/app/common/generic_pool.clj
index 8a97667713..21481367a2 100644
--- a/common/src/app/common/generic_pool.clj
+++ b/common/src/app/common/generic_pool.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.generic-pool
(:refer-clojure :exclude [get])
diff --git a/common/src/app/common/geom/align.cljc b/common/src/app/common/geom/align.cljc
index 759972e8bf..959cf18018 100644
--- a/common/src/app/common/geom/align.cljc
+++ b/common/src/app/common/geom/align.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.align
(:require
diff --git a/common/src/app/common/geom/bounds_map.cljc b/common/src/app/common/geom/bounds_map.cljc
index f9cffe73ab..7a6b272e61 100644
--- a/common/src/app/common/geom/bounds_map.cljc
+++ b/common/src/app/common/geom/bounds_map.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.bounds-map
(:require
diff --git a/common/src/app/common/geom/grid.cljc b/common/src/app/common/geom/grid.cljc
index 298824af61..209869bb06 100644
--- a/common/src/app/common/geom/grid.cljc
+++ b/common/src/app/common/geom/grid.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.grid
(:require
diff --git a/common/src/app/common/geom/line.cljc b/common/src/app/common/geom/line.cljc
index a20a56b082..55f5151a00 100644
--- a/common/src/app/common/geom/line.cljc
+++ b/common/src/app/common/geom/line.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.line)
diff --git a/common/src/app/common/geom/matrix.cljc b/common/src/app/common/geom/matrix.cljc
index 3b5612bdd5..9e7239f01d 100644
--- a/common/src/app/common/geom/matrix.cljc
+++ b/common/src/app/common/geom/matrix.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.matrix
(:require
diff --git a/common/src/app/common/geom/modif_tree.cljc b/common/src/app/common/geom/modif_tree.cljc
index c222d5f89b..1741588889 100644
--- a/common/src/app/common/geom/modif_tree.cljc
+++ b/common/src/app/common/geom/modif_tree.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.modif-tree
(:require
diff --git a/common/src/app/common/geom/modifiers.cljc b/common/src/app/common/geom/modifiers.cljc
index 946bcff269..4114ce37e0 100644
--- a/common/src/app/common/geom/modifiers.cljc
+++ b/common/src/app/common/geom/modifiers.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.modifiers
(:require
diff --git a/common/src/app/common/geom/point.cljc b/common/src/app/common/geom/point.cljc
index f281488660..56123e7a39 100644
--- a/common/src/app/common/geom/point.cljc
+++ b/common/src/app/common/geom/point.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.point
(:refer-clojure :exclude [divide min max abs zero?])
diff --git a/common/src/app/common/geom/proportions.cljc b/common/src/app/common/geom/proportions.cljc
index cc6fe27b79..2245af2f0f 100644
--- a/common/src/app/common/geom/proportions.cljc
+++ b/common/src/app/common/geom/proportions.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.proportions
(:require
diff --git a/common/src/app/common/geom/rect.cljc b/common/src/app/common/geom/rect.cljc
index e9e5e79c9d..3699ae5bd3 100644
--- a/common/src/app/common/geom/rect.cljc
+++ b/common/src/app/common/geom/rect.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.rect
(:require
diff --git a/common/src/app/common/geom/shapes.cljc b/common/src/app/common/geom/shapes.cljc
index 66caaa9aed..4f0ac90fe9 100644
--- a/common/src/app/common/geom/shapes.cljc
+++ b/common/src/app/common/geom/shapes.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes
(:require
diff --git a/common/src/app/common/geom/shapes/bounds.cljc b/common/src/app/common/geom/shapes/bounds.cljc
index 794d578c7d..489455a368 100644
--- a/common/src/app/common/geom/shapes/bounds.cljc
+++ b/common/src/app/common/geom/shapes/bounds.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.bounds
(:require
diff --git a/common/src/app/common/geom/shapes/common.cljc b/common/src/app/common/geom/shapes/common.cljc
index 2f0017b344..d344a5d09f 100644
--- a/common/src/app/common/geom/shapes/common.cljc
+++ b/common/src/app/common/geom/shapes/common.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.common
(:require
diff --git a/common/src/app/common/geom/shapes/constraints.cljc b/common/src/app/common/geom/shapes/constraints.cljc
index 954c426306..ca92c93cc7 100644
--- a/common/src/app/common/geom/shapes/constraints.cljc
+++ b/common/src/app/common/geom/shapes/constraints.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.constraints
(:require
diff --git a/common/src/app/common/geom/shapes/corners.cljc b/common/src/app/common/geom/shapes/corners.cljc
index d43df249c9..32e63c3d4e 100644
--- a/common/src/app/common/geom/shapes/corners.cljc
+++ b/common/src/app/common/geom/shapes/corners.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.corners
(:require
diff --git a/common/src/app/common/geom/shapes/effects.cljc b/common/src/app/common/geom/shapes/effects.cljc
index 7e1096e6b8..f03875a398 100644
--- a/common/src/app/common/geom/shapes/effects.cljc
+++ b/common/src/app/common/geom/shapes/effects.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.effects)
diff --git a/common/src/app/common/geom/shapes/fit_frame.cljc b/common/src/app/common/geom/shapes/fit_frame.cljc
index c93e663496..65d529c420 100644
--- a/common/src/app/common/geom/shapes/fit_frame.cljc
+++ b/common/src/app/common/geom/shapes/fit_frame.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.fit-frame
(:require
diff --git a/common/src/app/common/geom/shapes/flex_layout.cljc b/common/src/app/common/geom/shapes/flex_layout.cljc
index 509dce1ba5..e2ed76c5ca 100644
--- a/common/src/app/common/geom/shapes/flex_layout.cljc
+++ b/common/src/app/common/geom/shapes/flex_layout.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.flex-layout
(:require
diff --git a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc
index b434d99fc9..4f4cc1895c 100644
--- a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc
+++ b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.flex-layout.bounds
(:require
diff --git a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc
index 7f71da31d6..33553d671f 100644
--- a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc
+++ b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.flex-layout.drop-area
(:require
diff --git a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc
index 4144fa7119..dfdd8cd094 100644
--- a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc
+++ b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.flex-layout.layout-data
(:require
diff --git a/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc b/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc
index 5d27cbb69a..9aaadea470 100644
--- a/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc
+++ b/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.flex-layout.modifiers
(:require
diff --git a/common/src/app/common/geom/shapes/flex_layout/params.cljc b/common/src/app/common/geom/shapes/flex_layout/params.cljc
index ffd75a2e6f..57a1fab63d 100644
--- a/common/src/app/common/geom/shapes/flex_layout/params.cljc
+++ b/common/src/app/common/geom/shapes/flex_layout/params.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.flex-layout.params
(:require
diff --git a/common/src/app/common/geom/shapes/flex_layout/positions.cljc b/common/src/app/common/geom/shapes/flex_layout/positions.cljc
index 6251650055..2dcb28d42d 100644
--- a/common/src/app/common/geom/shapes/flex_layout/positions.cljc
+++ b/common/src/app/common/geom/shapes/flex_layout/positions.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.flex-layout.positions
(:require
diff --git a/common/src/app/common/geom/shapes/grid_layout.cljc b/common/src/app/common/geom/shapes/grid_layout.cljc
index e81de6711c..4ac7b85284 100644
--- a/common/src/app/common/geom/shapes/grid_layout.cljc
+++ b/common/src/app/common/geom/shapes/grid_layout.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.grid-layout
(:require
diff --git a/common/src/app/common/geom/shapes/grid_layout/areas.cljc b/common/src/app/common/geom/shapes/grid_layout/areas.cljc
index 01b56901c6..31837c5692 100644
--- a/common/src/app/common/geom/shapes/grid_layout/areas.cljc
+++ b/common/src/app/common/geom/shapes/grid_layout/areas.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
;; Based on the code in:
;; https://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Rectangle_difference
diff --git a/common/src/app/common/geom/shapes/grid_layout/bounds.cljc b/common/src/app/common/geom/shapes/grid_layout/bounds.cljc
index caadff4766..f92e365db6 100644
--- a/common/src/app/common/geom/shapes/grid_layout/bounds.cljc
+++ b/common/src/app/common/geom/shapes/grid_layout/bounds.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.grid-layout.bounds
(:require
diff --git a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc
index 93322435c9..756eb2924e 100644
--- a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc
+++ b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
;; Each track has specified minimum and maximum sizing functions (which may be the same)
;; - Fixed
diff --git a/common/src/app/common/geom/shapes/grid_layout/params.cljc b/common/src/app/common/geom/shapes/grid_layout/params.cljc
index 6cc1a2f36c..ee3a779942 100644
--- a/common/src/app/common/geom/shapes/grid_layout/params.cljc
+++ b/common/src/app/common/geom/shapes/grid_layout/params.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.grid-layout.params
(:require
diff --git a/common/src/app/common/geom/shapes/grid_layout/positions.cljc b/common/src/app/common/geom/shapes/grid_layout/positions.cljc
index 3144e68f82..3b818a56d2 100644
--- a/common/src/app/common/geom/shapes/grid_layout/positions.cljc
+++ b/common/src/app/common/geom/shapes/grid_layout/positions.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.grid-layout.positions
(:require
diff --git a/common/src/app/common/geom/shapes/intersect.cljc b/common/src/app/common/geom/shapes/intersect.cljc
index 9338a0ad56..f63fc7f740 100644
--- a/common/src/app/common/geom/shapes/intersect.cljc
+++ b/common/src/app/common/geom/shapes/intersect.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.intersect
(:require
diff --git a/common/src/app/common/geom/shapes/min_size_layout.cljc b/common/src/app/common/geom/shapes/min_size_layout.cljc
index 57375098e9..49617ce6ea 100644
--- a/common/src/app/common/geom/shapes/min_size_layout.cljc
+++ b/common/src/app/common/geom/shapes/min_size_layout.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.min-size-layout
(:require
diff --git a/common/src/app/common/geom/shapes/pixel_precision.cljc b/common/src/app/common/geom/shapes/pixel_precision.cljc
index 30fdf89f50..82210de222 100644
--- a/common/src/app/common/geom/shapes/pixel_precision.cljc
+++ b/common/src/app/common/geom/shapes/pixel_precision.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.pixel-precision
(:require
diff --git a/common/src/app/common/geom/shapes/points.cljc b/common/src/app/common/geom/shapes/points.cljc
index a1646ee44f..1ce8eaec76 100644
--- a/common/src/app/common/geom/shapes/points.cljc
+++ b/common/src/app/common/geom/shapes/points.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.points
(:require
diff --git a/common/src/app/common/geom/shapes/rect.cljc b/common/src/app/common/geom/shapes/rect.cljc
index 7a93ab8acb..951a0869d6 100644
--- a/common/src/app/common/geom/shapes/rect.cljc
+++ b/common/src/app/common/geom/shapes/rect.cljc
@@ -2,6 +2,6 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.rect)
diff --git a/common/src/app/common/geom/shapes/strokes.cljc b/common/src/app/common/geom/shapes/strokes.cljc
index d5876995ff..567c6a9417 100644
--- a/common/src/app/common/geom/shapes/strokes.cljc
+++ b/common/src/app/common/geom/shapes/strokes.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.strokes)
diff --git a/common/src/app/common/geom/shapes/text.cljc b/common/src/app/common/geom/shapes/text.cljc
index 35e2359ef1..1507621e6d 100644
--- a/common/src/app/common/geom/shapes/text.cljc
+++ b/common/src/app/common/geom/shapes/text.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.text
(:require
diff --git a/common/src/app/common/geom/shapes/transforms.cljc b/common/src/app/common/geom/shapes/transforms.cljc
index d0ff71a609..7ba56f2197 100644
--- a/common/src/app/common/geom/shapes/transforms.cljc
+++ b/common/src/app/common/geom/shapes/transforms.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.transforms
(:require
diff --git a/common/src/app/common/geom/shapes/tree_seq.cljc b/common/src/app/common/geom/shapes/tree_seq.cljc
index 17da34e8f8..2c069815f3 100644
--- a/common/src/app/common/geom/shapes/tree_seq.cljc
+++ b/common/src/app/common/geom/shapes/tree_seq.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.shapes.tree-seq
(:require
diff --git a/common/src/app/common/geom/snap.cljc b/common/src/app/common/geom/snap.cljc
index 2b3db1bda6..979b9cfad6 100644
--- a/common/src/app/common/geom/snap.cljc
+++ b/common/src/app/common/geom/snap.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.geom.snap
(:require
diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc
index 8aa8a61a0f..a542870813 100644
--- a/common/src/app/common/i18n.cljc
+++ b/common/src/app/common/i18n.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.i18n
"Dummy i18n functions, to be used by code in common that needs translations.")
diff --git a/common/src/app/common/json.cljc b/common/src/app/common/json.cljc
index 0861c33c1a..079f14073d 100644
--- a/common/src/app/common/json.cljc
+++ b/common/src/app/common/json.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.json
(:refer-clojure :exclude [read clj->js js->clj])
diff --git a/common/src/app/common/logging.cljc b/common/src/app/common/logging.cljc
index ffed8cc09f..42e4f84566 100644
--- a/common/src/app/common/logging.cljc
+++ b/common/src/app/common/logging.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.logging
"A lightweight and multiplaform (clj & cljs) asynchronous by default
diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc
index a89aa633ab..b8b1274478 100644
--- a/common/src/app/common/logic/libraries.cljc
+++ b/common/src/app/common/logic/libraries.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.logic.libraries
#?(:cljs (:require-macros [app.common.logic.libraries :refer [shape-log container-log]]))
@@ -2345,7 +2345,12 @@
updated-sync-groups (into #{}
(keep #(ctk/resolve-sync-group (:type previous-shape) %))
updated-attrs)
- new-touched (set/union (or (:touched current-shape) #{}) updated-sync-groups)
+ text-sub-touched #{:text-content-text :text-content-attribute :text-content-structure}
+ new-touched (set/union (or (:touched current-shape) #{})
+ updated-sync-groups
+ (when (contains? updated-sync-groups :content-group)
+ (set/intersection (or (:touched previous-shape) #{})
+ text-sub-touched)))
roperations (into [{:type :set-touched :touched new-touched}] roperations)
uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)]
(cond-> changes
diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc
index 831fce4076..638b5abd7c 100644
--- a/common/src/app/common/logic/shapes.cljc
+++ b/common/src/app/common/logic/shapes.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.logic.shapes
(:require
diff --git a/common/src/app/common/logic/tokens.cljc b/common/src/app/common/logic/tokens.cljc
index ffec485d54..493d23d1f4 100644
--- a/common/src/app/common/logic/tokens.cljc
+++ b/common/src/app/common/logic/tokens.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.logic.tokens
(:require
diff --git a/common/src/app/common/logic/variant_properties.cljc b/common/src/app/common/logic/variant_properties.cljc
index 9a39b82400..fe87fa3e5e 100644
--- a/common/src/app/common/logic/variant_properties.cljc
+++ b/common/src/app/common/logic/variant_properties.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.logic.variant-properties
(:require
[app.common.data :as d]
diff --git a/common/src/app/common/math.cljc b/common/src/app/common/math.cljc
index 839079efee..41e3047880 100644
--- a/common/src/app/common/math.cljc
+++ b/common/src/app/common/math.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.math
"A collection of math utils."
diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc
index 3507ba5f59..8e6038ea11 100644
--- a/common/src/app/common/media.cljc
+++ b/common/src/app/common/media.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.media
"Media assets helpers (images, fonts, etc)"
@@ -22,6 +22,9 @@
"image/gif"
"image/svg+xml"})
+(def tempfile-types
+ (conj image-types "application/pdf" "application/zip"))
+
(defn format->extension
[format]
(case format
diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc
index 90da658f17..de96093155 100644
--- a/common/src/app/common/path_names.cljc
+++ b/common/src/app/common/path_names.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.path-names
(:require
diff --git a/common/src/app/common/perf.cljc b/common/src/app/common/perf.cljc
index 009db14237..16a82126c1 100644
--- a/common/src/app/common/perf.cljc
+++ b/common/src/app/common/perf.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.perf
(:require
diff --git a/common/src/app/common/pprint.cljc b/common/src/app/common/pprint.cljc
index 386054ed49..5154fda27d 100644
--- a/common/src/app/common/pprint.cljc
+++ b/common/src/app/common/pprint.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.pprint
(:refer-clojure :exclude [prn])
diff --git a/common/src/app/common/record.cljc b/common/src/app/common/record.cljc
index ee3b191baa..0a7b4a7f97 100644
--- a/common/src/app/common/record.cljc
+++ b/common/src/app/common/record.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.record
"A collection of helpers and macros for defien a penpot customized record types."
diff --git a/common/src/app/common/render_wasm/README.md b/common/src/app/common/render_wasm/README.md
new file mode 100644
index 0000000000..b153832e52
--- /dev/null
+++ b/common/src/app/common/render_wasm/README.md
@@ -0,0 +1,24 @@
+# `app.common.render-wasm.*`
+
+The host-agnostic ClojureScript side of the render-wasm binary protocol: byte
+layouts, memory helpers and serializers that turn Penpot shapes into the buffers
+`render-wasm` consumes.
+
+The workspace drives it from `app.render-wasm.*`, the headless exporter from
+`app.wasm.*` — same code underneath, so the two cannot drift.
+
+Font knowledge is *not* here even though both hosts need it for rendering: it is
+not specific to the wasm backend, so the google fonts catalog (baked from
+`common/resources/fonts/gfonts.*.json`), the bundled builtin family and the
+emoji/script fallback tables live in `app.common.fonts`. Likewise the image-id
+enumeration lives in `app.common.types.shape.images`.
+
+`shared.js` is not here: it is a per-build artifact, so each host compiles
+against the copy from its own render-wasm build and passes it to
+`wasm/init-serializers!` (see `app.render-wasm.api.enums`, `app.wasm.enums`).
+
+## Rules for anything added here
+
+**Nothing here may depend on a browser (no DOM, no WebGL, no app state) or on
+`frontend/src`.** Dependencies are `app.common.*` and this subtree only. It also
+has to run under plain Node — a `js/document` here breaks the exporter.
diff --git a/frontend/src/app/render_wasm/api/props.cljs b/common/src/app/common/render_wasm/api/props.cljs
similarity index 89%
rename from frontend/src/app/render_wasm/api/props.cljs
rename to common/src/app/common/render_wasm/api/props.cljs
index 26bbdadc3a..816c154cab 100644
--- a/frontend/src/app/render_wasm/api/props.cljs
+++ b/common/src/app/common/render_wasm/api/props.cljs
@@ -2,9 +2,9 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
-(ns app.render-wasm.api.props
+(ns app.common.render-wasm.api.props
"Browser-free WASM shape property setters, shared by the workspace render
orchestrator (`app.render-wasm.api`) and the headless exporter
(`app.wasm.serialize`).
@@ -15,34 +15,17 @@
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
(:require
[app.common.math :as mth]
+ [app.common.render-wasm.helpers :as h]
+ [app.common.render-wasm.mem :as mem]
+ [app.common.render-wasm.serializers :as sr]
+ [app.common.render-wasm.serializers.color :as sr-clr]
+ [app.common.render-wasm.wasm :as wasm]
[app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl]
- [app.common.types.path :as path]
- [app.render-wasm.helpers :as h]
- [app.render-wasm.mem :as mem]
- [app.render-wasm.mem.heap32 :as mem.h32]
- [app.render-wasm.serializers :as sr]
- [app.render-wasm.serializers.color :as sr-clr]
- [app.render-wasm.wasm :as wasm]))
+ [app.common.types.path :as path]))
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
-(def ^:const UUID-U8-SIZE 16)
-
-(defn set-shape-children
- "Uploads the child id list via the dynamic `_set_children` path (handles any
- count). The browser also has fixed-arity fast paths for the incremental edit
- path; this dynamic one is the shared/batch version."
- [children]
- (let [children (into [] (filter uuid?) children)]
- (if (empty? children)
- (h/call wasm/internal-module "_set_children_0")
- (let [heap (mem/get-heap-u32)
- size (mem/get-alloc-size children UUID-U8-SIZE)
- offset (mem/alloc->offset-32 size)]
- (reduce (fn [o id] (mem.h32/write-uuid o heap id)) offset children)
- (h/call wasm/internal-module "_set_children")))))
-
(defn set-shape-bool-type
[bool-type]
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
diff --git a/common/src/app/common/render_wasm/api/upload.cljs b/common/src/app/common/render_wasm/api/upload.cljs
new file mode 100644
index 0000000000..eb2e42f57c
--- /dev/null
+++ b/common/src/app/common/render_wasm/api/upload.cljs
@@ -0,0 +1,453 @@
+;; 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 SUBSIDIARY SL
+
+(ns app.common.render-wasm.api.upload
+ "Enlarged per-shape + multi-shape structural upload for WASM cold load.
+
+ Writes a binary batch consumed by `_set_shapes_batch`. Remaining
+ host-specific attrs (image bytes, text, path, grid tracks) are applied
+ afterwards via the existing per-shape setters."
+ (:require
+ [app.common.buffer :as buf]
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.common.render-wasm.helpers :as h]
+ [app.common.render-wasm.mem :as mem]
+ [app.common.render-wasm.serializers :as sr]
+ [app.common.render-wasm.serializers.color :as sr-clr]
+ [app.common.render-wasm.wasm :as wasm]
+ [app.common.types.fills :as types.fills]
+ [app.common.types.fills.impl :as types.fills.impl]
+ [app.common.types.shape.layout :as ctl]
+ [app.common.uuid :as uuid]))
+
+(def ^:const BASE-PROPS-SIZE 104)
+(def ^:const FLAG-CLIP-CONTENT 0x01)
+(def ^:const FLAG-HIDDEN 0x02)
+
+(def ^:const SECTION-CHILDREN 0x01)
+(def ^:const SECTION-BLUR-LAYER 0x02)
+(def ^:const SECTION-BLUR-BG 0x04)
+(def ^:const SECTION-SHADOWS 0x08)
+(def ^:const SECTION-MASKED 0x10)
+(def ^:const SECTION-BOOL-TYPE 0x20)
+(def ^:const SECTION-GROW-TYPE 0x40)
+(def ^:const SECTION-LAYOUT-ITEM 0x80)
+(def ^:const SECTION-FLEX 0x100)
+(def ^:const SECTION-FILLS 0x200)
+(def ^:const SECTION-STROKES 0x400)
+
+;; Stroke header before RawFillData (must match upload_batch.rs).
+(def ^:const STROKE-HEADER-U8-SIZE 36)
+(def ^:const STROKE-ALIGN-CENTER 0)
+(def ^:const STROKE-ALIGN-INNER 1)
+(def ^:const STROKE-ALIGN-OUTER 2)
+
+(defn- write-uuid!
+ [dview offset id]
+ (buf/write-uuid dview offset id)
+ (+ offset 16))
+
+(defn- write-base-props!
+ "Write the 104-byte RawBasePropsData at `offset`. Returns next offset."
+ [dview offset shape]
+ (let [id (dm/get-prop shape :id)
+ parent-id (get shape :parent-id)
+ shape-type (dm/get-prop shape :type)
+ clip-content (if (= shape-type :frame)
+ (not (get shape :show-content))
+ false)
+ hidden (get shape :hidden false)
+ flags (cond-> 0
+ clip-content (bit-or FLAG-CLIP-CONTENT)
+ hidden (bit-or FLAG-HIDDEN))
+ blend-mode (sr/translate-blend-mode (get shape :blend-mode))
+ constraint-h (sr/translate-constraint-h (or (get shape :constraints-h) :none))
+ constraint-v (sr/translate-constraint-v (or (get shape :constraints-v) :none))
+ opacity (d/nilv (get shape :opacity) 1.0)
+ rotation (d/nilv (get shape :rotation) 0.0)
+ transform (get shape :transform)
+ [ta tb tc td te tf]
+ (if (some? transform)
+ [(dm/get-prop transform :a)
+ (dm/get-prop transform :b)
+ (dm/get-prop transform :c)
+ (dm/get-prop transform :d)
+ (dm/get-prop transform :e)
+ (dm/get-prop transform :f)]
+ [1.0 0.0 0.0 1.0 0.0 0.0])
+ selrect (get shape :selrect)
+ [sx1 sy1 sx2 sy2]
+ (if (some? selrect)
+ [(dm/get-prop selrect :x1)
+ (dm/get-prop selrect :y1)
+ (dm/get-prop selrect :x2)
+ (dm/get-prop selrect :y2)]
+ [0.0 0.0 0.0 0.0])
+ r1 (d/nilv (get shape :r1) 0.0)
+ r2 (d/nilv (get shape :r2) 0.0)
+ r3 (d/nilv (get shape :r3) 0.0)
+ r4 (d/nilv (get shape :r4) 0.0)]
+
+ (write-uuid! dview offset id)
+ (write-uuid! dview (+ offset 16) (d/nilv parent-id uuid/zero))
+ (buf/write-u8 dview (+ offset 32) (sr/translate-shape-type shape-type))
+ (buf/write-u8 dview (+ offset 33) flags)
+ (buf/write-u8 dview (+ offset 34) blend-mode)
+ (buf/write-u8 dview (+ offset 35) constraint-h)
+ (buf/write-u8 dview (+ offset 36) constraint-v)
+ (buf/write-f32 dview (+ offset 40) opacity)
+ (buf/write-f32 dview (+ offset 44) rotation)
+ (buf/write-f32 dview (+ offset 48) ta)
+ (buf/write-f32 dview (+ offset 52) tb)
+ (buf/write-f32 dview (+ offset 56) tc)
+ (buf/write-f32 dview (+ offset 60) td)
+ (buf/write-f32 dview (+ offset 64) te)
+ (buf/write-f32 dview (+ offset 68) tf)
+ (buf/write-f32 dview (+ offset 72) sx1)
+ (buf/write-f32 dview (+ offset 76) sy1)
+ (buf/write-f32 dview (+ offset 80) sx2)
+ (buf/write-f32 dview (+ offset 84) sy2)
+ (buf/write-f32 dview (+ offset 88) r1)
+ (buf/write-f32 dview (+ offset 92) r2)
+ (buf/write-f32 dview (+ offset 96) r3)
+ (buf/write-f32 dview (+ offset 100) r4)
+ (+ offset BASE-PROPS-SIZE)))
+
+(defn- write-blur!
+ [dview offset blur]
+ (buf/write-u8 dview offset (if (get blur :hidden) 1 0))
+ (buf/write-f32 dview (+ offset 4) (get blur :value 0))
+ (+ offset 8))
+
+(defn- write-shadow!
+ [dview offset shadow]
+ (let [color (get shadow :color)
+ rgba (sr-clr/hex->u32argb (get color :color)
+ (get color :opacity))]
+ (buf/write-u32 dview offset rgba)
+ (buf/write-f32 dview (+ offset 4) (get shadow :blur 0))
+ (buf/write-f32 dview (+ offset 8) (get shadow :spread 0))
+ (buf/write-f32 dview (+ offset 12) (get shadow :offset-x 0))
+ (buf/write-f32 dview (+ offset 16) (get shadow :offset-y 0))
+ (buf/write-u8 dview (+ offset 20) (sr/translate-shadow-style (get shadow :style)))
+ (buf/write-u8 dview (+ offset 21) (if (get shadow :hidden) 1 0))
+ (+ offset 24)))
+
+(defn- write-flex!
+ [dview offset shape]
+ (let [dir (-> (get shape :layout-flex-dir :row)
+ (sr/translate-layout-flex-dir))
+ gap (get shape :layout-gap)
+ row-gap (get gap :row-gap 0)
+ column-gap (get gap :column-gap 0)
+ align-items (-> (get shape :layout-align-items) sr/translate-layout-align-items)
+ align-content (-> (get shape :layout-align-content) sr/translate-layout-align-content)
+ justify-items (-> (get shape :layout-justify-items) sr/translate-layout-justify-items)
+ justify-content (-> (get shape :layout-justify-content) sr/translate-layout-justify-content)
+ wrap-type (-> (get shape :layout-wrap-type) sr/translate-layout-wrap-type)
+ padding (get shape :layout-padding)
+ padding-top (get padding :p1 0)
+ padding-right (get padding :p2 0)
+ padding-bottom (get padding :p3 0)
+ padding-left (get padding :p4 0)]
+ (buf/write-u8 dview offset dir)
+ (buf/write-u8 dview (+ offset 1) align-items)
+ (buf/write-u8 dview (+ offset 2) align-content)
+ (buf/write-u8 dview (+ offset 3) justify-items)
+ (buf/write-u8 dview (+ offset 4) justify-content)
+ (buf/write-u8 dview (+ offset 5) wrap-type)
+ (buf/write-f32 dview (+ offset 8) row-gap)
+ (buf/write-f32 dview (+ offset 12) column-gap)
+ (buf/write-f32 dview (+ offset 16) padding-top)
+ (buf/write-f32 dview (+ offset 20) padding-right)
+ (buf/write-f32 dview (+ offset 24) padding-bottom)
+ (buf/write-f32 dview (+ offset 28) padding-left)
+ (+ offset 32)))
+
+(defn- write-layout-item!
+ [dview offset shape]
+ (let [margins (get shape :layout-item-margin)
+ margin-top (get margins :m1 0)
+ margin-right (get margins :m2 0)
+ margin-bottom (get margins :m3 0)
+ margin-left (get margins :m4 0)
+ h-sizing (-> (get shape :layout-item-h-sizing) sr/translate-layout-sizing)
+ v-sizing (-> (get shape :layout-item-v-sizing) sr/translate-layout-sizing)
+ align-self (-> (get shape :layout-item-align-self) sr/translate-align-self)
+ max-h (get shape :layout-item-max-h)
+ min-h (get shape :layout-item-min-h)
+ max-w (get shape :layout-item-max-w)
+ min-w (get shape :layout-item-min-w)
+ is-absolute (boolean (get shape :layout-item-absolute))
+ z-index (get shape :layout-item-z-index)
+ flags (cond-> 0
+ (some? max-h) (bit-or 0x01)
+ (some? min-h) (bit-or 0x02)
+ (some? max-w) (bit-or 0x04)
+ (some? min-w) (bit-or 0x08)
+ is-absolute (bit-or 0x10))]
+ (buf/write-f32 dview offset margin-top)
+ (buf/write-f32 dview (+ offset 4) margin-right)
+ (buf/write-f32 dview (+ offset 8) margin-bottom)
+ (buf/write-f32 dview (+ offset 12) margin-left)
+ (buf/write-u8 dview (+ offset 16) (d/nilv h-sizing 0))
+ (buf/write-u8 dview (+ offset 17) (d/nilv v-sizing 0))
+ (buf/write-u8 dview (+ offset 18) flags)
+ (buf/write-u8 dview (+ offset 19) (d/nilv align-self 0))
+ (buf/write-f32 dview (+ offset 20) (d/nilv max-h 0))
+ (buf/write-f32 dview (+ offset 24) (d/nilv min-h 0))
+ (buf/write-f32 dview (+ offset 28) (d/nilv max-w 0))
+ (buf/write-f32 dview (+ offset 32) (d/nilv min-w 0))
+ (buf/write-i32 dview (+ offset 36) (d/nilv z-index 0))
+ (+ offset 40)))
+
+(defn- write-fills-section!
+ "Write fills in the same layout as `_set_shape_fills`:
+ [u8 n][u8;3 pad][n × FILL-U8-SIZE]. Returns next offset."
+ [dview offset fills]
+ (let [fills (types.fills/coerce (or fills []))
+ byte-size (types.fills/get-byte-size fills)
+ ;; write-to expects a Uint32Array heap + u32 element offset
+ heap-u32 (js/Uint32Array. (.-buffer dview))
+ u32-off (quot offset 4)]
+ (types.fills/write-to fills heap-u32 u32-off)
+ (+ offset byte-size)))
+
+(defn- write-stroke-fill!
+ [dview offset stroke]
+ (let [opacity (or (:stroke-opacity stroke) 1.0)
+ color (:stroke-color stroke)
+ gradient (:stroke-color-gradient stroke)
+ image (:stroke-image stroke)]
+ (cond
+ (some? gradient)
+ (types.fills.impl/write-gradient-fill offset dview opacity gradient)
+
+ (some? image)
+ (types.fills.impl/write-image-fill offset dview opacity image)
+
+ (some? color)
+ (types.fills.impl/write-solid-fill offset dview opacity color)
+
+ :else
+ (types.fills.impl/write-solid-fill offset dview 0.0 "#000000"))))
+
+(defn- write-stroke!
+ [dview offset stroke]
+ (let [width (or (:stroke-width stroke) 1.0)
+ style (-> stroke :stroke-style sr/translate-stroke-style)
+ align (case (:stroke-alignment stroke)
+ :inner STROKE-ALIGN-INNER
+ :outer STROKE-ALIGN-OUTER
+ STROKE-ALIGN-CENTER)
+ cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
+ cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
+ dash (or (:stroke-dash stroke) -1)
+ gap (or (:stroke-gap stroke) -1)
+ per-side? (boolean (:stroke-per-side stroke))
+ top (or (:stroke-width-top stroke) width)
+ right (or (:stroke-width-right stroke) width)
+ bottom (or (:stroke-width-bottom stroke) width)
+ left (or (:stroke-width-left stroke) width)
+ has-sides? (and per-side? (not= top right bottom left))]
+ (buf/write-f32 dview offset width)
+ (buf/write-u8 dview (+ offset 4) style)
+ (buf/write-u8 dview (+ offset 5) align)
+ (buf/write-u8 dview (+ offset 6) (d/nilv cap-start 0))
+ (buf/write-u8 dview (+ offset 7) (d/nilv cap-end 0))
+ (buf/write-f32 dview (+ offset 8) dash)
+ (buf/write-f32 dview (+ offset 12) gap)
+ (buf/write-u8 dview (+ offset 16) (if has-sides? 1 0))
+ (buf/write-f32 dview (+ offset 20) top)
+ (buf/write-f32 dview (+ offset 24) right)
+ (buf/write-f32 dview (+ offset 28) bottom)
+ (buf/write-f32 dview (+ offset 32) left)
+ (write-stroke-fill! dview (+ offset STROKE-HEADER-U8-SIZE) stroke)
+ (+ offset STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE)))
+
+(defn- visible-strokes
+ [shape]
+ (let [type (dm/get-prop shape :type)]
+ (if (= type :group)
+ []
+ (into [] (remove :hidden) (or (get shape :strokes) [])))))
+
+(defn- write-strokes-section!
+ [dview offset strokes]
+ (buf/write-u32 dview offset (count strokes))
+ (reduce (fn [o s] (write-stroke! dview o s))
+ (+ offset 4)
+ strokes))
+
+(defn write-shape-payload!
+ "Serialize one shape's structural payload into `dview` starting at `offset`
+ (payload only — no length prefix). Returns the offset after the payload.
+
+ Options:
+ - `:include-layout?` — when true, emit FLEX + LAYOUT-ITEM (workspace cold load).
+ - `:include-fills-strokes?` — when true, emit FILLS + STROKES sections."
+ [dview offset shape {:keys [include-layout? include-fills-strokes?]
+ :or {include-layout? false
+ include-fills-strokes? false}}]
+ (let [shape-type (dm/get-prop shape :type)
+ children (into [] (filter uuid?) (get shape :shapes))
+ blur (get shape :blur)
+ bg-blur (get shape :background-blur)
+ shadows (or (get shape :shadow) [])
+ masked? (and (= shape-type :group) (boolean (get shape :masked-group)))
+ bool-type (when (= shape-type :bool) (get shape :bool-type))
+ grow-type (when (= shape-type :text) (get shape :grow-type))
+ flex? (and include-layout? (ctl/flex-layout? shape))
+ layout-item? include-layout?
+ strokes (when include-fills-strokes? (visible-strokes shape))
+
+ mask (cond-> 0
+ true (bit-or SECTION-CHILDREN)
+ (some? blur) (bit-or SECTION-BLUR-LAYER)
+ (some? bg-blur) (bit-or SECTION-BLUR-BG)
+ (seq shadows) (bit-or SECTION-SHADOWS)
+ (= shape-type :group) (bit-or SECTION-MASKED)
+ (some? bool-type) (bit-or SECTION-BOOL-TYPE)
+ (some? grow-type) (bit-or SECTION-GROW-TYPE)
+ flex? (bit-or SECTION-FLEX)
+ layout-item? (bit-or SECTION-LAYOUT-ITEM)
+ include-fills-strokes? (bit-or SECTION-FILLS)
+ include-fills-strokes? (bit-or SECTION-STROKES))
+
+ offset (write-base-props! dview offset shape)
+ _ (buf/write-u32 dview offset mask)
+ offset (+ offset 4)
+
+ offset (let [o offset]
+ (buf/write-u32 dview o (count children))
+ (reduce (fn [o id] (write-uuid! dview o id))
+ (+ o 4)
+ children))
+
+ offset (cond-> offset
+ (some? blur)
+ (as-> o (write-blur! dview o blur)))
+
+ offset (cond-> offset
+ (some? bg-blur)
+ (as-> o (write-blur! dview o bg-blur)))
+
+ offset (cond-> offset
+ (seq shadows)
+ (as-> o
+ (do
+ (buf/write-u32 dview o (count shadows))
+ (reduce (fn [o s] (write-shadow! dview o s))
+ (+ o 4)
+ shadows))))
+
+ offset (cond-> offset
+ (= shape-type :group)
+ (as-> o
+ (do (buf/write-u8 dview o (if masked? 1 0))
+ (+ o 4))))
+
+ offset (cond-> offset
+ (some? bool-type)
+ (as-> o
+ (do (buf/write-u8 dview o (sr/translate-bool-type bool-type))
+ (+ o 4))))
+
+ offset (cond-> offset
+ (some? grow-type)
+ (as-> o
+ (do (buf/write-u8 dview o (sr/translate-grow-type grow-type))
+ (+ o 4))))
+
+ ;; FLEX before LAYOUT-ITEM (Rust clears layout on flex)
+ offset (cond-> offset
+ flex?
+ (as-> o (write-flex! dview o shape)))
+
+ offset (cond-> offset
+ layout-item?
+ (as-> o (write-layout-item! dview o shape)))
+
+ offset (cond-> offset
+ include-fills-strokes?
+ (as-> o (write-fills-section! dview o (get shape :fills))))
+
+ offset (cond-> offset
+ include-fills-strokes?
+ (as-> o (write-strokes-section! dview o strokes)))]
+ offset))
+
+(defn- payload-byte-size
+ [shape {:keys [include-layout? include-fills-strokes?]
+ :or {include-layout? false include-fills-strokes? false}}]
+ (let [children (into [] (filter uuid?) (get shape :shapes))
+ shadows (or (get shape :shadow) [])
+ shape-type (dm/get-prop shape :type)
+ blur (get shape :blur)
+ bg-blur (get shape :background-blur)
+ flex? (and include-layout? (ctl/flex-layout? shape))
+ fills-size (if include-fills-strokes?
+ (types.fills/get-byte-size (types.fills/coerce (or (get shape :fills) [])))
+ 0)
+ strokes (when include-fills-strokes? (visible-strokes shape))
+ strokes-size (if include-fills-strokes?
+ (+ 4 (* (count strokes)
+ (+ STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE)))
+ 0)]
+ (+ BASE-PROPS-SIZE
+ 4 ;; mask
+ (+ 4 (* 16 (count children)))
+ (if (some? blur) 8 0)
+ (if (some? bg-blur) 8 0)
+ (if (seq shadows) (+ 4 (* 24 (count shadows))) 0)
+ (if (= shape-type :group) 4 0)
+ (if (and (= shape-type :bool) (some? (get shape :bool-type))) 4 0)
+ (if (and (= shape-type :text) (some? (get shape :grow-type))) 4 0)
+ (if flex? 32 0)
+ (if include-layout? 40 0)
+ fills-size
+ strokes-size)))
+
+(defn- encode-shape-record
+ "Returns a Uint8Array: [u32 payload_len][payload]."
+ [shape opts]
+ (let [capacity (+ 4 (payload-byte-size shape opts))
+ buffer (js/ArrayBuffer. capacity)
+ dview (js/DataView. buffer)
+ end (write-shape-payload! dview 4 shape opts)
+ payload-len (- end 4)]
+ (assert (= end capacity)
+ (str "upload record size mismatch: wrote " end " expected " capacity))
+ (buf/write-u32 dview 0 payload-len)
+ (js/Uint8Array. buffer 0 end)))
+
+(defn flush-shapes-batch!
+ "Upload `shapes` as one `_set_shapes_batch` call.
+ `opts` passed to each record writer (`:include-layout?`,
+ `:include-fills-strokes?`)."
+ [shapes opts]
+ (when (and (wasm/live?) (seq shapes))
+ (let [records (mapv #(encode-shape-record % opts) shapes)
+ total (reduce (fn [acc ^js u8] (+ acc (.-byteLength u8))) 4 records)
+ offset (mem/alloc total)
+ heap (mem/get-heap-u8)
+ dview (js/DataView. (.-buffer heap))]
+ (buf/write-u32 dview offset (count records))
+ (reduce (fn [o ^js u8]
+ (.set heap u8 o)
+ (+ o (.-byteLength u8)))
+ (+ offset 4)
+ records)
+ (h/call wasm/internal-module "_set_shapes_batch")
+ nil)))
+
+(defn set-shape-upload!
+ "Single-shape structural upload (enlarged blob, one FFI)."
+ ([shape]
+ (set-shape-upload! shape {:include-layout? false}))
+ ([shape opts]
+ (flush-shapes-batch! [shape] opts)))
diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj
new file mode 100644
index 0000000000..0be29aacf9
--- /dev/null
+++ b/common/src/app/common/render_wasm/enums.clj
@@ -0,0 +1,54 @@
+;; 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 SUBSIDIARY SL
+
+(ns app.common.render-wasm.enums
+ "Serializer enum table from `shared.js`")
+
+(def ^:private serializer-exports
+ [["raster-format" "RasterFormat"]
+ ["blur-type" "RawBlurType"]
+ ["blend-mode" "RawBlendMode"]
+ ["bool-type" "RawBoolType"]
+ ["font-style" "RawFontStyle"]
+ ["flex-direction" "RawFlexDirection"]
+ ["grid-direction" "RawGridDirection"]
+ ["grow-type" "RawGrowType"]
+ ["align-items" "RawAlignItems"]
+ ["align-self" "RawAlignSelf"]
+ ["align-content" "RawAlignContent"]
+ ["justify-items" "RawJustifyItems"]
+ ["justify-content" "RawJustifyContent"]
+ ["justify-self" "RawJustifySelf"]
+ ["wrap-type" "RawWrapType"]
+ ["grid-track-type" "RawGridTrackType"]
+ ["shadow-style" "RawShadowStyle"]
+ ["guide-kind" "RawGuideKind"]
+ ["stroke-style" "RawStrokeStyle"]
+ ["stroke-cap" "RawStrokeCap"]
+ ["shape-type" "RawShapeType"]
+ ["constraint-h" "RawConstraintH"]
+ ["constraint-v" "RawConstraintV"]
+ ["sizing" "RawSizing"]
+ ["vertical-align" "RawVerticalAlign"]
+ ["fill-data" "RawFillData"]
+ ["text-align" "RawTextAlign"]
+ ["text-direction" "RawTextDirection"]
+ ["text-decoration" "RawTextDecoration"]
+ ["text-transform" "RawTextTransform"]
+ ["multiple-state" "MultipleState"]
+ ["transform-entry-kind" "RawTransformEntryKind"]
+ ["segment-data" "RawSegmentData"]
+ ["stroke-linecap" "RawStrokeLineCap"]
+ ["stroke-linejoin" "RawStrokeLineJoin"]
+ ["fill-rule" "RawFillRule"]])
+
+(defmacro serializers
+ [alias]
+ (let [alias (name alias)]
+ `(cljs.core/js-obj
+ ~@(mapcat (fn [[key export]]
+ [key (symbol alias export)])
+ serializer-exports))))
diff --git a/frontend/src/app/render_wasm/helpers.cljc b/common/src/app/common/render_wasm/helpers.cljc
similarity index 92%
rename from frontend/src/app/render_wasm/helpers.cljc
rename to common/src/app/common/render_wasm/helpers.cljc
index 452e3f1eb4..d96cc9f990 100644
--- a/frontend/src/app/render_wasm/helpers.cljc
+++ b/common/src/app/common/render_wasm/helpers.cljc
@@ -2,10 +2,10 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
-(ns app.render-wasm.helpers
- #?(:cljs (:require-macros [app.render-wasm.helpers]))
+(ns app.common.render-wasm.helpers
+ #?(:cljs (:require-macros [app.common.render-wasm.helpers]))
(:require [app.common.data :as d]))
(def error-code
diff --git a/frontend/src/app/render_wasm/mem.cljs b/common/src/app/common/render_wasm/mem.cljs
similarity index 96%
rename from frontend/src/app/render_wasm/mem.cljs
rename to common/src/app/common/render_wasm/mem.cljs
index d90d2f7fa7..43a4435f68 100644
--- a/frontend/src/app/render_wasm/mem.cljs
+++ b/common/src/app/common/render_wasm/mem.cljs
@@ -2,13 +2,13 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
-(ns app.render-wasm.mem
+(ns app.common.render-wasm.mem
(:require
[app.common.buffer :as buf]
- [app.render-wasm.helpers :as h]
- [app.render-wasm.wasm :as wasm]))
+ [app.common.render-wasm.helpers :as h]
+ [app.common.render-wasm.wasm :as wasm]))
(defn ->offset-32
"Convert a 8-bit (1 byte) offset to a 32-bit (4 bytes) offset"
diff --git a/frontend/src/app/render_wasm/mem/heap32.cljs b/common/src/app/common/render_wasm/mem/heap32.cljs
similarity index 95%
rename from frontend/src/app/render_wasm/mem/heap32.cljs
rename to common/src/app/common/render_wasm/mem/heap32.cljs
index 07ac6b749f..a07986eb20 100644
--- a/frontend/src/app/render_wasm/mem/heap32.cljs
+++ b/common/src/app/common/render_wasm/mem/heap32.cljs
@@ -2,9 +2,9 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
-(ns app.render-wasm.mem.heap32
+(ns app.common.render-wasm.mem.heap32
"A memory write helpers that uses 32 bits addressed offsets."
(:require
[app.common.data.macros :as dm]
diff --git a/common/src/app/common/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs
new file mode 100644
index 0000000000..868c0321f5
--- /dev/null
+++ b/common/src/app/common/render_wasm/serialize_shape.cljs
@@ -0,0 +1,40 @@
+;; 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 SUBSIDIARY SL
+
+(ns app.common.render-wasm.serialize-shape
+ "Single source of truth for the host-independent part of serializing a whole
+ shape into the WASM design state.
+
+ Both batch serializers call this so they can't drift:
+ - the workspace `app.render-wasm.api/set-object` (browser), and
+ - the headless exporter `app.wasm.serialize/set-shape!` (Node).
+
+ Structural attrs (base, children, blur, shadows, masked, bool, grow) go
+ through the enlarged `_set_shapes_batch` upload. Path geometry stays on the
+ chunked path FFI. Host-specific parts remain in each caller AFTER this runs:
+ - fills / strokes image bytes (records may already be in cold-load batch),
+ - text content (fonts),
+ - svg-raw markup (browser React),
+ - layout (grid/flex — workspace cold-load batches flex+item via upload;
+ incremental edits still use `set-shape-layout` / `set-layout-data`).
+
+ The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
+ dispatching per changed key through the same underlying `props` setters."
+ (:require
+ [app.common.render-wasm.api.props :as props]
+ [app.common.render-wasm.api.upload :as upload]))
+
+(defn serialize-shape!
+ "Applies every host-independent WASM property of `shape`."
+ [shape]
+ (let [type (get shape :type)]
+ (upload/set-shape-upload! shape {:include-layout? false})
+
+ (when (some? (get shape :svg-attrs))
+ (props/set-shape-svg-attrs (get shape :svg-attrs)))
+
+ (when (and (contains? #{:path :bool} type) (some? (get shape :content)))
+ (props/set-shape-path-content (get shape :content)))))
diff --git a/frontend/src/app/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs
similarity index 97%
rename from frontend/src/app/render_wasm/serializers.cljs
rename to common/src/app/common/render_wasm/serializers.cljs
index 83ecf4f4d2..02fe6632d2 100644
--- a/frontend/src/app/render_wasm/serializers.cljs
+++ b/common/src/app/common/render_wasm/serializers.cljs
@@ -2,18 +2,18 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
- (ns app.render-wasm.serializers
+ (ns app.common.render-wasm.serializers
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
+ [app.common.render-wasm.serializers.color :as sr-clr]
+ [app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as clr]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
- [app.render-wasm.serializers.color :as sr-clr]
- [app.render-wasm.wasm :as wasm]
[cuerdas.core :as str]))
(defn u8
@@ -116,13 +116,13 @@
(defn translate-constraint-h
[type]
(let [values (unchecked-get wasm/serializers "constraint-h")
- default 5] ;; TODO: fix code in rust so we have a proper None variant
+ default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name type)) default)))
(defn translate-constraint-v
[type]
(let [values (unchecked-get wasm/serializers "constraint-v")
- default 5] ;; TODO: fix code in rust so we have a proper None variant
+ default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name type)) default)))
(defn translate-bool-type
diff --git a/frontend/src/app/render_wasm/serializers/color.cljs b/common/src/app/common/render_wasm/serializers/color.cljs
similarity index 90%
rename from frontend/src/app/render_wasm/serializers/color.cljs
rename to common/src/app/common/render_wasm/serializers/color.cljs
index 7d5d28b8c7..c3ef27fdfc 100644
--- a/frontend/src/app/render_wasm/serializers/color.cljs
+++ b/common/src/app/common/render_wasm/serializers/color.cljs
@@ -1,4 +1,4 @@
-(ns app.render-wasm.serializers.color
+(ns app.common.render-wasm.serializers.color
(:require
[app.common.math :as mth]))
diff --git a/frontend/src/app/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs
similarity index 90%
rename from frontend/src/app/render_wasm/text_content.cljs
rename to common/src/app/common/render_wasm/text_content.cljs
index 457a633066..1ffbebb054 100644
--- a/frontend/src/app/render_wasm/text_content.cljs
+++ b/common/src/app/common/render_wasm/text_content.cljs
@@ -2,25 +2,26 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
-(ns app.render-wasm.text-content
+(ns app.common.render-wasm.text-content
"Single source of truth for writing a text shape's content into the WASM design
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
- identical for the workspace and the headless exporter — only *font resolution*
- differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts
- catalog + custom variants). So the byte-writing lives here and font resolution
- is injected via the `opts` map passed to `write-shape-text!`.
+ identical for the workspace and the headless exporter, and so is the font-id
+ -> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs —
+ the workspace has a loaded fonts DB, the exporter does not — so that part is
+ injected via the `opts` map passed to `write-shape-text!`.
Fully portable (no store/DOM/React), so it runs under Node too."
(:require
[app.common.data :as d]
+ [app.common.fonts :as cfnt]
+ [app.common.render-wasm.helpers :as h]
+ [app.common.render-wasm.mem :as mem]
+ [app.common.render-wasm.serializers :as sr]
+ [app.common.render-wasm.wasm :as wasm]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.uuid :as uuid]
- [app.render-wasm.helpers :as h]
- [app.render-wasm.mem :as mem]
- [app.render-wasm.serializers :as sr]
- [app.render-wasm.wasm :as wasm]
[cuerdas.core :as str]))
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
@@ -169,13 +170,15 @@
"Writes one paragraph's spans + text into WASM and appends it to the current
shape via `_set_shape_text_content`.
- `opts` injects host-specific font resolution:
- - `:normalize-font-id` (string font-id -> wasm uuid) — required in practice,
+ `opts` injects host-specific font handling:
+ - `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared
+ `cfnt/font-id->uuid`, which is what both hosts want — a host only
+ overrides it if it keys its font store some other way,
- `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a
fonts DB (workspace); default to identity (the exporter resolves variants
differently / not at all)."
[spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span]
- :or {normalize-font-id identity
+ :or {normalize-font-id cfnt/font-id->uuid
normalize-paragraph identity
normalize-span (fn [span _paragraph] span)}}]
(let [paragraph (normalize-paragraph paragraph)
diff --git a/frontend/src/app/render_wasm/wasm.cljs b/common/src/app/common/render_wasm/wasm.cljs
similarity index 56%
rename from frontend/src/app/render_wasm/wasm.cljs
rename to common/src/app/common/render_wasm/wasm.cljs
index 933b200530..81b21ca5d7 100644
--- a/frontend/src/app/render_wasm/wasm.cljs
+++ b/common/src/app/common/render_wasm/wasm.cljs
@@ -2,10 +2,9 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
-(ns app.render-wasm.wasm
- (:require ["./api/shared.js" :as shared]))
+(ns app.common.render-wasm.wasm)
(defonce internal-frame-id nil)
(defonce internal-frame-type 0)
@@ -59,47 +58,26 @@
WebGL is ready before re-init finishes."
[]
(set! internal-frame-id nil)
+ (set! internal-frame-type 0)
(set! canvas nil)
(set! canvas-snapshot nil)
(set! gl-context-handle nil)
(set! gl-context nil)
(set! context-initialized? false))
-(defonce serializers
- #js {:raster-format shared/RasterFormat
- :blur-type shared/RawBlurType
- :blend-mode shared/RawBlendMode
- :bool-type shared/RawBoolType
- :font-style shared/RawFontStyle
- :flex-direction shared/RawFlexDirection
- :grid-direction shared/RawGridDirection
- :grow-type shared/RawGrowType
- :align-items shared/RawAlignItems
- :align-self shared/RawAlignSelf
- :align-content shared/RawAlignContent
- :justify-items shared/RawJustifyItems
- :justify-content shared/RawJustifyContent
- :justify-self shared/RawJustifySelf
- :wrap-type shared/RawWrapType
- :grid-track-type shared/RawGridTrackType
- :shadow-style shared/RawShadowStyle
- :guide-kind shared/RawGuideKind
- :stroke-style shared/RawStrokeStyle
- :stroke-cap shared/RawStrokeCap
- :shape-type shared/RawShapeType
- :constraint-h shared/RawConstraintH
- :constraint-v shared/RawConstraintV
- :sizing shared/RawSizing
- :vertical-align shared/RawVerticalAlign
- :fill-data shared/RawFillData
- :text-align shared/RawTextAlign
- :text-direction shared/RawTextDirection
- :text-decoration shared/RawTextDecoration
- :text-transform shared/RawTextTransform
- :multiple-state shared/MultipleState
- :transform-entry-kind shared/RawTransformEntryKind
- :segment-data shared/RawSegmentData
- :stroke-linecap shared/RawStrokeLineCap
- :stroke-linejoin shared/RawStrokeLineJoin
- :fill-rule shared/RawFillRule})
+(defonce serializers nil)
+
+(defn init-serializers!
+ "Binds the enum table produced by the `enums/serializers` macro."
+ [table]
+ (let [missing (array)]
+ (doseq [key (js/Object.keys table)]
+ (when (undefined? (unchecked-get table key))
+ (.push missing key)))
+
+ (when (pos? (alength missing))
+ (throw (ex-info "stale or incomplete render-wasm shared.js"
+ {:missing (vec missing)})))
+
+ (set! serializers table)))
diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc
index fba8169bcd..3ecfd15c37 100644
--- a/common/src/app/common/schema.cljc
+++ b/common/src/app/common/schema.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.schema
(:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys select-keys])
diff --git a/common/src/app/common/schema/desc_js_like.cljc b/common/src/app/common/schema/desc_js_like.cljc
index f3d772541a..c837789224 100644
--- a/common/src/app/common/schema/desc_js_like.cljc
+++ b/common/src/app/common/schema/desc_js_like.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.schema.desc-js-like
(:require
diff --git a/common/src/app/common/schema/desc_native.cljc b/common/src/app/common/schema/desc_native.cljc
index 003400e101..b226b5bdc1 100644
--- a/common/src/app/common/schema/desc_native.cljc
+++ b/common/src/app/common/schema/desc_native.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.schema.desc-native
(:require
diff --git a/common/src/app/common/schema/generators.cljc b/common/src/app/common/schema/generators.cljc
index 1268d08ae4..c5069f400b 100644
--- a/common/src/app/common/schema/generators.cljc
+++ b/common/src/app/common/schema/generators.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.schema.generators
(:refer-clojure :exclude [set subseq uuid filter map let boolean vector keyword int double not-empty])
diff --git a/common/src/app/common/schema/messages.cljc b/common/src/app/common/schema/messages.cljc
index 912acc7686..90ee3a21ce 100644
--- a/common/src/app/common/schema/messages.cljc
+++ b/common/src/app/common/schema/messages.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.schema.messages
(:require
diff --git a/common/src/app/common/schema/openapi.cljc b/common/src/app/common/schema/openapi.cljc
index 03291a24da..d9ccd2be36 100644
--- a/common/src/app/common/schema/openapi.cljc
+++ b/common/src/app/common/schema/openapi.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.schema.openapi
(:require
diff --git a/common/src/app/common/schema/registry.cljc b/common/src/app/common/schema/registry.cljc
index ad1fcffdbc..edb262f55c 100644
--- a/common/src/app/common/schema/registry.cljc
+++ b/common/src/app/common/schema/registry.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.schema.registry
(:require
diff --git a/common/src/app/common/schema/test.cljc b/common/src/app/common/schema/test.cljc
index b800651cb3..1ad26ddbc6 100644
--- a/common/src/app/common/schema/test.cljc
+++ b/common/src/app/common/schema/test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.schema.test
(:refer-clojure :exclude [for])
diff --git a/common/src/app/common/spec.cljc b/common/src/app/common/spec.cljc
index 26f84a9a07..30d97be4c9 100644
--- a/common/src/app/common/spec.cljc
+++ b/common/src/app/common/spec.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.spec
"Data validation & assertion helpers."
diff --git a/common/src/app/common/svg.cljc b/common/src/app/common/svg.cljc
index 69d35c3ad1..313c2e674f 100644
--- a/common/src/app/common/svg.cljc
+++ b/common/src/app/common/svg.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.svg
(:require
diff --git a/common/src/app/common/svg/path.cljc b/common/src/app/common/svg/path.cljc
index 98dcfc6e04..628c024e6e 100644
--- a/common/src/app/common/svg/path.cljc
+++ b/common/src/app/common/svg/path.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.svg.path
#?(:clj
diff --git a/common/src/app/common/svg/path/Parser.java b/common/src/app/common/svg/path/Parser.java
index 482cf1676d..4c653c6349 100644
--- a/common/src/app/common/svg/path/Parser.java
+++ b/common/src/app/common/svg/path/Parser.java
@@ -2,7 +2,7 @@
* Performance focused pure java implementation of the
* SVG path parser.
*
- * @author KALEIDOS INC Sucursal en España SL
+ * @author KALEIDOS SUBSIDIARY SL
* @license MPL-2.0
*/
diff --git a/common/src/app/common/svg/path/arc_to_bezier.js b/common/src/app/common/svg/path/arc_to_bezier.js
index f7911a8b24..25a102e638 100644
--- a/common/src/app/common/svg/path/arc_to_bezier.js
+++ b/common/src/app/common/svg/path/arc_to_bezier.js
@@ -5,7 +5,7 @@
* functions by https://github.com/fontello/svgpath used as reference
* implementation for tests
*
- * @author KALEIDOS INC Sucursal en España SL
+ * @author KALEIDOS SUBSIDIARY SL
* @license MIT License
*/
diff --git a/common/src/app/common/svg/path/legacy_parser2.cljc b/common/src/app/common/svg/path/legacy_parser2.cljc
index 8af87feb90..3b28178902 100644
--- a/common/src/app/common/svg/path/legacy_parser2.cljc
+++ b/common/src/app/common/svg/path/legacy_parser2.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.svg.path.legacy-parser2
"The second SVG Path parser implementation.
diff --git a/common/src/app/common/svg/path/parser.js b/common/src/app/common/svg/path/parser.js
index dd102f5d55..1007180f6b 100644
--- a/common/src/app/common/svg/path/parser.js
+++ b/common/src/app/common/svg/path/parser.js
@@ -2,7 +2,7 @@
* Performance focused pure javascript implementation of the
* SVG path parser.
*
- * @author KALEIDOS INC Sucursal en España SL
+ * @author KALEIDOS SUBSIDIARY SL
* @license MPL-2.0
*/
diff --git a/common/src/app/common/test_helpers/components.cljc b/common/src/app/common/test_helpers/components.cljc
index 2d214e8c78..248b08619a 100644
--- a/common/src/app/common/test_helpers/components.cljc
+++ b/common/src/app/common/test_helpers/components.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.test-helpers.components
(:require
diff --git a/common/src/app/common/test_helpers/compositions.cljc b/common/src/app/common/test_helpers/compositions.cljc
index 5089e8b3e6..a4fa6b748b 100644
--- a/common/src/app/common/test_helpers/compositions.cljc
+++ b/common/src/app/common/test_helpers/compositions.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.test-helpers.compositions
(:require
diff --git a/common/src/app/common/test_helpers/files.cljc b/common/src/app/common/test_helpers/files.cljc
index 027b095a5f..448e0d2562 100644
--- a/common/src/app/common/test_helpers/files.cljc
+++ b/common/src/app/common/test_helpers/files.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.test-helpers.files
(:require
diff --git a/common/src/app/common/test_helpers/ids_map.cljc b/common/src/app/common/test_helpers/ids_map.cljc
index 60649cf31d..9648b42c37 100644
--- a/common/src/app/common/test_helpers/ids_map.cljc
+++ b/common/src/app/common/test_helpers/ids_map.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.test-helpers.ids-map
(:require
diff --git a/common/src/app/common/test_helpers/shapes.cljc b/common/src/app/common/test_helpers/shapes.cljc
index 11f1a79acf..21e51d377d 100644
--- a/common/src/app/common/test_helpers/shapes.cljc
+++ b/common/src/app/common/test_helpers/shapes.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.test-helpers.shapes
(:require
diff --git a/common/src/app/common/test_helpers/tokens.cljc b/common/src/app/common/test_helpers/tokens.cljc
index 02becdc27a..81697ac4b8 100644
--- a/common/src/app/common/test_helpers/tokens.cljc
+++ b/common/src/app/common/test_helpers/tokens.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.test-helpers.tokens
(:require
diff --git a/common/src/app/common/test_helpers/variants.cljc b/common/src/app/common/test_helpers/variants.cljc
index bf2e6fc973..8bbadf4c40 100644
--- a/common/src/app/common/test_helpers/variants.cljc
+++ b/common/src/app/common/test_helpers/variants.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.test-helpers.variants
(:require
@@ -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 {}}}]
diff --git a/common/src/app/common/text.cljc b/common/src/app/common/text.cljc
index 6cd32e61b6..45f7b6f534 100644
--- a/common/src/app/common/text.cljc
+++ b/common/src/app/common/text.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.text
"Legacy editor helpers (draftjs).
diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc
index 5410ee6a49..080d609761 100644
--- a/common/src/app/common/time.cljc
+++ b/common/src/app/common/time.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
#_{:clj-kondo/ignore [:unused-namespace]}
(ns app.common.time
diff --git a/common/src/app/common/transit.cljc b/common/src/app/common/transit.cljc
index 21b73cf942..dc3e37b380 100644
--- a/common/src/app/common/transit.cljc
+++ b/common/src/app/common/transit.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.transit
(:require
diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc
index a6938c4a6f..58cd5d970f 100644
--- a/common/src/app/common/types/color.cljc
+++ b/common/src/app/common/types/color.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.color
(:refer-clojure :exclude [test])
diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc
index 37a090217b..deb5379e72 100644
--- a/common/src/app/common/types/component.cljc
+++ b/common/src/app/common/types/component.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.component
(:require
diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc
index 0c19718641..cc7ea9bd07 100644
--- a/common/src/app/common/types/container.cljc
+++ b/common/src/app/common/types/container.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.container
(:require
diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc
index 8c97f4cce0..bcc4ddc44d 100644
--- a/common/src/app/common/types/file.cljc
+++ b/common/src/app/common/types/file.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.file
(:require
@@ -28,7 +28,8 @@
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.types.text :as txt]
- [app.common.types.tokens-lib :refer [schema:tokens-lib]]
+ [app.common.types.tokens-lib :as ctob]
+ [app.common.types.tokens-status :as ctos]
[app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as cty]
[app.common.uuid :as uuid]
@@ -86,7 +87,15 @@
[:components {:optional true} schema:components]
[:typographies {:optional true} schema:typographies]
[:plugin-data {:optional true} schema:plugin-data]
- [:tokens-lib {:optional true} schema:tokens-lib]])
+ [:tokens-source {:optional true} ::sm/uuid] ;; Forward-compat: UUID of external library containing tokens-lib (full support in follow-up PR)
+ [:tokens-lib {:optional true} ctob/schema:tokens-lib]
+ [:tokens-status {:optional true} ctos/schema:tokens-status]])
+
+(def schema:file-metadata
+ [:map {:title "Metadata"}
+ [:storage-ref-id {:optional true} ::sm/uuid]
+ [:generated-by {:optional true} :string]
+ [:referer {:optional true} :string]])
(def schema:file
"A schema for validate a file data structure; data is optional
@@ -106,6 +115,7 @@
[:data {:optional true} schema:data]
[:version :int]
[:features ::cfeat/features]
+ [:metadata {:optional true} schema:file-metadata]
[:migrations {:optional true}
[::sm/set {:ordered true} :string]]])
@@ -123,6 +133,9 @@
(def check-file-media
(sm/check-fn schema:media))
+(def decode-file-metadata
+ (sm/decoder schema:file-metadata sm/json-transformer))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -306,6 +319,7 @@
(update-objects-tree container f)))))
;; Asset helpers
+
(defn find-component-file
[file libraries component-file]
(if (and (some? file) (= component-file (:id file)))
@@ -912,8 +926,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
diff --git a/common/src/app/common/types/fills.cljc b/common/src/app/common/types/fills.cljc
index 62bc999b55..1c9e100e4a 100644
--- a/common/src/app/common/types/fills.cljc
+++ b/common/src/app/common/types/fills.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.fills
(:refer-clojure :exclude [assoc update])
diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc
index 571b5577fd..32f806568f 100644
--- a/common/src/app/common/types/fills/impl.cljc
+++ b/common/src/app/common/types/fills/impl.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.fills.impl
(:require
diff --git a/common/src/app/common/types/font.cljc b/common/src/app/common/types/font.cljc
index 0e90a676b8..fb7c737171 100644
--- a/common/src/app/common/types/font.cljc
+++ b/common/src/app/common/types/font.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.font
(:require
diff --git a/common/src/app/common/types/grid.cljc b/common/src/app/common/types/grid.cljc
index 41220417bd..4fa1e8bf4e 100644
--- a/common/src/app/common/types/grid.cljc
+++ b/common/src/app/common/types/grid.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.grid
(:require
diff --git a/common/src/app/common/types/library.cljc b/common/src/app/common/types/library.cljc
index edc441cc91..62e703898a 100644
--- a/common/src/app/common/types/library.cljc
+++ b/common/src/app/common/types/library.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.library
"Exposes file library type data helpers.
diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc
index ad53f4f594..77289a2b1e 100644
--- a/common/src/app/common/types/objects_map.cljc
+++ b/common/src/app/common/types/objects_map.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.objects-map
"Implements a specialized map-like data structure for store an UUID =>
diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc
index 8a79598d74..f601cf2971 100644
--- a/common/src/app/common/types/organization.cljc
+++ b/common/src/app/common/types/organization.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.organization
(:require
diff --git a/common/src/app/common/types/page.cljc b/common/src/app/common/types/page.cljc
index 5704c84e87..b4631a3698 100644
--- a/common/src/app/common/types/page.cljc
+++ b/common/src/app/common/types/page.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.page
(:refer-clojure :exclude [empty?])
diff --git a/common/src/app/common/types/pages_list.cljc b/common/src/app/common/types/pages_list.cljc
index f55443287e..85e73c78d3 100644
--- a/common/src/app/common/types/pages_list.cljc
+++ b/common/src/app/common/types/pages_list.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.pages-list
(:require
diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc
index 2b1188682f..a198e57cfa 100644
--- a/common/src/app/common/types/path.cljc
+++ b/common/src/app/common/types/path.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path
(:require
@@ -18,6 +18,7 @@
[app.common.types.path.helpers :as helpers]
[app.common.types.path.impl :as impl]
[app.common.types.path.segment :as segment]
+ [app.common.types.path.selection :as selection]
[app.common.types.path.shape-to-path :as stp]
[app.common.types.path.subpath :as subpath]))
@@ -209,6 +210,120 @@
(let [content (impl/path-data content)]
(segment/get-points content)))
+(defn segment-entries
+ "Selectable path segments with their command index and endpoints."
+ [content]
+ (let [content (impl/path-data content)]
+ (segment/segment-entries content)))
+
+(defn single-line?
+ "True when the content is a single straight segment: a move-to
+ followed by exactly one line-to."
+ [content]
+ (and (some? content)
+ (= 2 (count content))
+ (= :move-to (:command (nth content 0)))
+ (= :line-to (:command (nth content 1)))))
+
+(defn close-loops
+ "Closes subpaths whose endpoints meet and returns PathData."
+ [content]
+ (-> (subpath/close-loops content)
+ (impl/from-plain)))
+
+(defn extract-content
+ "Extracts selected segments and segments between selected nodes into new
+ subpaths."
+ [content {:keys [nodes segments]}]
+ (let [content (impl/path-data content)
+ nodes (or nodes #{})
+ segments (or segments #{})
+ selected? (fn [{:keys [index from-index to-index]}]
+ (or (contains? segments index)
+ (and (contains? nodes from-index)
+ (contains? nodes to-index))))
+ entries (filterv selected? (segment/segment-entries content))
+ plain (loop [entries (seq entries)
+ prev nil
+ result (transient [])]
+ (if-let [{:keys [from from-index to segment] :as entry} (first entries)]
+ (let [result (cond-> result
+ (not= from-index (:to-index prev))
+ (conj! {:command :move-to
+ :params {:x (:x from) :y (:y from)}}))
+ result (conj! result
+ (if (= :close-path (:command segment))
+ {:command :line-to
+ :params {:x (:x to) :y (:y to)}}
+ segment))]
+ (recur (next entries) entry result))
+ (persistent! result)))]
+ (-> (close-subpaths (impl/path-data plain))
+ (close-loops))))
+
+(defn splice-content
+ "Appends sub-content as new subpaths."
+ [content sub-content]
+ (impl/path-data (into (vec content) (vec sub-content))))
+
+(defn- move-segment-end
+ "Moves a segment endpoint and its incoming handle by `delta`."
+ [segment {dx :x dy :y}]
+ (cond-> (-> segment
+ (update-in [:params :x] + dx)
+ (update-in [:params :y] + dy))
+ (= :curve-to (:command segment))
+ (-> (update-in [:params :c2x] + dx)
+ (update-in [:params :c2y] + dy))))
+
+(defn- segment->end
+ "Returns the command arriving at an entry's end node as a drawable segment."
+ [{:keys [to segment]}]
+ (if (= :close-path (:command segment))
+ {:command :line-to :params {:x (:x to) :y (:y to)}}
+ segment))
+
+(defn- reverse-segment
+ "Reverses a segment toward `from`, swapping curve handles."
+ [segment {fx :x fy :y}]
+ (if (= :curve-to (:command segment))
+ (let [{:keys [c1x c1y c2x c2y]} (:params segment)]
+ {:command :curve-to
+ :params {:x fx :y fy :c1x c2x :c1y c2y :c2x c1x :c2y c1y}})
+ {:command :line-to :params {:x fx :y fy}}))
+
+(defn duplicate-node-content
+ "Copies a node and its incident segments, keeping their far ends attached.
+ Returns copied content and the relative indices of the new node."
+ [content index node-offset]
+ (let [content (impl/path-data content)
+ entries (segment-entries content)
+ incident (filterv #(or (= index (:to-index %))
+ (= index (:from-index %)))
+ entries)]
+ (if (seq incident)
+ (reduce (fn [{:keys [content selected]} {:keys [from to to-index segment] :as entry}]
+ (let [incoming? (= index to-index)
+ start (if incoming? from to)
+ end (if incoming?
+ (segment->end entry)
+ (reverse-segment segment from))
+ end (cond-> end
+ (some? node-offset) (move-segment-end node-offset))]
+ {:content (conj content
+ {:command :move-to
+ :params {:x (:x start) :y (:y start)}}
+ end)
+ :selected (conj selected (inc (count content)))}))
+ {:content [] :selected #{}}
+ incident)
+ (when-let [{:keys [x y]} (:params (nth content index nil))]
+ {:content [{:command :move-to
+ :params (if (some? node-offset)
+ {:x (+ x (:x node-offset)) :y (+ y (:y node-offset))}
+ {:x x :y y})}]
+ :selected #{0}}))))
+
(defn calc-selrect
"Calculate selrect from a content. The content can be in a PathData
instance or plain vector of segments."
@@ -279,6 +394,11 @@
[points & {:keys [close]}]
(segment/points->content points :close close))
+(defn smooth-points->content
+ "Fits smooth path content through `points`."
+ [points tolerance]
+ (segment/smooth-points->content points tolerance))
+
(defn closest-point
"Returns the closest point in the path to position, at a given precision."
[content position precision]
@@ -304,6 +424,30 @@
(let [content (impl/path-data content)]
(segment/split-segments content points value)))
+(defn is-curve-point?
+ "True when a node has at least one visible handler."
+ [content point]
+ (let [content (impl/path-data content)]
+ (boolean (segment/is-curve? content point))))
+
+(defn collapse-handler
+ "Collapses a handler onto its node and simplifies flat curves to lines."
+ [content index prefix]
+ (let [content (impl/path-data content)]
+ (segment/collapse-handler content index prefix)))
+
+(defn toggle-segment-curve
+ "Toggles a segment between a line and a curve."
+ [content index]
+ (let [content (impl/path-data content)]
+ (segment/toggle-segment-curve content index)))
+
+(defn remove-segments
+ "Removes segments, opening their subpaths and dropping empty ones."
+ [content indices]
+ (let [content (impl/path-data content)]
+ (segment/remove-segments content indices)))
+
(defn remove-nodes
"Removes the given points from content, reconstructing paths as needed."
[content points]
@@ -323,10 +467,49 @@
(segment/join-nodes content points)))
(defn separate-nodes
- "Removes the segments between the given points."
- [content points]
+ "Removes segments between points or splits one node into offset open ends."
+ ([content points]
+ (let [content (impl/path-data content)]
+ (segment/separate-nodes content points)))
+ ([content points offset]
+ (let [content (impl/path-data content)]
+ (segment/separate-nodes content points offset))))
+
+(defn flip-content
+ "Flips selected nodes and handles across their bounding box."
+ [content indices axis]
(let [content (impl/path-data content)]
- (segment/separate-nodes content points)))
+ (selection/flip-content content indices axis)))
+
+(defn align-content
+ "Aligns selected nodes and handles within their bounding box."
+ [content indices axis]
+ (let [content (impl/path-data content)]
+ (selection/align-content content indices axis)))
+
+(defn distribute-content
+ "Distributes selected nodes evenly along `axis`."
+ [content indices axis]
+ (let [content (impl/path-data content)]
+ (selection/distribute-content content indices axis)))
+
+(defn set-nodes-coordinate
+ "Sets one coordinate of selected nodes and their handles."
+ [content indices axis value]
+ (let [content (impl/path-data content)]
+ (selection/set-nodes-coordinate content indices axis value)))
+
+(defn set-handler-points
+ "Moves each handler in `pts` to its target point."
+ [content pts]
+ (let [content (impl/path-data content)]
+ (selection/set-handler-points content pts)))
+
+(defn translate-selected-nodes
+ "Moves selected nodes and their handles by `delta`."
+ [content indices delta]
+ (let [content (impl/path-data content)]
+ (selection/translate-selected-nodes content indices delta)))
(defn- calc-bool-content*
"Calculate the boolean content from shape and objects. Returns plain
diff --git a/common/src/app/common/types/path/bool.cljc b/common/src/app/common/types/path/bool.cljc
index d2fdf01cb7..2193798529 100644
--- a/common/src/app/common/types/path/bool.cljc
+++ b/common/src/app/common/types/path/bool.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.bool
(:require
diff --git a/common/src/app/common/types/path/fit.cljc b/common/src/app/common/types/path/fit.cljc
new file mode 100644
index 0000000000..486822a79f
--- /dev/null
+++ b/common/src/app/common/types/path/fit.cljc
@@ -0,0 +1,209 @@
+;; 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.common.types.path.fit
+ "Curve fitting helpers."
+ (:require
+ [app.common.geom.point :as gpt]
+ [app.common.math :as mth]
+ [app.common.types.path.helpers :as helpers]))
+
+(defn- chord-length-params
+ "Returns normalized chord-length parameters for `points`."
+ [points]
+ (let [dists (->> (map gpt/distance points (rest points))
+ (reductions + 0)
+ (vec))
+ total (peek dists)]
+ (if (mth/almost-zero? total)
+ (let [n (max 1 (dec (count points)))]
+ (mapv #(/ (double %) n) (range (count points))))
+ (mapv #(/ % total) dists))))
+
+(defn fit-cubic
+ "Fits one cubic through `points` with fixed endpoints and tangents."
+ ([points tan1 tan2]
+ (let [points (vec points)]
+ (fit-cubic points (chord-length-params points) tan1 tan2)))
+ ([points params tan1 tan2]
+ (let [points (vec points)
+ p0 (first points)
+ p3 (peek points)
+
+ [c00 c01 c11 x0 x1]
+ (reduce
+ (fn [[c00 c01 c11 x0 x1] [point u]]
+ (let [u' (- 1.0 u)
+ b0 (* u' u' u')
+ b1 (* 3.0 u u' u')
+ b2 (* 3.0 u u u')
+ b3 (* u u u)
+ a1 (gpt/scale tan1 b1)
+ a2 (gpt/scale tan2 b2)
+ tmp (-> point
+ (gpt/subtract (gpt/scale p0 (+ b0 b1)))
+ (gpt/subtract (gpt/scale p3 (+ b2 b3))))]
+ [(+ c00 (gpt/dot a1 a1))
+ (+ c01 (gpt/dot a1 a2))
+ (+ c11 (gpt/dot a2 a2))
+ (+ x0 (gpt/dot a1 tmp))
+ (+ x1 (gpt/dot a2 tmp))]))
+ [0.0 0.0 0.0 0.0 0.0]
+ (map vector points params))
+
+ det-c (- (* c00 c11) (* c01 c01))
+ alpha1 (when-not (mth/almost-zero? det-c)
+ (/ (- (* x0 c11) (* x1 c01)) det-c))
+ alpha2 (when-not (mth/almost-zero? det-c)
+ (/ (- (* c00 x1) (* c01 x0)) det-c))
+
+ chord (gpt/distance p0 p3)
+ epsilon (* 0.000001 chord)
+
+ [alpha1 alpha2]
+ (if (or (nil? alpha1) (nil? alpha2)
+ (< alpha1 epsilon) (< alpha2 epsilon))
+ [(/ chord 3.0) (/ chord 3.0)]
+ [alpha1 alpha2])]
+
+ [(gpt/add p0 (gpt/scale tan1 alpha1))
+ (gpt/add p3 (gpt/scale tan2 alpha2))])))
+
+(defn- curve-d1
+ "Returns the first derivative at `t`."
+ [[start end h1 h2] t]
+ (let [t' (- 1.0 t)
+ a (* 3.0 t' t')
+ b (* 6.0 t' t)
+ c (* 3.0 t t)]
+ (gpt/point (+ (* a (- (:x h1) (:x start)))
+ (* b (- (:x h2) (:x h1)))
+ (* c (- (:x end) (:x h2))))
+ (+ (* a (- (:y h1) (:y start)))
+ (* b (- (:y h2) (:y h1)))
+ (* c (- (:y end) (:y h2)))))))
+
+(defn- curve-d2
+ "Returns the second derivative at `t`."
+ [[start end h1 h2] t]
+ (let [t' (- 1.0 t)]
+ (gpt/point (+ (* 6.0 t' (+ (:x h2) (* -2.0 (:x h1)) (:x start)))
+ (* 6.0 t (+ (:x end) (* -2.0 (:x h2)) (:x h1))))
+ (+ (* 6.0 t' (+ (:y h2) (* -2.0 (:y h1)) (:y start)))
+ (* 6.0 t (+ (:y end) (* -2.0 (:y h2)) (:y h1)))))))
+
+(defn- refine-parameter
+ "Moves `u` toward the closest point on `curve`."
+ [curve point u]
+ (let [d (gpt/subtract (helpers/curve-values curve u) point)
+ d1 (curve-d1 curve u)
+ d2 (curve-d2 curve u)
+ den (+ (gpt/dot d1 d1) (gpt/dot d d2))]
+ (if (mth/almost-zero? den)
+ u
+ (mth/clamp (- u (/ (gpt/dot d d1) den)) 0.0 1.0))))
+
+(defn- max-fit-error
+ "Returns the largest interior fit error and its index."
+ [points params curve]
+ (let [n (count points)]
+ (loop [i 1
+ max-err 0.0
+ split (quot n 2)]
+ (if (>= i (dec n))
+ [max-err split]
+ (let [d (gpt/subtract (helpers/curve-values curve (nth params i))
+ (nth points i))
+ err (gpt/dot d d)]
+ (if (> err max-err)
+ (recur (inc i) err i)
+ (recur (inc i) max-err split)))))))
+
+(def ^:private ^:const max-fit-iterations 4)
+
+(defn- fit-curve*
+ "Fits one or more curves through at least two points."
+ [points tan1 tan2 tol2]
+ (let [n (count points)
+ p0 (first points)
+ p3 (peek points)]
+ (if (= n 2)
+ (let [alpha (/ (gpt/distance p0 p3) 3.0)]
+ [[p0 p3
+ (gpt/add p0 (gpt/scale tan1 alpha))
+ (gpt/add p3 (gpt/scale tan2 alpha))]])
+
+ (let [params (chord-length-params points)
+ [h1 h2] (fit-cubic points params tan1 tan2)
+ curve [p0 p3 h1 h2]
+ [err split] (max-fit-error points params curve)
+
+ [curve err split]
+ (if (and (> err tol2) (<= err (* 16.0 tol2)))
+ (loop [it 0
+ params params
+ curve curve
+ err err
+ split split]
+ (if (or (>= it max-fit-iterations) (<= err tol2))
+ [curve err split]
+ (let [params (mapv #(refine-parameter curve %1 %2) points params)
+ [h1 h2] (fit-cubic points params tan1 tan2)
+ curve [p0 p3 h1 h2]
+ [err split] (max-fit-error points params curve)]
+ (recur (inc it) params curve err split))))
+ [curve err split])]
+
+ (if (<= err tol2)
+ [curve]
+ (let [split (mth/clamp split 1 (- n 2))
+ center (let [v (gpt/to-vec (nth points (inc split))
+ (nth points (dec split)))]
+ (if (mth/almost-zero? (gpt/length v))
+ (gpt/unit (gpt/to-vec (nth points split)
+ (nth points (dec split))))
+ (gpt/unit v)))]
+ (into (fit-curve* (subvec points 0 (inc split)) tan1 center tol2)
+ (fit-curve* (subvec points split) (gpt/negate center) tan2 tol2))))))))
+
+(def ^:private default-corner-angle 60.0)
+
+(defn- corner-index?
+ "True when point `i` turns more than `corner-angle` degrees."
+ [points i corner-angle]
+ (let [v-in (gpt/to-vec (nth points (dec i)) (nth points i))
+ v-out (gpt/to-vec (nth points i) (nth points (inc i)))]
+ (and (not (mth/almost-zero? (gpt/length v-in)))
+ (not (mth/almost-zero? (gpt/length v-out)))
+ (> (gpt/angle-with-other v-in v-out) corner-angle))))
+
+(defn fit-curve
+ "Fits chained cubic curves through `points` within `tolerance`."
+ ([points tolerance]
+ (fit-curve points tolerance default-corner-angle))
+ ([points tolerance corner-angle]
+ (let [points (reduce (fn [acc point]
+ (if (and (seq acc)
+ (< (gpt/distance (peek acc) point) 0.01))
+ acc
+ (conj acc point)))
+ []
+ points)
+ n (count points)]
+ (when (>= n 2)
+ (let [tol2 (* (double tolerance) (double tolerance))
+ corners (into [] (filter #(corner-index? points % corner-angle))
+ (range 1 (dec n)))
+ bounds (concat [0] corners [(dec n)])]
+ (into []
+ (mapcat (fn [[a b]]
+ (let [span (subvec points a (inc b))
+ m (count span)]
+ (when (>= m 2)
+ (let [tan1 (gpt/unit (gpt/to-vec (nth span 0) (nth span 1)))
+ tan2 (gpt/unit (gpt/to-vec (nth span (dec m)) (nth span (- m 2))))]
+ (fit-curve* span tan1 tan2 tol2))))))
+ (partition 2 1 bounds)))))))
diff --git a/common/src/app/common/types/path/helpers.cljc b/common/src/app/common/types/path/helpers.cljc
index bd0db1640b..fd9ecdf64e 100644
--- a/common/src/app/common/types/path/helpers.cljc
+++ b/common/src/app/common/types/path/helpers.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.helpers
"A collection of path internal helpers that does not depend on other
@@ -72,16 +72,9 @@
nil))
(defn- closest-angle
+ "Snaps an angle (in degrees) to the nearest 15° increment."
[angle]
- (cond
- (or (> angle 337.5) (<= angle 22.5)) 0
- (and (> angle 22.5) (<= angle 67.5)) 45
- (and (> angle 67.5) (<= angle 112.5)) 90
- (and (> angle 112.5) (<= angle 157.5)) 135
- (and (> angle 157.5) (<= angle 202.5)) 180
- (and (> angle 202.5) (<= angle 247.5)) 225
- (and (> angle 247.5) (<= angle 292.5)) 270
- (and (> angle 292.5) (<= angle 337.5)) 315))
+ (mth/round angle 15))
(defn position-fixed-angle
[point from-point]
@@ -119,6 +112,13 @@
(gpt/point (-> segment :params :c1x) (-> segment :params :c1y))
(gpt/point (-> segment :params :c2x) (-> segment :params :c2y))]))
+(defn entry->bezier
+ "Returns a segment entry as `[start end h1 h2]`."
+ [{:keys [from to segment]}]
+ (if (= :curve-to (:command segment))
+ (command->bezier segment from)
+ [from to from to]))
+
(declare curve-extremities)
(declare curve-values)
@@ -189,6 +189,70 @@
(gpt/point (coord-v :x) (coord-v :y)))))
+(defn curve-closest-t
+ "Finds the cubic parameter closest to `position`."
+ [[start end h1 h2] position precision]
+ (let [d (fn [t] (gpt/distance position (curve-values start end h1 h2 t)))]
+ (loop [t1 0.0
+ t2 1.0]
+ (if (<= (mth/abs (- t1 t2)) precision)
+ t1
+ (let [ht (+ t1 (/ (- t2 t1) 2))
+ ht1 (+ t1 (/ (- t2 t1) 4))
+ ht2 (+ t1 (/ (* 3 (- t2 t1)) 4))
+
+ [t1 t2] (cond
+ (< (d ht1) (d ht2)) [t1 ht]
+ (< (d ht2) (d ht1)) [ht t2]
+ (and (< (d ht) (d t1)) (< (d ht) (d t2))) [ht1 ht2]
+ (< (d t1) (d t2)) [t1 ht]
+ :else [ht t2])]
+ (recur (double t1) (double t2)))))))
+
+(def ^:private arc-length-samples
+ "Samples for approximating a cubic's length."
+ 100)
+
+(defn curve-arc-length-t
+ "Finds the cubic parameter at half its arc length."
+ [[start end h1 h2]]
+ (let [n arc-length-samples
+ pts (mapv (fn [i] (curve-values start end h1 h2 (/ (double i) n)))
+ (range (inc n)))
+ dists (->> (map gpt/distance pts (rest pts))
+ (reductions + 0.0)
+ (vec))
+ total (peek dists)]
+ (if (mth/almost-zero? total)
+ 0.5
+ (let [half (/ total 2.0)
+ i (loop [i 0]
+ (if (and (< (inc i) (count dists))
+ (< (nth dists (inc i)) half))
+ (recur (inc i))
+ i))
+ d0 (nth dists i)
+ d1 (nth dists (inc i))
+ frac (if (mth/almost-zero? (- d1 d0))
+ 0.0
+ (/ (- half d0) (- d1 d0)))]
+ (/ (+ i frac) n)))))
+
+(defn bend-curve-deltas
+ "Returns the smallest handler deltas that move the point at `t` to `target`."
+ [curve t target]
+ (let [t' (- 1.0 t)
+ b (* 3.0 t' t' t)
+ c (* 3.0 t' t t)
+ delta (gpt/subtract target (curve-values curve t))
+ denom (+ (* b b) (* c c))]
+ (if (mth/almost-zero? denom)
+ {:c1x 0.0 :c1y 0.0 :c2x 0.0 :c2y 0.0}
+ (let [k1 (/ b denom)
+ k2 (/ c denom)]
+ {:c1x (* k1 (:x delta)) :c1y (* k1 (:y delta))
+ :c2x (* k2 (:x delta)) :c2y (* k2 (:y delta))}))))
+
(defn solve-roots*
"Solvers a quadratic or cubic equation given by the parameters a b c d.
diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc
index 483ebdd54b..449f2cfb17 100644
--- a/common/src/app/common/types/path/impl.cljc
+++ b/common/src/app/common/types/path/impl.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.impl
"Contains schemas and data type implementation for PathData binary
diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc
index d0742ced93..82c83483ca 100644
--- a/common/src/app/common/types/path/segment.cljc
+++ b/common/src/app/common/types/path/segment.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.segment
"A collection of helpers for work with plain segment type"
@@ -13,8 +13,10 @@
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.math :as mth]
+ [app.common.types.path.fit :as fit]
[app.common.types.path.helpers :as helpers]
[app.common.types.path.impl :as impl]
+ [app.common.types.path.subpath :as subpath]
[clojure.set :as set]))
#?(:clj (set! *warn-on-reflection* true))
@@ -139,6 +141,42 @@
(gpt/point x y)))
[])))
+(defn segment-entries
+ "Returns selectable segments with their command index and endpoints."
+ [content]
+ (loop [index 0
+ pending (seq content)
+ previous nil
+ previous-index nil
+ subpath-start nil
+ subpath-start-index nil
+ result []]
+ (if-let [{:keys [command] :as segment} (first pending)]
+ (let [close-path? (= command :close-path)
+ move-to? (= command :move-to)
+ point (if close-path?
+ subpath-start
+ (helpers/segment->point segment))
+ point-index (if close-path? subpath-start-index index)
+ result (cond-> result
+ (and previous point (not move-to?))
+ (conj {:index index
+ :from previous
+ :from-index previous-index
+ :to point
+ :to-index point-index
+ :segment segment}))
+ subpath-start (if move-to? point subpath-start)
+ subpath-start-index (if move-to? index subpath-start-index)]
+ (recur (inc index)
+ (next pending)
+ point
+ point-index
+ subpath-start
+ subpath-start-index
+ result))
+ result)))
+
;; FIXME: incorrect API, don't need full shape
(defn path->lines
"Given a path returns a list of lines that approximate the path"
@@ -312,34 +350,6 @@
(impl/from-plain content)))
-(defn- line->curve
- [from-p segment]
-
- (let [to-p (helpers/segment->point segment)
-
- v (gpt/to-vec from-p to-p)
- d (gpt/distance from-p to-p)
-
- dv1 (-> (gpt/normal-left v)
- (gpt/scale (/ d 3)))
-
- h1 (gpt/add from-p dv1)
-
- dv2 (-> (gpt/to-vec to-p h1)
- (gpt/unit)
- (gpt/scale (/ d 3)))
-
- h2 (gpt/add to-p dv2)]
- (-> segment
- (assoc :command :curve-to)
- (update :params (fn [params]
- ;; ensure plain map
- (-> (into {} params)
- (assoc :c1x (:x h1))
- (assoc :c1y (:y h1))
- (assoc :c2x (:x h2))
- (assoc :c2y (:y h2))))))))
-
;; FIXME: optimize
(defn is-curve?
[content point]
@@ -353,111 +363,123 @@
(mapcat #(list (:next-p %) (:prev-p %)))
(remove nil?)))
+(defn- curve-neighbourhood
+ "Returns the adjacent segments and points for one node."
+ [content index]
+ (let [segment (get content index)
+ prev-i (dec index)
+ prev (when (not= :move-to (:command segment))
+ (get content prev-i))
+ next-i (inc index)
+ next (get content next-i)
+ next (when (not= :move-to (:command next)) next)]
+ {:index index
+ :prev-i (when (some? prev) prev-i)
+ :prev-c prev
+ :prev-p (helpers/segment->point prev)
+ :next-i (when (some? next) next-i)
+ :next-c next
+ :next-p (helpers/segment->point next)
+ :segment segment}))
+
+(defn- smooth-tangent
+ "Returns tangent data for a smooth curve node."
+ [content point indices neighbourhoods neighbour-points]
+ (let [[first-point second-point] (vec neighbour-points)
+ prev-neighbour (some :prev-p neighbourhoods)
+ next-neighbour (some :next-p neighbourhoods)
+ seam? (and (= 2 (count indices))
+ (= :move-to (:command (get content (first indices))))
+ (let [end-index (last indices)]
+ (or (= end-index (dec (count content)))
+ (= :close-path
+ (:command (get content (inc end-index)))))))
+ first-unit (gpt/unit (gpt/to-vec point first-point))
+ second-unit (gpt/unit (gpt/to-vec point second-point))
+ angle-tangent (let [delta (gpt/subtract second-unit first-unit)]
+ (if (mth/almost-zero? (gpt/length delta))
+ (gpt/perpendicular first-unit)
+ (gpt/unit delta)))
+ tangent (if seam?
+ (let [chord (gpt/to-vec prev-neighbour next-neighbour)]
+ (if (mth/almost-zero? (gpt/length chord))
+ angle-tangent
+ (gpt/unit chord)))
+ angle-tangent)
+ length (/ (min (gpt/distance point first-point)
+ (gpt/distance point second-point))
+ 3)]
+ {:tangent tangent
+ :length length
+ :seam? seam?
+ :prev-neighbour prev-neighbour
+ :next-neighbour next-neighbour}))
+
+(defn- smooth-handle
+ "Returns a smooth handle toward `neighbour`."
+ [point {:keys [tangent length seam? prev-neighbour next-neighbour]} neighbour]
+ (when (some? neighbour)
+ (let [direction (gpt/unit (gpt/to-vec point neighbour))
+ side (cond
+ (and seam? (= neighbour prev-neighbour)) -1
+ (and seam? (= neighbour next-neighbour)) 1
+ :else (if (neg? (gpt/dot direction tangent)) -1 1))]
+ (gpt/add point (gpt/scale tangent (* side length))))))
+
+(defn- apply-smooth-neighbour
+ "Adds smooth handles around one matching node."
+ [content point tangent-data {:keys [index prev-p next-p next-i]}]
+ (let [curr-command (:command (get content index))
+ next-command (:command (get content next-i))
+ prev-h (smooth-handle point tangent-data prev-p)
+ next-h (smooth-handle point tangent-data next-p)]
+ (cond-> content
+ (and (= :line-to curr-command) (some? prev-p))
+ (update index helpers/update-curve-to prev-p prev-h)
+
+ (and (= :line-to next-command) (some? next-p))
+ (update next-i helpers/update-curve-to next-h next-p)
+
+ (and (= :curve-to curr-command) (some? prev-p))
+ (update index update-handler :c2 prev-h)
+
+ (and (= :curve-to next-command) (some? next-p))
+ (update next-i update-handler :c1 next-h))))
+
+(defn- corner-handle
+ [point neighbour]
+ (gpt/add point (gpt/scale (gpt/to-vec point neighbour) (/ 1 3))))
+
+(defn- apply-corner-neighbour
+ "Adds independent handles around one matching node."
+ [content point {:keys [index segment prev-p next-c next-i next-p]}]
+ (cond-> content
+ (and (= :line-to (:command segment)) (some? prev-p))
+ (update index helpers/update-curve-to prev-p (corner-handle point prev-p))
+
+ (and (= :curve-to (:command segment)) (some? prev-p))
+ (update index update-handler :c2 (corner-handle point prev-p))
+
+ (and (= :line-to (:command next-c)) (some? next-p))
+ (update next-i helpers/update-curve-to (corner-handle point next-p) next-p)
+
+ (and (= :curve-to (:command next-c)) (some? next-p))
+ (update next-i update-handler :c1 (corner-handle point next-p))))
+
(defn make-curve-point
- "Changes the content to make the point a 'curve'. The handlers will be
- positioned in the same vector that results from the previous->next
- points but with fixed length; return a plain segments vector"
+ "Adds curve handles to every node at `point`."
[content point]
-
- (let [;; We perform this operation before because it can be
- ;; optimized with internal reduction so is better to use the
- ;; PathData type before converting it to plain vector.
- indices
- (point-indices content point)
-
- ;; We transform content to a plain format for execute the
- ;; algorithm because right now is the only way to execute it
- content
- (vec content)
-
- vectors
- (map (fn [index]
- (let [segment (get content index)
- prev-i (dec index)
- prev (when (not (= :move-to (:command segment)))
- (get content prev-i))
- next-i (inc index)
- next (get content next-i)
- next (when (not (= :move-to (:command next)))
- next)]
- {:index index
- :prev-i (when (some? prev) prev-i)
- :prev-c prev
- :prev-p (helpers/segment->point prev)
- :next-i (when (some? next) next-i)
- :next-c next
- :next-p (helpers/segment->point next)
- :segment segment}))
- indices)
-
- points
- (into #{} xf:mapcat-points vectors)]
-
- (if (= (count points) 2)
- (let [[fpoint spoint] (vec points)
- v1 (gpt/to-vec fpoint point)
- v2 (gpt/to-vec fpoint spoint)
- vp (gpt/project v1 v2)
- vh (gpt/subtract v1 vp)
-
- add-curve
- (fn [content {:keys [index prev-p next-p next-i]}]
- (let [curr-segment (get content index)
- curr-command (get curr-segment :command)
-
- next-segment (get content next-i)
- next-command (get next-segment :command)
-
- ;; New handlers for prev-point and next-point
- prev-h
- (when (some? prev-p) (gpt/add prev-p vh))
-
- next-h
- (when (some? next-p) (gpt/add next-p vh))
-
- ;; Correct 1/3 to the point improves the curve
- prev-correction
- (when (some? prev-h) (gpt/scale (gpt/to-vec prev-h point) (/ 1 3)))
-
- next-correction
- (when (some? next-h) (gpt/scale (gpt/to-vec next-h point) (/ 1 3)))
-
- prev-h
- (when (some? prev-h) (gpt/add prev-h prev-correction))
-
- next-h
- (when (some? next-h) (gpt/add next-h next-correction))]
-
- (cond-> content
- (and (= :line-to curr-command) (some? prev-p))
- (update index helpers/update-curve-to prev-p prev-h)
-
- (and (= :line-to next-command) (some? next-p))
- (update next-i helpers/update-curve-to next-h next-p)
-
- (and (= :curve-to curr-command) (some? prev-p))
- (update index update-handler :c2 prev-h)
-
- (and (= :curve-to next-command) (some? next-p))
- (update next-i update-handler :c1 next-h))))]
-
- (reduce add-curve content vectors))
-
- (let [add-curve
- (fn [content {:keys [index segment prev-p next-c next-i]}]
- (cond-> content
- (= :line-to (:command segment))
- (update index #(line->curve prev-p %))
-
- (= :curve-to (:command segment))
- (update index #(line->curve prev-p %))
-
- (= :line-to (:command next-c))
- (update next-i #(line->curve point %))
-
- (= :curve-to (:command next-c))
- (update next-i #(line->curve point %))))]
- (reduce add-curve content vectors)))))
+ (let [indices (vec (point-indices content point))
+ content (vec content)
+ neighbourhoods (mapv #(curve-neighbourhood content %) indices)
+ neighbour-points (into #{} xf:mapcat-points neighbourhoods)]
+ (if (= (count neighbour-points) 2)
+ (let [tangent-data (smooth-tangent
+ content point indices neighbourhoods neighbour-points)]
+ (reduce #(apply-smooth-neighbour %1 point tangent-data %2)
+ content
+ neighbourhoods))
+ (reduce #(apply-corner-neighbour %1 point %2) content neighbourhoods))))
(defn get-segments-with-points
"Given a content and a set of points return all the segments in the path
@@ -528,6 +550,103 @@
(into [] (mapcat process-segments) (d/enumerate content))))
+(defn collapse-handler
+ "Collapses a handler onto its node and simplifies flat curves to lines."
+ [content index prefix]
+ (let [content (vec content)
+ node (handler->node content index prefix)
+ [cx cy] (helpers/prefix->coords prefix)]
+ (if (and (some? node)
+ (= :curve-to (dm/get-in content [index :command])))
+ (impl/from-plain
+ (-> content
+ (assoc-in [index :params cx] (:x node))
+ (assoc-in [index :params cy] (:y node))
+ (remove-line-curves)))
+ (impl/from-plain content))))
+
+(def ^:private curve-toggle-bow
+ "Perpendicular handle offset used when curving a line."
+ 0.25)
+
+(defn toggle-segment-curve
+ "Toggles a segment between a line and a bowed curve."
+ [content index]
+ (let [content (vec content)
+ segment (get content index)
+ from (helpers/segment->point (get content (dec index)))
+ to (helpers/segment->point segment)]
+ (impl/from-plain
+ (case (:command segment)
+ :line-to
+ (if (some? from)
+ (let [v (gpt/to-vec from to)
+ perp (gpt/scale (gpt/point (- (:y v)) (:x v)) curve-toggle-bow)
+ h1 (-> from (gpt/add (gpt/scale v (/ 1 3))) (gpt/add perp))
+ h2 (-> from (gpt/add (gpt/scale v (/ 2 3))) (gpt/add perp))]
+ (update content index helpers/update-curve-to h1 h2))
+ content)
+
+ :curve-to
+ (assoc content index {:command :line-to
+ :params (select-keys (:params segment) [:x :y])})
+
+ content))))
+
+(defn- subpath-start-indices
+ "Returns the starting command index for every command in `content`."
+ [content]
+ (loop [i 0
+ start 0
+ result (transient [])]
+ (if (>= i (count content))
+ (persistent! result)
+ (let [start (if (= :move-to (:command (nth content i))) i start)]
+ (recur (inc i) start (conj! result start))))))
+
+(defn remove-segments
+ "Removes segments and opens their subpaths. Closing segments become
+ lines when needed to preserve geometry."
+ [content indices]
+ (let [content (vec content)
+ indices (set indices)
+ starts (subpath-start-indices content)
+
+ broken (into #{} (keep #(nth starts % nil)) indices)
+
+ content
+ (into []
+ (comp
+ (map-indexed
+ (fn [i cmd]
+ (cond
+ (contains? indices i)
+ (when-not (= :close-path (:command cmd))
+ {:command :move-to
+ :params (select-keys (:params cmd) [:x :y])})
+
+ ;; Preserve the closing edge of broken subpaths.
+ (and (= :close-path (:command cmd))
+ (contains? broken (nth starts i)))
+ {:command :line-to
+ :params (-> (nth content (nth starts i))
+ (get :params)
+ (select-keys [:x :y]))}
+
+ :else cmd)))
+ (remove nil?))
+ content)
+
+ subpaths
+ (reduce (fn [acc cmd]
+ (if (or (= :move-to (:command cmd)) (empty? acc))
+ (conj acc [cmd])
+ (update acc (dec (count acc)) conj cmd)))
+ []
+ content)]
+ (impl/from-plain
+ (into [] (comp (filter #(> (count %) 1)) cat) subpaths))))
+
;; FIXME: rename to next-segment
(defn next-node
"Calculates the next-node to be inserted."
@@ -543,78 +662,237 @@
:params (helpers/make-curve-params position prev-handler)}
:else {:command :move-to
:params position})))
-(defn remove-nodes
- "Removes from content the points given. Will try to reconstruct the paths
- to keep everything consistent"
- [content points]
+(def ^:private ^:const chain-samples-per-segment 8)
+(defn- chain-samples
+ "Returns ordered samples along a segment chain."
+ [chain]
+ (into [(:start (first chain))]
+ (mapcat
+ (fn [{:keys [start end segment]}]
+ (let [ts (map #(/ (double %) chain-samples-per-segment)
+ (range 1 (inc chain-samples-per-segment)))]
+ (if (= :curve-to (:command segment))
+ (let [curve (helpers/command->bezier segment start)]
+ (map #(helpers/curve-values curve %) ts))
+ (map #(helpers/line-values [start end] %) ts)))))
+ chain))
+
+(defn- chain-tangent
+ "Returns an inward unit tangent at one end of a chain."
+ [{:keys [start end segment]} at-start? origin samples]
+ (let [tangent
+ (if (= :curve-to (:command segment))
+ (let [curve (helpers/command->bezier segment start)]
+ (cond-> (helpers/curve-tangent curve (if at-start? 0 1))
+ (not at-start?) (gpt/negate)))
+ (if at-start?
+ (gpt/to-vec start end)
+ (gpt/to-vec end start)))
+ tangent (gpt/unit tangent)]
+ (if (gpt/almost-zero? tangent)
+ (->> samples
+ (map #(gpt/to-vec origin %))
+ (remove gpt/almost-zero?)
+ (map gpt/unit)
+ (first))
+ tangent)))
+
+(defn- flat-chain?
+ "True when a sampled chain is nearly straight."
+ [start end samples]
+ (or (mth/almost-zero? (gpt/distance start end))
+ (every? #(< (gpt/point-line-distance % start end) 0.01) samples)))
+
+(defn- restore-split-curve
+ "Rejoins two untouched De Casteljau pieces into one cubic."
+ [chain]
+ (when (= 2 (count chain))
+ (let [{left-segment :segment left-start :start} (first chain)
+ {right-segment :segment} (second chain)]
+ (when (and (= :curve-to (:command left-segment))
+ (= :curve-to (:command right-segment)))
+ (let [[start split left-h1 left-h2 :as left-curve]
+ (helpers/command->bezier left-segment left-start)
+ [_ end right-h1 right-h2 :as right-curve]
+ (helpers/command->bezier right-segment split)
+ left-length (gpt/distance left-h2 split)
+ right-length (gpt/distance split right-h1)]
+ (when (and (not (mth/almost-zero? left-length))
+ (not (mth/almost-zero? right-length)))
+ (let [t (/ left-length (+ left-length right-length))
+ original-h1 (-> (gpt/to-vec start left-h1)
+ (gpt/scale (/ 1.0 t))
+ (gpt/add start))
+ original-h2 (-> (gpt/to-vec end right-h2)
+ (gpt/scale (/ 1.0 (- 1.0 t)))
+ (gpt/add end))
+ candidate [start end original-h1 original-h2]
+ [left' right'] (helpers/curve-split candidate t)]
+ (when (every? true?
+ (map gpt/close?
+ (concat left-curve right-curve)
+ (concat left' right')))
+ (helpers/make-curve-to end original-h1 original-h2)))))))))
+
+(defn- approximate-chain
+ "Replaces a segment chain with a line or fitted curve."
+ [chain]
+ (or (restore-split-curve chain)
+ (let [start (:start (first chain))
+ end (:end (peek chain))
+ samples (chain-samples chain)
+ tan1 (chain-tangent (first chain) true start (rest samples))
+ tan2 (chain-tangent (peek chain) false end (rest (rseq samples)))]
+ (if (or (flat-chain? start end samples)
+ (nil? tan1)
+ (nil? tan2))
+ (helpers/make-line-to end)
+ (let [[h1 h2] (fit/fit-cubic samples tan1 tan2)]
+ (helpers/make-curve-to end h1 h2))))))
+
+(defn- split-content-subpaths
+ "Splits plain path commands into subpath command vectors."
+ [content]
+ (reduce
+ (fn [subpaths segment]
+ (if (= :move-to (:command segment))
+ (conj subpaths [segment])
+ (if (seq subpaths)
+ (update subpaths (dec (count subpaths)) conj segment)
+ subpaths)))
+ []
+ content))
+
+(defn- removed-point-joins-subpaths?
+ "True when a removed point is an endpoint shared by open subpaths."
+ [subpaths points]
+ (let [open-endpoints
+ (keep (fn [subpath]
+ (let [start (some-> subpath first helpers/segment->point)
+ end (some-> subpath peek helpers/segment->point)]
+ (when (and (some? start)
+ (some? end)
+ (not (subpath/pt= start end)))
+ #{start end})))
+ subpaths)]
+ (some (fn [point]
+ (< 1 (count (filter (fn [endpoints]
+ (some #(subpath/pt= point %) endpoints))
+ open-endpoints))))
+ points)))
+
+(defn- rotate-removed-closed-start
+ "Rotates a closed subpath so a removed seam becomes an interior node."
+ [subpath points]
+ (let [subpath (vec subpath)
+ close? (= :close-path (:command (peek subpath)))
+ body (cond-> subpath close? pop)
+ start (some-> body first helpers/segment->point)
+ end (some-> body peek helpers/segment->point)
+ closed? (or close? (= start end))]
+ (if-not (and closed? (contains? points start))
+ subpath
+ (let [segments (subvec body 1)
+ ;; Materialize an implicit close segment before rotating.
+ segments (cond-> segments
+ (and close? (not= start end))
+ (conj (helpers/make-line-to start)))
+ new-start-index
+ (first
+ (keep-indexed
+ (fn [index segment]
+ (when-not (contains? points (helpers/segment->point segment))
+ index))
+ segments))]
+ (if (nil? new-start-index)
+ []
+ (let [new-start (helpers/segment->point
+ (nth segments new-start-index))
+ rotated (into []
+ (concat
+ (subvec segments (inc new-start-index))
+ (subvec segments 0 (inc new-start-index))))]
+ (cond-> (into [(helpers/make-move-to new-start)] rotated)
+ close? (conj {:command :close-path :params {}}))))))))
+
+(defn- remove-nodes*
+ "Removes interior nodes from prepared content."
+ [content points]
+ (loop [result []
+ pending []
+ subpath-start nil
+ prev-point nil
+ segments (seq content)]
+
+ (if (nil? segments)
+ ;; Drop subpaths left with only a start point.
+ (into [] (comp (filter #(> (count %) 1)) cat) result)
+
+ (let [segment (first segments)
+ move? (= :move-to (:command segment))
+ close? (= :close-path (:command segment))
+ point (if close? subpath-start (helpers/segment->point segment))
+ remove? (and (not close?) (contains? points point))
+
+ ;; Start a result subpath for each move command.
+ result (if move? (conj result []) result)
+ head (dec (count result))
+ subpath (peek result)
+
+ [result pending]
+ (cond
+ ;; Collect removed interior nodes until the next kept node.
+ remove?
+ [result (if (seq subpath)
+ (conj pending {:start prev-point :end point :segment segment})
+ [])]
+
+ move?
+ [(update result head conj segment) []]
+
+ ;; Promote the first kept node to the subpath start.
+ (empty? subpath)
+ [(update result head conj (helpers/make-move-to point)) []]
+
+ (seq pending)
+ (if (and close? (contains? points subpath-start))
+ ;; Close straight onto the new start.
+ [(update result head conj segment) []]
+ (let [chain (conj pending {:start prev-point :end point :segment segment})
+ approx (approximate-chain chain)
+ ;; The close command already draws a zero-length replacement.
+ skip? (and close?
+ (= :line-to (:command approx))
+ (< (gpt/distance (:start (first chain)) point) 0.01))
+ result (cond-> result
+ (not skip?) (update head conj approx)
+ close? (update head conj segment))]
+ [result []]))
+
+ :else
+ [(update result head conj segment) []])]
+
+ (recur result
+ pending
+ (if move? point subpath-start)
+ point
+ (next segments))))))
+
+(defn remove-nodes
+ "Removes nodes and joins surrounding segments with a fitted replacement."
+ [content points]
(if (empty? points)
content
-
- (let [content (d/with-prev content)]
-
- (loop [result []
- last-handler nil
- [cur-segment prev-segment] (first content)
- content (rest content)]
-
- (if (nil? cur-segment)
- ;; The result with be an array of arrays were every entry is a subpath
- (->> result
- ;; remove empty and only 1 node subpaths
- (filter #(> (count %) 1))
- ;; flatten array-of-arrays plain array
- (flatten)
- (into []))
-
- (let [move? (= :move-to (:command cur-segment))
- curve? (= :curve-to (:command cur-segment))
-
- ;; When the old command was a move we start a subpath
- result (if move? (conj result []) result)
-
- subpath (peek result)
-
- point (helpers/segment->point cur-segment)
-
- old-prev-point (helpers/segment->point prev-segment)
- new-prev-point (helpers/segment->point (peek subpath))
-
- remove? (contains? points point)
-
-
- ;; We store the first handler for the first curve to be removed to
- ;; use it for the first handler of the regenerated path
- cur-handler (cond
- (and (not last-handler) remove? curve?)
- (select-keys (:params cur-segment) [:c1x :c1y])
-
- (not remove?)
- nil
-
- :else
- last-handler)
-
- cur-segment (cond-> cur-segment
- ;; If we're starting a subpath and it's not a move make it a move
- (and (not move?) (empty? subpath))
- (assoc :command :move-to
- :params (select-keys (:params cur-segment) [:x :y]))
-
- ;; If have a curve the first handler will be relative to the previous
- ;; point. We change the handler to the new previous point
- (and curve? (seq subpath) (not= old-prev-point new-prev-point))
- (update :params merge last-handler))
-
- head-idx (dec (count result))
-
- result (cond-> result
- (not remove?)
- (update head-idx conj cur-segment))]
- (recur result
- cur-handler
- (first content)
- (rest content))))))))
+ (let [subpaths (split-content-subpaths content)
+ content (if (removed-point-joins-subpaths? subpaths points)
+ (subpath/close-subpaths content)
+ content)
+ content (into []
+ (mapcat #(rotate-removed-closed-start % points))
+ (split-content-subpaths
+ content))]
+ (remove-nodes* content points))))
(defn join-nodes
"Creates new segments between points that weren't previously.
@@ -649,41 +927,119 @@
(into content new-content)))
+(def ^:private separate-node-offset (gpt/point 8 8))
+
+(defn- separate-node
+ "Splits a node into offset open ends, preserving adjacent handles."
+ [content point offset]
+ (let [content (vec content)
+ n (count content)
+ {ox :x oy :y} offset
+ seg? (fn [c] (and (some? c)
+ (not= :move-to (:command c))
+ (not= :close-path (:command c))))]
+ (loop [i 0
+ k 0
+ result (transient [])]
+ (if (>= i n)
+ (persistent! result)
+ (let [cmd (nth content i)
+ nxt (nth content (inc i) nil)
+ at-p? (and (not= :close-path (:command cmd))
+ (gpt/close? point (helpers/segment->point cmd)))]
+ (cond
+ ;; Offset a subpath start.
+ (and at-p? (= :move-to (:command cmd)))
+ (let [off (gpt/point (* k ox) (* k oy))]
+ (recur (inc i) (inc k)
+ (conj! result (-> cmd
+ (update-in [:params :x] + (:x off))
+ (update-in [:params :y] + (:y off))))))
+
+ ;; Split an interior node into two subpaths.
+ (and at-p? (seg? cmd) (seg? nxt))
+ (let [off (gpt/point (* k ox) (* k oy))
+ cmd' (cond-> (-> cmd
+ (update-in [:params :x] + (:x off))
+ (update-in [:params :y] + (:y off)))
+ (= :curve-to (:command cmd))
+ (-> (update-in [:params :c2x] + (:x off))
+ (update-in [:params :c2y] + (:y off))))
+ k2 (inc k)
+ off2 (gpt/point (* k2 ox) (* k2 oy))
+ mv (helpers/make-move-to (gpt/add point off2))
+ nxt' (cond-> nxt
+ (= :curve-to (:command nxt))
+ (-> (update-in [:params :c1x] + (:x off2))
+ (update-in [:params :c1y] + (:y off2))))]
+ (recur (+ i 2) (inc k2)
+ (-> result (conj! cmd') (conj! mv) (conj! nxt'))))
+
+ ;; Open and offset a closed seam.
+ (and at-p? (seg? cmd) (= :close-path (:command nxt)))
+ (let [off (gpt/point (* k ox) (* k oy))
+ cmd' (cond-> (-> cmd
+ (update-in [:params :x] + (:x off))
+ (update-in [:params :y] + (:y off)))
+ (= :curve-to (:command cmd))
+ (-> (update-in [:params :c2x] + (:x off))
+ (update-in [:params :c2y] + (:y off))))]
+ ;; Drop the close command so the seam stays open.
+ (recur (+ i 2) (inc k) (conj! result cmd')))
+
+ ;; Offset the end of an open subpath.
+ (and at-p? (seg? cmd) (not= :close-path (:command nxt)))
+ (let [off (gpt/point (* k ox) (* k oy))]
+ (recur (inc i) (inc k)
+ (conj! result (cond-> (-> cmd
+ (update-in [:params :x] + (:x off))
+ (update-in [:params :y] + (:y off)))
+ (= :curve-to (:command cmd))
+ (-> (update-in [:params :c2x] + (:x off))
+ (update-in [:params :c2y] + (:y off)))))))
+
+ :else
+ (recur (inc i) k (conj! result cmd))))))))
+
(defn separate-nodes
- "Removes the segments between the points given"
- [content points]
+ "Removes segments between points or splits one node into offset open ends."
+ ([content points]
+ (separate-nodes content points separate-node-offset))
+ ([content points offset]
+ (if (= 1 (count points))
+ (separate-node (vec content) (first points) offset)
- (let [content (d/with-prev content)]
- (loop [result []
- [cur-segment prev-segment] (first content)
- content (rest content)]
+ (let [content (d/with-prev content)]
+ (loop [result []
+ [cur-segment prev-segment] (first content)
+ content (rest content)]
- (if (nil? cur-segment)
- (->> result
- (filter #(> (count %) 1))
- (flatten)
- (into []))
+ (if (nil? cur-segment)
+ (->> result
+ (filter #(> (count %) 1))
+ (flatten)
+ (into []))
- (let [prev-point (helpers/segment->point prev-segment)
- cur-point (helpers/segment->point cur-segment)
+ (let [prev-point (helpers/segment->point prev-segment)
+ cur-point (helpers/segment->point cur-segment)
- cur-segment (cond-> cur-segment
- (and (contains? points prev-point)
- (contains? points cur-point))
+ cur-segment (cond-> cur-segment
+ (and (contains? points prev-point)
+ (contains? points cur-point))
- (assoc :command :move-to
- :params (select-keys (:params cur-segment) [:x :y])))
+ (assoc :command :move-to
+ :params (select-keys (:params cur-segment) [:x :y])))
- move? (= :move-to (:command cur-segment))
+ move? (= :move-to (:command cur-segment))
- result (if move? (conj result []) result)
- head-idx (dec (count result))
+ result (if move? (conj result []) result)
+ head-idx (dec (count result))
- result (-> result
- (update head-idx conj cur-segment))]
- (recur result
- (first content)
- (rest content)))))))
+ result (-> result
+ (update head-idx conj cur-segment))]
+ (recur result
+ (first content)
+ (rest content)))))))))
(defn- add-to-set
@@ -753,9 +1109,10 @@
(mapv replace-command))))
(defn merge-nodes
- "Reduces the contiguous segments in points to a single point"
+ "Joins and merges `points` into one point."
[content points]
- (let [segments (get-segments-with-points content points)]
+ (let [content (join-nodes content points)
+ segments (get-segments-with-points content points)]
(if (seq segments)
(let [point->merge-point (-> segments
(group-segments)
@@ -889,3 +1246,16 @@
(conj result {:command :close-path})
result)]
(impl/from-plain result))))))
+
+(defn smooth-points->content
+ "Fits smooth path content through `points`, falling back to lines."
+ [points tolerance]
+ (let [curves (when (>= (count points) 3)
+ (fit/fit-curve points tolerance))]
+ (if (empty? curves)
+ (points->content points)
+ (impl/from-plain
+ (into [(helpers/make-move-to (ffirst curves))]
+ (map (fn [[_ end h1 h2]]
+ (helpers/make-curve-to end h1 h2)))
+ curves)))))
diff --git a/common/src/app/common/types/path/selection.cljc b/common/src/app/common/types/path/selection.cljc
new file mode 100644
index 0000000000..826047431b
--- /dev/null
+++ b/common/src/app/common/types/path/selection.cljc
@@ -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 app.common.types.path.selection
+ "Transforms selected path nodes and handlers."
+ (:require
+ [app.common.data :as d]
+ [app.common.geom.point :as gpt]
+ [app.common.math :as mth]
+ [app.common.types.path.helpers :as helpers]
+ [app.common.types.path.impl :as impl]))
+
+(def align-nodes-axis
+ "Valid alignment axes."
+ #{:hleft :hcenter :hright :vtop :vcenter :vbottom})
+
+(def distribute-nodes-axis
+ "Valid distribution axes."
+ #{:horizontal :vertical})
+
+(defn- selected-node-entries
+ "Returns selected nodes as `[index point]` pairs."
+ [content indices]
+ (into []
+ (comp (filter (fn [[i seg]]
+ (and (contains? indices i)
+ (not= :close-path (:command seg)))))
+ (map (fn [[i seg]] [i (helpers/segment->point seg)])))
+ (d/enumerate content)))
+
+(defn- expand-coincident-node-indices
+ "Includes every command that represents a selected logical node."
+ [content indices]
+ (let [indices (set indices)
+ points (into #{} (map second) (selected-node-entries content indices))]
+ (into indices
+ (comp
+ (filter (fn [[_ segment]]
+ (and (not= :close-path (:command segment))
+ (some #(gpt/close? % (helpers/segment->point segment)) points))))
+ (map first))
+ (d/enumerate content))))
+
+(defn- translate-nodes
+ "Moves selected nodes and handlers by their node deltas."
+ [content indices deltas]
+ (let [node-sel? (fn [i] (contains? indices i))
+ add-delta (fn [params xk yk delta]
+ (if (and delta (contains? params xk))
+ (-> params
+ (update xk + (:x delta))
+ (update yk + (:y delta)))
+ params))
+ move-cmd (fn [i {:keys [command params] :as seg}]
+ (let [curve? (= :curve-to command)
+ params (cond-> params
+ (and (not= :close-path command) (node-sel? i))
+ (add-delta :x :y (get deltas i))
+
+ (and curve? (node-sel? (dec i)))
+ (add-delta :c1x :c1y (get deltas (dec i)))
+
+ (and curve? (node-sel? i))
+ (add-delta :c2x :c2y (get deltas i)))]
+ (assoc seg :params params)))]
+ (into [] (map-indexed move-cmd) content)))
+
+(defn flip-content
+ "Flips selected nodes and handlers across their bounds."
+ [content indices axis]
+ (let [content (vec content)
+ indices (set indices)
+ node-sel? (fn [i] (contains? indices i))
+ positions (into []
+ (comp (filter (fn [[i seg]]
+ (and (node-sel? i)
+ (not= :close-path (:command seg)))))
+ (map (fn [[_ seg]] (helpers/segment->point seg))))
+ (d/enumerate content))]
+ (if (empty? positions)
+ (impl/from-plain content)
+ (let [xs (map :x positions)
+ ys (map :y positions)
+ cx (/ (+ (reduce min xs) (reduce max xs)) 2.0)
+ cy (/ (+ (reduce min ys) (reduce max ys)) 2.0)
+ flip-x? (= axis :horizontal)
+ reflect (fn [params xk yk]
+ (if flip-x?
+ (cond-> params
+ (contains? params xk) (update xk #(- (* 2.0 cx) %)))
+ (cond-> params
+ (contains? params yk) (update yk #(- (* 2.0 cy) %)))))
+ flip-cmd (fn [i {:keys [command params] :as seg}]
+ (let [curve? (= :curve-to command)
+ params (cond-> params
+ (and (not= :close-path command) (node-sel? i))
+ (reflect :x :y)
+
+ (and curve? (node-sel? (dec i)))
+ (reflect :c1x :c1y)
+
+ (and curve? (node-sel? i))
+ (reflect :c2x :c2y))]
+ (assoc seg :params params)))]
+ (impl/from-plain
+ (into [] (map-indexed flip-cmd) content))))))
+
+(defn align-content
+ "Aligns two or more selected nodes within their bounds."
+ [content indices axis]
+ (let [content (vec content)
+ indices (set indices)
+ entries (selected-node-entries content indices)]
+ (if (< (count entries) 2)
+ (impl/from-plain content)
+ (let [pts (map second entries)
+ xs (map :x pts)
+ ys (map :y pts)
+ minx (reduce min xs)
+ maxx (reduce max xs)
+ miny (reduce min ys)
+ maxy (reduce max ys)
+ [coord target] (case axis
+ :hleft [:x minx]
+ :hcenter [:x (/ (+ minx maxx) 2.0)]
+ :hright [:x maxx]
+ :vtop [:y miny]
+ :vcenter [:y (/ (+ miny maxy) 2.0)]
+ :vbottom [:y maxy])
+ deltas (into {}
+ (map (fn [[i p]]
+ [i (if (= coord :x)
+ (gpt/point (- target (:x p)) 0)
+ (gpt/point 0 (- target (:y p))))]))
+ entries)]
+ (impl/from-plain (translate-nodes content indices deltas))))))
+
+(defn set-nodes-coordinate
+ "Sets one coordinate of selected nodes and handlers."
+ [content indices axis value]
+ (let [content (vec content)
+ indices (expand-coincident-node-indices content indices)
+ entries (selected-node-entries content indices)
+ deltas (into {}
+ (map (fn [[i p]]
+ [i (if (= axis :x)
+ (gpt/point (- value (:x p)) 0)
+ (gpt/point 0 (- value (:y p))))]))
+ entries)]
+ (impl/from-plain (translate-nodes content indices deltas))))
+
+(defn set-handler-points
+ "Moves handlers to their target points."
+ [content pts]
+ (impl/from-plain
+ (reduce
+ (fn [content [[index prefix] pt]]
+ (if (= :curve-to (:command (get content index)))
+ (let [[cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y])]
+ (-> content
+ (assoc-in [index :params cx] (:x pt))
+ (assoc-in [index :params cy] (:y pt))))
+ content))
+ (vec content)
+ pts)))
+
+(defn translate-selected-nodes
+ "Moves selected nodes and handlers by `delta`."
+ [content indices delta]
+ (let [content (vec content)
+ indices (expand-coincident-node-indices content indices)]
+ (impl/from-plain
+ (translate-nodes content indices (into {} (map (fn [i] [i delta])) indices)))))
+
+(defn distribute-content
+ "Distributes three or more selected positions along `axis`."
+ [content indices axis]
+ (let [content (vec content)
+ indices (set indices)
+ entries (selected-node-entries content indices)
+ index->point (into {} entries)
+ horizontal? (= axis :horizontal)
+ coord (fn [p] (if horizontal? (:x p) (:y p)))
+ groups (->> entries
+ (group-by (fn [[_ p]] [(mth/round (:x p) 0.1) (mth/round (:y p) 0.1)]))
+ (mapv (fn [[_ es]]
+ {:point (second (first es))
+ :indices (mapv first es)})))
+ sorted (sort-by (comp coord :point) groups)]
+ (if (< (count groups) 3)
+ (impl/from-plain content)
+ (let [lo (coord (:point (first sorted)))
+ hi (coord (:point (last sorted)))
+ step (/ (- hi lo) (dec (count sorted)))
+ deltas (into {}
+ (comp
+ (map-indexed
+ (fn [k {:keys [indices]}]
+ (let [target (+ lo (* k step))]
+ (map (fn [i]
+ (let [node-point (get index->point i)
+ d (- target (coord node-point))
+ dp (if horizontal?
+ (gpt/point d 0)
+ (gpt/point 0 d))]
+ [i dp]))
+ indices))))
+ cat)
+ sorted)]
+ (impl/from-plain (translate-nodes content indices deltas))))))
diff --git a/common/src/app/common/types/path/shape_to_path.cljc b/common/src/app/common/types/path/shape_to_path.cljc
index ffd8140adf..665eb592fe 100644
--- a/common/src/app/common/types/path/shape_to_path.cljc
+++ b/common/src/app/common/types/path/shape_to_path.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.shape-to-path
(:require
diff --git a/common/src/app/common/types/path/subpath.cljc b/common/src/app/common/types/path/subpath.cljc
index 50c0055466..95e16664f2 100644
--- a/common/src/app/common/types/path/subpath.cljc
+++ b/common/src/app/common/types/path/subpath.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.subpath
(:require
@@ -28,13 +28,17 @@
(defn add-subpath-command
"Adds a command to the subpath"
[subpath command]
- (let [command (if (= :close-path (:command command))
- (helpers/make-line-to (:from subpath))
- command)
- p (helpers/segment->point command)]
- (-> subpath
- (assoc :to p)
- (update :data conj command))))
+ (let [close? (= :close-path (:command command))]
+ (if (and close? (pt= (:from subpath) (:to subpath)))
+ ;; Avoid adding a duplicate node at an already closed seam.
+ subpath
+ (let [command (if close?
+ (helpers/make-line-to (:from subpath))
+ command)
+ p (helpers/segment->point command)]
+ (-> subpath
+ (assoc :to p)
+ (update :data conj command))))))
(defn reverse-command
"Reverses a single command"
@@ -189,6 +193,27 @@
(into [] xf-mapcat-data closed-subpaths)))
+(defn- close-loop
+ "Adds an explicit close command when a subpath's endpoints meet."
+ [{:keys [from to data] :as subpath}]
+ (let [last-seg (peek data)]
+ (if (or (< (count data) 2)
+ (= :close-path (:command last-seg))
+ (not (pt= from to)))
+ subpath
+ (let [data (cond-> data
+ (= :line-to (:command last-seg)) (pop))]
+ (assoc subpath
+ :to from
+ :data (conj data {:command :close-path :params {}}))))))
+
+(defn close-loops
+ "Adds close commands to subpaths whose endpoints meet."
+ [content]
+ (->> (get-subpaths content)
+ (mapv close-loop)
+ (into [] xf-mapcat-data)))
+
;; FIXME: revisit this fn impl for perfromance
(defn reverse-content
"Given a content reverse the order of the commands"
diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc
index 7fe8a4c7d4..bb74bfcc09 100644
--- a/common/src/app/common/types/plugins.cljc
+++ b/common/src/app/common/types/plugins.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.plugins
(:require
@@ -27,6 +27,20 @@
schema:string
schema:string]])
+(def valid-permissions
+ "Set of valid plugin permissions that can be granted to plugins."
+ #{"content:read" "content:write"
+ "library:read" "library:write"
+ "comment:read" "comment:write"
+ "clipboard:read" "clipboard:write"
+ "user:read"
+ "allow:downloads"
+ "allow:localstorage"})
+
+(def schema:permissions
+ "Schema for plugin permissions - a set of valid permission strings."
+ [:set {:gen/max 11} (into [:enum] (sort valid-permissions))])
+
(def schema:registry-entry
[:map
[:plugin-id :string]
@@ -36,7 +50,7 @@
[:host :string]
[:code :string]
[:icon {:optional true} :string]
- [:permissions [:set :string]]])
+ [:permissions schema:permissions]])
(def schema:plugin-registry
[:map
diff --git a/common/src/app/common/types/profile.cljc b/common/src/app/common/types/profile.cljc
index 742e2dd1c0..9a02d3eb0c 100644
--- a/common/src/app/common/types/profile.cljc
+++ b/common/src/app/common/types/profile.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.profile
(:require
diff --git a/common/src/app/common/types/project.cljc b/common/src/app/common/types/project.cljc
index 0b20e2c232..f6f20ea36a 100644
--- a/common/src/app/common/types/project.cljc
+++ b/common/src/app/common/types/project.cljc
@@ -3,7 +3,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.project
(:require
diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc
index d645e72ae1..6a617c21cc 100644
--- a/common/src/app/common/types/shape.cljc
+++ b/common/src/app/common/types/shape.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape
(:require
@@ -233,7 +233,50 @@
[:grow-type {:optional true}
[::sm/one-of grow-types]]
[:applied-tokens {:optional true} cto/schema:applied-tokens]
- [:plugin-data {:optional true} ctpg/schema:plugin-data]])
+ [:plugin-data {:optional true} ctpg/schema:plugin-data]
+
+ ;; `rotation`, `flip-x` and `flip-y` are fields of the `Shape` record (see
+ ;; `cr/defrecord Shape` above) and this schema did not declare them.
+ ;; `rotation` was already named in `allowed-shape-attrs` here and in
+ ;; `app.common.types.shape.attrs/editable-attrs`, so the omission was in this
+ ;; schema and not in the model. Anything reading the model from the schema
+ ;; rather than from a live shape missed all three: the graph projection
+ ;; derives one column per entry (`app.graph.schema.projection`), so shape
+ ;; nodes carried no rotation at all, and a consumer cannot place a shape
+ ;; without it.
+ ;;
+ ;; Nilable, because `app.common.record/defrecord` cannot remove a base
+ ;; field: its `without` assocs nil and its `containsKey` answers true
+ ;; whatever the field holds, so nil is how a record field says "unset".
+ ;; `flip-x` and `flip-y` are nil on every shape `setup-shape` builds, since
+ ;; `make-minimal-shape` gives them no default.
+ ;;
+ ;; Optional as well, unlike the geometry group below, because this schema
+ ;; has a second job: `check-shape-generic-attrs` validates partial update
+ ;; payloads with it, such as the `{:blocked true}` that
+ ;; `app.main.data.workspace/update-shape` passes. A required key here would
+ ;; reject every such payload.
+ [:rotation {:optional true} [:maybe ::sm/safe-number]]
+ [:flip-x {:optional true} [:maybe :boolean]]
+ [:flip-y {:optional true} [:maybe :boolean]]
+
+ ;; Carried on circles, rects and texts too, not only on frames, so it
+ ;; belongs here rather than in `schema:frame-attrs`. Not nilable: the key
+ ;; lives outside the record, `app.common.logic.shapes` dissocs it to unset
+ ;; it, and `setup-shape` drops it when a caller passes nil.
+ [:hide-in-viewer {:optional true} :boolean]
+
+ ;; The SVG provenance an import leaves on a shape. Typed `:map` rather than
+ ;; more precisely on purpose: legacy files hold `svg-transform` as a plain
+ ;; `{:a … :f}` map rather than a `::gmt/matrix` record, and `svg-viewbox` as
+ ;; either a `::grc/rect` record or a plain map, so a tighter schema here
+ ;; would reject files that are otherwise valid. The graph *column* types are
+ ;; tightened separately, where a wrong guess costs a column rather than a
+ ;; rejected file (`app.graph.schema.contract/type-overrides`).
+ [:svg-attrs {:optional true} :map]
+ [:svg-defs {:optional true} :map]
+ [:svg-transform {:optional true} :map]
+ [:svg-viewbox {:optional true} :map]])
(def schema:group-attrs
[:map {:title "GroupAttrs"}
@@ -244,7 +287,30 @@
[:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]]
[:hide-fill-on-export {:optional true} :boolean]
[:show-content {:optional true} :boolean]
- [:hide-in-viewer {:optional true} :boolean]])
+ ;; `hide-in-viewer` moved to `schema:shape-generic-attrs`: stored files carry
+ ;; it on circles, rects and texts too, not only on frames.
+ ;; `use-for-thumbnail` is a frame attribute the model has long had, since
+ ;; `app.common.files.migrations` renames `:use-for-thumbnail?` to it and
+ ;; `app.common.logic.libraries` reads it, and this schema had not declared.
+ [:use-for-thumbnail {:optional true} :boolean]])
+
+(def ^:private schema:nilable-geom-attrs
+ "`schema:shape-geom-attrs`, but nilable.
+
+ Bools and paths are the only two shape types whose geometry can be nil:
+ `make-minimal-shape` gives `x`, `y`, `width` and `height` a default for every
+ other type and skips those two, whose extent their content and `selrect`
+ imply instead. The four keys stay required, because they are `Shape` record
+ fields and `app.common.record/defrecord` keeps a base field present whatever
+ it holds. So these two branches cannot merge `schema:shape-geom-attrs`, which
+ rejects the nil, and declare the same four keys nilable instead. A
+ schema-derived reader previously saw a bool or a path as having no position or
+ size at all."
+ [:map {:title "NilableGeometryAttrs"}
+ [:x [:maybe ::sm/safe-number]]
+ [:y [:maybe ::sm/safe-number]]
+ [:width [:maybe ::sm/safe-number]]
+ [:height [:maybe ::sm/safe-number]]])
(def ^:private schema:bool-attrs
[:map {:title "BoolAttrs"}
@@ -253,13 +319,37 @@
[:content path/schema:content]])
(def ^:private schema:rect-attrs
- [:map {:title "RectAttrs"}])
+ [:map {:title "RectAttrs"}
+ ;; Legacy radii, set by SVG import (`app.common.files.shapes-builder` parses
+ ;; `rx`/`ry` off the element) and by migration 0003, which assocs `0`.
+ ;; Superseded by `r1` to `r4`, but stored files still carry them. Not
+ ;; nilable: both keys live outside the `Shape` record, so a dissoc removes
+ ;; them, and `setup-shape` drops a nil before the merge.
+ [:rx {:optional true} ::sm/safe-number]
+ [:ry {:optional true} ::sm/safe-number]])
(def ^:private schema:circle-attrs
- [:map {:title "CircleAttrs"}])
+ [:map {:title "CircleAttrs"}
+ [:rx {:optional true} ::sm/safe-number]
+ [:ry {:optional true} ::sm/safe-number]])
(def ^:private schema:svg-raw-attrs
- [:map {:title "SvgRawAttrs"}])
+ [:map {:title "SvgRawAttrs"}
+ ;; An svg-raw shape can be a container: importing an SVG builds a
+ ;; tree of svg-raw shapes, and `cfh/group-like-shape?` treats an
+ ;; svg-raw with children as group-like. Declaring `:shapes` here
+ ;; keeps the child ids typed as uuid, so a JSON round trip (binfile
+ ;; export/import) decodes them back to uuids instead of leaving
+ ;; strings that no longer resolve against the objects map.
+ [:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]]
+ ;; The raw SVG node an import kept.
+ ;; `app.common.files.shapes-builder/create-raw-svg` sets it and
+ ;; `allowed-svg-attrs` names it. Usually the parsed element,
+ ;; `{:tag … :attrs … :content …}`, but a bare text node arrives as the
+ ;; string itself: `hi` becomes one svg-raw for the element
+ ;; and another for `"hi"`. `app.common.files.shapes-builder/parse-svg-element`
+ ;; carries a FIXME about exactly that. Both forms are legal and stored.
+ [:content {:optional true} [:or :map :string]]])
(def schema:image-attrs
[:map {:title "ImageAttrs"}
@@ -294,7 +384,10 @@
(->> (sg/generator schema:shape-base-attrs)
(sg/mcat (fn [{:keys [type] :as shape}]
(sg/let [attrs1 (sg/generator schema:shape-generic-attrs)
- attrs2 (sg/generator schema:shape-geom-attrs)
+ attrs2 (if (or (= type :path)
+ (= type :bool))
+ (sg/generator schema:nilable-geom-attrs)
+ (sg/generator schema:shape-geom-attrs))
attrs3 (case type
:text (sg/generator schema:text-attrs)
:path (sg/generator schema:path-attrs)
@@ -305,10 +398,7 @@
:bool (sg/generator schema:bool-attrs)
:group (sg/generator schema:group-attrs)
:frame (sg/generator schema:frame-attrs))]
- (if (or (= type :path)
- (= type :bool))
- (merge attrs1 shape attrs3)
- (merge attrs1 shape attrs2 attrs3)))))
+ (merge attrs1 shape attrs2 attrs3))))
(sg/fmap create-shape)))
(def schema:shape-attrs
@@ -340,6 +430,7 @@
ctsl/schema:layout-child-attrs
schema:bool-attrs
schema:shape-generic-attrs
+ schema:nilable-geom-attrs
schema:shape-base-attrs]]
[:rect
@@ -379,6 +470,7 @@
ctsl/schema:layout-child-attrs
schema:path-attrs
schema:shape-generic-attrs
+ schema:nilable-geom-attrs
schema:shape-base-attrs]]
[:text
@@ -532,8 +624,9 @@
{:type :path
:name "Path"
:fills []
+ ;; Paths use centered strokes by default.
:strokes [{:stroke-style :solid
- :stroke-alignment :inner
+ :stroke-alignment :center
:stroke-width 1
:stroke-color clr/black
:stroke-opacity 1}]})
@@ -565,10 +658,15 @@
[type]
(let [type (if (= type :curve) :path type)
attrs (get-minimal-shape type)
- attrs (cond-> attrs
- (and (not= :path type)
- (not= :bool type))
- (-> (assoc :x 0)
+ attrs (if (or (= :path type)
+ (= :bool type))
+ (-> attrs
+ (assoc :x nil)
+ (assoc :y nil)
+ (assoc :width nil)
+ (assoc :height nil))
+ (-> attrs
+ (assoc :x 0)
(assoc :y 0)
(assoc :width 0.01)
(assoc :height 0.01)))
diff --git a/common/src/app/common/types/shape/attrs.cljc b/common/src/app/common/types/shape/attrs.cljc
index 0d76f5afa4..eb8dc69193 100644
--- a/common/src/app/common/types/shape/attrs.cljc
+++ b/common/src/app/common/types/shape/attrs.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape.attrs
(:require
diff --git a/common/src/app/common/types/shape/background_blur.cljc b/common/src/app/common/types/shape/background_blur.cljc
index 214629a580..6eb6b7ad38 100644
--- a/common/src/app/common/types/shape/background_blur.cljc
+++ b/common/src/app/common/types/shape/background_blur.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape.background-blur
(:require
diff --git a/common/src/app/common/types/shape/blur.cljc b/common/src/app/common/types/shape/blur.cljc
index e0a149d2bc..59decef09e 100644
--- a/common/src/app/common/types/shape/blur.cljc
+++ b/common/src/app/common/types/shape/blur.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape.blur
(:require
diff --git a/common/src/app/common/types/shape/export.cljc b/common/src/app/common/types/shape/export.cljc
index babc18157b..5c54f0455b 100644
--- a/common/src/app/common/types/shape/export.cljc
+++ b/common/src/app/common/types/shape/export.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape.export
(:require
diff --git a/frontend/src/app/render_wasm/resources.cljs b/common/src/app/common/types/shape/images.cljs
similarity index 87%
rename from frontend/src/app/render_wasm/resources.cljs
rename to common/src/app/common/types/shape/images.cljs
index 9f564aa990..18186fe065 100644
--- a/frontend/src/app/render_wasm/resources.cljs
+++ b/common/src/app/common/types/shape/images.cljs
@@ -2,14 +2,14 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
-(ns app.render-wasm.resources
+(ns app.common.types.shape.images
"Host-agnostic enumeration of the external resources a scene needs to
render: which image bytes its shapes reference. Pure data walking — no
browser or Node dependencies — so the workspace and the headless exporter
- derive the same set from the same source (sibling of
- `app.render-wasm.fallback-fonts`, which does the same for fonts)."
+ derive the same set from the same source (counterpart of
+ `app.common.fonts`, which does the same for fonts)."
(:require
[app.common.types.fills :as types.fills]))
diff --git a/common/src/app/common/types/shape/interactions.cljc b/common/src/app/common/types/shape/interactions.cljc
index 6b06b68897..f3e7912717 100644
--- a/common/src/app/common/types/shape/interactions.cljc
+++ b/common/src/app/common/types/shape/interactions.cljc
@@ -2,14 +2,13 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape.interactions
(:require
[app.common.data :as d]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
- [app.common.geom.shapes.bounds :as gsb]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]))
@@ -482,7 +481,13 @@
(if (nil? dest-frame)
[(gpt/point 0 0) [:top :left]]
- (let [overlay-size (gsb/get-object-bounds objects dest-frame)
+ (let [;; Use the destination frame selrect (the visible frame box) to compute
+ ;; the overlay position, not its full object bounds. Bounds include
+ ;; padding for shadows, blur, strokes and overflowing children, which
+ ;; would make centered/right/bottom positions off by half that padding
+ ;; (the visible frame ends up shifted). The viewer reserves the bounds
+ ;; size and re-aligns the selrect separately (see viewer/calculate-delta).
+ overlay-size (:selrect dest-frame)
base-frame-size (:selrect base-frame)
relative-to-shape-size (:selrect relative-to-shape)
relative-to-adjusted-to-base-frame {:x (- (:x relative-to-shape-size) (:x base-frame-size))
diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc
index 03532db4ec..b66aabc27d 100644
--- a/common/src/app/common/types/shape/layout.cljc
+++ b/common/src/app/common/types/shape/layout.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape.layout
(:require
diff --git a/common/src/app/common/types/shape/radius.cljc b/common/src/app/common/types/shape/radius.cljc
index 34fdd067c9..46dfa2dacb 100644
--- a/common/src/app/common/types/shape/radius.cljc
+++ b/common/src/app/common/types/shape/radius.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape.radius
(:require
diff --git a/common/src/app/common/types/shape/shadow.cljc b/common/src/app/common/types/shape/shadow.cljc
index 7ec688ab4a..1398a7b5eb 100644
--- a/common/src/app/common/types/shape/shadow.cljc
+++ b/common/src/app/common/types/shape/shadow.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape.shadow
(:require
diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc
index 8c0595daa6..122aae80a8 100644
--- a/common/src/app/common/types/shape/text.cljc
+++ b/common/src/app/common/types/shape/text.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape.text
(:require
diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc
index f9ce8ded20..92a889a6d5 100644
--- a/common/src/app/common/types/shape_tree.cljc
+++ b/common/src/app/common/types/shape_tree.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.shape-tree
(:require
diff --git a/common/src/app/common/types/stroke.cljc b/common/src/app/common/types/stroke.cljc
index a3a84f6921..792b56244a 100644
--- a/common/src/app/common/types/stroke.cljc
+++ b/common/src/app/common/types/stroke.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.stroke
(:require
diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc
index fd099d78db..b515ef9be0 100644
--- a/common/src/app/common/types/team.cljc
+++ b/common/src/app/common/types/team.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.team
(:require
diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc
index 6068cfc829..f7aeb37664 100644
--- a/common/src/app/common/types/text.cljc
+++ b/common/src/app/common/types/text.cljc
@@ -2,13 +2,14 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.text
(:require
[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,
diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc
index 10cedd5c19..880149afbb 100644
--- a/common/src/app/common/types/token.cljc
+++ b/common/src/app/common/types/token.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.token
(:require
@@ -423,11 +423,8 @@
:stroke-width :strokes
token-attr))
-(defn shape-attr->token-attrs
- "Returns the token-attr affected when a given attribute in a shape is changed.
- The sub-attr is for attributes that may have multiple values, like strokes
- (may be width or color) and layout padding & margin (may have 4 edges)."
- ([shape-attr] (shape-attr->token-attrs shape-attr nil))
+(defn- shape-attr->token-attrs*
+ ([shape-attr] (shape-attr->token-attrs* shape-attr nil))
([shape-attr changed-sub-attr]
(cond
(= :fills shape-attr)
@@ -468,6 +465,20 @@
(number-keys shape-attr) #{shape-attr}
(axis-keys shape-attr) #{shape-attr})))
+(def ^:private shape-attr->token-attrs-1
+ (memoize shape-attr->token-attrs*))
+
+(defn shape-attr->token-attrs
+ "Returns the token-attr affected when a given attribute in a shape is changed.
+ The sub-attr is for attributes that may have multiple values, like strokes
+ (may be width or color) and layout padding & margin (may have 4 edges)."
+ ([shape-attr]
+ (shape-attr->token-attrs-1 shape-attr))
+ ([shape-attr changed-sub-attr]
+ (if (nil? changed-sub-attr)
+ (shape-attr->token-attrs-1 shape-attr)
+ (shape-attr->token-attrs* shape-attr changed-sub-attr))))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for token attributes by shape type
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc
index 2376dd7563..1fe5b87658 100644
--- a/common/src/app/common/types/tokens_lib.cljc
+++ b/common/src/app/common/types/tokens_lib.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.tokens-lib
(:require
diff --git a/common/src/app/common/types/tokens_status.cljc b/common/src/app/common/types/tokens_status.cljc
new file mode 100644
index 0000000000..c2953cc4c1
--- /dev/null
+++ b/common/src/app/common/types/tokens_status.cljc
@@ -0,0 +1,147 @@
+;; 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.common.types.tokens-status
+ (:require
+ #?(:clj [app.common.fressian :as fres])
+ #?(:clj [clojure.data.json :as c.json])
+ [app.common.schema :as sm]
+ [app.common.schema.generators :as sg]
+ [app.common.transit :as t]
+ [clojure.core.protocols :as cp]
+ [clojure.datafy :refer [datafy]]
+ [clojure.pprint :as pp]))
+
+;; TokensStatus datatype contains the activation status of the themes and sets
+;; in a tokens library.
+
+(defprotocol ITokensStatus
+ (get-active-theme-ids [_] "Return a clojure set of active theme ids")
+ (get-active-set-ids [_] "Return a clojure set of active set ids")
+ (theme-active? [_ theme-id] "Check if a theme is active")
+ (set-active? [_ set-id] "Check if a set is active")
+ (set-tokens-status [_ theme-ids set-ids] "Set the activation status of the themes and sets"))
+
+(deftype TokensStatus [active-theme-ids active-set-ids]
+ cp/Datafiable
+ (datafy [_]
+ {:active-theme-ids active-theme-ids
+ :active-set-ids active-set-ids})
+
+ #?@(:clj
+ [c.json/JSONWriter
+ (-write [this writter options]
+ (c.json/-write (datafy this) writter options))])
+
+ ITokensStatus
+ (get-active-theme-ids [_]
+ active-theme-ids)
+
+ (get-active-set-ids [_]
+ active-set-ids)
+
+ (theme-active? [_ theme-id]
+ (assert (uuid? theme-id))
+ (contains? active-theme-ids theme-id))
+
+ (set-active? [_ set-id]
+ (assert (uuid? set-id))
+ (contains? active-set-ids set-id))
+
+ (set-tokens-status [_ theme-ids set-ids]
+ (assert (set? theme-ids))
+ (assert (set? set-ids))
+ (TokensStatus. theme-ids set-ids)))
+
+;; === Helper & Predicate ===
+
+(defn map->TokensStatus
+ [{:keys [active-theme-ids active-set-ids]}]
+ (TokensStatus. active-theme-ids active-set-ids))
+
+(defn tokens-status?
+ [o]
+ (instance? TokensStatus o))
+
+;; === Schemas, Check functions & Constructor ===
+
+(declare make-tokens-status)
+
+(def schema:tokens-status-attrs
+ [:map {:title "TokensStatus"}
+ [:active-theme-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]]
+ [:active-set-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]]])
+
+(def schema:tokens-status
+ [:and {:gen/gen (->> (sg/generator schema:tokens-status-attrs)
+ (sg/fmap #(make-tokens-status %)))}
+ [:fn tokens-status?]])
+
+(def ^:private check-tokens-status-attrs
+ (sm/check-fn schema:tokens-status-attrs
+ :hint "expected valid params for tokens-status"))
+
+(def check-tokens-status
+ (sm/check-fn schema:tokens-status
+ :hint "expected valid tokens-status"))
+
+(defn make-tokens-status
+ [& {:as attrs}]
+ (-> attrs
+ (update :active-theme-ids #(or % #{}))
+ (update :active-set-ids #(or % #{}))
+ (check-tokens-status-attrs)
+ (map->TokensStatus)))
+
+;; === Pretty-print for debugging ===
+
+(defmethod pp/simple-dispatch TokensStatus [^TokensStatus obj]
+ (.write *out* "#penpot/tokens-status ")
+ (pp/pprint-newline :miser)
+ (pp/pprint (datafy obj)))
+
+#?(:clj
+ (do
+ (defmethod print-method TokensStatus
+ [^TokensStatus this ^java.io.Writer w]
+ (.write w "#penpot/tokens-status ")
+ (print-method (datafy this) w))
+
+ (defmethod print-dup TokensStatus
+ [^TokensStatus this ^java.io.Writer w]
+ (print-method this w)))
+
+ :cljs
+ (extend-type TokensStatus
+ cljs.core/IPrintWithWriter
+ (-pr-writer [this writer opts]
+ (-write writer "#penpot/tokens-status ")
+ (-pr-writer (datafy this) writer opts))
+
+ cljs.core/IEncodeJS
+ (-clj->js [this]
+ (clj->js (datafy this)))))
+
+;; === Transit serialization ===
+
+(t/add-handlers!
+ {:id "penpot/tokens-status"
+ :class TokensStatus
+ :wfn datafy
+ :rfn #(make-tokens-status %)})
+
+;; === Fressian serialization ===
+
+#?(:clj
+ (fres/add-handlers!
+ {:name "penpot/tokens-status/v1"
+ :class TokensStatus
+ :wfn (fn [n w o]
+ (fres/write-tag! w n 1)
+ (fres/write-object! w (datafy o)))
+ :rfn (fn [r]
+ (let [obj (fres/read-object! r)]
+ (make-tokens-status obj)))}))
diff --git a/common/src/app/common/types/typographies_list.cljc b/common/src/app/common/types/typographies_list.cljc
index 635c1431d3..fb0daec536 100644
--- a/common/src/app/common/types/typographies_list.cljc
+++ b/common/src/app/common/types/typographies_list.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.typographies-list
(:require
diff --git a/common/src/app/common/types/typography.cljc b/common/src/app/common/types/typography.cljc
index 241f5f3079..1c3f2fab93 100644
--- a/common/src/app/common/types/typography.cljc
+++ b/common/src/app/common/types/typography.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.typography
(:require
diff --git a/common/src/app/common/types/variant.cljc b/common/src/app/common/types/variant.cljc
index db65e42806..8a37939756 100644
--- a/common/src/app/common/types/variant.cljc
+++ b/common/src/app/common/types/variant.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.variant
(:require
diff --git a/common/src/app/common/uri.cljc b/common/src/app/common/uri.cljc
index b82b5c0e74..4284b47290 100644
--- a/common/src/app/common/uri.cljc
+++ b/common/src/app/common/uri.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.uri
(:refer-clojure :exclude [uri?])
@@ -67,6 +67,36 @@
path
(str path "/")))))
+(defn- update-query-params
+ "Apply `f` to the query-params map of `url`, returning the updated URL string.
+ Handles both plain query strings and fragment-based (hash) URLs."
+ [url f]
+ (let [transform (fn [parsed]
+ (update parsed :query
+ (fn [q]
+ (-> (query-string->map (or q ""))
+ f
+ map->query-string))))
+ parsed (uri url)
+ fragment (:fragment parsed)]
+ (if (str/blank? fragment)
+ (str (transform parsed))
+ (-> parsed
+ (assoc :fragment (str (transform (parse fragment))))
+ str))))
+
+(defn append-query-param
+ "Return a new URL string with the given query parameter added or replaced.
+ Handles both plain query strings and fragment-based (hash) URLs."
+ [url key value]
+ (update-query-params url #(assoc % key value)))
+
+(defn remove-query-param
+ "Return a new URL string with the given query parameter removed.
+ Handles both plain query strings and fragment-based (hash) URLs."
+ [url key]
+ (update-query-params url #(dissoc % key)))
+
#?(:clj
(defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer]
(.write writer "#")
diff --git a/common/src/app/common/uuid.cljc b/common/src/app/common/uuid.cljc
index d094d4c06d..e890a595c1 100644
--- a/common/src/app/common/uuid.cljc
+++ b/common/src/app/common/uuid.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
#_:clj-kondo/ignore
(ns app.common.uuid
diff --git a/common/src/app/common/uuid_impl.js b/common/src/app/common/uuid_impl.js
index 9a868cc9a5..07a20269cb 100644
--- a/common/src/app/common/uuid_impl.js
+++ b/common/src/app/common/uuid_impl.js
@@ -3,7 +3,7 @@
* 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
+ * Copyright (c) KALEIDOS SUBSIDIARY SL
*/
"use strict";
diff --git a/common/src/app/common/version.cljc b/common/src/app/common/version.cljc
index fb4098557b..1b6a646f10 100644
--- a/common/src/app/common/version.cljc
+++ b/common/src/app/common/version.cljc
@@ -2,11 +2,12 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.version
"A version parsing helper."
(:require
+ [app.common.data :as d]
[cuerdas.core :as str]))
(def version-re #"^(([A-Za-z]+)\-?)?((\d+)\.(\d+)\.(\d+))(\-?((RC|DEV)(\d+)?))?(\-?(\d+))?(\-?g(\w+))?$")
@@ -49,3 +50,24 @@
:else nil))
+(defn- version-components
+ [version]
+ (let [{:keys [major minor patch]} (or (parse version) {})]
+ [(d/parse-integer major 0)
+ (d/parse-integer minor 0)
+ (d/parse-integer patch 0)]))
+
+(defn compare-versions
+ "Compare two X.Y.Z base versions. Returns negative if a < b, zero if
+ equal, positive if a > b."
+ [version-a version-b]
+ (let [[major-a minor-a patch-a] (version-components version-a)
+ [major-b minor-b patch-b] (version-components version-b)]
+ (or (when (not= major-a major-b) (- major-a major-b))
+ (when (not= minor-a minor-b) (- minor-a minor-b))
+ (- patch-a patch-b))))
+
+(defn newer?
+ [version-a version-b]
+ (pos? (compare-versions version-a version-b)))
+
diff --git a/common/src/app/common/weak.cljc b/common/src/app/common/weak.cljc
index 7733f58ce6..6306d422d9 100644
--- a/common/src/app/common/weak.cljc
+++ b/common/src/app/common/weak.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.weak
"A collection of helpers for work with weak references and weak
diff --git a/common/src/app/common/weak/impl_loadable_weak_value_map.clj b/common/src/app/common/weak/impl_loadable_weak_value_map.clj
index a7801d8cec..6e582f26f1 100644
--- a/common/src/app/common/weak/impl_loadable_weak_value_map.clj
+++ b/common/src/app/common/weak/impl_loadable_weak_value_map.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.weak.impl-loadable-weak-value-map
(:import
diff --git a/common/src/app/common/weak/impl_weak_map.js b/common/src/app/common/weak/impl_weak_map.js
index 1a4d4fc31e..3c900634bd 100644
--- a/common/src/app/common/weak/impl_weak_map.js
+++ b/common/src/app/common/weak/impl_weak_map.js
@@ -3,7 +3,7 @@
* 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
+ * Copyright (c) KALEIDOS SUBSIDIARY SL
*/
"use strict";
diff --git a/common/src/app/common/weak/impl_weak_value_map.js b/common/src/app/common/weak/impl_weak_value_map.js
index 11eaba8b0d..8f8dc27586 100644
--- a/common/src/app/common/weak/impl_weak_value_map.js
+++ b/common/src/app/common/weak/impl_weak_value_map.js
@@ -3,7 +3,7 @@
* 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
+ * Copyright (c) KALEIDOS SUBSIDIARY SL
*/
"use strict";
diff --git a/common/test/common_tests/attrs_test.cljc b/common/test/common_tests/attrs_test.cljc
index bab8b9fbaf..340a89e0da 100644
--- a/common/test/common_tests/attrs_test.cljc
+++ b/common/test/common_tests/attrs_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.attrs-test
(:require
diff --git a/common/test/common_tests/buffer_test.cljc b/common/test/common_tests/buffer_test.cljc
index 612a4248cd..43b35e05d4 100644
--- a/common/test/common_tests/buffer_test.cljc
+++ b/common/test/common_tests/buffer_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.buffer-test
(:require
diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc
index b79f9c6176..ee13f2e3b0 100644
--- a/common/test/common_tests/colors_test.cljc
+++ b/common/test/common_tests/colors_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.colors-test
(:require
diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc
index 39f3370de8..cd3e1c5eae 100644
--- a/common/test/common_tests/data_test.cljc
+++ b/common/test/common_tests/data_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.data-test
(:require
@@ -36,6 +36,43 @@
(t/is (= "" (d/get-initials nil)))
(t/is (= "" (d/get-initials "!!! ???"))))
+(t/deftest normalize-string-test
+ ;; nil input returns empty string
+ (t/is (= "" (d/normalize-string nil)))
+ ;; empty string returns empty string
+ (t/is (= "" (d/normalize-string "")))
+ ;; leading whitespace is trimmed
+ (t/is (= "hello" (d/normalize-string " hello")))
+ ;; trailing whitespace is trimmed
+ (t/is (= "hello" (d/normalize-string "hello ")))
+ ;; both leading and trailing whitespace are trimmed
+ (t/is (= "hello" (d/normalize-string " hello ")))
+ ;; internal whitespace is preserved
+ (t/is (= "hello world" (d/normalize-string " hello world ")))
+ ;; non-string input is returned unchanged
+ (t/is (= 42 (d/normalize-string 42)))
+ (t/is (= :keyword (d/normalize-string :keyword)))
+ (t/is (= true (d/normalize-string true))))
+
+(t/deftest escape-markdown-test
+ (t/is (= "hello" (d/escape-markdown "hello")))
+ (t/is (= "" (d/escape-markdown nil)))
+ (t/is (= "" (d/escape-markdown "")))
+ (t/is (= "\\*bold\\*" (d/escape-markdown "*bold*")))
+ (t/is (= "\\_italic\\_" (d/escape-markdown "_italic_")))
+ (t/is (= "\\~strikethrough\\~" (d/escape-markdown "~strikethrough~")))
+ (t/is (= "\\`code\\`" (d/escape-markdown "`code`")))
+ (t/is (= "\\[link\\]\\(http://evil\\.com\\)" (d/escape-markdown "[link](http://evil.com)")))
+ (t/is (= "\\> quote" (d/escape-markdown "> quote")))
+ (t/is (= "\\# heading" (d/escape-markdown "# heading")))
+ (t/is (= "\\@channel" (d/escape-markdown "@channel")))
+ (t/is (= "\\!bang" (d/escape-markdown "!bang")))
+ (t/is (= "normal\\-text" (d/escape-markdown "normal-text")))
+ (t/is (= "a\\+b\\=c" (d/escape-markdown "a+b=c")))
+ (t/is (= "pipe\\|separated" (d/escape-markdown "pipe|separated")))
+ (t/is (= "curly\\{\\}braces" (d/escape-markdown "curly{}braces")))
+ (t/is (= "backslash\\\\slash" (d/escape-markdown "backslash\\slash"))))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Ordered Data Structures
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/common/test/common_tests/files/comp_processors_test.cljc b/common/test/common_tests/files/comp_processors_test.cljc
index 412986ede4..6eb30ad82b 100644
--- a/common/test/common_tests/files/comp_processors_test.cljc
+++ b/common/test/common_tests/files/comp_processors_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files.comp-processors-test
(:require
diff --git a/common/test/common_tests/files/helpers_test.cljc b/common/test/common_tests/files/helpers_test.cljc
index 4205d562c7..56ef876499 100644
--- a/common/test/common_tests/files/helpers_test.cljc
+++ b/common/test/common_tests/files/helpers_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files.helpers-test
(:require
diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc
new file mode 100644
index 0000000000..ad9532e497
--- /dev/null
+++ b/common/test/common_tests/files/repair_test.cljc
@@ -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 SUBSIDIARY 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'))))))
diff --git a/common/test/common_tests/files/shapes_builder_test.cljc b/common/test/common_tests/files/shapes_builder_test.cljc
index f57fe7ac07..614ff7cf77 100644
--- a/common/test/common_tests/files/shapes_builder_test.cljc
+++ b/common/test/common_tests/files/shapes_builder_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files.shapes-builder-test
(:require
diff --git a/common/test/common_tests/files/tokens_test.cljc b/common/test/common_tests/files/tokens_test.cljc
index 63f083ce5e..e196ba4ac1 100644
--- a/common/test/common_tests/files/tokens_test.cljc
+++ b/common/test/common_tests/files/tokens_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files.tokens-test
(:require
diff --git a/common/test/common_tests/files/validate_test.cljc b/common/test/common_tests/files/validate_test.cljc
index 271cfa611e..48fd3e3f07 100644
--- a/common/test/common_tests/files/validate_test.cljc
+++ b/common/test/common_tests/files/validate_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files.validate-test
"Exhaustive tests for the change-scoped partial validation functions in
diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc
index 993f5cd827..707d56d3f0 100644
--- a/common/test/common_tests/files_builder_test.cljc
+++ b/common/test/common_tests/files_builder_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files-builder-test
(:require
diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc
index 7671f7a787..4d91bf4f7f 100644
--- a/common/test/common_tests/files_changes_test.cljc
+++ b/common/test/common_tests/files_changes_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files-changes-test
(:require
diff --git a/common/test/common_tests/files_migrations_0025_test.cljc b/common/test/common_tests/files_migrations_0025_test.cljc
index 4d57398953..36480bd3cc 100644
--- a/common/test/common_tests/files_migrations_0025_test.cljc
+++ b/common/test/common_tests/files_migrations_0025_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files-migrations-0025-test
(:require
diff --git a/common/test/common_tests/files_migrations_0026_test.cljc b/common/test/common_tests/files_migrations_0026_test.cljc
new file mode 100644
index 0000000000..92dde088e8
--- /dev/null
+++ b/common/test/common_tests/files_migrations_0026_test.cljc
@@ -0,0 +1,110 @@
+;; 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-migrations-0026-test
+ (:require
+ [app.common.files.migrations :as cfm]
+ [app.common.uuid :as uuid]
+ [clojure.test :as t]))
+
+;; 0026-fix-svg-raw-shapes-uuids
+;; Before the svg-raw schema declared :shapes as a vector of uuid, the
+;; JSON decoder had no type information for those child ids and left
+;; them as plain strings on any round trip, so they got persisted as
+;; strings. Once the schema was tightened, such files fail schema
+;; validation; this migration parses the strings back into uuids.
+
+(defn- make-svg-raw-shape
+ "Build a minimal svg-raw shape with the supplied :shapes vector.
+ When `shapes` is nil the :shapes key is omitted, like a leaf svg-raw
+ shape."
+ [shape-id shapes]
+ (cond-> {:id shape-id
+ :type :svg-raw}
+ (some? shapes)
+ (assoc :shapes shapes)))
+
+(defn- make-other-shape
+ "Build a minimal non-svg-raw shape that must stay untouched."
+ [shape-id shapes]
+ {:id shape-id
+ :type :group
+ :shapes shapes})
+
+(t/deftest migration-0026-converts-svg-raw-shapes-strings-to-uuids-in-pages
+ (let [shape-id (uuid/next)
+ child-id (uuid/next)
+ page-id (uuid/next)
+ data {:pages-index
+ {page-id
+ {:objects
+ {shape-id (make-svg-raw-shape
+ shape-id
+ [(str child-id)
+ "1c2986ce-4a0f-8001-8007-1fb8f3b5ab31"])}}}}
+ data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids")
+ shape (get-in data' [:pages-index page-id :objects shape-id])]
+
+ (t/is (= 2 (count (:shapes shape))) "child ids preserved")
+ (t/is (= child-id (first (:shapes shape))) "existing uuid string parsed to uuid")
+ (t/is (= #uuid "1c2986ce-4a0f-8001-8007-1fb8f3b5ab31" (second (:shapes shape)))
+ "foreign uuid string parsed to uuid")
+ (t/is (every? uuid? (:shapes shape)) "all child ids are uuids")))
+
+(t/deftest migration-0026-converts-svg-raw-shapes-strings-to-uuids-in-components
+ (let [shape-id (uuid/next)
+ child-id (uuid/next)
+ component-id (uuid/next)
+ data {:components
+ {component-id
+ {:objects
+ {shape-id (make-svg-raw-shape
+ shape-id
+ [(str child-id)
+ "1c2986ce-4a0f-8001-8007-1fb92196e65f"])}}}}
+ data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids")
+ shape (get-in data' [:components component-id :objects shape-id])]
+
+ (t/is (= 2 (count (:shapes shape))) "child ids preserved")
+ (t/is (= child-id (first (:shapes shape))) "existing uuid string parsed to uuid")
+ (t/is (= #uuid "1c2986ce-4a0f-8001-8007-1fb92196e65f" (second (:shapes shape)))
+ "foreign uuid string parsed to uuid")
+ (t/is (every? uuid? (:shapes shape)) "all child ids are uuids")))
+
+(t/deftest migration-0026-leaves-uuids-and-other-shapes-untouched
+ (let [svg-raw-id (uuid/next)
+ child-id (uuid/next)
+ group-id (uuid/next)
+ leaf-id (uuid/next)
+ page-id (uuid/next)
+ data {:pages-index
+ {page-id
+ {:objects
+ {svg-raw-id (make-svg-raw-shape svg-raw-id [child-id])
+ group-id (make-other-shape group-id [(str child-id)])
+ leaf-id (make-svg-raw-shape leaf-id nil)}}}}
+ data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids")
+ objects (get-in data' [:pages-index page-id :objects])]
+
+ (t/is (= [child-id] (:shapes (get objects svg-raw-id)))
+ "already-uuid svg-raw children untouched")
+ (t/is (= [(str child-id)] (:shapes (get objects group-id)))
+ "non-svg-raw shapes untouched")
+ (t/is (nil? (:shapes (get objects leaf-id)))
+ "svg-raw leaf without :shapes untouched")))
+
+(t/deftest migration-0026-is-idempotent
+ (let [shape-id (uuid/next)
+ child-id (uuid/next)
+ page-id (uuid/next)
+ data {:pages-index
+ {page-id
+ {:objects
+ {shape-id (make-svg-raw-shape shape-id [(str child-id)])}}}}
+ data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids")
+ data'' (cfm/migrate-data data' "0026-fix-svg-raw-shapes-uuids")]
+
+ (t/is (= data' data'') "second run is a no-op")))
\ No newline at end of file
diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc
index 36ff3a09e2..7a8f757c45 100644
--- a/common/test/common_tests/files_migrations_test.cljc
+++ b/common/test/common_tests/files_migrations_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files-migrations-test
(:require
diff --git a/common/test/common_tests/fressian_test.clj b/common/test/common_tests/fressian_test.clj
index 9af54464a5..d405a01e7c 100644
--- a/common/test/common_tests/fressian_test.clj
+++ b/common/test/common_tests/fressian_test.clj
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.fressian-test
"Exhaustive unit tests for app.common.fressian encode/decode functions.
@@ -21,7 +21,8 @@
(:import
java.time.Instant
java.time.OffsetDateTime
- java.time.ZoneOffset))
+ java.time.ZoneOffset
+ java.util.UUID))
;; ---------------------------------------------------------------------------
;; Helpers
@@ -524,3 +525,18 @@
(t/is (d/ordered-map? rt))
(t/is (= om rt))
(t/is (= (keys om) (keys rt)))))
+
+(t/deftest decode-rejects-excessive-recursion-depth
+ ;; N2-01: deeply nested structures must be rejected before stack overflow
+ (let [depth (+ fres/max-read-depth 50)
+ data (reduce (fn [acc _i] [acc])
+ :leaf
+ (range depth))
+ encoded (fres/encode data)]
+ (try
+ (fres/decode encoded)
+ (t/is false "expected exception for excessive recursion depth")
+ (catch clojure.lang.ExceptionInfo e
+ (let [d (ex-data e)]
+ (t/is (= :validation (:type d)))
+ (t/is (= :max-read-depth-reached (:code d))))))))
diff --git a/common/test/common_tests/geom_align_test.cljc b/common/test/common_tests/geom_align_test.cljc
index dc72fccf5d..5d71bf6030 100644
--- a/common/test/common_tests/geom_align_test.cljc
+++ b/common/test/common_tests/geom_align_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-align-test
(:require
diff --git a/common/test/common_tests/geom_bounds_layout_nil_test.cljc b/common/test/common_tests/geom_bounds_layout_nil_test.cljc
index db070ce4f4..dda9fa957f 100644
--- a/common/test/common_tests/geom_bounds_layout_nil_test.cljc
+++ b/common/test/common_tests/geom_bounds_layout_nil_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-bounds-layout-nil-test
(:require
diff --git a/common/test/common_tests/geom_bounds_map_test.cljc b/common/test/common_tests/geom_bounds_map_test.cljc
index 23b239eb43..4321c6a13e 100644
--- a/common/test/common_tests/geom_bounds_map_test.cljc
+++ b/common/test/common_tests/geom_bounds_map_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-bounds-map-test
(:require
diff --git a/common/test/common_tests/geom_flex_layout_test.cljc b/common/test/common_tests/geom_flex_layout_test.cljc
index bc63b03c8c..c016daeea7 100644
--- a/common/test/common_tests/geom_flex_layout_test.cljc
+++ b/common/test/common_tests/geom_flex_layout_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-flex-layout-test
(:require
diff --git a/common/test/common_tests/geom_grid_layout_test.cljc b/common/test/common_tests/geom_grid_layout_test.cljc
index 369406ef38..1769ab634a 100644
--- a/common/test/common_tests/geom_grid_layout_test.cljc
+++ b/common/test/common_tests/geom_grid_layout_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-grid-layout-test
(:require
diff --git a/common/test/common_tests/geom_grid_test.cljc b/common/test/common_tests/geom_grid_test.cljc
index 9a3645eb8b..6d3d823882 100644
--- a/common/test/common_tests/geom_grid_test.cljc
+++ b/common/test/common_tests/geom_grid_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-grid-test
(:require
diff --git a/common/test/common_tests/geom_line_test.cljc b/common/test/common_tests/geom_line_test.cljc
index d6e6df5360..b1f0f8e659 100644
--- a/common/test/common_tests/geom_line_test.cljc
+++ b/common/test/common_tests/geom_line_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-line-test
(:require
diff --git a/common/test/common_tests/geom_modif_tree_test.cljc b/common/test/common_tests/geom_modif_tree_test.cljc
index 80088703ce..1ce29057e1 100644
--- a/common/test/common_tests/geom_modif_tree_test.cljc
+++ b/common/test/common_tests/geom_modif_tree_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-modif-tree-test
(:require
diff --git a/common/test/common_tests/geom_modifiers_test.cljc b/common/test/common_tests/geom_modifiers_test.cljc
index 8784ff25f7..8494975e53 100644
--- a/common/test/common_tests/geom_modifiers_test.cljc
+++ b/common/test/common_tests/geom_modifiers_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-modifiers-test
(:require
diff --git a/common/test/common_tests/geom_point_test.cljc b/common/test/common_tests/geom_point_test.cljc
index 1691c06757..d2742741bd 100644
--- a/common/test/common_tests/geom_point_test.cljc
+++ b/common/test/common_tests/geom_point_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-point-test
(:require
diff --git a/common/test/common_tests/geom_proportions_test.cljc b/common/test/common_tests/geom_proportions_test.cljc
index 3cb94c99fd..8aa05bd693 100644
--- a/common/test/common_tests/geom_proportions_test.cljc
+++ b/common/test/common_tests/geom_proportions_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-proportions-test
(:require
diff --git a/common/test/common_tests/geom_rect_test.cljc b/common/test/common_tests/geom_rect_test.cljc
index 8abfb76854..2f6e8e80f7 100644
--- a/common/test/common_tests/geom_rect_test.cljc
+++ b/common/test/common_tests/geom_rect_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-rect-test
(:require
diff --git a/common/test/common_tests/geom_shapes_common_test.cljc b/common/test/common_tests/geom_shapes_common_test.cljc
index 0a9e47f21d..1d7d47112b 100644
--- a/common/test/common_tests/geom_shapes_common_test.cljc
+++ b/common/test/common_tests/geom_shapes_common_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-shapes-common-test
(:require
diff --git a/common/test/common_tests/geom_shapes_constraints_test.cljc b/common/test/common_tests/geom_shapes_constraints_test.cljc
index 175cc6f77b..4f6cd3ce4c 100644
--- a/common/test/common_tests/geom_shapes_constraints_test.cljc
+++ b/common/test/common_tests/geom_shapes_constraints_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-shapes-constraints-test
(:require
diff --git a/common/test/common_tests/geom_shapes_corners_test.cljc b/common/test/common_tests/geom_shapes_corners_test.cljc
index 80efad7679..46692a28dd 100644
--- a/common/test/common_tests/geom_shapes_corners_test.cljc
+++ b/common/test/common_tests/geom_shapes_corners_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-shapes-corners-test
(:require
diff --git a/common/test/common_tests/geom_shapes_effects_test.cljc b/common/test/common_tests/geom_shapes_effects_test.cljc
index eeccde458a..af669686c9 100644
--- a/common/test/common_tests/geom_shapes_effects_test.cljc
+++ b/common/test/common_tests/geom_shapes_effects_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-shapes-effects-test
(:require
diff --git a/common/test/common_tests/geom_shapes_intersect_test.cljc b/common/test/common_tests/geom_shapes_intersect_test.cljc
index a670d938c4..ee1c082534 100644
--- a/common/test/common_tests/geom_shapes_intersect_test.cljc
+++ b/common/test/common_tests/geom_shapes_intersect_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-shapes-intersect-test
(:require
diff --git a/common/test/common_tests/geom_shapes_strokes_test.cljc b/common/test/common_tests/geom_shapes_strokes_test.cljc
index 1993add116..1ba89f00cb 100644
--- a/common/test/common_tests/geom_shapes_strokes_test.cljc
+++ b/common/test/common_tests/geom_shapes_strokes_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-shapes-strokes-test
(:require
diff --git a/common/test/common_tests/geom_shapes_test.cljc b/common/test/common_tests/geom_shapes_test.cljc
index 87805559e6..e60ded0fe7 100644
--- a/common/test/common_tests/geom_shapes_test.cljc
+++ b/common/test/common_tests/geom_shapes_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-shapes-test
(:require
diff --git a/common/test/common_tests/geom_shapes_text_test.cljc b/common/test/common_tests/geom_shapes_text_test.cljc
index 17185a67a1..7c1eef6bf3 100644
--- a/common/test/common_tests/geom_shapes_text_test.cljc
+++ b/common/test/common_tests/geom_shapes_text_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-shapes-text-test
(:require
diff --git a/common/test/common_tests/geom_shapes_tree_seq_test.cljc b/common/test/common_tests/geom_shapes_tree_seq_test.cljc
index b2df798e41..58c5c535f8 100644
--- a/common/test/common_tests/geom_shapes_tree_seq_test.cljc
+++ b/common/test/common_tests/geom_shapes_tree_seq_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-shapes-tree-seq-test
(:require
diff --git a/common/test/common_tests/geom_snap_test.cljc b/common/test/common_tests/geom_snap_test.cljc
index 14776e1293..a7af7443b1 100644
--- a/common/test/common_tests/geom_snap_test.cljc
+++ b/common/test/common_tests/geom_snap_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-snap-test
(:require
diff --git a/common/test/common_tests/geom_test.cljc b/common/test/common_tests/geom_test.cljc
index ff14261eb7..98e80f7302 100644
--- a/common/test/common_tests/geom_test.cljc
+++ b/common/test/common_tests/geom_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-test
(:require
diff --git a/common/test/common_tests/helpers_test.cljc b/common/test/common_tests/helpers_test.cljc
index 96c5b37fa0..437874b019 100644
--- a/common/test/common_tests/helpers_test.cljc
+++ b/common/test/common_tests/helpers_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.helpers-test
(:require
diff --git a/common/test/common_tests/logic/chained_propagation_test.cljc b/common/test/common_tests/logic/chained_propagation_test.cljc
index be9a05487e..32a98e68dc 100644
--- a/common/test/common_tests/logic/chained_propagation_test.cljc
+++ b/common/test/common_tests/logic/chained_propagation_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.chained-propagation-test
(:require
diff --git a/common/test/common_tests/logic/comp_creation_test.cljc b/common/test/common_tests/logic/comp_creation_test.cljc
index 4021d3138b..d28e5f3b31 100644
--- a/common/test/common_tests/logic/comp_creation_test.cljc
+++ b/common/test/common_tests/logic/comp_creation_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.comp-creation-test
(:require
diff --git a/common/test/common_tests/logic/comp_detach_with_nested_test.cljc b/common/test/common_tests/logic/comp_detach_with_nested_test.cljc
index c4020a48f2..3e06167e35 100644
--- a/common/test/common_tests/logic/comp_detach_with_nested_test.cljc
+++ b/common/test/common_tests/logic/comp_detach_with_nested_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.comp-detach-with-nested-test
(:require
diff --git a/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc b/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc
index 03ceb6b8ee..d02a06b9e0 100644
--- a/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc
+++ b/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.comp-main-edit-breaks-copy-slots-test
(:require
diff --git a/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc b/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc
index 08c852bfc3..da766cd71d 100644
--- a/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc
+++ b/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.comp-remove-swap-slots-test
(:require
diff --git a/common/test/common_tests/logic/comp_reset_test.cljc b/common/test/common_tests/logic/comp_reset_test.cljc
index 23b1136657..d9491271a3 100644
--- a/common/test/common_tests/logic/comp_reset_test.cljc
+++ b/common/test/common_tests/logic/comp_reset_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.comp-reset-test
(:require
diff --git a/common/test/common_tests/logic/comp_sync_test.cljc b/common/test/common_tests/logic/comp_sync_test.cljc
index 8a7b652328..a929c7a616 100644
--- a/common/test/common_tests/logic/comp_sync_test.cljc
+++ b/common/test/common_tests/logic/comp_sync_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.comp-sync-test
(:require
diff --git a/common/test/common_tests/logic/comp_touched_test.cljc b/common/test/common_tests/logic/comp_touched_test.cljc
index 5d91fcbc1a..b31874d128 100644
--- a/common/test/common_tests/logic/comp_touched_test.cljc
+++ b/common/test/common_tests/logic/comp_touched_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.comp-touched-test
(:require
diff --git a/common/test/common_tests/logic/copying_and_duplicating_test.cljc b/common/test/common_tests/logic/copying_and_duplicating_test.cljc
index 784c3d81ff..2c8f1161ee 100644
--- a/common/test/common_tests/logic/copying_and_duplicating_test.cljc
+++ b/common/test/common_tests/logic/copying_and_duplicating_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.copying-and-duplicating-test
(:require
diff --git a/common/test/common_tests/logic/duplicated_pages_test.cljc b/common/test/common_tests/logic/duplicated_pages_test.cljc
index 70a0e9d206..34222703ab 100644
--- a/common/test/common_tests/logic/duplicated_pages_test.cljc
+++ b/common/test/common_tests/logic/duplicated_pages_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.duplicated-pages-test
(:require
diff --git a/common/test/common_tests/logic/move_shapes_test.cljc b/common/test/common_tests/logic/move_shapes_test.cljc
index 09ec4c09db..846f9c98f3 100644
--- a/common/test/common_tests/logic/move_shapes_test.cljc
+++ b/common/test/common_tests/logic/move_shapes_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.move-shapes-test
(:require
diff --git a/common/test/common_tests/logic/multiple_nesting_levels_test.cljc b/common/test/common_tests/logic/multiple_nesting_levels_test.cljc
index 01544ce3f2..f1afd06b9d 100644
--- a/common/test/common_tests/logic/multiple_nesting_levels_test.cljc
+++ b/common/test/common_tests/logic/multiple_nesting_levels_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.multiple-nesting-levels-test
(:require
diff --git a/common/test/common_tests/logic/swap_and_reset_test.cljc b/common/test/common_tests/logic/swap_and_reset_test.cljc
index c9cad989cc..6e1fa2e3da 100644
--- a/common/test/common_tests/logic/swap_and_reset_test.cljc
+++ b/common/test/common_tests/logic/swap_and_reset_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.swap-and-reset-test
(:require
diff --git a/common/test/common_tests/logic/swap_as_override_test.cljc b/common/test/common_tests/logic/swap_as_override_test.cljc
index 6a0a0b0491..4c41b030e9 100644
--- a/common/test/common_tests/logic/swap_as_override_test.cljc
+++ b/common/test/common_tests/logic/swap_as_override_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.swap-as-override-test
(:require
diff --git a/common/test/common_tests/logic/swap_keeps_id_test.cljc b/common/test/common_tests/logic/swap_keeps_id_test.cljc
index c3d001a0b6..d94981ff55 100644
--- a/common/test/common_tests/logic/swap_keeps_id_test.cljc
+++ b/common/test/common_tests/logic/swap_keeps_id_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.swap-keeps-id-test
(:require
diff --git a/common/test/common_tests/logic/text_sync_test.cljc b/common/test/common_tests/logic/text_sync_test.cljc
index 335994c05d..0863b3e75e 100644
--- a/common/test/common_tests/logic/text_sync_test.cljc
+++ b/common/test/common_tests/logic/text_sync_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.text-sync-test
(:require
diff --git a/common/test/common_tests/logic/text_touched_test.cljc b/common/test/common_tests/logic/text_touched_test.cljc
index f102880ba6..a82c635b5f 100644
--- a/common/test/common_tests/logic/text_touched_test.cljc
+++ b/common/test/common_tests/logic/text_touched_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.text-touched-test
(:require
diff --git a/common/test/common_tests/logic/token_apply_test.cljc b/common/test/common_tests/logic/token_apply_test.cljc
index 9715b7f235..b4a2c92c1f 100644
--- a/common/test/common_tests/logic/token_apply_test.cljc
+++ b/common/test/common_tests/logic/token_apply_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.token-apply-test
(:require
diff --git a/common/test/common_tests/logic/token_test.cljc b/common/test/common_tests/logic/token_test.cljc
index 6d54874ae4..16258b8c8f 100644
--- a/common/test/common_tests/logic/token_test.cljc
+++ b/common/test/common_tests/logic/token_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.token-test
(:require
diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc
index ed9eeae783..06e8b5ac34 100644
--- a/common/test/common_tests/logic/variants_switch_test.cljc
+++ b/common/test/common_tests/logic/variants_switch_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.variants-switch-test
(:require
@@ -10,6 +10,7 @@
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
+ [app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
@@ -3101,3 +3102,82 @@
(t/is (= 150 (:width rect02')))
(t/is (= (+ (:y copy02') 70) (:y rect02')))
(t/is (= (:y rect02') (get-in rect02' [:selrect :y])))))
+
+;; ============================================================
+;; PRESERVE TEXT SUB-TOUCHED FLAGS ACROSS VARIANT SWITCH
+;; ============================================================
+
+(t/deftest test-switch-preserves-text-sub-touched-flags
+ ;; 1. Creates a component with text "hello world" + font-size "14", variant with font-size "20"
+ ;; 2. Overrides only text on the copy → verifies :text-content-text in touched
+ ;; 3. Switches to variant → verifies text override preserved, font-size updated, :text-content-text preserved
+ ;; 4. Updates main font-size to "30" and syncs → verifies font-size synced but text override preserved
+ (let [;; ==== Setup
+ file (-> (thf/sample-file :file1)
+ ;; c01 has text "hello world" font-size "14"
+ ;; c02 has text "hello world" font-size "20" (same text, different font-size)
+ (thv/add-variant-with-text
+ :v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello world" "hello world")
+ (update-attr :t02 font-size-path-0 "20")
+ (thc/instantiate-component :c01
+ :copy01
+ :children-labels [:copy-t01]))
+
+ ;; Override only the TEXT on the copy (not font-size)
+ file (update-attr file :copy-t01 text-path-0 "custom text")
+ copy-t01 (ths/get-shape file :copy-t01)]
+
+ ;; Verify the copy has the text override and correct touched flags
+ (t/is (= (get-in copy-t01 text-path-0) "custom text"))
+ (t/is (= (get-in copy-t01 font-size-path-0) "14"))
+ (t/is (contains? (:touched copy-t01) :content-group))
+ (t/is (contains? (:touched copy-t01) :text-content-text))
+ (t/is (not (contains? (:touched copy-t01) :text-content-attribute)))
+ (t/is (not (contains? (:touched copy-t01) :text-content-structure)))
+
+ ;; ==== Action: Switch copy to c02 variant (same text, different font-size)
+ (let [file' (tho/swap-component-in-shape file :copy01 :c02
+ {:new-shape-label :copy02
+ :keep-touched? true})
+ page' (thf/current-page file')
+ copy02' (ths/get-shape file' :copy02)
+ copy-t02' (get-in page' [:objects (-> copy02' :shapes first)])]
+
+ ;; After switch: text override preserved (same text between variants),
+ ;; font-size updated from variant, touched preserves text-content-text
+ (t/is (= (get-in copy-t02' text-path-0) "custom text"))
+ (t/is (= (get-in copy-t02' font-size-path-0) "20"))
+ (t/is (contains? (:touched copy-t02') :content-group))
+ (t/is (contains? (:touched copy-t02') :text-content-text))
+ (t/is (not (contains? (:touched copy-t02') :text-content-attribute)))
+
+ ;; ==== Now test subsequent component sync
+ ;; Modify the main component's font-size to "30" (keeping text "hello world")
+ (let [main-text (ths/get-shape file' :t02)
+ changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page'))
+ #{(:id main-text)}
+ (fn [shape]
+ (assoc-in shape font-size-path-0 "30"))
+ (:objects page')
+ {})
+ updated-file (thf/apply-changes file' changes1)
+
+ changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
+ nil
+ :components
+ (:id updated-file)
+ (thi/id :c02)
+ (:id updated-file)
+ {(:id updated-file) updated-file}
+ (:id updated-file))
+
+ synced-file (thf/apply-changes updated-file changes2)
+ synced-copy (ths/get-shape synced-file :copy02)
+ synced-t (get-in (thf/current-page synced-file)
+ [:objects (-> synced-copy :shapes first)])]
+
+ ;; The text override is preserved and font-size is synced
+ (t/is (= (get-in synced-t text-path-0) "custom text"))
+ (t/is (= (get-in synced-t font-size-path-0) "30"))
+ (t/is (contains? (:touched synced-t) :content-group))
+ (t/is (contains? (:touched synced-t) :text-content-text))))))
diff --git a/common/test/common_tests/logic/variants_test.cljc b/common/test/common_tests/logic/variants_test.cljc
index 43cb163c1e..c4cfc1b95a 100644
--- a/common/test/common_tests/logic/variants_test.cljc
+++ b/common/test/common_tests/logic/variants_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.variants-test
(:require
diff --git a/common/test/common_tests/math_test.cljc b/common/test/common_tests/math_test.cljc
index e644101895..524963df9b 100644
--- a/common/test/common_tests/math_test.cljc
+++ b/common/test/common_tests/math_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.math-test
(:require
diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc
index c6916e3216..24302a02d1 100644
--- a/common/test/common_tests/media_test.cljc
+++ b/common/test/common_tests/media_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.media-test
(:require
diff --git a/common/test/common_tests/path_names_test.cljc b/common/test/common_tests/path_names_test.cljc
index bddb94fc13..9e0d3fb873 100644
--- a/common/test/common_tests/path_names_test.cljc
+++ b/common/test/common_tests/path_names_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.path-names-test
(:require
diff --git a/common/test/common_tests/record_test.cljc b/common/test/common_tests/record_test.cljc
index 6878978414..2a5e6206de 100644
--- a/common/test/common_tests/record_test.cljc
+++ b/common/test/common_tests/record_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.record-test
(:require
diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc
index b24b045e1a..ab075151ee 100644
--- a/common/test/common_tests/runner.cljc
+++ b/common/test/common_tests/runner.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.runner
(:require
@@ -19,6 +19,7 @@
[common-tests.files-builder-test]
[common-tests.files-changes-test]
[common-tests.files-migrations-0025-test]
+ [common-tests.files-migrations-0026-test]
[common-tests.files-migrations-test]
[common-tests.files.shapes-builder-test]
[common-tests.files.validate-test]
@@ -85,6 +86,7 @@
[common-tests.types.shape-layout-test]
[common-tests.types.token-test]
[common-tests.types.tokens-lib-test]
+ [common-tests.types.tokens-status-test]
[common-tests.undo-stack-test]
[common-tests.uuid-test]))
@@ -97,6 +99,7 @@
'common-tests.files-changes-test
'common-tests.files-builder-test
'common-tests.files-migrations-0025-test
+ 'common-tests.files-migrations-0026-test
'common-tests.files-migrations-test
'common-tests.files.validate-test
'common-tests.geom-align-test
@@ -162,6 +165,7 @@
'common-tests.types.shape-layout-test
'common-tests.types.token-test
'common-tests.types.tokens-lib-test
+ 'common-tests.types.tokens-status-test
'common-tests.undo-stack-test
'common-tests.uuid-test])
diff --git a/common/test/common_tests/schema_test.cljc b/common/test/common_tests/schema_test.cljc
index b14f1df0f5..041e9a2670 100644
--- a/common/test/common_tests/schema_test.cljc
+++ b/common/test/common_tests/schema_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.schema-test
(:require
diff --git a/common/test/common_tests/spec_test.cljc b/common/test/common_tests/spec_test.cljc
index 30eda62fc0..e40fa52157 100644
--- a/common/test/common_tests/spec_test.cljc
+++ b/common/test/common_tests/spec_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.spec-test
(:require
diff --git a/common/test/common_tests/svg_path_test.cljc b/common/test/common_tests/svg_path_test.cljc
index a59ed8c521..b82f1838a8 100644
--- a/common/test/common_tests/svg_path_test.cljc
+++ b/common/test/common_tests/svg_path_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.svg-path-test
(:require
diff --git a/common/test/common_tests/svg_test.cljc b/common/test/common_tests/svg_test.cljc
index 89fa30eae7..ed2c345037 100644
--- a/common/test/common_tests/svg_test.cljc
+++ b/common/test/common_tests/svg_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.svg-test
(:require
diff --git a/common/test/common_tests/text_test.cljc b/common/test/common_tests/text_test.cljc
index dcb07bca15..c3db06c5b9 100644
--- a/common/test/common_tests/text_test.cljc
+++ b/common/test/common_tests/text_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.text-test
(:require
diff --git a/common/test/common_tests/time_test.cljc b/common/test/common_tests/time_test.cljc
index 3015c4fd36..a80ed8f16b 100644
--- a/common/test/common_tests/time_test.cljc
+++ b/common/test/common_tests/time_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.time-test
(:require
diff --git a/common/test/common_tests/token_test.cljc b/common/test/common_tests/token_test.cljc
index 5065bfc396..0a998939dd 100644
--- a/common/test/common_tests/token_test.cljc
+++ b/common/test/common_tests/token_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.token-test
(:require
diff --git a/common/test/common_tests/types/absorb_assets_test.cljc b/common/test/common_tests/types/absorb_assets_test.cljc
index f115e1accc..8e8ee2ee36 100644
--- a/common/test/common_tests/types/absorb_assets_test.cljc
+++ b/common/test/common_tests/types/absorb_assets_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.absorb-assets-test
(:require
diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc
index ad0155adf3..0679d7130f 100644
--- a/common/test/common_tests/types/color_test.cljc
+++ b/common/test/common_tests/types/color_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.color-test
(:require
diff --git a/common/test/common_tests/types/components_test.cljc b/common/test/common_tests/types/components_test.cljc
index 9a63464ce3..4d42df92f8 100644
--- a/common/test/common_tests/types/components_test.cljc
+++ b/common/test/common_tests/types/components_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.components-test
(:require
diff --git a/common/test/common_tests/types/container_test.cljc b/common/test/common_tests/types/container_test.cljc
index 6ab45c4f0a..e00b1fb9c6 100644
--- a/common/test/common_tests/types/container_test.cljc
+++ b/common/test/common_tests/types/container_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.container-test
(:require
diff --git a/common/test/common_tests/types/fill_test.cljc b/common/test/common_tests/types/fill_test.cljc
index 0a22c1e866..147da3f098 100644
--- a/common/test/common_tests/types/fill_test.cljc
+++ b/common/test/common_tests/types/fill_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.fill-test
(:require
diff --git a/common/test/common_tests/types/font_test.cljc b/common/test/common_tests/types/font_test.cljc
index c381c14893..b9281e7fd5 100644
--- a/common/test/common_tests/types/font_test.cljc
+++ b/common/test/common_tests/types/font_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.font-test
(:require
diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc
index 405da89935..c427f6e93f 100644
--- a/common/test/common_tests/types/modifiers_test.cljc
+++ b/common/test/common_tests/types/modifiers_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.modifiers-test
(:require
diff --git a/common/test/common_tests/types/objects_map_test.cljc b/common/test/common_tests/types/objects_map_test.cljc
index c9999a6260..7eae046e3a 100644
--- a/common/test/common_tests/types/objects_map_test.cljc
+++ b/common/test/common_tests/types/objects_map_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.objects-map-test
(:require
diff --git a/common/test/common_tests/types/organization_test.cljc b/common/test/common_tests/types/organization_test.cljc
index e6a24aeb00..1d2c48be01 100644
--- a/common/test/common_tests/types/organization_test.cljc
+++ b/common/test/common_tests/types/organization_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.organization-test
(:require
diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc
index 714270cb62..70285f21ad 100644
--- a/common/test/common_tests/types/path_data_test.cljc
+++ b/common/test/common_tests/types/path_data_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.path-data-test
(:require
@@ -17,6 +17,7 @@
[app.common.transit :as trans]
[app.common.types.path :as path]
[app.common.types.path.bool :as path.bool]
+ [app.common.types.path.fit :as path.fit]
[app.common.types.path.helpers :as path.helpers]
[app.common.types.path.impl :as path.impl]
[app.common.types.path.segment :as path.segment]
@@ -656,6 +657,21 @@
(t/testing "content that is already a closed triangle stays closed"
(let [result (path.subpath/close-subpaths simple-closed-content)]
(t/is (seq result))))
+ (t/testing "a close after a curve already landing on the start is not materialized twice"
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to
+ :params {:c1x 3.0 :c1y -2.0
+ :c2x 6.0 :c2y -2.0
+ :x 10.0 :y 0.0}}
+ {:command :curve-to
+ :params {:c1x 6.0 :c1y 2.0
+ :c2x 3.0 :c2y 2.0
+ :x 0.0 :y 0.0}}
+ {:command :close-path :params {}}]
+ result (path.subpath/close-subpaths content)]
+ (t/is (= [:move-to :curve-to :curve-to] (mapv :command result)))
+ ;; Rendering/persistence can still recover the explicit SVG close.
+ (t/is (= content (path.subpath/close-loops content)))))
(t/testing "two open fragments that form a closed loop get merged"
;; fragment A: 0,0 → 5,0
;; fragment B: 10,0 → 5,0 (reversed, connects to A's end)
@@ -667,6 +683,65 @@
result (path.subpath/close-subpaths content)]
(t/is (seq result)))))
+(t/deftest subpath-close-loops
+ (t/testing "trailing line-to landing on the subpath start becomes a close-path"
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}]
+ result (path.subpath/close-loops content)]
+ (t/is (= [:move-to :line-to :line-to :close-path] (mapv :command result)))))
+
+ (t/testing "coincident endpoints within tolerance also close"
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 0.05 :y 0.0}}]
+ result (path.subpath/close-loops content)]
+ (t/is (= [:move-to :line-to :close-path] (mapv :command result)))))
+
+ (t/testing "curve landing on the subpath start keeps the curve and appends a close-path"
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 10.0 :c1y 5.0 :c2x 5.0 :c2y 5.0 :x 0.0 :y 0.0}}]
+ result (path.subpath/close-loops content)]
+ (t/is (= [:move-to :line-to :curve-to :close-path] (mapv :command result)))))
+
+ (t/testing "already command-closed content is unchanged"
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :close-path :params {}}]
+ result (path.subpath/close-loops content)]
+ (t/is (= content (vec result)))))
+
+ (t/testing "open subpaths are left untouched"
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}]
+ result (path.subpath/close-loops content)]
+ (t/is (= content (vec result)))))
+
+ (t/testing "multi-subpath content closes only the coincident loops"
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}
+ {:command :move-to :params {:x 20.0 :y 20.0}}
+ {:command :line-to :params {:x 30.0 :y 20.0}}]
+ result (path.subpath/close-loops content)]
+ (t/is (= [:move-to :line-to :close-path :move-to :line-to]
+ (mapv :command result))))))
+
+(t/deftest path-close-loops-path-data
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}])
+ result (path/close-loops content)]
+ (t/is (path.impl/path-data? result))
+ (t/is (= [:move-to :line-to :line-to :close-path]
+ (mapv :command (vec result))))))
+
(t/deftest subpath-merge-touching-subpaths
(t/testing "adjacent subpaths sharing an endpoint collapse into one chain"
;; Heroicons-style fragment: continuous polyline split as M-L M-L M-L
@@ -785,6 +860,70 @@
(t/is (= 3.0 (get-in cmd [:params :c1x])))
(t/is (= 7.0 (get-in cmd [:params :c2x])))))
+(t/deftest segment-make-curve-point-keeps-neighbors-corners
+ ;; Curving a node leaves its neighbours as corners.
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 100.0 :y 0.0}}
+ {:command :line-to :params {:x 200.0 :y 0.0}}]]
+
+ (t/testing "curving the first (endpoint) node keeps its neighbour a corner"
+ (let [r (vec (seq (path/make-curve-point content (gpt/point 0.0 0.0))))
+ seg1 (get r 1)]
+ (t/is (= :curve-to (:command seg1)))
+ ;; The neighbour's handle stays collapsed.
+ (t/is (= 100.0 (get-in seg1 [:params :c2x])))
+ (t/is (= 0.0 (get-in seg1 [:params :c2y])))
+ ;; The selected node gets a handle.
+ (t/is (not= 0.0 (get-in seg1 [:params :c1x])))))
+
+ (t/testing "curving the last node keeps its neighbour a corner"
+ (let [r (vec (seq (path/make-curve-point content (gpt/point 200.0 0.0))))
+ seg2 (get r 2)]
+ (t/is (= :curve-to (:command seg2)))
+ ;; The neighbour's handle stays collapsed.
+ (t/is (= 100.0 (get-in seg2 [:params :c1x])))
+ (t/is (= 0.0 (get-in seg2 [:params :c1y])))))
+
+ (t/testing "curving a middle node keeps both neighbours corners"
+ (let [r (vec (seq (path/make-curve-point content (gpt/point 100.0 0.0))))
+ seg1 (get r 1)
+ seg2 (get r 2)]
+ (t/is (= 0.0 (get-in seg1 [:params :c1x])))
+ (t/is (= 200.0 (get-in seg2 [:params :c2x])))))))
+
+(t/deftest segment-make-curve-point-acute-corner-is-smooth
+ ;; Acute corners get equal and opposite handles.
+ (let [content [{:command :move-to :params {:x 10.0 :y 1.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y -1.0}}]
+ r (vec (seq (path/make-curve-point (path/content content)
+ (gpt/point 0.0 0.0))))
+ ;; Read the node's incoming and outgoing handles.
+ c2y (get-in (get r 1) [:params :c2y])
+ c1y (get-in (get r 2) [:params :c1y])]
+ ;; Both handles extend from the node.
+ (t/is (not (zero? c2y)))
+ ;; The node is the midpoint between equal-length handles.
+ (t/is (= c2y (- c1y)))))
+
+(t/deftest segment-make-curve-point-closed-seam-follows-neighbour-tangent
+ ;; Closed seams follow the chord between their neighbours.
+ (let [point (gpt/point 0.0 0.0)
+ content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y -8.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 6.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}
+ {:command :close-path :params {}}]
+ result (vec (path/make-curve-point (path/content content) point))
+ outgoing (get result 1)
+ incoming (get result 4)]
+ ;; Seam handles lie on the chord and oppose each other.
+ (t/is (mth/close? 0.0 (get-in outgoing [:params :c1x]) 0.001))
+ (t/is (mth/close? 0.0 (get-in incoming [:params :c2x]) 0.001))
+ (t/is (neg? (get-in outgoing [:params :c1y])))
+ (t/is (pos? (get-in incoming [:params :c2y])))))
+
(t/deftest helpers-prefix->coords
(t/is (= [:c1x :c1y] (path.helpers/prefix->coords :c1)))
(t/is (= [:c2x :c2y] (path.helpers/prefix->coords :c2)))
@@ -794,15 +933,22 @@
(t/testing "returns point unchanged when from-point is nil"
(let [pt (gpt/point 5.0 3.0)]
(t/is (= pt (path.helpers/position-fixed-angle pt nil)))))
- (t/testing "snaps to nearest 45-degree angle"
- (let [from (gpt/point 0 0)
- ;; Angle ~30° from from, should snap to 45°
- to (gpt/point 10 6)
- snapped (path.helpers/position-fixed-angle to from)]
- ;; result should have same distance
- (let [d-orig (gpt/distance to from)
- d-snapped (gpt/distance snapped from)]
- (t/is (mth/close? d-orig d-snapped 0.01))))))
+ (t/testing "snaps to nearest 15-degree angle"
+ (let [from (gpt/point 0 0)
+ ;; ~31° from `from`: snaps to 30° (15° granularity), not 45°
+ to (gpt/point 10 6)
+ snapped (path.helpers/position-fixed-angle to from)
+ d-orig (gpt/distance to from)
+ d-snapped (gpt/distance snapped from)
+ snap-ang (gpt/angle snapped from)
+ orig-ang (gpt/angle to from)
+ delta (let [d (mod (- snap-ang orig-ang) 360)] (min d (- 360 d)))]
+ ;; distance preserved
+ (t/is (mth/close? d-orig d-snapped 0.01))
+ ;; snapped onto a 15° multiple
+ (t/is (let [m (mod snap-ang 15)] (or (< m 0.01) (> m 14.99))))
+ ;; Stay within half a 15° bucket of the input angle.
+ (t/is (<= delta 7.5)))))
(t/deftest helpers-command->line
(let [prev {:command :move-to :params {:x 0.0 :y 0.0}}
@@ -821,6 +967,18 @@
(t/is (= (gpt/point 3.0 5.0) h1))
(t/is (= (gpt/point 7.0 5.0) h2))))
+(t/deftest helpers-entry->bezier
+ (let [from (gpt/point 0 0)
+ to (gpt/point 10 0)
+ line {:from from :to to :segment {:command :line-to}}
+ curve {:from from
+ :to to
+ :segment {:command :curve-to
+ :params {:x 10 :y 0 :c1x 3 :c1y 5 :c2x 7 :c2y 5}}}]
+ (t/is (= [from to from to] (path.helpers/entry->bezier line)))
+ (t/is (= [from to (gpt/point 3 5) (gpt/point 7 5)]
+ (path.helpers/entry->bezier curve)))))
+
(t/deftest helpers-line-values
(let [from (gpt/point 0.0 0.0)
to (gpt/point 10.0 0.0)
@@ -1102,6 +1260,637 @@
;; should have fewer segments
(t/is (< (count result) (count simple-open-content)))))
+(t/deftest helpers-fit-cubic-recovers-curve
+ ;; fitting samples of a known cubic recovers control points close to it
+ (let [curve [(gpt/point 0.0 0.0) (gpt/point 30.0 0.0)
+ (gpt/point 10.0 10.0) (gpt/point 20.0 10.0)]
+ samples (mapv #(path.helpers/curve-values curve (/ % 20.0)) (range 21))
+ tan1 (path.helpers/curve-tangent curve 0)
+ tan2 (gpt/negate (path.helpers/curve-tangent curve 1))
+ [h1 h2] (path.fit/fit-cubic samples tan1 tan2)]
+ (t/is (mth/close? 10.0 (:x h1) 1.0))
+ (t/is (mth/close? 10.0 (:y h1) 1.0))
+ (t/is (mth/close? 20.0 (:x h2) 1.0))
+ (t/is (mth/close? 10.0 (:y h2) 1.0))))
+
+(t/deftest helpers-curve-closest-t
+ ;; A degenerate cubic maps points back onto the same line.
+ (let [curve [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0)
+ (gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]]
+ (t/is (mth/close? 0.5 (path.helpers/curve-closest-t curve (gpt/point 5.0 0.0) 0.001) 0.01))
+ (doseq [q [(gpt/point 2.5 0.0) (gpt/point 7.0 0.0)]]
+ (let [t (path.helpers/curve-closest-t curve q 0.001)
+ p (path.helpers/curve-values curve t)]
+ (t/is (mth/close? (:x q) (:x p) 0.05))))))
+
+(t/deftest helpers-bend-curve-deltas-passes-through-target
+ ;; the handle deltas move the point at t exactly onto the target, for any t
+ (let [curve [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0)
+ (gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]]
+ (doseq [t [0.3 0.5 0.7]
+ target [(gpt/point 5.0 4.0) (gpt/point 3.0 -6.0)]]
+ (let [{:keys [c1x c1y c2x c2y]} (path.helpers/bend-curve-deltas curve t target)
+ bent [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0)
+ (gpt/point c1x c1y) (gpt/point (+ 10.0 c2x) c2y)]
+ p (path.helpers/curve-values bent t)]
+ (t/is (mth/close? (:x target) (:x p) 0.001))
+ (t/is (mth/close? (:y target) (:y p) 0.001))))))
+
+(t/deftest segment-flip-content-horizontal
+ ;; mirror every node across the bbox center on the vertical axis
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}])
+ result (path/flip-content content #{0 1 2} :horizontal)
+ pts (mapv (comp (juxt :x :y) :params) (vec result))]
+ (t/is (= [[10.0 0.0] [0.0 0.0] [0.0 10.0]] pts))))
+
+(t/deftest segment-flip-content-curve-handles
+ ;; a curve mirrors its anchors and both handles, keeping shape symmetry
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 2.0 :c1y 5.0 :c2x 8.0 :c2y 5.0 :x 10.0 :y 0.0}}])
+ result (vec (path/flip-content content #{0 1} :horizontal))]
+ (t/is (= {:x 10.0 :y 0.0} (:params (first result))))
+ (t/is (= {:c1x 8.0 :c1y 5.0 :c2x 2.0 :c2y 5.0 :x 0.0 :y 0.0}
+ (:params (second result))))))
+
+(t/deftest segment-flip-content-vertical
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 2.0 :c1y 5.0 :c2x 8.0 :c2y 5.0 :x 10.0 :y 0.0}}])
+ result (vec (path/flip-content content #{0 1} :vertical))]
+ (t/is (= {:c1x 2.0 :c1y -5.0 :c2x 8.0 :c2y -5.0 :x 10.0 :y 0.0}
+ (:params (second result))))))
+
+(t/deftest segment-separate-single-node
+ ;; Separating an interior node creates two open ends.
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}])
+ result (vec (path/separate-nodes content #{(gpt/point 10.0 0.0)}))]
+ ;; move-to, line-to (to node1 kept at 10,0), move-to (node2 offset), line-to
+ (t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result)))
+ (t/is (= {:x 10.0 :y 0.0} (:params (nth result 1))))
+ (t/is (= {:x 18.0 :y 8.0} (:params (nth result 2))))
+ (t/is (= {:x 20.0 :y 0.0} (:params (nth result 3))))))
+
+(t/deftest segment-separate-single-node-custom-offset
+ ;; The supplied offset controls the gap between split ends.
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}])
+ result (vec (path/separate-nodes content #{(gpt/point 10.0 0.0)} (gpt/point 2.0 2.0)))]
+ (t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result)))
+ (t/is (= {:x 10.0 :y 0.0} (:params (nth result 1))))
+ (t/is (= {:x 12.0 :y 2.0} (:params (nth result 2))))
+ (t/is (= {:x 20.0 :y 0.0} (:params (nth result 3))))))
+
+(t/deftest segment-separate-single-node-closed-seam
+ ;; Separating a closed seam creates two endpoints.
+ (let [point (gpt/point 0.0 0.0)
+ content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to
+ :params {:c1x 2.0 :c1y -2.0
+ :c2x 8.0 :c2y -2.0
+ :x 10.0 :y 0.0}}
+ {:command :curve-to
+ :params {:c1x 8.0 :c1y 2.0
+ :c2x 2.0 :c2y 2.0
+ :x 0.0 :y 0.0}}
+ {:command :close-path :params {}}])
+ result (vec (path/separate-nodes content #{point} (gpt/point 2.0 2.0)))]
+ (t/is (= [:move-to :curve-to :curve-to] (mapv :command result)))
+ (t/is (= {:x 0.0 :y 0.0}
+ (select-keys (:params (first result)) [:x :y])))
+ (t/is (= {:x 2.0 :y 2.0}
+ (select-keys (:params (peek result)) [:x :y])))
+ ;; The incoming c2 stays attached to the shifted endpoint.
+ (t/is (= {:c2x 4.0 :c2y 4.0}
+ (select-keys (:params (peek result)) [:c2x :c2y])))))
+
+(t/deftest segment-separate-single-node-endpoint-noop
+ ;; an endpoint node has no following segment, so nothing is split
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}])
+ result (vec (path/separate-nodes content #{(gpt/point 20.0 0.0)}))]
+ (t/is (= [:move-to :line-to :line-to] (mapv :command result)))))
+
+(t/deftest segment-separate-single-node-curve-carries-handler
+ ;; the outgoing curve's leading handler is shifted with the new start
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}}])
+ result (vec (path/separate-nodes content #{(gpt/point 10.0 0.0)}))
+ curve (nth result 3)]
+ (t/is (= [:move-to :line-to :move-to :curve-to] (mapv :command result)))
+ (t/is (= {:x 18.0 :y 8.0} (:params (nth result 2))))
+ ;; c1 shifted by the same (8,8) offset, c2/end untouched
+ (t/is (= 20.0 (get-in curve [:params :c1x])))
+ (t/is (= 8.0 (get-in curve [:params :c1y])))
+ (t/is (= 18.0 (get-in curve [:params :c2x])))
+ (t/is (= 20.0 (get-in curve [:params :x])))))
+
+(t/deftest segment-separate-single-node-junction
+ ;; Separating coincident subpaths creates one open end per line.
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 5.0 :y 5.0}}
+ {:command :move-to :params {:x 5.0 :y 5.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}])
+ result (vec (path/separate-nodes content #{(gpt/point 5.0 5.0)}))]
+ (t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result)))
+ ;; the first line keeps (5,5); the second subpath's start is offset by (8,8)
+ (t/is (= {:x 5.0 :y 5.0} (:params (nth result 1))))
+ (t/is (= {:x 13.0 :y 13.0} (:params (nth result 2))))
+ (t/is (= {:x 10.0 :y 10.0} (:params (nth result 3))))))
+
+(t/deftest segment-separate-single-node-junction-three-lines
+ ;; three lines meeting at a point separate into three distinct offset ends
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 5.0 :y 5.0}}
+ {:command :move-to :params {:x 20.0 :y 0.0}}
+ {:command :line-to :params {:x 5.0 :y 5.0}}
+ {:command :move-to :params {:x 5.0 :y 5.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}])
+ result (vec (path/separate-nodes content #{(gpt/point 5.0 5.0)}))]
+ (t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0}
+ {:x 20.0 :y 0.0} {:x 13.0 :y 13.0}
+ {:x 21.0 :y 21.0} {:x 10.0 :y 10.0}]
+ (mapv #(select-keys (:params %) [:x :y]) result)))))
+
+(t/deftest segment-flip-content-partial-selection
+ ;; only the selected nodes and their handles move; others stay put
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}])
+ result (path/flip-content content #{0 1} :horizontal)
+ pts (mapv (comp (juxt :x :y) :params) (vec result))]
+ (t/is (= [[10.0 0.0] [0.0 0.0] [10.0 10.0]] pts))))
+
+(t/deftest segment-align-content
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 2.0}}
+ {:command :line-to :params {:x 4.0 :y 20.0}}])
+ pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))]
+ ;; align to the left edge: every selected x becomes the min x
+ (t/is (= [[0.0 0.0] [0.0 2.0] [0.0 20.0]]
+ (pts (path/align-content content #{0 1 2} :hleft))))
+ ;; align to horizontal center: x becomes the bbox center
+ (t/is (= [[5.0 0.0] [5.0 2.0] [5.0 20.0]]
+ (pts (path/align-content content #{0 1 2} :hcenter))))
+ ;; align to the top edge: every selected y becomes the min y
+ (t/is (= [[0.0 0.0] [10.0 0.0] [4.0 0.0]]
+ (pts (path/align-content content #{0 1 2} :vtop))))))
+
+(t/deftest segment-align-content-partial-and-guard
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 2.0}}
+ {:command :line-to :params {:x 4.0 :y 20.0}}])
+ pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))]
+ ;; only the selected nodes align; the unselected node stays put
+ (t/is (= [[0.0 0.0] [0.0 2.0] [4.0 20.0]]
+ (pts (path/align-content content #{0 1} :hleft))))
+ ;; fewer than two selected nodes is a no-op
+ (t/is (= (pts content)
+ (pts (path/align-content content #{0} :hleft))))))
+
+(t/deftest segment-align-content-moves-handles
+ ;; a selected node's attached handles move rigidly with its anchor
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 2.0 :c1y 1.0 :c2x 8.0 :c2y 1.0 :x 10.0 :y 0.0}}])
+ result (vec (path/align-content content #{0 1} :vtop))]
+ ;; both nodes already share y=0, so vtop is a no-op on the anchors and
+ ;; leaves the handles untouched
+ (t/is (= {:c1x 2.0 :c1y 1.0 :c2x 8.0 :c2y 1.0 :x 10.0 :y 0.0}
+ (:params (second result))))))
+
+(t/deftest segment-set-nodes-coordinate
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}])]
+ ;; setting x for two nodes moves each to that x (per-node delta), the
+ ;; unselected node is untouched
+ (t/is (= [[:move-to {:x 5.0 :y 0.0}]
+ [:line-to {:x 10.0 :y 0.0}]
+ [:line-to {:x 5.0 :y 0.0}]]
+ (mapv (juxt :command :params)
+ (vec (path/set-nodes-coordinate content #{0 2} :x 5.0)))))
+ ;; a single node's y moves only that node, and its attached handle moves
+ ;; rigidly with the anchor
+ (let [curved (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 2.0 :c1y 0.0 :c2x 8.0 :c2y 0.0 :x 10.0 :y 0.0}}])
+ r (vec (path/set-nodes-coordinate curved #{1} :y 5.0))]
+ ;; node 1 anchor y 0 -> 5 (delta +5); its :c2 handle (owned by node 1)
+ ;; moves +5 too; :c1 (owned by node 0, unselected) stays
+ (t/is (= {:c1x 2.0 :c1y 0.0 :c2x 8.0 :c2y 5.0 :x 10.0 :y 5.0}
+ (:params (second r)))))))
+
+(t/deftest segment-set-nodes-coordinate-keeps-coincident-nodes-together
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}])
+ result (vec (path/set-nodes-coordinate content #{0} :y 5.0))]
+ ;; The first and last commands are the same logical closed-seam node.
+ (t/is (= (gpt/point 0.0 5.0)
+ (path.helpers/segment->point (nth result 0))))
+ (t/is (= (gpt/point 0.0 5.0)
+ (path.helpers/segment->point (nth result 2))))
+ (t/is (= (gpt/point 10.0 0.0)
+ (path.helpers/segment->point (nth result 1))))))
+
+(t/deftest segment-align-content-coincident-nodes
+ (t/testing "align-content groups coincident nodes with sub-epsilon coordinate differences"
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0001 :y 2.0}}
+ {:command :line-to :params {:x 10.0 :y 20.0}}])
+ pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))]
+ ;; Nodes at ~10.0 should be grouped together for alignment
+ (t/is (= [[0.0 0.0] [0.0 2.0] [0.0 20.0]]
+ (pts (path/align-content content #{0 1 2} :hleft)))))))
+
+(t/deftest segment-flip-content-coincident-nodes
+ (t/testing "flip-content handles coincident nodes with sub-epsilon coordinate differences"
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0001 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}])
+ pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))
+ result (path/flip-content content #{0 1 2} :horizontal)]
+ ;; All nodes should flip across the horizontal center.
+ ;; Floating-point imprecision from the 10.0001 input is expected.
+ (let [[[x0 y0] [x1 y1] [x2 y2]] (pts result)]
+ (t/is (mth/close? 10.0 x0 0.001))
+ (t/is (mth/close? 0.0 y0 0.001))
+ (t/is (mth/close? 0.0 x1 0.001))
+ (t/is (mth/close? 0.0 y1 0.001))
+ (t/is (mth/close? 0.0 x2 0.001))
+ (t/is (mth/close? 10.0 y2 0.001))))))
+
+(t/deftest segment-set-handler-points
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 2.0 :c1y 2.0 :c2x 8.0 :c2y 2.0 :x 10.0 :y 0.0}}])
+ r (vec (path/set-handler-points content {[1 :c2] (gpt/point 7.0 6.0)}))]
+ ;; c2 set to the target point; c1 and the anchor stay put
+ (t/is (= {:c1x 2.0 :c1y 2.0 :c2x 7.0 :c2y 6.0 :x 10.0 :y 0.0}
+ (:params (second r))))))
+
+(t/deftest segment-translate-selected-nodes
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}])
+ ;; translate nodes 1 and 2 by (0, 5): both move down, node 0 stays
+ r (vec (path/translate-selected-nodes content #{1 2} (gpt/point 0.0 5.0)))]
+ (t/is (= [[:move-to {:x 0.0 :y 0.0}]
+ [:line-to {:x 10.0 :y 5.0}]
+ [:line-to {:x 20.0 :y 5.0}]]
+ (mapv (juxt :command :params) r)))))
+
+(t/deftest segment-distribute-content
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 3.0 :y 5.0}}
+ {:command :line-to :params {:x 10.0 :y 9.0}}])
+ pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))]
+ ;; the middle node is spaced evenly between the two extremes on x
+ (t/is (= [[0.0 0.0] [5.0 5.0] [10.0 9.0]]
+ (pts (path/distribute-content content #{0 1 2} :horizontal))))
+ ;; fewer than three selected nodes is a no-op
+ (t/is (= (pts content)
+ (pts (path/distribute-content content #{0 1} :horizontal))))))
+
+(t/deftest segment-distribute-content-keeps-coincident-nodes-together
+ ;; Coincident selected nodes move as one group.
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 3.0 :y 7.0}}
+ {:command :line-to :params {:x 3.0 :y 7.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}])
+ pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))]
+ ;; three distinct positions (0, 3, 10); the coincident pair is one group
+ ;; centred at x=5 and both nodes move there together, staying coincident
+ (t/is (= [[0.0 0.0] [5.0 7.0] [5.0 7.0] [10.0 0.0]]
+ (pts (path/distribute-content content #{0 1 2 3} :horizontal))))
+ ;; only two distinct positions among the selection is a no-op
+ (t/is (= (pts content)
+ (pts (path/distribute-content content #{1 2 3} :horizontal))))))
+
+(t/deftest segment-distribute-content-with-floating-point-coordinates
+ (t/testing "distribute-content groups nodes with floating-point rounding differences"
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 3.0001 :y 7.0}}
+ {:command :line-to :params {:x 3.0002 :y 7.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}])
+ pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))]
+ ;; Nodes at ~3.0 should be grouped together
+ (t/is (= [[0.0 0.0] [5.0 7.0] [5.0 7.0] [10.0 0.0]]
+ (pts (path/distribute-content content #{0 1 2 3} :horizontal)))))))
+
+(t/deftest helpers-curve-arc-length-t
+ (let [arc-len (fn [curve a b]
+ (->> (range 1001)
+ (map #(path.helpers/curve-values
+ curve (+ a (* (/ (double %) 1000) (- b a)))))
+ (partition 2 1)
+ (map (fn [[p q]] (gpt/distance p q)))
+ (reduce +)))]
+ ;; a straight line (degenerate cubic) has its visual middle at t=0.5
+ (let [line [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0)
+ (gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]]
+ (t/is (mth/close? 0.5 (path.helpers/curve-arc-length-t line) 0.01)))
+ ;; An uneven curve's arc midpoint differs from its parametric midpoint.
+ (let [curve [(gpt/point 0.0 0.0) (gpt/point 100.0 100.0)
+ (gpt/point 0.0 0.0) (gpt/point 0.0 100.0)]
+ t (path.helpers/curve-arc-length-t curve)
+ total (arc-len curve 0.0 1.0)
+ first-half (arc-len curve 0.0 t)]
+ (t/is (< 0.5 t 1.0))
+ ;; the length up to t is within 1% of half the total
+ (t/is (< (mth/abs (- first-half (/ total 2.0))) (* 0.01 total))))))
+
+(t/deftest helpers-fit-curve-single-curve
+ ;; samples of one gentle cubic are fitted back with a single curve
+ (let [curve [(gpt/point 0.0 0.0) (gpt/point 30.0 0.0)
+ (gpt/point 10.0 10.0) (gpt/point 20.0 10.0)]
+ samples (mapv #(path.helpers/curve-values curve (/ % 24.0)) (range 25))
+ result (path.fit/fit-curve samples 0.5)]
+ (t/is (= 1 (count result)))
+ (let [[start end h1 h2] (first result)]
+ (t/is (= (gpt/point 0.0 0.0) start))
+ (t/is (= (gpt/point 30.0 0.0) end))
+ (t/is (mth/close? 10.0 (:x h1) 1.5))
+ (t/is (mth/close? 10.0 (:y h1) 1.5))
+ (t/is (mth/close? 20.0 (:x h2) 1.5))
+ (t/is (mth/close? 10.0 (:y h2) 1.5)))))
+
+(t/deftest helpers-fit-curve-splits-and-chains
+ ;; Sharp corners split the fit into chained curves.
+ (let [pts (into []
+ (concat
+ (map #(gpt/point (double %) (double %)) (range 0 11))
+ (map #(gpt/point (+ 10.0 %) (- 10.0 %)) (range 1 11))))
+ result (path.fit/fit-curve pts 0.1)]
+ (t/is (> (count result) 1))
+ (t/is (every? (fn [[c1 c2]] (= (nth c1 1) (nth c2 0)))
+ (map vector result (rest result))))
+ (t/is (= (gpt/point 0.0 0.0) (get-in result [0 0])))
+ (t/is (= (gpt/point 20.0 0.0) (nth (peek result) 1)))))
+
+(t/deftest helpers-fit-curve-respects-tolerance
+ ;; every input point stays within tolerance of the fitted sequence
+ (let [pts (mapv #(gpt/point (double %) (* 5.0 (mth/sin (/ % 3.0))))
+ (range 0 31))
+ tol 0.5
+ result (path.fit/fit-curve pts tol)
+ curve-pts (into []
+ (mapcat (fn [c]
+ (map #(path.helpers/curve-values c (/ % 100.0))
+ (range 101))))
+ result)
+ max-dev (reduce max
+ (map (fn [p]
+ (reduce min (map #(gpt/distance p %) curve-pts)))
+ pts))]
+ (t/is (<= max-dev (+ tol 0.05)))))
+
+(t/deftest helpers-fit-curve-keeps-sharp-corners
+ ;; Sharp-corner handles follow their own legs.
+ (let [corner (gpt/point 10.0 10.0)
+ ;; two legs meeting at a 90 degree corner: (0,0)->(10,10)->(20,0)
+ pts (into []
+ (concat
+ (map #(gpt/point (double %) (double %)) (range 0 11))
+ (map #(gpt/point (+ 10.0 %) (- 10.0 %)) (range 1 11))))
+ result (path.fit/fit-curve pts 0.1)
+ ;; the two curves meeting at the corner
+ left (first (filter #(= corner (nth % 1)) result))
+ right (first (filter #(= corner (nth % 0)) result))
+ v-in (gpt/to-vec corner (nth left 3)) ;; incoming handle (h2) direction
+ v-out (gpt/to-vec corner (nth right 2)) ;; outgoing handle (h1) direction
+ angle (gpt/angle-with-other v-in v-out)]
+ (t/is (some? left))
+ (t/is (some? right))
+ ;; The join keeps the corner's angle.
+ (t/is (< angle 135.0))
+ (t/is (mth/close? 90.0 angle 15.0))))
+
+(t/deftest segment-smooth-points->content
+ (t/testing "two points produce a straight segment"
+ (let [content (path.segment/smooth-points->content
+ [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0)] 1.0)]
+ (t/is (= [:move-to :line-to] (mapv :command content)))))
+ (t/testing "freehand-like points produce fewer, fitted curve segments"
+ (let [pts (mapv #(gpt/point (double %) (* 5.0 (mth/sin (/ % 3.0))))
+ (range 0 31))
+ content (path.segment/smooth-points->content pts 1.0)
+ cmds (mapv :command content)]
+ (t/is (= :move-to (first cmds)))
+ (t/is (every? #(= :curve-to %) (rest cmds)))
+ (t/is (< (count cmds) (count pts)))
+ (t/is (= {:x 0.0 :y 0.0} (:params (first (vec content)))))
+ (let [last-params (:params (peek (vec content)))]
+ (t/is (mth/close? 30.0 (:x last-params)))
+ (t/is (mth/close? (* 5.0 (mth/sin 10.0)) (:y last-params)))))))
+
+(t/deftest segment-remove-nodes-collinear-keeps-line
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}]
+ result (path.segment/remove-nodes (path/content content)
+ #{(gpt/point 10.0 0.0)})]
+ (t/is (= [:move-to :line-to] (mapv :command result)))
+ (t/is (= {:x 20.0 :y 0.0} (:params (second result))))))
+
+(t/deftest segment-remove-nodes-corner-fits-curve
+ ;; Removing a slanted corner keeps both endpoint tangents.
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}]
+ result (path.segment/remove-nodes (path/content content)
+ #{(gpt/point 10.0 10.0)})
+ curve (second result)]
+ (t/is (= [:move-to :curve-to] (mapv :command result)))
+ (let [{:keys [c1x c1y c2x c2y x y]} (:params curve)
+ mid (path.helpers/curve-values (gpt/point 0.0 0.0) (gpt/point x y)
+ (gpt/point c1x c1y) (gpt/point c2x c2y) 0.5)]
+ (t/is (mth/close? 20.0 x))
+ (t/is (mth/close? 0.0 y))
+ ;; handlers stay on the removed segments' directions (45 degrees)
+ (t/is (mth/close? c1x c1y 0.01))
+ (t/is (mth/close? (- 20.0 c2x) c2y 0.01))
+ ;; the curve bulges towards the removed corner
+ (t/is (< 2.0 (:y mid) 10.0))
+ (t/is (mth/close? 10.0 (:x mid) 0.5)))))
+
+(t/deftest segment-remove-nodes-between-curves-approximates
+ ;; Joined quarter arcs collapse into a fitted semicircle.
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 0.0 :c1y 5.52 :c2x 4.48 :c2y 10.0 :x 10.0 :y 10.0}}
+ {:command :curve-to :params {:c1x 15.52 :c1y 10.0 :c2x 20.0 :c2y 5.52 :x 20.0 :y 0.0}}]
+ result (path.segment/remove-nodes (path/content content)
+ #{(gpt/point 10.0 10.0)})
+ curve (second result)]
+ (t/is (= [:move-to :curve-to] (mapv :command result)))
+ (let [{:keys [c1x c1y c2x c2y x y]} (:params curve)
+ mid (path.helpers/curve-values (gpt/point 0.0 0.0) (gpt/point x y)
+ (gpt/point c1x c1y) (gpt/point c2x c2y) 0.5)]
+ ;; The fitted curve keeps the semicircle apex.
+ (t/is (mth/close? 10.0 (:x mid) 0.5))
+ (t/is (mth/close? 10.0 (:y mid) 0.5)))))
+
+(t/deftest segment-remove-node-restores-a-split-curve
+ ;; Removing an untouched split node rejoins the cubic exactly.
+ (let [from (gpt/point 0.0 0.0)
+ original {:command :curve-to
+ :params {:c1x 0.0 :c1y 0.0
+ :c2x 0.0 :c2y 100.0
+ :x 100.0 :y 100.0}}
+ content (path/content [(path.helpers/make-move-to from) original])
+ curve (path.helpers/command->bezier original from)
+ t-val (path.helpers/curve-arc-length-t curve)
+ split (-> (path.segment/split-segments content #{from (gpt/point 100.0 100.0)} t-val)
+ (path/content))
+ inserted (path.helpers/segment->point (nth split 1))
+ result (vec (path.segment/remove-nodes split #{inserted}))
+ healed (second result)]
+ ;; Split at the asymmetric curve's arc midpoint.
+ (t/is (not (mth/close? 0.5 t-val 0.01)))
+ (t/is (= [:move-to :curve-to] (mapv :command result)))
+ (doseq [coord [:c1x :c1y :c2x :c2y :x :y]]
+ (t/is (mth/close? (get-in original [:params coord])
+ (get-in healed [:params coord]))))))
+
+(t/deftest segment-remove-nodes-multiple-consecutive
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 5.0 :y 5.0}}
+ {:command :line-to :params {:x 10.0 :y 7.0}}
+ {:command :line-to :params {:x 15.0 :y 5.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}]
+ result (path.segment/remove-nodes (path/content content)
+ #{(gpt/point 5.0 5.0)
+ (gpt/point 10.0 7.0)
+ (gpt/point 15.0 5.0)})
+ curve (second result)]
+ (t/is (= [:move-to :curve-to] (mapv :command result)))
+ (let [{:keys [c1x c1y c2x c2y x y]} (:params curve)
+ mid (path.helpers/curve-values (gpt/point 0.0 0.0) (gpt/point x y)
+ (gpt/point c1x c1y) (gpt/point c2x c2y) 0.5)]
+ (t/is (mth/close? 10.0 (:x mid) 1.0))
+ (t/is (< 4.0 (:y mid) 8.5)))))
+
+(t/deftest segment-remove-nodes-endpoints-drop-segments
+ (let [content (path/content simple-open-content)]
+ (t/testing "removing the first node drops the leading segment"
+ (let [result (path.segment/remove-nodes content #{(gpt/point 0.0 0.0)})]
+ (t/is (= [:move-to :line-to] (mapv :command result)))
+ (t/is (= {:x 10.0 :y 0.0} (:params (first result))))))
+ (t/testing "removing the last node drops the trailing segment"
+ (let [result (path.segment/remove-nodes content #{(gpt/point 10.0 10.0)})]
+ (t/is (= [:move-to :line-to] (mapv :command result)))
+ (t/is (= {:x 10.0 :y 0.0} (:params (second result))))))))
+
+(t/deftest segment-remove-nodes-closed-path-keeps-closure
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}
+ {:command :close-path :params {}}]
+ result (path.segment/remove-nodes (path/content content)
+ #{(gpt/point 10.0 10.0)})]
+ (t/is (= [:move-to :curve-to :line-to :close-path] (mapv :command result)))))
+
+(t/deftest segment-remove-nodes-heals-a-closed-seam
+ (let [line-closed [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :line-to :params {:x 0.0 :y 10.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}]
+ command-closed (conj line-closed {:command :close-path :params {}})
+ seam (gpt/point 0.0 0.0)]
+ (doseq [content [line-closed command-closed]]
+ (let [result (vec (path.segment/remove-nodes (path/content content) #{seam}))
+ commands (mapv :command result)
+ points (mapv path.helpers/segment->point
+ (remove #(= :close-path (:command %)) result))]
+ ;; Both non-seam sides survive and are joined through one fitted segment.
+ (t/is (= [:move-to :line-to :line-to :curve-to]
+ (cond-> commands
+ (= :close-path (peek commands)) pop)))
+ (t/is (= [(gpt/point 10.0 0.0)
+ (gpt/point 10.0 10.0)
+ (gpt/point 0.0 10.0)
+ (gpt/point 10.0 0.0)]
+ points))))))
+
+(t/deftest segment-remove-nodes-heals-a-touching-subpath-seam
+ ;; During path edition, duplicated and merged halves can still be stored as
+ ;; two open subpaths whose endpoints touch. Finalizing the path joins them,
+ ;; but deleting their shared node must behave the same before finalization.
+ (let [content
+ (path/content
+ [{:command :move-to :params {:x 0.0 :y 10.0}}
+ {:command :line-to :params {:x -10.0 :y 7.0}}
+ {:command :line-to :params {:x -10.0 :y 3.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}
+ {:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 3.0}}
+ {:command :line-to :params {:x 10.0 :y 7.0}}
+ {:command :line-to :params {:x 0.0 :y 10.0}}])
+ result (vec (path.segment/remove-nodes content #{(gpt/point 0.0 10.0)}))]
+ (t/is (= [:move-to :line-to :line-to :line-to :line-to :curve-to]
+ (mapv :command result)))
+ (t/is (= (gpt/point -10.0 7.0)
+ (path.helpers/segment->point (first result))))
+ (t/is (= (gpt/point -10.0 7.0)
+ (path.helpers/segment->point (peek result))))))
+
+(t/deftest segment-remove-nodes-chain-ending-on-close-path
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :close-path :params {}}]
+ result (path.segment/remove-nodes (path/content content)
+ #{(gpt/point 10.0 10.0)})]
+ ;; the geometry back to the start is approximated and the path stays closed
+ (t/is (= [:move-to :line-to :curve-to :close-path] (mapv :command result)))
+ (t/is (mth/close? 0.0 (get-in (vec result) [2 :params :x])))
+ (t/is (mth/close? 0.0 (get-in (vec result) [2 :params :y])))))
+
+(t/deftest segment-remove-nodes-heals-removed-close-target
+ ;; Removing the closed seam preserves both adjacent sides and fits their
+ ;; replacement across the former start point.
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}
+ {:command :line-to :params {:x 0.0 :y 0.0}}
+ {:command :close-path :params {}}]
+ result (path.segment/remove-nodes (path/content content)
+ #{(gpt/point 0.0 0.0)})]
+ (t/is (= [:move-to :line-to :curve-to :close-path] (mapv :command result)))
+ (t/is (= (gpt/point 10.0 10.0)
+ (path.helpers/segment->point (first result))))
+ (t/is (= (gpt/point 10.0 10.0)
+ (path.helpers/segment->point (nth result 2))))))
+
(t/deftest segment-join-nodes
(let [content (path/content simple-open-content)
pt1 (gpt/point 0.0 0.0)
@@ -1117,6 +1906,18 @@
;; separate-nodes should return a collection (vector or seq)
(t/is (coll? result))))
+(t/deftest segment-separate-nodes-with-floating-point-coordinates
+ (t/testing "separate-nodes finds nodes with floating-point rounding differences"
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0001 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}])
+ pt (gpt/point 10.0002 0.0)
+ result (path.segment/separate-nodes content #{pt})]
+ ;; Should still find and separate the node
+ (t/is (coll? result))
+ (t/is (> (count result) (count content))))))
+
(t/deftest segment-make-corner-point
(let [content (path/content sample-content-2)
;; Take a curve point and make it a corner
@@ -1290,6 +2091,118 @@
(let [result (path/merge-nodes nil #{(gpt/point 0 0)})]
(t/is (some? result)))))
+(t/deftest path-merge-disconnected-nodes
+ ;; Merging separate subpaths joins them at the shared midpoint.
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :move-to :params {:x 0.0 :y 10.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}])
+ pts #{(gpt/point 10.0 0.0) (gpt/point 0.0 10.0)}
+ result (vec (path/merge-nodes content pts))]
+ (t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0}
+ {:x 5.0 :y 5.0} {:x 10.0 :y 10.0}]
+ (mapv :params result)))))
+
+(t/deftest path-duplicate-node-content
+ ;; Duplicating a node copies its incident segments as subpaths.
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 100.0 :y 0.0}}
+ {:command :curve-to :params {:x 200.0 :y 100.0 :c1x 100.0 :c1y 50.0 :c2x 150.0 :c2y 100.0}}])
+ off (gpt/point 10 10)]
+ ;; Interior copies meet at the offset node.
+ (let [{ext :content selected :selected} (path/duplicate-node-content content 1 off)]
+ (t/is (= [[:move-to {:x 0.0 :y 0.0}]
+ [:line-to {:x 110.0 :y 10.0}]
+ [:move-to {:x 200.0 :y 100.0}]
+ [:curve-to {:x 110.0 :y 10.0 :c1x 150.0 :c1y 100.0 :c2x 110.0 :c2y 60.0}]]
+ (mapv (juxt :command :params) ext)))
+ (t/is (= #{1 3} selected)))
+ ;; Endpoint copies keep only the incoming curve.
+ (let [{ext :content selected :selected} (path/duplicate-node-content content 2 off)]
+ (t/is (= [[:move-to {:x 100.0 :y 0.0}]
+ [:curve-to {:c1x 100.0 :c1y 50.0 :c2x 160.0 :c2y 110.0 :x 210.0 :y 110.0}]]
+ (mapv (juxt :command :params) ext)))
+ (t/is (= #{1} selected)))
+ ;; Subpath-start copies reverse the outgoing segment.
+ (let [{ext :content selected :selected} (path/duplicate-node-content content 0 off)]
+ (t/is (= [[:move-to {:x 100.0 :y 0.0}]
+ [:line-to {:x 10.0 :y 10.0}]]
+ (mapv (juxt :command :params) ext)))
+ (t/is (= #{1} selected)))
+ ;; a lone point (subpath with only a move-to) is copied as an offset point
+ (let [lone (path/content [{:command :move-to :params {:x 5.0 :y 5.0}}])
+ {ext :content selected :selected} (path/duplicate-node-content lone 0 off)]
+ (t/is (= [[:move-to {:x 15.0 :y 15.0}]]
+ (mapv (juxt :command :params) ext)))
+ (t/is (= #{0} selected)))))
+
+(t/deftest segment-collapse-handler
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to :params {:x 100.0 :y 0.0 :c1x 20.0 :c1y 40.0 :c2x 80.0 :c2y 40.0}}])]
+ ;; Collapsing one handler keeps the other handle unchanged.
+ (t/is (= [[:move-to {:x 0.0 :y 0.0}]
+ [:curve-to {:x 100.0 :y 0.0 :c1x 0.0 :c1y 0.0 :c2x 80.0 :c2y 40.0}]]
+ (mapv (juxt :command :params) (path/collapse-handler content 1 :c1))))
+ ;; collapsing the second handler too degenerates the curve into a line-to
+ (let [collapsed (-> content
+ (path/collapse-handler 1 :c1)
+ (path/collapse-handler 1 :c2))]
+ (t/is (= [[:move-to {:x 0.0 :y 0.0}]
+ [:line-to {:x 100.0 :y 0.0}]]
+ (mapv (juxt :command :params) collapsed))))))
+
+(t/deftest segment-toggle-segment-curve
+ (let [line (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 90.0 :y 0.0}}])
+ curve (path/toggle-segment-curve line 1)]
+ ;; Curved lines use perpendicular bowed handles.
+ (t/is (= [[:move-to {:x 0.0 :y 0.0}]
+ [:curve-to {:x 90.0 :y 0.0 :c1x 30.0 :c1y 22.5 :c2x 60.0 :c2y 22.5}]]
+ (mapv (juxt :command :params) curve)))
+ ;; curve -> line: drops the control points
+ (t/is (= [[:move-to {:x 0.0 :y 0.0}]
+ [:line-to {:x 90.0 :y 0.0}]]
+ (mapv (juxt :command :params) (path/toggle-segment-curve curve 1))))
+ ;; move-to / close-path are untouched
+ (t/is (= (vec line) (vec (path/toggle-segment-curve line 0))))))
+
+(t/deftest segment-remove-segments
+ (let [content (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}
+ {:command :line-to :params {:x 30.0 :y 0.0}}])]
+ ;; Removing an interior segment keeps both endpoints.
+ (t/is (= [[:move-to {:x 0.0 :y 0.0}]
+ [:line-to {:x 10.0 :y 0.0}]
+ [:move-to {:x 20.0 :y 0.0}]
+ [:line-to {:x 30.0 :y 0.0}]]
+ (mapv (juxt :command :params) (path/remove-segments content #{2}))))
+ ;; Removing the first segment drops the dangling start node.
+ (t/is (= [[:move-to {:x 10.0 :y 0.0}]
+ [:line-to {:x 20.0 :y 0.0}]
+ [:line-to {:x 30.0 :y 0.0}]]
+ (mapv (juxt :command :params) (path/remove-segments content #{1}))))
+ ;; a closed subpath broken elsewhere keeps its closing line geometry
+ (let [closed (path/content
+ [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :close-path :params {}}])]
+ (t/is (= [[:move-to {:x 10.0 :y 0.0}]
+ [:line-to {:x 10.0 :y 10.0}]
+ [:line-to {:x 0.0 :y 0.0}]]
+ (mapv (juxt :command :params) (path/remove-segments closed #{1}))))
+ ;; removing the close-path just leaves the subpath open
+ (t/is (= [[:move-to {:x 0.0 :y 0.0}]
+ [:line-to {:x 10.0 :y 0.0}]
+ [:line-to {:x 10.0 :y 10.0}]]
+ (mapv (juxt :command :params) (path/remove-segments closed #{3})))))))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; BOOL OPERATIONS — INTERSECTION / DIFFERENCE / EXCLUSION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -1592,3 +2505,85 @@
[])]
(t/is (= max-safe (:x move-res)) "reduce first x should be clamped")
(t/is (= min-safe (:y move-res)) "reduce first y should be clamped")))))
+
+(t/deftest segment-entries-identity
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}}
+ {:command :close-path :params {}}
+ {:command :move-to :params {:x 30.0 :y 30.0}}
+ {:command :line-to :params {:x 40.0 :y 30.0}}]
+ entries (path/segment-entries content)]
+ (t/is (= [1 2 3 5] (mapv :index entries)))
+ ;; The closing segment goes back to the subpath start node
+ (t/is (= 0 (:to-index (nth entries 2))))
+ (t/is (= (gpt/point 0.0 0.0) (:to (nth entries 2))))
+ ;; The second subpath starts from its own move-to
+ (t/is (= 4 (:from-index (nth entries 3))))))
+
+(t/deftest single-line-predicate
+ ;; A move-to followed by exactly one line-to is a single line
+ (t/is (path/single-line?
+ (path/content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}])))
+ ;; A curve, a polyline and a closed loop are not
+ (t/is (not (path/single-line?
+ (path/content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 1.0 :c1y 0.0 :c2x 2.0 :c2y 0.0 :x 10.0 :y 0.0}}]))))
+ (t/is (not (path/single-line?
+ (path/content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}]))))
+ (t/is (not (path/single-line? nil))))
+
+(t/deftest extract-content-chains-and-breaks
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}
+ {:command :line-to :params {:x 30.0 :y 0.0}}
+ {:command :line-to :params {:x 40.0 :y 0.0}}]]
+ ;; Adjacent selected segments chain into one subpath
+ (t/is (= [{:command :move-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 20.0 :y 0.0}}
+ {:command :line-to :params {:x 30.0 :y 0.0}}]
+ (vec (path/extract-content content {:segments #{2 3}}))))
+ ;; A gap starts a new subpath
+ (t/is (= [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :move-to :params {:x 30.0 :y 0.0}}
+ {:command :line-to :params {:x 40.0 :y 0.0}}]
+ (vec (path/extract-content content {:segments #{1 4}}))))))
+
+(t/deftest extract-content-from-selected-nodes
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}}
+ {:command :line-to :params {:x 30.0 :y 0.0}}]]
+ ;; Segments whose two endpoint nodes are selected are included
+ (t/is (= [{:command :move-to :params {:x 10.0 :y 0.0}}
+ {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}}]
+ (vec (path/extract-content content {:nodes #{1 2}}))))
+ ;; A single selected node produces no content
+ (t/is (empty? (path/extract-content content {:nodes #{1}})))
+ ;; Non-adjacent selected nodes produce no content
+ (t/is (empty? (path/extract-content content {:nodes #{0 2}})))))
+
+(t/deftest extract-content-closes-full-loops
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 10.0}}
+ {:command :close-path :params {}}]
+ result (vec (path/extract-content content {:nodes #{0 1 2}}))]
+ (t/is (= :move-to (:command (nth result 0))))
+ (t/is (= :line-to (:command (nth result 1))))
+ (t/is (= :line-to (:command (nth result 2))))
+ (t/is (= :close-path (:command (nth result 3))))))
+
+(t/deftest splice-content-appends-subpaths
+ (let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
+ {:command :line-to :params {:x 10.0 :y 0.0}}]
+ sub [{:command :move-to :params {:x 30.0 :y 30.0}}
+ {:command :line-to :params {:x 40.0 :y 30.0}}]
+ result (path/splice-content content sub)]
+ (t/is (path.impl/path-data? result))
+ (t/is (= (into (vec content) sub) (vec result)))))
diff --git a/common/test/common_tests/types/shape_decode_encode_test.cljc b/common/test/common_tests/types/shape_decode_encode_test.cljc
index 0068633d32..8d24cff871 100644
--- a/common/test/common_tests/types/shape_decode_encode_test.cljc
+++ b/common/test/common_tests/types/shape_decode_encode_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.shape-decode-encode-test
(:require
@@ -146,3 +146,24 @@
;; (app.common.pprint/pprint shape-3)
(= shape shape-3)))
{:num 200})))
+
+(t/deftest shape-generator-key-presence
+ "The generator must produce the keys the schema declares required, even when
+ nilable. This is a targeted check for the attributes added to
+ `schema:shape-generic-attrs` and `schema:nilable-geom-attrs`."
+ (let [shapes (sg/sample (sg/generator schema:shape) {:size 200})
+ by-type (group-by :type shapes)]
+ ;; All shapes: rotation, flip-x, flip-y are base record fields, always
+ ;; present (possibly nil).
+ (doseq [shape shapes]
+ (t/is (contains? shape :rotation) "missing :rotation")
+ (t/is (contains? shape :flip-x) "missing :flip-x")
+ (t/is (contains? shape :flip-y) "missing :flip-y"))
+ ;; Bool and path: x/y/width/height are required-but-nilable in the
+ ;; schema. The generator must produce them (nil is a valid value).
+ (doseq [shape (concat (get by-type :bool [])
+ (get by-type :path []))]
+ (t/is (contains? shape :x) "bool/path missing :x")
+ (t/is (contains? shape :y) "bool/path missing :y")
+ (t/is (contains? shape :width) "bool/path missing :width")
+ (t/is (contains? shape :height) "bool/path missing :height"))))
diff --git a/common/test/common_tests/types/shape_interactions_test.cljc b/common/test/common_tests/types/shape_interactions_test.cljc
index da056ae136..6dbf7115c0 100644
--- a/common/test/common_tests/types/shape_interactions_test.cljc
+++ b/common/test/common_tests/types/shape_interactions_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.shape-interactions-test
(:require
@@ -10,6 +10,7 @@
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
+ [app.common.geom.shapes.bounds :as gsb]
[app.common.math :as mth]
[app.common.types.shape :as cts]
[app.common.types.shape.interactions :as ctsi]
@@ -1078,3 +1079,49 @@
[overlay-pos snap] (ctsi/calc-overlay-position frame-relative base-frame objects base-frame base-frame overlay-frame frame-offset)]
(t/is (= (gpt/point 18 22) overlay-pos))
(t/is (= [:top :left] snap))))))
+
+(t/deftest calc-overlay-position-ignores-filter-bounds
+ ;; Regression for #9048: the overlay position must be computed from the
+ ;; destination frame selrect (the visible frame box), not from its
+ ;; filter-inflated object bounds. Shadows, blur, strokes or overflowing
+ ;; children make get-object-bounds larger than the selrect, which used to
+ ;; shift centered/right/bottom overlays by half that extra padding (the
+ ;; overlay appeared offset, e.g. "a bit to the left").
+ (let [base-frame (cts/setup-shape {:type :frame :width 100 :height 100})
+ overlay-plain (cts/setup-shape {:type :frame :width 30 :height 20})
+ ;; same selrect as overlay-plain, but with a drop shadow that widens
+ ;; and heightens its object bounds well beyond the selrect.
+ overlay-shadow (-> (cts/setup-shape {:type :frame :width 30 :height 20})
+ (assoc :shadow [{:style :drop-shadow
+ :offset-x 0 :offset-y 0
+ :spread 10 :blur 0 :hidden false}]))
+ objects {(:id base-frame) base-frame
+ (:id overlay-plain) overlay-plain
+ (:id overlay-shadow) overlay-shadow}
+ frame-offset (gpt/point 5 5)
+ interaction (-> ctsi/default-interaction
+ (ctsi/set-action-type :open-overlay)
+ (ctsi/set-position-relative-to (:id base-frame)))]
+
+ ;; Precondition: the shadow really does inflate the object bounds, so the
+ ;; assertions below are meaningful (otherwise the test would be vacuous).
+ (t/is (> (:width (gsb/get-object-bounds objects overlay-shadow))
+ (:width (:selrect overlay-shadow))))
+ (t/is (> (:height (gsb/get-object-bounds objects overlay-shadow))
+ (:height (:selrect overlay-shadow))))
+
+ ;; For every position type that depends on the overlay size, the computed
+ ;; position must be identical whether or not the destination frame has a
+ ;; bounds-inflating shadow.
+ (doseq [pos-type [:center :top-center :top-right :bottom-center :bottom-right]]
+ (let [i-plain (-> interaction
+ (ctsi/set-destination (:id overlay-plain))
+ (ctsi/set-overlay-pos-type pos-type base-frame objects))
+ i-shadow (-> interaction
+ (ctsi/set-destination (:id overlay-shadow))
+ (ctsi/set-overlay-pos-type pos-type base-frame objects))
+ [pos-plain snap-plain] (ctsi/calc-overlay-position i-plain base-frame objects base-frame base-frame overlay-plain frame-offset)
+ [pos-shadow snap-shadow] (ctsi/calc-overlay-position i-shadow base-frame objects base-frame base-frame overlay-shadow frame-offset)]
+ (t/testing (str "overlay position ignores filter bounds for " pos-type)
+ (t/is (= pos-plain pos-shadow))
+ (t/is (= snap-plain snap-shadow)))))))
diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc
index e655c66713..308dd758a6 100644
--- a/common/test/common_tests/types/shape_layout_test.cljc
+++ b/common/test/common_tests/types/shape_layout_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.shape-layout-test
(:require
diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc
index b63a6db6e1..8165558bed 100644
--- a/common/test/common_tests/types/text_test.cljc
+++ b/common/test/common_tests/types/text_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.text-test
(:require
@@ -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))
diff --git a/common/test/common_tests/types/token_test.cljc b/common/test/common_tests/types/token_test.cljc
index 6f9106b6ad..3cded0dc4f 100644
--- a/common/test/common_tests/types/token_test.cljc
+++ b/common/test/common_tests/types/token_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.token-test
(:require
diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc
index bb2d5cf204..3a37c82a51 100644
--- a/common/test/common_tests/types/tokens_lib_test.cljc
+++ b/common/test/common_tests/types/tokens_lib_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.tokens-lib-test
(:require
diff --git a/common/test/common_tests/types/tokens_migrations_test.cljc b/common/test/common_tests/types/tokens_migrations_test.cljc
index 04bbd9d7a1..779c24395f 100644
--- a/common/test/common_tests/types/tokens_migrations_test.cljc
+++ b/common/test/common_tests/types/tokens_migrations_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.tokens-migrations-test
(:require
diff --git a/common/test/common_tests/types/tokens_status_test.cljc b/common/test/common_tests/types/tokens_status_test.cljc
new file mode 100644
index 0000000000..d2ebb81dd3
--- /dev/null
+++ b/common/test/common_tests/types/tokens_status_test.cljc
@@ -0,0 +1,101 @@
+;; 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.types.tokens-status-test
+ (:require
+ #?(:clj [app.common.fressian :as fres])
+ #?(:clj [clojure.data.json :as json])
+ [app.common.transit :as tr]
+ [app.common.types.tokens-status :as ctos]
+ [app.common.uuid :as uuid]
+ [clojure.datafy :refer [datafy]]
+ [clojure.test :as t]))
+
+(t/deftest make-tokens-status
+ (let [theme-id (uuid/next)
+ set-id (uuid/next)
+ status (ctos/make-tokens-status :active-theme-ids #{theme-id}
+ :active-set-ids #{set-id})]
+ (t/is (ctos/tokens-status? status))
+ (t/is (ctos/check-tokens-status status))
+ (t/is (= 1 (count (ctos/get-active-theme-ids status))))
+ (t/is (ctos/theme-active? status theme-id))
+ (t/is (= 1 (count (ctos/get-active-set-ids status))))
+ (t/is (ctos/set-active? status set-id))))
+
+(t/deftest make-tokens-status-defaults
+ (let [status (ctos/make-tokens-status)]
+ (t/is (ctos/tokens-status? status))
+ (t/is (ctos/check-tokens-status status))
+ (t/is (= 0 (count (ctos/get-active-theme-ids status))))
+ (t/is (= 0 (count (ctos/get-active-set-ids status))))))
+
+(t/deftest make-invalid-tokens-status
+ (t/testing "non-set for active-themes"
+ (t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception)
+ #"expected valid params for tokens-status"
+ (ctos/make-tokens-status :active-theme-ids []))))
+ (t/testing "non-uuid in active-sets"
+ (t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception)
+ #"expected valid params for tokens-status"
+ (ctos/make-tokens-status :active-set-ids #{"not-a-uuid"})))))
+
+(t/deftest set-tokens-status
+ (let [theme1-id (uuid/next)
+ theme2-id (uuid/next)
+ theme3-id (uuid/next)
+ set1-id (uuid/next)
+ set2-id (uuid/next)
+ set3-id (uuid/next)
+ status (-> (ctos/make-tokens-status {:active-theme-ids #{theme3-id}
+ :active-set-ids #{set3-id}})
+ (ctos/set-tokens-status #{theme1-id theme2-id} #{set1-id set2-id}))]
+ (t/is (= #{theme1-id theme2-id} (ctos/get-active-theme-ids status)))
+ (t/is (= #{set1-id set2-id} (ctos/get-active-set-ids status)))))
+
+(t/deftest datafy-tokens-status
+ (let [theme-id (uuid/next)
+ set-id (uuid/next)
+ status (ctos/make-tokens-status :active-theme-ids #{theme-id}
+ :active-set-ids #{set-id})
+ result (datafy status)]
+ (t/is (map? result))
+ (t/is (not (ctos/tokens-status? result)))
+ (t/is (= (:active-theme-ids result) #{theme-id}))
+ (t/is (= (:active-set-ids result) #{set-id}))))
+
+(t/deftest transit-serialization
+ (let [theme-id (uuid/next)
+ set-id (uuid/next)
+ status (ctos/make-tokens-status :active-theme-ids #{theme-id}
+ :active-set-ids #{set-id})
+ encoded (tr/encode-str status)
+ status' (tr/decode-str encoded)]
+ (t/is (ctos/tokens-status? status'))
+ (t/is (= (datafy status') (datafy status)))))
+
+#?(:clj
+ (t/deftest fressian-serialization
+ (let [theme-id (uuid/next)
+ set-id (uuid/next)
+ status (ctos/make-tokens-status :active-theme-ids #{theme-id}
+ :active-set-ids #{set-id})
+ encoded (fres/encode status)
+ status' (fres/decode encoded)]
+ (t/is (ctos/tokens-status? status'))
+ (t/is (= (datafy status') (datafy status))))))
+
+#?(:clj
+ (t/deftest json-serialization
+ (let [theme-id (uuid/next)
+ set-id (uuid/next)
+ status (ctos/make-tokens-status :active-theme-ids #{theme-id}
+ :active-set-ids #{set-id})
+ json-str (json/write-str status)
+ parsed (json/read-str json-str :key-fn keyword)]
+ (t/is (map? parsed))
+ (t/is (= [(str theme-id)] (:active-theme-ids parsed)))
+ (t/is (= [(str set-id)] (:active-set-ids parsed))))))
diff --git a/common/test/common_tests/types/variant_test.cljc b/common/test/common_tests/types/variant_test.cljc
index 6261798946..f477da5bd9 100644
--- a/common/test/common_tests/types/variant_test.cljc
+++ b/common/test/common_tests/types/variant_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.variant-test
(:require
diff --git a/common/test/common_tests/undo_stack_test.cljc b/common/test/common_tests/undo_stack_test.cljc
index f751506228..10b65ff66c 100644
--- a/common/test/common_tests/undo_stack_test.cljc
+++ b/common/test/common_tests/undo_stack_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.undo-stack-test
(:require
diff --git a/common/test/common_tests/uuid_test.cljc b/common/test/common_tests/uuid_test.cljc
index ec6f14ceee..d30a8e8a09 100644
--- a/common/test/common_tests/uuid_test.cljc
+++ b/common/test/common_tests/uuid_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.uuid-test
(:require
diff --git a/common/test/common_tests/variant_test.cljc b/common/test/common_tests/variant_test.cljc
index f097e112b1..d0a7099dc7 100644
--- a/common/test/common_tests/variant_test.cljc
+++ b/common/test/common_tests/variant_test.cljc
@@ -2,7 +2,7 @@
;; 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
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.variant-test
(:require
diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile
index 4aff930e9e..fb9e54fb63 100644
--- a/docker/devenv/Dockerfile
+++ b/docker/devenv/Dockerfile
@@ -66,7 +66,7 @@ RUN set -ex; \
FROM base AS setup-node
-ENV NODE_VERSION=v24.18.1 \
+ENV NODE_VERSION=v24.20.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \
@@ -100,7 +100,7 @@ RUN set -eux; \
FROM base AS setup-opencode
-ENV OPENCODE_VERSION=1.18.11
+ENV OPENCODE_VERSION=1.18.25
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
diff --git a/docker/devenv/docker-compose.opencode.yml b/docker/devenv/docker-compose.opencode.yml
new file mode 100644
index 0000000000..1af29e3749
--- /dev/null
+++ b/docker/devenv/docker-compose.opencode.yml
@@ -0,0 +1,10 @@
+# Optional compose overlay, included by manage.sh's instance-compose ONLY
+# when PENPOT_OPENCODE_CONFIG_DIR is set (run-devenv --opencode-config-dir
+# DIR). Bind-mounts a host directory over the container's opencode global
+# config dir (~/.config/opencode) so personal agents/prompts/skills kept in
+# a separate repo are available inside the devenv without committing them
+# here. Without the flag this file is never referenced.
+services:
+ main:
+ volumes:
+ - "${PENPOT_OPENCODE_CONFIG_DIR}:/home/penpot/.config/opencode:z"
diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter
index 7c0b1a14ff..46fbb0041d 100644
--- a/docker/images/Dockerfile.exporter
+++ b/docker/images/Dockerfile.exporter
@@ -1,4 +1,4 @@
-FROM dhi.io/node:24.18.1-debian13-dev
+FROM dhi.io/node:24.20.0-debian13-dev
LABEL maintainer="Penpot "
ENV LANG=en_US.UTF-8 \
diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend
index 306c42467d..a134f15f27 100644
--- a/docker/images/Dockerfile.frontend
+++ b/docker/images/Dockerfile.frontend
@@ -25,6 +25,7 @@ COPY $BUNDLE_PATH /var/www/app/
COPY ./files/config.js /var/www/app/js/config.js
COPY ./files/nginx.conf.template /tmp/nginx.conf.template
COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template
+COPY ./files/nginx-admin-console-locations.conf.template /tmp/nginx-admin-console-locations.conf.template
COPY ./files/nginx-mcp-locations.conf.template /tmp/nginx-mcp-locations.conf.template
COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf
COPY ./files/nginx-mime.types /etc/nginx/mime.types
diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp
index 8d943c7e27..bad957b07c 100644
--- a/docker/images/Dockerfile.mcp
+++ b/docker/images/Dockerfile.mcp
@@ -1,4 +1,4 @@
-FROM dhi.io/node:24.18.1-debian13-dev AS build
+FROM dhi.io/node:24.20.0-debian13-dev AS build
LABEL maintainer="Penpot "
ENV DEBIAN_FRONTEND=noninteractive
diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor
new file mode 100644
index 0000000000..3a1c15e9ae
--- /dev/null
+++ b/docker/images/Dockerfile.media-processor
@@ -0,0 +1,86 @@
+FROM ubuntu:26.04
+LABEL maintainer="Penpot "
+
+ENV LANG=en_US.UTF-8 \
+ LC_ALL=en_US.UTF-8 \
+ NODE_VERSION=v24.20.0 \
+ DEBIAN_FRONTEND=noninteractive \
+ PATH=/opt/node/bin:$PATH
+
+RUN set -ex; \
+ useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
+ mkdir -p /etc/resolvconf/resolv.conf.d; \
+ echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
+ apt-get -qq update; \
+ apt-get -qq dist-upgrade; \
+ apt-get -qqy --no-install-recommends install \
+ curl \
+ tzdata \
+ locales \
+ ca-certificates \
+ ; \
+ apt-get clean; \
+ rm -rf /var/lib/apt/lists/*; \
+ echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \
+ locale-gen; \
+ find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete;
+
+RUN set -ex; \
+ apt-get -qq update; \
+ apt-get -qqy --no-install-recommends install \
+ fontforge \
+ woff-tools \
+ woff2 \
+ \
+ libgomp1 \
+ libheif1 \
+ libjpeg-turbo8 \
+ liblcms2-2 \
+ libopenexr-3-1-30 \
+ libopenjp2-7 \
+ libpng16-16 \
+ librsvg2-2 \
+ libtiff6 \
+ libwebp7 \
+ libwebpdemux2 \
+ libwebpmux3 \
+ libxml2-16 \
+ libzip5 \
+ libzstd1 \
+ ; \
+ apt-get clean; \
+ rm -rf /var/lib/apt/lists/*;
+
+RUN set -eux; \
+ ARCH="$(dpkg --print-architecture)"; \
+ case "${ARCH}" in \
+ aarch64|arm64) \
+ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \
+ ;; \
+ amd64|x86_64) \
+ BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \
+ ;; \
+ *) \
+ echo "Unsupported arch: ${ARCH}"; \
+ exit 1; \
+ ;; \
+ esac; \
+ curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \
+ mkdir -p /opt/node; \
+ cd /opt/node; \
+ tar -xf /tmp/nodejs.tar.gz --strip-components=1; \
+ chown -R root /opt/node; \
+ rm -rf /tmp/nodejs.tar.gz; \
+ corepack enable; \
+ mkdir -p /opt/penpot; \
+ chown -R penpot:penpot /opt/penpot;
+
+ARG BUNDLE_PATH="./bundle-media-processor/"
+COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/media-processor/
+
+WORKDIR /opt/penpot/media-processor
+USER penpot:penpot
+
+RUN ./setup
+
+CMD ["node", "dist/index.js"]
diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml
index 59b326c76d..63e92d9916 100644
--- a/docker/images/docker-compose.yaml
+++ b/docker/images/docker-compose.yaml
@@ -38,7 +38,7 @@ x-body-size: &penpot-http-body-size
## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems
## (eg http sessions, or invitations) are derived.
##
-## We recommend to use a trully randomly generated
+## We recommend to use a truly randomly generated
## 512 bits base64 encoded string here. You can generate one with:
##
## python3 -c "import secrets; print(secrets.token_urlsafe(64))"
@@ -78,7 +78,7 @@ services:
# - "443:443"
penpot-frontend:
- image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
+ image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
restart: always
ports:
- 9001:8080
@@ -111,7 +111,7 @@ services:
# PENPOT_DISABLE_IPV6_LISTEN: "true"
penpot-backend:
- image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
+ image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
restart: always
volumes:
@@ -180,13 +180,13 @@ services:
PENPOT_SMTP_SSL: "false"
penpot-mcp:
- image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
+ image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
restart: always
networks:
- penpot
penpot-exporter:
- image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
+ image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
restart: always
depends_on:
@@ -197,14 +197,24 @@ services:
- penpot
environment:
- << : [*penpot-secret-key, *penpot-public-uri]
+ << : [*penpot-flags, *penpot-secret-key, *penpot-public-uri]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
- ## Valkey (or previously Redis) is used for the websockets notifications.
+ ## Valkey (or previously Redis) is used for the websockets notifications
+ ## and for storing the state export jobs
PENPOT_REDIS_URI: redis://penpot-valkey/0
+ # PENPOT_EXPORTER_MAX_CONCURRENT_JOBS: 4
+ # PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE: 2
+ # PENPOT_EXPORTER_QUEUE_MAX: 64
+ # PENPOT_EXPORTER_JOB_TTL: 3600
+ # PENPOT_WASM_WORKER_POOL_MAX: 2
+ # PENPOT_WASM_WORKER_POOL_MIN: 1
+ # PENPOT_WASM_WORKER_IDLE_TIMEOUT: 300
+ # PENPOT_WASM_WORKER_IMAGE_CACHE_SIZE: 134217728
+
penpot-postgres:
image: "postgres:15"
restart: always
diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template
new file mode 100644
index 0000000000..fead3783c9
--- /dev/null
+++ b/docker/images/files/nginx-admin-console-locations.conf.template
@@ -0,0 +1,7 @@
+location /admin-console {
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $http_cf_connecting_ip;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri;
+}
diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh
index fe2fbc9abf..47a8a5544b 100644
--- a/docker/images/files/nginx-entrypoint.sh
+++ b/docker/images/files/nginx-entrypoint.sh
@@ -53,15 +53,22 @@ update_oidc_name /var/www/app/js/config.js
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
-export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-nitrate:3000}
export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB
export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::]:8080 default_server reuseport backlog=16384;"}
if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then
export PENPOT_IPV6_LISTEN_DIRECTIVE=""
fi
-envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \
+envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
+if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then
+ export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000}
+ envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \
+ < /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf
+else
+ rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf
+fi
+
if [[ $PENPOT_FLAGS == *"enable-mcp"* ]]; then
export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401}
export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402}
diff --git a/docker/images/files/nginx-mcp-locations.conf.template b/docker/images/files/nginx-mcp-locations.conf.template
index ab4df0acbb..6ff6fda592 100644
--- a/docker/images/files/nginx-mcp-locations.conf.template
+++ b/docker/images/files/nginx-mcp-locations.conf.template
@@ -1,16 +1,19 @@
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
- proxy_pass $PENPOT_MCP_URI_WS;
+ set $mcp_ws_backend $PENPOT_MCP_URI_WS;
+ proxy_pass $mcp_ws_backend;
proxy_http_version 1.1;
}
location /mcp/stream {
- proxy_pass $PENPOT_MCP_URI/mcp;
+ set $mcp_stream_backend $PENPOT_MCP_URI/mcp$is_args$args;
+ proxy_pass $mcp_stream_backend;
proxy_http_version 1.1;
}
location /mcp/sse {
- proxy_pass $PENPOT_MCP_URI/sse;
+ set $mcp_sse_backend $PENPOT_MCP_URI/sse$is_args$args;
+ proxy_pass $mcp_sse_backend;
proxy_http_version 1.1;
}
diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template
index 8524f4fb3d..75a385f9d6 100644
--- a/docker/images/files/nginx.conf.template
+++ b/docker/images/files/nginx.conf.template
@@ -111,6 +111,7 @@ http {
}
location /assets {
+ proxy_set_header Host $proxy_host;
proxy_pass $PENPOT_BACKEND_URI/assets;
recursive_error_pages on;
proxy_intercept_errors on;
@@ -127,10 +128,12 @@ http {
}
location /api/export {
+ proxy_set_header Host $proxy_host;
proxy_pass $PENPOT_EXPORTER_URI;
}
location /api {
+ proxy_set_header Host $proxy_host;
proxy_pass $PENPOT_BACKEND_URI/api;
proxy_buffering off;
}
@@ -142,28 +145,32 @@ http {
location /readyz {
access_log off;
+ proxy_set_header Host $proxy_host;
proxy_pass $PENPOT_BACKEND_URI$request_uri;
}
location /ws/notifications {
+ proxy_set_header Host $proxy_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass $PENPOT_BACKEND_URI/ws/notifications;
}
- location /admin-console {
- proxy_http_version 1.1;
- proxy_set_header Host $http_host;
- proxy_set_header X-Real-IP $http_cf_connecting_ip;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri;
- }
-
include /etc/nginx/overrides/server.d/*.conf;
location / {
include /etc/nginx/overrides/location.d/*.conf;
+ # Regenerated from the environment on every container start
+ # (see nginx-entrypoint.sh) while its URL is only versioned by
+ # the build, so a flags only restart leaves the URL untouched.
+ # Caching it like a build asset would keep returning users on
+ # the previous PENPOT_FLAGS for up to a week.
+ location = /js/config.js {
+ include /etc/nginx/nginx-security-headers.conf;
+ add_header Cache-Control "no-store, no-cache, max-age=0" always;
+ }
+
location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ {
include /etc/nginx/nginx-security-headers.conf;
add_header Cache-Control "public, max-age=604800" always; # 7 days
diff --git a/docs/img/teams/team-selector-projects.webp b/docs/img/teams/team-selector-projects.webp
index e1696680ec..c7ba762604 100644
Binary files a/docs/img/teams/team-selector-projects.webp and b/docs/img/teams/team-selector-projects.webp differ
diff --git a/docs/img/teams/team-selector.webp b/docs/img/teams/team-selector.webp
index c29357d264..e7af5e1523 100644
Binary files a/docs/img/teams/team-selector.webp and b/docs/img/teams/team-selector.webp differ
diff --git a/docs/mcp/index.md b/docs/mcp/index.md
index 8509bb7574..3f11995d2b 100644
--- a/docs/mcp/index.md
+++ b/docs/mcp/index.md
@@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca
title="Quick demo: Penpot MCP server in action"
width="100%"
height="480"
- src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0"
+ src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
diff --git a/docs/package.json b/docs/package.json
index c5286a7d57..7d498ee015 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -29,15 +29,15 @@
"@11ty/eleventy-plugin-rss": "^3.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@tigersway/eleventy-plugin-ancestry": "^1.0.3",
- "@types/markdown-it": "14.1.2",
+ "@types/markdown-it": "14.2.0",
"elasticlunr": "^0.9.5",
"eleventy-plugin-metagen": "^1.8.4",
"eleventy-plugin-nesting-toc": "^1.3.0",
"eleventy-plugin-youtube-embed": "^1.13.2",
"luxon": "^3.7.2",
- "markdown-it": "^14.3.0",
+ "markdown-it": "^15.0.0",
"markdown-it-anchor": "^9.2.1",
"markdown-it-plantuml": "^1.4.1"
},
- "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c"
+ "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
}
diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md
index 9a25d101b9..0a8f14a8fe 100644
--- a/docs/plugins/create-a-plugin.md
+++ b/docs/plugins/create-a-plugin.md
@@ -116,6 +116,10 @@ Your plugin can capture incoming messages from Penpot using the {
+ // Validate the source to ensure messages come from the parent (Penpot)
+ if (event.source !== window.parent) {
+ return;
+ }
// Handle the incoming message
console.log(event.data);
});
@@ -129,11 +133,11 @@ This setup allows for two-way communication between Penpot and your plugin. Penp
```js
// Sending a message back to Penpot from your plugin
-parent.postMessage(responseMessage, targetOrigin);
+parent.postMessage(responseMessage, "*");
```
-responseMessage is the data you want to send back to Penpot.
--targetOrigin should be the origin of the Penpot application to ensure messages are only sent to the intended recipient. You can use'*' to allow all.
+- Using'*' as the target origin is acceptable here because the message content is controlled by your plugin (the sender), not by untrusted input. If you know the exact Penpot origin, you can use it instead for stricter security.
### Summary
diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml
index 2d324a4b2a..5938203566 100644
--- a/docs/pnpm-lock.yaml
+++ b/docs/pnpm-lock.yaml
@@ -1,3 +1,104 @@
+---
+lockfileVersion: '9.0'
+
+importers:
+
+ .:
+ configDependencies: {}
+ packageManagerDependencies:
+ pnpm:
+ specifier: 12.0.0
+ version: 12.0.0
+
+packages:
+
+ '@pnpm/exe.darwin-arm64@12.0.0':
+ resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@pnpm/exe.darwin-x64@12.0.0':
+ resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@pnpm/exe.linux-arm64-musl@12.0.0':
+ resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-arm64@12.0.0':
+ resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.linux-x64-musl@12.0.0':
+ resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@pnpm/exe.linux-x64@12.0.0':
+ resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@pnpm/exe.win32-arm64@12.0.0':
+ resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@pnpm/exe.win32-x64@12.0.0':
+ resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
+ cpu: [x64]
+ os: [win32]
+
+ pnpm@12.0.0:
+ resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
+ engines: {node: '>=18.*'}
+ hasBin: true
+
+snapshots:
+
+ '@pnpm/exe.darwin-arm64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.darwin-x64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.linux-arm64-musl@12.0.0':
+ optional: true
+
+ '@pnpm/exe.linux-arm64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.linux-x64-musl@12.0.0':
+ optional: true
+
+ '@pnpm/exe.linux-x64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.win32-arm64@12.0.0':
+ optional: true
+
+ '@pnpm/exe.win32-x64@12.0.0':
+ optional: true
+
+ pnpm@12.0.0:
+ optionalDependencies:
+ '@pnpm/exe.darwin-arm64': 12.0.0
+ '@pnpm/exe.darwin-x64': 12.0.0
+ '@pnpm/exe.linux-arm64': 12.0.0
+ '@pnpm/exe.linux-arm64-musl': 12.0.0
+ '@pnpm/exe.linux-x64': 12.0.0
+ '@pnpm/exe.linux-x64-musl': 12.0.0
+ '@pnpm/exe.win32-arm64': 12.0.0
+ '@pnpm/exe.win32-x64': 12.0.0
+
+---
lockfileVersion: '9.0'
settings:
@@ -24,8 +125,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3(@11ty/eleventy@3.1.6)
'@types/markdown-it':
- specifier: 14.1.2
- version: 14.1.2
+ specifier: 14.2.0
+ version: 14.2.0
elasticlunr:
specifier: ^0.9.5
version: 0.9.5
@@ -42,11 +143,11 @@ importers:
specifier: ^3.7.2
version: 3.7.2
markdown-it:
- specifier: ^14.3.0
- version: 14.3.0
+ specifier: ^15.0.0
+ version: 15.0.0
markdown-it-anchor:
specifier: ^9.2.1
- version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0)
+ version: 9.2.1(@types/markdown-it@14.2.0)(markdown-it@15.0.0)
markdown-it-plantuml:
specifier: ^1.4.1
version: 1.4.1
@@ -64,8 +165,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
- '@11ty/eleventy-fetch@5.1.2':
- resolution: {integrity: sha512-YxDARdR3S9UT4gOGRWgGNyokYT9jkCAjJge3OVKFdNMv1cyvWg1NHCvj9NVvK9XHenCLFy3cwvM/YYpZZTZopw==}
+ '@11ty/eleventy-fetch@5.1.3':
+ resolution: {integrity: sha512-4HS6QB/mVWTVlE6kjCKPkjkC1Z0YOqizJaHR05zK+E7a2b4EUC3T+wLktIy7wEl76ZoarkY45EKLmTw1XuR8fQ==}
engines: {node: '>=18'}
'@11ty/eleventy-navigation@1.0.5':
@@ -102,8 +203,8 @@ packages:
resolution: {integrity: sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==}
engines: {node: '>=18'}
- '@rgrove/parse-xml@4.2.0':
- resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==}
+ '@rgrove/parse-xml@4.2.3':
+ resolution: {integrity: sha512-Jhlb+0zYez1T1yXUQs3F1qAtFuJljBVNdy9TKmLDauAXkxsOXopKYOyQ5Wm6SvP3fycav0GviX4Y15WWhGetMw==}
engines: {node: '>=14.0.0'}
'@sindresorhus/slugify@2.2.1':
@@ -122,8 +223,8 @@ packages:
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
- '@types/markdown-it@14.1.2':
- resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
+ '@types/markdown-it@14.2.0':
+ resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@@ -135,8 +236,8 @@ packages:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
- acorn@8.17.0:
- resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
+ acorn@8.18.0:
+ resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -150,6 +251,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ argparse@3.0.1:
+ resolution: {integrity: sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==}
+
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
@@ -162,8 +266,8 @@ packages:
bcp-47-normalize@2.3.0:
resolution: {integrity: sha512-8I/wfzqQvttUFz7HVJgIZ7+dj3vUaIyIxYXaTRP1YWoSDfzt6TUmxaKZeuXR62qBmYr+nvuWINFRl6pZ5DlN4Q==}
- bcp-47@2.1.0:
- resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==}
+ bcp-47@2.1.1:
+ resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
@@ -172,8 +276,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
- brace-expansion@1.1.15:
- resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
+ brace-expansion@1.1.18:
+ resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -301,6 +405,10 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
errno@1.0.0:
resolution: {integrity: sha512-3zV5mFS1E8/1bPxt/B0xxzI1snsg3uSCIh6Zo1qKg6iMw93hzPANk9oBFzSFBFrwuVoQuE3rLoouAUfwOAj1wQ==}
hasBin: true
@@ -356,8 +464,8 @@ packages:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
- flatted@3.4.2:
- resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+ flatted@3.4.4:
+ resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
@@ -432,16 +540,16 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
- iso-639-1@3.1.5:
- resolution: {integrity: sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==}
+ iso-639-1@3.1.6:
+ resolution: {integrity: sha512-ZFar/L4ngX7wZh2QX+Fiftmuf0igWJsrJtfizrovWifF1gAWkfmRa5Z1m0LQZbm0hKCHRDYhLRSLFrSqNe4EJA==}
engines: {node: '>=6.0'}
- js-yaml@3.15.0:
- resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
+ js-yaml@3.15.1:
+ resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==}
hasBin: true
- js-yaml@4.2.0:
- resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
+ js-yaml@4.3.1:
+ resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
junk@3.1.0:
@@ -459,8 +567,11 @@ packages:
linkify-it@5.0.2:
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
- liquidjs@10.27.0:
- resolution: {integrity: sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==}
+ linkify-it@6.1.0:
+ resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
+
+ liquidjs@10.29.0:
+ resolution: {integrity: sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw==}
engines: {node: '>=16'}
hasBin: true
@@ -487,6 +598,10 @@ packages:
resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==}
hasBin: true
+ markdown-it@15.0.0:
+ resolution: {integrity: sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==}
+ hasBin: true
+
mdurl@2.1.0:
resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
@@ -584,8 +699,8 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
- picomatch@4.0.4:
- resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
please-upgrade-node@3.2.0:
@@ -620,8 +735,8 @@ packages:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
- range-parser@1.2.1:
- resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+ range-parser@1.3.0:
+ resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
engines: {node: '>= 0.6'}
readdirp@3.6.0:
@@ -638,8 +753,8 @@ packages:
semver-compare@1.0.0:
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
- semver@7.8.4:
- resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
@@ -692,8 +807,11 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
- undici@7.28.0:
- resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
+ uc.micro@3.0.0:
+ resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==}
+
+ undici@7.29.0:
+ resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
unpipe@1.0.0:
@@ -712,8 +830,8 @@ packages:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
- ws@8.21.0:
- resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
+ ws@8.21.3:
+ resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -729,7 +847,7 @@ snapshots:
'@11ty/dependency-tree-esm@2.0.4':
dependencies:
'@11ty/eleventy-utils': 2.0.7
- acorn: 8.17.0
+ acorn: 8.18.0
dependency-graph: 1.0.0
normalize-path: 3.0.0
@@ -750,18 +868,18 @@ snapshots:
send: 1.2.1
ssri: 11.0.0
urlpattern-polyfill: 10.1.0
- ws: 8.21.0
+ ws: 8.21.3
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
- '@11ty/eleventy-fetch@5.1.2':
+ '@11ty/eleventy-fetch@5.1.3':
dependencies:
'@11ty/eleventy-utils': 2.0.7
- '@rgrove/parse-xml': 4.2.0
+ '@rgrove/parse-xml': 4.2.3
debug: 4.4.3
- flatted: 3.4.2
+ flatted: 3.4.4
p-queue: 6.6.2
transitivePeerDependencies:
- supports-color
@@ -812,21 +930,21 @@ snapshots:
entities: 6.0.1
filesize: 10.1.6
gray-matter: 4.0.3
- iso-639-1: 3.1.5
- js-yaml: 4.2.0
+ iso-639-1: 3.1.6
+ js-yaml: 4.3.1
kleur: 4.1.5
- liquidjs: 10.27.0
+ liquidjs: 10.29.0
luxon: 3.7.2
markdown-it: 14.3.0
minimist: 1.2.8
moo: 0.5.2
node-retrieve-globals: 6.0.1
nunjucks: 3.2.4(chokidar@3.6.0)
- picomatch: 4.0.4
+ picomatch: 4.0.7
please-upgrade-node: 3.2.0
posthtml: 0.16.7
posthtml-match-helper: 2.0.3(posthtml@0.16.7)
- semver: 7.8.4
+ semver: 7.8.5
slugify: 1.6.9
tinyglobby: 0.2.17
transitivePeerDependencies:
@@ -850,7 +968,7 @@ snapshots:
minimatch: 3.1.5
slash: 3.0.0
- '@rgrove/parse-xml@4.2.0': {}
+ '@rgrove/parse-xml@4.2.3': {}
'@sindresorhus/slugify@2.2.1':
dependencies:
@@ -867,7 +985,7 @@ snapshots:
'@types/linkify-it@5.0.0': {}
- '@types/markdown-it@14.1.2':
+ '@types/markdown-it@14.2.0':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
@@ -878,9 +996,9 @@ snapshots:
acorn-walk@8.3.5:
dependencies:
- acorn: 8.17.0
+ acorn: 8.18.0
- acorn@8.17.0: {}
+ acorn@8.18.0: {}
anymatch@3.1.3:
dependencies:
@@ -893,6 +1011,8 @@ snapshots:
argparse@2.0.1: {}
+ argparse@3.0.1: {}
+
asap@2.0.6: {}
balanced-match@1.0.2: {}
@@ -901,10 +1021,10 @@ snapshots:
bcp-47-normalize@2.3.0:
dependencies:
- bcp-47: 2.1.0
+ bcp-47: 2.1.1
bcp-47-match: 2.0.3
- bcp-47@2.1.0:
+ bcp-47@2.1.1:
dependencies:
is-alphabetical: 2.0.1
is-alphanumerical: 2.0.1
@@ -914,7 +1034,7 @@ snapshots:
boolbase@1.0.0: {}
- brace-expansion@1.1.15:
+ brace-expansion@1.1.18:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
@@ -943,7 +1063,7 @@ snapshots:
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
- undici: 7.28.0
+ undici: 7.29.0
whatwg-mimetype: 4.0.0
chokidar@3.6.0:
@@ -1041,7 +1161,7 @@ snapshots:
eleventy-plugin-youtube-embed@1.13.2:
dependencies:
- '@11ty/eleventy-fetch': 5.1.2
+ '@11ty/eleventy-fetch': 5.1.3
deepmerge: 4.3.1
lite-youtube-embed: 0.3.4
string-replace-async: 3.0.2
@@ -1065,6 +1185,8 @@ snapshots:
entities@7.0.1: {}
+ entities@8.0.0: {}
+
errno@1.0.0:
dependencies:
prr: 1.0.1
@@ -1075,7 +1197,7 @@ snapshots:
esm-import-transformer@3.0.5:
dependencies:
- acorn: 8.17.0
+ acorn: 8.18.0
esprima@4.0.1: {}
@@ -1089,9 +1211,9 @@ snapshots:
dependencies:
is-extendable: 0.1.1
- fdir@6.5.0(picomatch@4.0.4):
+ fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
- picomatch: 4.0.4
+ picomatch: 4.0.7
filesize@10.1.6: {}
@@ -1111,7 +1233,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- flatted@3.4.2: {}
+ flatted@3.4.4: {}
fresh@2.0.0: {}
@@ -1126,7 +1248,7 @@ snapshots:
gray-matter@4.0.3:
dependencies:
- js-yaml: 3.15.0
+ js-yaml: 3.15.1
kind-of: 6.0.3
section-matter: 1.0.0
strip-bom-string: 1.0.0
@@ -1186,14 +1308,14 @@ snapshots:
is-number@7.0.0: {}
- iso-639-1@3.1.5: {}
+ iso-639-1@3.1.6: {}
- js-yaml@3.15.0:
+ js-yaml@3.15.1:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
- js-yaml@4.2.0:
+ js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -1207,7 +1329,11 @@ snapshots:
dependencies:
uc.micro: 2.1.0
- liquidjs@10.27.0:
+ linkify-it@6.1.0:
+ dependencies:
+ uc.micro: 3.0.0
+
+ liquidjs@10.29.0:
dependencies:
commander: 10.0.1
@@ -1217,10 +1343,10 @@ snapshots:
luxon@3.7.2: {}
- markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0):
+ markdown-it-anchor@9.2.1(@types/markdown-it@14.2.0)(markdown-it@15.0.0):
dependencies:
- '@types/markdown-it': 14.1.2
- markdown-it: 14.3.0
+ '@types/markdown-it': 14.2.0
+ markdown-it: 15.0.0
markdown-it-plantuml@1.4.1: {}
@@ -1233,6 +1359,15 @@ snapshots:
punycode.js: 2.3.1
uc.micro: 2.1.0
+ markdown-it@15.0.0:
+ dependencies:
+ argparse: 3.0.1
+ entities: 8.0.0
+ linkify-it: 6.1.0
+ mdurl: 2.1.0
+ punycode.js: 2.3.1
+ uc.micro: 3.0.0
+
mdurl@2.1.0: {}
meta-generator@0.1.5:
@@ -1249,7 +1384,7 @@ snapshots:
minimatch@3.1.5:
dependencies:
- brace-expansion: 1.1.15
+ brace-expansion: 1.1.18
minimist@1.2.8: {}
@@ -1265,7 +1400,7 @@ snapshots:
node-retrieve-globals@6.0.1:
dependencies:
- acorn: 8.17.0
+ acorn: 8.18.0
acorn-walk: 8.3.5
esm-import-transformer: 3.0.5
@@ -1317,7 +1452,7 @@ snapshots:
picomatch@2.3.2: {}
- picomatch@4.0.4: {}
+ picomatch@4.0.7: {}
please-upgrade-node@3.2.0:
dependencies:
@@ -1346,7 +1481,7 @@ snapshots:
punycode.js@2.3.1: {}
- range-parser@1.2.1: {}
+ range-parser@1.3.0: {}
readdirp@3.6.0:
dependencies:
@@ -1361,7 +1496,7 @@ snapshots:
semver-compare@1.0.0: {}
- semver@7.8.4: {}
+ semver@7.8.5: {}
send@1.2.1:
dependencies:
@@ -1374,7 +1509,7 @@ snapshots:
mime-types: 3.0.2
ms: 2.1.3
on-finished: 2.4.1
- range-parser: 1.2.1
+ range-parser: 1.3.0
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
@@ -1399,8 +1534,8 @@ snapshots:
tinyglobby@0.2.17:
dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
to-regex-range@5.0.1:
dependencies:
@@ -1410,7 +1545,9 @@ snapshots:
uc.micro@2.1.0: {}
- undici@7.28.0: {}
+ uc.micro@3.0.0: {}
+
+ undici@7.29.0: {}
unpipe@1.0.0: {}
@@ -1422,4 +1559,4 @@ snapshots:
whatwg-mimetype@4.0.0: {}
- ws@8.21.0: {}
+ ws@8.21.3: {}
diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml
index 79b7d49256..bd2aa47620 100644
--- a/docs/pnpm-workspace.yaml
+++ b/docs/pnpm-workspace.yaml
@@ -1,3 +1,5 @@
minimumReleaseAgeExclude:
- - undici@7.28.0
- - js-yaml@3.15.0
+ - undici@7.28.0 || 7.29.0
+ - js-yaml@3.15.0 || 4.3.0
+ - brace-expansion@1.1.16 || 1.1.17 || 1.1.18
+ - liquidjs@10.27.1
diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md
index 49d5a23e8d..1541f1d3b8 100644
--- a/docs/technical-guide/configuration.md
+++ b/docs/technical-guide/configuration.md
@@ -438,7 +438,7 @@ with this flag enabled, the Penpot configuration will disable as well the librar
The mechanisms for installing Penpot in HA depend largely on how each infrastructure is managed.
In this section, we mention the key factors to consider when replicating a Penpot installation:
-The components that can be replicated are the `frontend`, the `backend`, and the `exporter`.
+The components that can be replicated are the `frontend`, the `backend`, the `exporter` and the `mcp`.
Replication management depends on the infrastructure, whether it's a load balancer or a Kubernetes deployment with HPA.
In a high-availability (HA) scenario, managing the state outside of replicas is crucial. This affects the following components:
@@ -447,12 +447,6 @@ In a high-availability (HA) scenario, managing the state outside of replicas is
- Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential.
- User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3.
-
-__Since version 2.15.0__
-
-Starting with version 2.15, we have introduced the MCP server. Due to architectural constraints, using the MCP server requires running only a single instance of Penpot.
-If the MCP server is not installed, then Penpot can scale normally and multiple application instances may be deployed without restrictions.
-
## Backend
This section enumerates the backend only configuration variables.
@@ -588,7 +582,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.
diff --git a/docs/technical-guide/developer/agentic-devenv.md b/docs/technical-guide/developer/agentic-devenv.md
index 55b03c1492..932f555d6f 100644
--- a/docs/technical-guide/developer/agentic-devenv.md
+++ b/docs/technical-guide/developer/agentic-devenv.md
@@ -148,10 +148,15 @@ automatically, so regular users never run this.
```bash
./manage.sh run-devenv --agentic \
[--ws N] [--sync] [--serena-context CTX] \
+ [--opencode-config-dir DIR] \
[--git-user-name NAME] [--git-user-email EMAIL]
```
Brings one agentic instance up. Errors out if the target is already running.
+`--opencode-config-dir DIR` bind-mounts DIR over the container's
+`~/.config/opencode` so personal agents/prompts/skills kept in a separate
+repository are available to the coding agent; see the
+[Dev environment guide](./devenv.md#personal-opencode-config-inside-the-container).
`--ws N` (N ≥ 1) brings that workspace up independently — workspaces can be
started and stopped in any order. Per-instance ports
diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md
index 922ed24f1d..b5496abe2f 100644
--- a/docs/technical-guide/developer/devenv.md
+++ b/docs/technical-guide/developer/devenv.md
@@ -139,6 +139,27 @@ until you set an identity. The values are applied every time
`run-devenv` brings an instance up (idempotent), so re-running
with different flags is the way to change the in-container identity.
+### Personal opencode config inside the container
+
+`run-devenv --opencode-config-dir DIR` bind-mounts a host directory over the
+container's `~/.config/opencode` (opencode's global config dir). This is how
+you keep personal agents, prompts, and skills in a separate repository and
+use them inside the devenv without committing them here or leaving untracked
+files in the repo:
+
+```bash
+./manage.sh run-devenv --agentic --opencode-config-dir ../penpot-opencode
+```
+
+The path must be an existing directory; `~` is expanded and the value is
+resolved to an absolute path automatically. The mount is applied at container
+creation, so changing it requires stopping and re-running `run-devenv` for
+that instance, and it applies only to instances brought up with the flag —
+other workspaces mount nothing. The directory is shared read-write with the
+container (same UID mapping as the source tree). Opencode's own state
+(sessions, `auth.json`) lives in `~/.local/share/opencode`, which stays in
+the container's data volume regardless of this flag.
+
### Shared state and workers
All instances share one Penpot database and one MinIO bucket; users, teams,
@@ -338,7 +359,7 @@ cd mcp
pnpm run bootstrap:multi-user
```
-This will start the MCP server and the multi-user plugin that will be loaded automaticaly by Penpot.
+This will start the MCP server and the multi-user plugin that will be loaded automatically by Penpot.
There is a NGINX proxy that makes a proxy-pass from outside the docker container so you don't need to remember the ports it's using.
@@ -419,16 +440,28 @@ After creating or modifying this file, **reload the browser** (no need to restar
### Backend flags via PENPOT_FLAGS
Backend feature flags are controlled through the `PENPOT_FLAGS` environment
-variable using the same `enable-` / `disable-` format. You can set
-this in the `docker/devenv/docker-compose.yaml` file under the `main` service
-`environment` section:
+variable using the same `enable-` / `disable-` format. The devenv
+sets its own list in `backend/scripts/_env`.
-```yaml
-environment:
- - PENPOT_FLAGS=enable-access-tokens enable-mcp
+To change that list for your checkout, create `backend/scripts/_env.local`.
+`backend/scripts/start-dev` sources it immediately after `_env`, and the file
+is gitignored, so your override never appears in `git status`:
+
+```bash
+export PENPOT_FLAGS="$PENPOT_FLAGS enable-access-tokens enable-mcp"
```
-This requires **restarting the backend** to take effect.
+Flags are applied left to right and the last entry wins, so appending to
+`$PENPOT_FLAGS` both adds flags and switches off ones that `_env` enables:
+`disable-demo-users` at the end turns off the demo users that `_env` enables
+earlier.
+
+Setting `PENPOT_FLAGS` in the container environment does not work for this,
+because `_env` expands the inherited value *before* its own list. Any flag it
+sets afterwards wins over yours.
+
+This requires **restarting the backend** to take effect: stop the process in
+the `backend` tmux window and run `./scripts/start-dev` again.
> **Note**: Some features (e.g., access tokens, webhooks) need both frontend and
> backend flags enabled to work end-to-end. The frontend flag enables the UI, while
diff --git a/docs/user-guide/account-teams/projects-files.njk b/docs/user-guide/account-teams/projects-files.njk
index 9345cd4f45..02f1afd38c 100644
--- a/docs/user-guide/account-teams/projects-files.njk
+++ b/docs/user-guide/account-teams/projects-files.njk
@@ -10,7 +10,7 @@ desc: Learn how to organize your work in Penpot. Create, manage and organize pro
Projects
Projects are containers that help you organize and group related design files together. Think of them as folders in a file system. You can create as many projects as you need to organize your work by client, product, feature, or any other structure that fits your workflow.
-
If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Your Penpot") remain private to you.
+
If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Personal Projects") remain private to you.
diff --git a/docs/user-guide/account-teams/teams.njk b/docs/user-guide/account-teams/teams.njk
index 67fbc78187..550cf81390 100644
--- a/docs/user-guide/account-teams/teams.njk
+++ b/docs/user-guide/account-teams/teams.njk
@@ -16,7 +16,7 @@ member is allowed to do depends on their permissions.
Select team
At the top left of the dashboard you can find the team selector.
-
"Your Penpot" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.
+
"Personal Projects" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.
Create teams
diff --git a/docs/user-guide/first-steps/index.njk b/docs/user-guide/first-steps/index.njk
index c26777b24f..e707f6d46e 100644
--- a/docs/user-guide/first-steps/index.njk
+++ b/docs/user-guide/first-steps/index.njk
@@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
diff --git a/docs/user-guide/first-steps/migration-guide.njk b/docs/user-guide/first-steps/migration-guide.njk
new file mode 100644
index 0000000000..cd8600ff68
--- /dev/null
+++ b/docs/user-guide/first-steps/migration-guide.njk
@@ -0,0 +1,31 @@
+---
+title: Migration Guide
+order: 6
+desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF.
+---
+
+
Migration Guide
+
+
If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.
The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.
+
+
+
Before you export: audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.
+
Static assets: export SVG, PNG, or JPG from Figma and place them in Penpot.
+
Complex files and libraries: use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.
+
Tokens: if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.
+
Validate before you scale: migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.
+
People and pilots: the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.
+
+
+
The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.