mirror of
https://github.com/penpot/penpot.git
synced 2026-06-20 14:22:08 +00:00
Merge branch 'develop' into palba-nitrate-sso
This commit is contained in:
commit
9d4b71aebe
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -1,6 +1,5 @@
|
||||
description: Create a report to help us improve
|
||||
name: Bug report
|
||||
title: "bug: "
|
||||
type: Bug
|
||||
labels: ["needs triage"]
|
||||
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@ -1,7 +1,6 @@
|
||||
description: Suggest an idea for this project.
|
||||
labels: ["needs triage"]
|
||||
name: "Feature request"
|
||||
title: "feature: "
|
||||
type: Enhancement
|
||||
|
||||
body:
|
||||
|
||||
84
.github/workflows/tests-backend.yml
vendored
Normal file
84
.github/workflows/tests-backend.yml
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
name: "CI: Backend"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'common/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'common/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Backend 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
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
# Provide the password for postgres
|
||||
env:
|
||||
POSTGRES_USER: penpot_test
|
||||
POSTGRES_PASSWORD: penpot_test
|
||||
POSTGRES_DB: penpot_test
|
||||
|
||||
# Set health checks to wait until postgres has started
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:9
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./backend
|
||||
env:
|
||||
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
|
||||
PENPOT_TEST_DATABASE_USERNAME: penpot_test
|
||||
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
|
||||
PENPOT_TEST_REDIS_URI: "redis://redis/1"
|
||||
|
||||
run: |
|
||||
mkdir -p /tmp/penpot;
|
||||
clojure -M:dev:test --reporter kaocha.report/documentation
|
||||
57
.github/workflows/tests-common.yml
vendored
Normal file
57
.github/workflows/tests-common.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
name: "CI: Common"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'common/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'common/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-common:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Common 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: ./common
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run check-fmt:js
|
||||
pnpm run lint:clj
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./common
|
||||
run: |
|
||||
./scripts/test
|
||||
71
.github/workflows/tests-frontend.yml
vendored
Normal file
71
.github/workflows/tests-frontend.yml
vendored
Normal file
@ -0,0 +1,71 @@
|
||||
name: "CI: Frontend"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-frontend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Frontend 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: ./frontend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:js
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run check-fmt:scss
|
||||
pnpm run lint:clj
|
||||
pnpm run lint:js
|
||||
pnpm run lint:scss
|
||||
|
||||
- name: Unit Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test
|
||||
|
||||
- name: Component Tests
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
VITEST_BROWSER_TIMEOUT: 120000
|
||||
run: |
|
||||
./scripts/test-components
|
||||
93
.github/workflows/tests-integration.yml
vendored
Normal file
93
.github/workflows/tests-integration.yml
vendored
Normal file
@ -0,0 +1,93 @@
|
||||
name: "CI: Integration"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Build Integration Bundle"
|
||||
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: Build Bundle
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/build
|
||||
|
||||
- name: Store Bundle Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
|
||||
test-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration 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
|
||||
|
||||
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
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test-e2e
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result
|
||||
path: frontend/test-results/
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
58
.github/workflows/tests-library.yml
vendored
Normal file
58
.github/workflows/tests-library.yml
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
name: "CI: Library"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'common/**'
|
||||
- 'library/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'common/**'
|
||||
- 'library/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-library:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Library 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: ./library
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./library
|
||||
run: |
|
||||
./scripts/test
|
||||
5
.github/workflows/tests-mcp.yml
vendored
5
.github/workflows/tests-mcp.yml
vendored
@ -45,3 +45,8 @@ jobs:
|
||||
pnpm run fmt:check;
|
||||
pnpm -r run build;
|
||||
pnpm -r run types:check;
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./mcp
|
||||
run: |
|
||||
pnpm -r run test;
|
||||
|
||||
83
.github/workflows/tests-plugins.yml
vendored
Normal file
83
.github/workflows/tests-plugins.yml
vendored
Normal file
@ -0,0 +1,83 @@
|
||||
name: "CI: Plugins"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'plugins/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'plugins/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-plugins:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: Plugins Runtime Linter & 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:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
|
||||
- name: Run Lint
|
||||
working-directory: ./plugins
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run Format Check
|
||||
working-directory: ./plugins
|
||||
run: pnpm run format:check
|
||||
|
||||
- name: Run Test
|
||||
working-directory: ./plugins
|
||||
run: pnpm run test
|
||||
|
||||
- name: Build runtime
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:runtime
|
||||
|
||||
- name: Build doc
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:doc
|
||||
|
||||
- name: Build plugins
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:plugins
|
||||
|
||||
- name: Build styles
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:styles-example
|
||||
57
.github/workflows/tests-wasm.yml
vendored
Normal file
57
.github/workflows/tests-wasm.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
name: "CI: WASM"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'render-wasm/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'render-wasm/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-render-wasm:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Render WASM 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: Format
|
||||
working-directory: ./render-wasm
|
||||
run: |
|
||||
cargo fmt --check
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./render-wasm
|
||||
run: |
|
||||
./lint
|
||||
|
||||
- name: Test
|
||||
working-directory: ./render-wasm
|
||||
run: |
|
||||
./test
|
||||
411
.github/workflows/tests.yml
vendored
411
.github/workflows/tests.yml
vendored
@ -1,411 +0,0 @@
|
||||
name: "CI"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Linter"
|
||||
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 Common
|
||||
working-directory: ./common
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run check-fmt:js
|
||||
pnpm run lint:clj
|
||||
|
||||
- name: Lint Frontend
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:js
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run check-fmt:scss
|
||||
pnpm run lint:clj
|
||||
pnpm run lint:js
|
||||
pnpm run lint:scss
|
||||
|
||||
- name: Lint Backend
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
|
||||
- name: Lint Exporter
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
|
||||
- name: Lint Library
|
||||
working-directory: ./library
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
|
||||
test-common:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Common 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: Run tests
|
||||
working-directory: ./common
|
||||
run: |
|
||||
./scripts/test
|
||||
|
||||
test-plugins:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: Plugins Runtime Linter & 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:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
|
||||
- name: Run Lint
|
||||
working-directory: ./plugins
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run Format Check
|
||||
working-directory: ./plugins
|
||||
run: pnpm run format:check
|
||||
|
||||
- name: Run Test
|
||||
working-directory: ./plugins
|
||||
run: pnpm run test
|
||||
|
||||
- name: Build runtime
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:runtime
|
||||
|
||||
- name: Build doc
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:doc
|
||||
|
||||
- name: Build plugins
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:plugins
|
||||
|
||||
- name: Build styles
|
||||
working-directory: ./plugins
|
||||
run: pnpm run build:styles-example
|
||||
|
||||
test-frontend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Frontend 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: Unit Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test
|
||||
|
||||
- name: Component Tests
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
VITEST_BROWSER_TIMEOUT: 120000
|
||||
run: |
|
||||
./scripts/test-components
|
||||
|
||||
test-render-wasm:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Render WASM 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: Format
|
||||
working-directory: ./render-wasm
|
||||
run: |
|
||||
cargo fmt --check
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./render-wasm
|
||||
run: |
|
||||
./lint
|
||||
|
||||
- name: Test
|
||||
working-directory: ./render-wasm
|
||||
run: |
|
||||
./test
|
||||
|
||||
test-backend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Backend 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
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
# Provide the password for postgres
|
||||
env:
|
||||
POSTGRES_USER: penpot_test
|
||||
POSTGRES_PASSWORD: penpot_test
|
||||
POSTGRES_DB: penpot_test
|
||||
|
||||
# Set health checks to wait until postgres has started
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:9
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./backend
|
||||
env:
|
||||
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
|
||||
PENPOT_TEST_DATABASE_USERNAME: penpot_test
|
||||
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
|
||||
PENPOT_TEST_REDIS_URI: "redis://redis/1"
|
||||
|
||||
run: |
|
||||
mkdir -p /tmp/penpot;
|
||||
clojure -M:dev:test --reporter kaocha.report/documentation
|
||||
|
||||
test-library:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Library 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: Run tests
|
||||
working-directory: ./library
|
||||
run: |
|
||||
./scripts/test
|
||||
|
||||
build-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Build Integration Bundle"
|
||||
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: Build Bundle
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/build
|
||||
|
||||
- name: Store Bundle Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
|
||||
test-integration-1:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests 1/3"
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test-e2e --shard="1/3";
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result-1
|
||||
path: frontend/test-results/
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
|
||||
test-integration-2:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests 2/3"
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test-e2e --shard="2/3";
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result-2
|
||||
path: frontend/test-results/
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
|
||||
test-integration-3:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests 3/3"
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
./scripts/test-e2e --shard="3/3";
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result-3
|
||||
path: frontend/test-results/
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
81
.opencode/skills/create-pr/SKILL.md
Normal file
81
.opencode/skills/create-pr/SKILL.md
Normal file
@ -0,0 +1,81 @@
|
||||
---
|
||||
name: create-pr
|
||||
description: Create a GitHub PR following Penpot conventions, with a concise engineer-focused description
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
|
||||
Create a GitHub PR with proper title format and a concise description that explains reasoning, not implementation details.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Opening a new pull request
|
||||
- The user asks to create a PR
|
||||
- Code changes are ready and committed
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Verify Prerequisites
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
git log --oneline main..HEAD
|
||||
```
|
||||
|
||||
### 2. Check if Branch is Pushed
|
||||
|
||||
```bash
|
||||
BRANCH=$(git branch --show-current)
|
||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
||||
echo "Branch is pushed, proceeding with PR creation"
|
||||
else
|
||||
echo "ERROR: Branch '$BRANCH' is not pushed to remote. Please push the branch first."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**If the branch is not pushed, STOP here and ask the user to push it. The LLM does not have push permissions.**
|
||||
|
||||
### 3. Create PR Body
|
||||
|
||||
Write to `/tmp/pr-body.md` to avoid shell quoting issues:
|
||||
|
||||
```bash
|
||||
cat > /tmp/pr-body.md << 'EOF'
|
||||
**Note:** This PR was created with AI assistance.
|
||||
|
||||
## What
|
||||
|
||||
<one paragraph: the problem or feature, user-facing impact>
|
||||
|
||||
## Why
|
||||
|
||||
<root cause or motivation, why this change was necessary>
|
||||
|
||||
## How
|
||||
|
||||
<high-level approach, key technical decisions>
|
||||
EOF
|
||||
```
|
||||
|
||||
### 4. Create the PR
|
||||
|
||||
Follow title and description format from `mem:workflow/creating-prs` and `mem:workflow/creating-commits`.
|
||||
|
||||
```bash
|
||||
gh pr create --base main --project "Main" --title "<title>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
### 5. What NOT to Include
|
||||
|
||||
- ❌ List of files changed (visible in diff)
|
||||
- ❌ Testing steps (CI handles this)
|
||||
- ❌ Screenshots unless UI-visible
|
||||
- ❌ Migration notes unless breaking changes
|
||||
- ❌ Regression fixes introduced during the PR (they're part of the development process, not the feature)
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Write for humans.** The diff shows what changed. The description explains why.
|
||||
- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it?
|
||||
- **Skip the obvious.** Don't explain what `git diff` already shows.
|
||||
@ -39,8 +39,8 @@ Identify:
|
||||
|
||||
| Field | Source | Rule |
|
||||
|-------|--------|------|
|
||||
| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. |
|
||||
| **Labels** | PR labels | Copy user-facing labels (`bug`, `enhancement`, `community contribution`). Skip workflow labels (`backport candidate`, `team-qa`). |
|
||||
| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. Use the `issue-title` skill to generate this. |
|
||||
| **Labels** | PR labels | Copy `community contribution` if present. Skip `bug` and `enhancement` (redundant with Issue Type). Skip workflow labels (`backport candidate`, `team-qa`). |
|
||||
| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view <PR_NUMBER> --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. |
|
||||
| **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. |
|
||||
| **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. |
|
||||
@ -101,8 +101,7 @@ Create:
|
||||
gh issue create \
|
||||
--repo penpot/penpot \
|
||||
--title "<Title>" \
|
||||
--label "<label1>" \
|
||||
--label "<label2>" \
|
||||
--label "community contribution" \ # only if PR has this label
|
||||
--milestone "<milestone>" \
|
||||
--project "Main" \
|
||||
--body-file /tmp/issue-body.md
|
||||
@ -198,12 +197,10 @@ rm -f /tmp/issue-body.md /tmp/pr-body.md
|
||||
|
||||
| PR has | Issue gets |
|
||||
|--------|-----------|
|
||||
| `bug` | `bug` |
|
||||
| `enhancement` | `enhancement` |
|
||||
| `community contribution` | `community contribution` |
|
||||
| `bug`, `enhancement` | *(skip — redundant with Issue Type)* |
|
||||
| `backport candidate` | *(skip — workflow label)* |
|
||||
| `team-qa` | *(skip — workflow label)* |
|
||||
| No user-facing label | Infer from title: `:bug:` → `bug`, `:sparkles:` → `enhancement` |
|
||||
|
||||
## Issue Type mapping
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||
**Exclusion rules (issue-level):**
|
||||
- `no changelog` label — Chore/refactor work that doesn't need a changelog entry
|
||||
- `release blocker` label — Blocked issues not yet ready for changelog
|
||||
- `Task` issue type — Internal chores are not user-facing; filter these out after fetching
|
||||
- `Task` issue type — Internal chores are not user-facing; automatically excluded by `gh.py`. Use `--include-tasks` to override.
|
||||
- **Rejected project status** — Issues with a "Rejected" status in the "Main" project board are automatically excluded by `gh.py`. This project-level status (independent of the GitHub issue `state`) indicates the issue was rejected from the release. Use `--include-rejected` to override.
|
||||
|
||||
**Exclusion rules (PR-level):**
|
||||
@ -347,71 +347,233 @@ if closed:
|
||||
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
```
|
||||
|
||||
### 10. Cross-reference milestone PRs against the changelog
|
||||
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
|
||||
|
||||
Issues can be fixed by PRs that aren't in the milestone, and merged PRs in
|
||||
the milestone may not close any tracked issue. After writing, run a full
|
||||
cross-reference to catch gaps:
|
||||
After all edits and cross-referencing are complete, generate a structured
|
||||
anomaly report and save it to `CHANGES-ISSUES.md` (overwriting if exists).
|
||||
This provides a persistent record of any discrepancies between the milestone
|
||||
and the changelog.
|
||||
|
||||
Run this self-contained script:
|
||||
|
||||
```bash
|
||||
# List all merged PRs in the milestone
|
||||
python3 tools/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
|
||||
python3 << 'PYEOF'
|
||||
import json, re, subprocess, sys
|
||||
|
||||
# Extract PR numbers from the changelog section
|
||||
python3 -c "
|
||||
import json, re
|
||||
MILESTONE = "<MILESTONE>"
|
||||
CHANGES_MD = "CHANGES.md"
|
||||
OUTPUT = "CHANGES-ISSUES.md"
|
||||
|
||||
with open('CHANGES.md') as f:
|
||||
# Fetch milestone issues (all states)
|
||||
result = subprocess.run(
|
||||
["python3", "tools/gh.py", "issues", MILESTONE, "--state", "all"],
|
||||
capture_output=True, text=True)
|
||||
all_issues = json.loads(result.stdout)
|
||||
issue_by_num = {i['number']: i for i in all_issues}
|
||||
|
||||
# Fetch milestone PRs (all states)
|
||||
result = subprocess.run(
|
||||
["python3", "tools/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
|
||||
capture_output=True, text=True)
|
||||
all_prs = json.loads(result.stdout)
|
||||
pr_by_num = {p['number']: p for p in all_prs}
|
||||
|
||||
# Read changelog
|
||||
with open(CHANGES_MD) as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract the version section (adjust regex to match the actual version)
|
||||
match = re.search(r'## <MILESTONE> \(Unreleased\)\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
|
||||
section = match.group(1)
|
||||
m = re.search(rf'## {MILESTONE} \(Unreleased\)\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
|
||||
section = m.group(1) if m else ""
|
||||
|
||||
# Collect issue and PR references from the changelog section
|
||||
changelog_issues = set()
|
||||
for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/issues/\d+\)', section):
|
||||
changelog_issues.add(int(num))
|
||||
for num in re.findall(r'\[Github #(\d+)\]', section):
|
||||
changelog_issues.add(int(num))
|
||||
|
||||
# Collect all PR numbers referenced
|
||||
changelog_prs = set()
|
||||
for m in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section):
|
||||
changelog_prs.add(int(m))
|
||||
for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section):
|
||||
changelog_prs.add(int(num))
|
||||
for num in re.findall(r'PR:\[(\d+)\]', section):
|
||||
changelog_prs.add(int(num))
|
||||
|
||||
# Collect all milestone PRs (filtered)
|
||||
with open('/tmp/milestone-prs.json') as f:
|
||||
milestone_prs = json.load(f)
|
||||
# Determine valid (non-excluded) milestone issues
|
||||
EXCLUDED_LABELS = {'release blocker', 'no changelog'}
|
||||
EXCLUDED_ISSUE_TYPES = {'Task'}
|
||||
EXCLUDED_PROJECT_STATUS = {'Rejected'}
|
||||
|
||||
milestone_merged = {pr['number'] for pr in milestone_prs}
|
||||
valid_issues = []
|
||||
for issue in all_issues:
|
||||
labels = set(issue.get('labels', []))
|
||||
if issue.get('state') != 'CLOSED': continue
|
||||
if issue.get('issue_type') in EXCLUDED_ISSUE_TYPES: continue
|
||||
if issue.get('project_status') in EXCLUDED_PROJECT_STATUS: continue
|
||||
if EXCLUDED_LABELS & labels: continue
|
||||
valid_issues.append(issue)
|
||||
valid_nums = {i['number'] for i in valid_issues}
|
||||
|
||||
# PRs in milestone but not in changelog
|
||||
missing = sorted(milestone_merged - changelog_prs)
|
||||
print(f'Milestone merged PRs: {len(milestone_merged)}')
|
||||
print(f'Changelog referenced PRs: {len(changelog_prs)}')
|
||||
print(f'PRs in milestone but NOT in changelog: {len(missing)}')
|
||||
for num in missing:
|
||||
pr = next(p for p in milestone_prs if p['number'] == num)
|
||||
print(f' #{num} {pr[\"title\"][:80]}')
|
||||
"
|
||||
# --- Gather anomalies ---
|
||||
anomalies = []
|
||||
|
||||
# Type 1: Entries in changelog that should be excluded
|
||||
for num in sorted(changelog_issues):
|
||||
issue = issue_by_num.get(num)
|
||||
if issue is None:
|
||||
anomalies.append({
|
||||
'type': 'should_remove',
|
||||
'severity': 'HIGH',
|
||||
'number': num,
|
||||
'title': '',
|
||||
'reason': 'Issue not found in milestone (deleted or moved)'
|
||||
})
|
||||
continue
|
||||
labels = set(issue.get('labels', []))
|
||||
reasons = []
|
||||
if issue.get('state') != 'CLOSED':
|
||||
reasons.append(f'state is "{issue["state"]}" (should be CLOSED)')
|
||||
if 'release blocker' in labels:
|
||||
reasons.append('has "release blocker" label')
|
||||
if 'no changelog' in labels:
|
||||
reasons.append('has "no changelog" label')
|
||||
if issue.get('issue_type') == 'Task':
|
||||
reasons.append(f'issue_type is Task (internal chore)')
|
||||
if issue.get('project_status') == 'Rejected':
|
||||
reasons.append('project_status is Rejected')
|
||||
if reasons:
|
||||
anomalies.append({
|
||||
'type': 'should_remove',
|
||||
'severity': 'MEDIUM' if issue.get('issue_type') == 'Task' else 'HIGH',
|
||||
'number': num,
|
||||
'title': issue.get('title', '')[:80],
|
||||
'reason': '; '.join(reasons)
|
||||
})
|
||||
|
||||
# Type 2: Valid issues not in changelog
|
||||
for num in sorted(valid_nums - changelog_issues):
|
||||
issue = issue_by_num[num]
|
||||
info = {
|
||||
'type': 'missing',
|
||||
'severity': 'MEDIUM',
|
||||
'number': num,
|
||||
'title': issue['title'][:80],
|
||||
'issue_type': issue['issue_type'],
|
||||
'closing_prs': issue.get('closing_prs', []),
|
||||
'note': ''
|
||||
}
|
||||
# Check for duplicate (same PR as existing entry)
|
||||
existing = []
|
||||
for pr_num in issue.get('closing_prs', []):
|
||||
for cl_num in changelog_issues:
|
||||
cl_issue = issue_by_num.get(cl_num)
|
||||
if cl_issue and pr_num in cl_issue.get('closing_prs', []):
|
||||
existing.append(f'#{cl_num}')
|
||||
if existing:
|
||||
info['note'] = f'DUPLICATE: same PR as existing entry(ies): {", ".join(existing)}'
|
||||
# Check closing PRs not merged
|
||||
unmerged = []
|
||||
for pr_num in issue.get('closing_prs', []):
|
||||
pr = pr_by_num.get(pr_num)
|
||||
if pr is None:
|
||||
unmerged.append(f'#{pr_num} (unknown)')
|
||||
elif pr.get('state') != 'MERGED':
|
||||
unmerged.append(f'#{pr_num} (state={pr["state"]})')
|
||||
if unmerged:
|
||||
info['note'] = (info['note'] + '; ' if info['note'] else '') + f'Closing PRs not merged: {", ".join(unmerged)}'
|
||||
anomalies.append(info)
|
||||
|
||||
# Type 3: PRs in changelog that are not merged
|
||||
for pr_num in sorted(changelog_prs):
|
||||
pr = pr_by_num.get(pr_num)
|
||||
if pr is None:
|
||||
anomalies.append({
|
||||
'type': 'unmerged_pr',
|
||||
'severity': 'HIGH',
|
||||
'number': pr_num,
|
||||
'title': '',
|
||||
'reason': 'PR not found in milestone PR list'
|
||||
})
|
||||
elif pr.get('state') != 'MERGED':
|
||||
anomalies.append({
|
||||
'type': 'unmerged_pr',
|
||||
'severity': 'HIGH',
|
||||
'number': pr_num,
|
||||
'title': pr.get('title', '')[:80],
|
||||
'reason': f'state={pr["state"]} (should be MERGED)'
|
||||
})
|
||||
|
||||
# --- Write report to CHANGES-ISSUES.md ---
|
||||
with open(OUTPUT, 'w') as f:
|
||||
f.write(f'# Changelog Anomaly Report — {MILESTONE}\n\n')
|
||||
f.write(f'Generated: {__import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M UTC")}\n\n')
|
||||
f.write('---\n\n')
|
||||
|
||||
# Summary
|
||||
n_remove = sum(1 for a in anomalies if a['type'] == 'should_remove')
|
||||
n_missing = sum(1 for a in anomalies if a['type'] == 'missing')
|
||||
n_pr = sum(1 for a in anomalies if a['type'] == 'unmerged_pr')
|
||||
f.write(f'## Summary\n\n')
|
||||
f.write(f'- **Issues to remove from changelog:** {n_remove}\n')
|
||||
f.write(f'- **Valid issues missing from changelog:** {n_missing}\n')
|
||||
f.write(f'- **Unmerged PRs referenced:** {n_pr}\n')
|
||||
f.write(f'- **Total anomalies:** {len(anomalies)}\n\n')
|
||||
|
||||
if not anomalies:
|
||||
f.write('✅ No anomalies found. The changelog is fully consistent with the milestone.\n\n')
|
||||
else:
|
||||
# Type 1
|
||||
if n_remove:
|
||||
f.write(f'## Issues to Remove\n\n')
|
||||
f.write('These entries are in the changelog but should be excluded based on current issue metadata.\n\n')
|
||||
for a in anomalies:
|
||||
if a['type'] != 'should_remove': continue
|
||||
badge = '🔴' if a['severity'] == 'HIGH' else '🟡'
|
||||
f.write(f'{badge} **#{a["number"]}**')
|
||||
if a.get('title'): f.write(f' — {a["title"]}')
|
||||
f.write(f'\n - Reason: {a["reason"]}\n\n')
|
||||
|
||||
# Type 2
|
||||
if n_missing:
|
||||
f.write(f'## Valid Issues Not in Changelog\n\n')
|
||||
f.write('These issues are closed, non-excluded milestone items that lack a changelog entry.\n\n')
|
||||
for a in anomalies:
|
||||
if a['type'] != 'missing': continue
|
||||
f.write(f'❓ **#{a["number"]}** — {a["title"]}\n')
|
||||
f.write(f' - Type: {a["issue_type"]}, Closing PRs: {a["closing_prs"]}\n')
|
||||
if a.get('note'): f.write(f' - Note: {a["note"]}\n')
|
||||
f.write('\n')
|
||||
|
||||
# Type 3
|
||||
if n_pr:
|
||||
f.write(f'## Unmerged PRs Referenced in Changelog\n\n')
|
||||
f.write('These PR numbers appear in the changelog but are not merged.\n\n')
|
||||
for a in anomalies:
|
||||
if a['type'] != 'unmerged_pr': continue
|
||||
f.write(f'🔴 **#{a["number"]}**')
|
||||
if a.get('title'): f.write(f' — {a["title"]}')
|
||||
f.write(f'\n - {a["reason"]}\n\n')
|
||||
|
||||
# Appendix: counts
|
||||
f.write('---\n\n')
|
||||
f.write(f'## Context\n\n')
|
||||
f.write(f'- Milestone total issues (all states): {len(all_issues)}\n')
|
||||
f.write(f'- Valid issues after exclusions: {len(valid_issues)}\n')
|
||||
f.write(f'- Issues referenced in changelog: {len(changelog_issues)}\n')
|
||||
f.write(f'- PRs referenced in changelog: {len(changelog_prs)}\n')
|
||||
|
||||
print(f"Anomaly report written to {OUTPUT}")
|
||||
PYEOF
|
||||
```
|
||||
|
||||
For each missing PR found, decide whether it should be added to the
|
||||
changelog or is legitimately excluded (check its labels).
|
||||
This generates `CHANGES-ISSUES.md` with three sections:
|
||||
1. **Issues to Remove** — Entries in the changelog that should be excluded based
|
||||
on current issue metadata (labels, type, project status, or deletion).
|
||||
2. **Valid Issues Not in Changelog** — Closed, non-excluded milestone issues
|
||||
that lack a changelog entry (with notes on duplicates and unmerged closing PRs).
|
||||
3. **Unmerged PRs Referenced** — PRs in the changelog that are not merged.
|
||||
|
||||
Also verify that no closed-unmerged PRs remain in the changelog:
|
||||
|
||||
```bash
|
||||
python3 tools/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
closed = [p for p in data if p['state'] == 'CLOSED']
|
||||
if closed:
|
||||
print('WARNING: CLOSED (unmerged) PRs in milestone:')
|
||||
for p in closed:
|
||||
print(f' #{p[\"number\"]} {p[\"title\"][:80]}')
|
||||
"
|
||||
```
|
||||
|
||||
**Post-edit audit checklist:**
|
||||
- ✅ All referenced PRs are merged (no closed-unmerged artifacts)
|
||||
- ✅ 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
|
||||
The report is overwritten each time it's generated, reflecting the current
|
||||
state of the milestone and changelog.
|
||||
|
||||
## Key Principles
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
|
||||
# Development workflow
|
||||
|
||||
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`.
|
||||
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. Issue creation (titles, labels, body templates, Issue Types): `mem:workflow/creating-issues`.
|
||||
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
|
||||
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
|
||||
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.
|
||||
|
||||
160
.serena/memories/workflow/creating-issues.md
Normal file
160
.serena/memories/workflow/creating-issues.md
Normal file
@ -0,0 +1,160 @@
|
||||
# Creating Issues
|
||||
|
||||
Create GitHub issues only on explicit request. Use `gh` CLI authenticated to `penpot/penpot`.
|
||||
|
||||
## Title Derivation
|
||||
|
||||
Derive the title from the source material (bug report, user feedback, feature request, etc.) — not from any pre-existing title which may be auto-generated or stale.
|
||||
|
||||
### Bug titles (descriptive present tense)
|
||||
|
||||
Describe the symptom as it appears to the user. Format: `[Where] [present-tense verb] when [condition]`.
|
||||
|
||||
- *"Plugin API crashes when setting text fills"*
|
||||
- *"Canvas renders glitches when zooming quickly"*
|
||||
- *"French Canada locale falls back to French (fr) translations"*
|
||||
|
||||
Do **not** start bug titles with "Fix" or any imperative verb — state what's broken, not command a fix.
|
||||
|
||||
### Feature / Enhancement titles (imperative mood)
|
||||
|
||||
Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
|
||||
|
||||
- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"*
|
||||
- *"Show user, timestamp, and hash in the workspace history panel like git commits"*
|
||||
|
||||
### Universal rules
|
||||
|
||||
- **Include the "where"** — specify the UI location or module (e.g. "in the sidebar", "on the stroke options")
|
||||
- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`, `[PENPOT FEEDBACK]`, etc.
|
||||
- **No emoji** — plain text only
|
||||
- **Be specific** — prefer concrete detail over generality
|
||||
- **Two problems → cover both** — if the description has two distinct but related issues, capture both joined by "and"
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Rule |
|
||||
|-------|------|
|
||||
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) |
|
||||
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
|
||||
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
|
||||
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
|
||||
|
||||
## Issue Body Template
|
||||
|
||||
Write the body to a temp file to avoid shell quoting issues:
|
||||
|
||||
**Bug template:**
|
||||
```markdown
|
||||
### Description
|
||||
|
||||
<what breaks, what the user experiences>
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1. <step 1>
|
||||
2. <step 2>
|
||||
|
||||
### Expected behavior
|
||||
|
||||
<what should happen instead>
|
||||
|
||||
### Affected versions
|
||||
|
||||
<version>
|
||||
```
|
||||
|
||||
**Enhancement template:**
|
||||
```markdown
|
||||
### Description
|
||||
|
||||
<what the user can now do that they couldn't before>
|
||||
|
||||
### Use case
|
||||
|
||||
<why this is useful, who benefits>
|
||||
|
||||
### Affected versions
|
||||
|
||||
<version>
|
||||
```
|
||||
|
||||
## Creating the Issue
|
||||
|
||||
```bash
|
||||
cat > /tmp/issue-body.md << 'ISSUE_BODY'
|
||||
<body content here>
|
||||
ISSUE_BODY
|
||||
|
||||
gh issue create \
|
||||
--repo penpot/penpot \
|
||||
--title "<Derived title>" \
|
||||
--label "<label>" \
|
||||
--project "Main" \
|
||||
--body-file /tmp/issue-body.md
|
||||
```
|
||||
|
||||
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
|
||||
|
||||
## Setting the Issue Type
|
||||
|
||||
`gh issue create` can't set Issue Type directly. Use GraphQL after creation.
|
||||
|
||||
**Issue Type IDs for penpot/penpot:**
|
||||
|
||||
| Type | ID |
|
||||
|------|----|
|
||||
| Bug | `IT_kwDOAcyBPM4AX5Nb` |
|
||||
| Enhancement | `IT_kwDOAcyBPM4B_IQN` |
|
||||
| Feature | `IT_kwDOAcyBPM4AX5Nf` |
|
||||
| Task | `IT_kwDOAcyBPM4AX5NY` |
|
||||
| Question | `IT_kwDOAcyBPM4B_IQj` |
|
||||
| Docs | `IT_kwDOAcyBPM4B_IQz` |
|
||||
|
||||
**Map:**
|
||||
- `bug` label → Bug
|
||||
- `enhancement` label → Enhancement
|
||||
- Feature/epic → Feature
|
||||
- Docs → Docs
|
||||
- None of the above → Task
|
||||
|
||||
**Set it:**
|
||||
```bash
|
||||
ISSUE_ID=$(gh api graphql -f query='
|
||||
query { repository(owner: "penpot", name: "penpot") {
|
||||
issue(number: <NUMBER>) { id }
|
||||
}}' --jq '.data.repository.issue.id')
|
||||
|
||||
gh api graphql -f query='
|
||||
mutation {
|
||||
updateIssue(input: {
|
||||
id: "'"$ISSUE_ID"'"
|
||||
issueTypeId: "<TYPE_ID>"
|
||||
}) {
|
||||
issue { number issueType { name } }
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
gh issue view <NUMBER> --repo penpot/penpot \
|
||||
--json title,labels,milestone,projectItems \
|
||||
--jq '{title, milestone: .milestone.title, labels: [.labels[].name], projects: [.projectItems[].title]}'
|
||||
|
||||
gh api graphql -f query='
|
||||
query { repository(owner: "penpot", name: "penpot") {
|
||||
issue(number: <NUMBER>) { issueType { name } }
|
||||
}}' --jq '.data.repository.issue.issueType.name'
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
rm -f /tmp/issue-body.md
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- Creating issues **from PRs** (separating WHAT from HOW): `mem:workflow/creating-prs`
|
||||
71
CHANGES.md
71
CHANGES.md
@ -23,6 +23,16 @@
|
||||
- Remove stray debug log in exporter upload-resource (by @iot2edge) [#9270](https://github.com/penpot/penpot/issues/9270) (PR: [#9272](https://github.com/penpot/penpot/pull/9272))
|
||||
- Release pool connection during font variant creation (by @Dexterity104) [#9286](https://github.com/penpot/penpot/issues/9286) (PR: [#9287](https://github.com/penpot/penpot/pull/9287))
|
||||
- Add autocomplete combobox to token creation and edition forms [#9899](https://github.com/penpot/penpot/issues/9899) (PR: [#9109](https://github.com/penpot/penpot/pull/9109))
|
||||
- Add list view mode to color picker UI [#4420](https://github.com/penpot/penpot/issues/4420) (PR: [#9953](https://github.com/penpot/penpot/pull/9953))
|
||||
- Use Clipboard API consistently across the application (by @MilosM348) [#6514](https://github.com/penpot/penpot/issues/6514) (PR: [#9188](https://github.com/penpot/penpot/pull/9188))
|
||||
- Use `$` as DTCG token/group discriminator and make `$description` optional [#8342](https://github.com/penpot/penpot/issues/8342) (PR: [#9912](https://github.com/penpot/penpot/pull/9912))
|
||||
- Match version preview banner text to History sidebar labels (by @MilosM348) [#9503](https://github.com/penpot/penpot/issues/9503) (PR: [#9697](https://github.com/penpot/penpot/pull/9697))
|
||||
- Use "copia" as duplicate suffix for Spanish (by @Rene0422) [#9623](https://github.com/penpot/penpot/issues/9623) (PR: [#9671](https://github.com/penpot/penpot/pull/9671))
|
||||
- Harden CORS middleware to not reflect Origin with credentials enabled [#9659](https://github.com/penpot/penpot/issues/9659) (PR: [#9675](https://github.com/penpot/penpot/pull/9675))
|
||||
- Revert token migrations on clashing names to prevent data loss [#9816](https://github.com/penpot/penpot/issues/9816) (PR: [#9950](https://github.com/penpot/penpot/pull/9950))
|
||||
- Update contributing guidelines with current issue tags and CSS linting rules [#9900](https://github.com/penpot/penpot/issues/9900) (PR: [#9418](https://github.com/penpot/penpot/pull/9418))
|
||||
- Add composite typography token input to the Design sidebar [#9932](https://github.com/penpot/penpot/issues/9932) (PR: [#9128](https://github.com/penpot/penpot/pull/9128), [#9375](https://github.com/penpot/penpot/pull/9375), [#8749](https://github.com/penpot/penpot/pull/8749))
|
||||
- Avoid deduplicating temporary export files to prevent stale content (by @yong2bba) [#9970](https://github.com/penpot/penpot/issues/9970) (PR: [#9959](https://github.com/penpot/penpot/pull/9959))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
@ -63,8 +73,46 @@
|
||||
- Fix plugin `addInteraction` silently rejecting `open-overlay` actions with `manualPositionLocation` [Github #8409](https://github.com/penpot/penpot/issues/8409)
|
||||
- Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479)
|
||||
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
|
||||
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
|
||||
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @jack-stormentswe) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
|
||||
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
|
||||
- Fix Storybook UI missing scrollbar (by @MilosM348) [#6049](https://github.com/penpot/penpot/issues/6049) (PR: [#9319](https://github.com/penpot/penpot/pull/9319))
|
||||
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
|
||||
- Fix plugin API `typography.remove()` passing wrong parameter format (by @leonaIee) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
|
||||
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
|
||||
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
|
||||
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
|
||||
- Fix prototype interaction targets appearing in View Mode automatically when library component changes (by @jeffrey701) [#9049](https://github.com/penpot/penpot/issues/9049) (PR: [#9695](https://github.com/penpot/penpot/pull/9695))
|
||||
- Fix Docker frontend image missing CSS reference (by @NativeTeachingAidsB) [#9135](https://github.com/penpot/penpot/issues/9135) (PR: [#9840](https://github.com/penpot/penpot/pull/9840))
|
||||
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
|
||||
- Fix lost-update race on team features during concurrent file creation (by @JPette1783) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
|
||||
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @jack-stormentswe) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
|
||||
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
|
||||
- Fix conditional use-ctx hook violation in shape-wrapper (by @Dexterity104) [#9280](https://github.com/penpot/penpot/issues/9280) (PR: [#9281](https://github.com/penpot/penpot/pull/9281))
|
||||
- Make ShapeImageIds byte conversion fallible to prevent panics (by @Dexterity104) [#9282](https://github.com/penpot/penpot/issues/9282) (PR: [#9283](https://github.com/penpot/penpot/pull/9283))
|
||||
- Prevent viewers from overwriting file thumbnails (by @jony376) [#9284](https://github.com/penpot/penpot/issues/9284) (PR: [#9285](https://github.com/penpot/penpot/pull/9285))
|
||||
- Fix plugin API showing incorrect error messages for invalid operations (by @bitcompass) [#9417](https://github.com/penpot/penpot/issues/9417) (PR: [#9486](https://github.com/penpot/penpot/pull/9486))
|
||||
- Add inactivity timeout to SSE sessions to match Streamable HTTP sessions [#9432](https://github.com/penpot/penpot/issues/9432) (PR: [#9464](https://github.com/penpot/penpot/pull/9464))
|
||||
- Fix component variant switching behaving differently on two identical copies (by @MischaPanch) [#9498](https://github.com/penpot/penpot/issues/9498) (PR: [#9434](https://github.com/penpot/penpot/pull/9434))
|
||||
- Populate is-indirect flag on file libraries from relation graph (by @Dexterity104) [#9506](https://github.com/penpot/penpot/issues/9506) (PR: [#9289](https://github.com/penpot/penpot/pull/9289))
|
||||
- Add missing error message for invalid shadow token [#9583](https://github.com/penpot/penpot/issues/9583) (PR: [#9809](https://github.com/penpot/penpot/pull/9809))
|
||||
- Fix moving a component in a library triggering stale update notification in dependent files [#9629](https://github.com/penpot/penpot/issues/9629) (PR: [#9616](https://github.com/penpot/penpot/pull/9616))
|
||||
- Fix newly created token not visible when placed above existing tokens in the tree [#9711](https://github.com/penpot/penpot/issues/9711) (PR: [#9803](https://github.com/penpot/penpot/pull/9803))
|
||||
- Fix B(V) input label misalignment in HSB color picker [#9731](https://github.com/penpot/penpot/issues/9731) (PR: [#9793](https://github.com/penpot/penpot/pull/9793))
|
||||
- Fix text style name input appending font name instead of replacing it when edited [#9785](https://github.com/penpot/penpot/issues/9785) (PR: [#9784](https://github.com/penpot/penpot/pull/9784))
|
||||
- Fix shadow token creation not allowing empty blur or spread value [#9808](https://github.com/penpot/penpot/issues/9808) (PR: [#9809](https://github.com/penpot/penpot/pull/9809))
|
||||
- Fix thinner line in path when its stroke is deleted and added again [#9823](https://github.com/penpot/penpot/issues/9823) (PR: [#9836](https://github.com/penpot/penpot/pull/9836))
|
||||
- Fix layers panel perceivable lag when displaying changes [#9834](https://github.com/penpot/penpot/issues/9834)
|
||||
- Fix settings form visual layout broken after recent contribution [#9882](https://github.com/penpot/penpot/issues/9882) (PR: [#9883](https://github.com/penpot/penpot/pull/9883))
|
||||
- Fix crash when duplicating shapes with fill/stroke properties [#9893](https://github.com/penpot/penpot/issues/9893) (PR: [#9647](https://github.com/penpot/penpot/pull/9647))
|
||||
- Fix S3 storage failing with IRSA/Web Identity Token credentials (by @jpc2350) [#9927](https://github.com/penpot/penpot/issues/9927) (PR: [#9928](https://github.com/penpot/penpot/pull/9928))
|
||||
- Fix onboarding template spinner stuck after failed template download (by @jeffrey701) [#9931](https://github.com/penpot/penpot/issues/9931) (PR: [#9504](https://github.com/penpot/penpot/pull/9504))
|
||||
- Fix stroke caps not working correctly when there are other nodes in the middle of a path [#9987](https://github.com/penpot/penpot/issues/9987) (PR: [#9989](https://github.com/penpot/penpot/pull/9989))
|
||||
- Fix missing three dots button for column and row edit menu in WebKit/Safari [#9993](https://github.com/penpot/penpot/issues/9993) (PR: [#9994](https://github.com/penpot/penpot/pull/9994))
|
||||
- Fix exported path with strokes being cut off in SVG file [#9995](https://github.com/penpot/penpot/issues/9995) (PR: [#9996](https://github.com/penpot/penpot/pull/9996))
|
||||
- Fix French Canada locale falling back to French translations instead of French Canadian (by @alexismo) [#10017](https://github.com/penpot/penpot/issues/10017) (PR: [#10027](https://github.com/penpot/penpot/pull/10027))
|
||||
|
||||
## 2.16.0 (Unreleased)
|
||||
## 2.16.0
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
@ -90,7 +138,6 @@
|
||||
- Duplicate token group [#9638](https://github.com/penpot/penpot/issues/9638) (PR: [#8886](https://github.com/penpot/penpot/pull/8886))
|
||||
- Copy token name from contextual menu [#9639](https://github.com/penpot/penpot/issues/9639) (PR: [#8566](https://github.com/penpot/penpot/pull/8566))
|
||||
- Add drag-to-change for numeric inputs in workspace sidebar (by @RenzoMXD) [#2466](https://github.com/penpot/penpot/issues/2466) (PR: [#8536](https://github.com/penpot/penpot/pull/8536))
|
||||
- Add CSS linter [#9636](https://github.com/penpot/penpot/issues/9636) (PR: [#8592](https://github.com/penpot/penpot/pull/8592))
|
||||
- Add per-group add button for typographies (by @eureka0928) [#5275](https://github.com/penpot/penpot/issues/5275) (PR: [#8895](https://github.com/penpot/penpot/pull/8895))
|
||||
- Add Find & Replace for text content and layer names (by @statxc) [#7108](https://github.com/penpot/penpot/issues/7108) (PR: [#8899](https://github.com/penpot/penpot/pull/8899), [#9687](https://github.com/penpot/penpot/pull/9687))
|
||||
- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [#8773](https://github.com/penpot/penpot/issues/8773) (PR: [#8874](https://github.com/penpot/penpot/pull/8874))
|
||||
@ -121,10 +168,9 @@
|
||||
- Preserve Inkscape labels when pasting SVGs (by @jeffrey701) [#7869](https://github.com/penpot/penpot/issues/7869) (PR: [#9252](https://github.com/penpot/penpot/pull/9252))
|
||||
- Add Alt+click to expand layer subtree (by @MilosM348) [#7736](https://github.com/penpot/penpot/issues/7736) (PR: [#9179](https://github.com/penpot/penpot/pull/9179))
|
||||
- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068))
|
||||
- Clarify self-hosted OIDC configuration for containerized (by @sancfc) [#9764](https://github.com/penpot/penpot/issues/9764) (PR: [#9758](https://github.com/penpot/penpot/pull/9758))
|
||||
- Update User Guide with 2.16 features (by @myfunnyandy) [#9767](https://github.com/penpot/penpot/issues/9767) (PR: [#9768](https://github.com/penpot/penpot/pull/9768))
|
||||
- Improve file validation performance and fix orphan shape detection [#9790](https://github.com/penpot/penpot/issues/9790) (PR: [#9789](https://github.com/penpot/penpot/pull/9789))
|
||||
- Add v2.16 release notes (What's new modal) [#9945](https://github.com/penpot/penpot/issues/9945) (PR: [#9940](https://github.com/penpot/penpot/pull/9940))
|
||||
- Enable multi-instance horizontal scaling for MCP server [#10000](https://github.com/penpot/penpot/issues/10000) (PR: [#10013](https://github.com/penpot/penpot/pull/10013))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
@ -186,7 +232,6 @@
|
||||
- Fix `:heigth` typo in clipboard frame-same-size? (by @iot2edge) [#9249](https://github.com/penpot/penpot/issues/9249) (PR: [#9250](https://github.com/penpot/penpot/pull/9250))
|
||||
- Fix Settings Update button enabled state (by @moorsecopers99) [#9090](https://github.com/penpot/penpot/issues/9090) (PR: [#9091](https://github.com/penpot/penpot/pull/9091))
|
||||
- Fix library updates reappearing after reload [#9326](https://github.com/penpot/penpot/issues/9326) (PR: [#9563](https://github.com/penpot/penpot/pull/9563))
|
||||
- Fix dependency libraries visible after unlinking main library [#9331](https://github.com/penpot/penpot/issues/9331) (PR: [#9511](https://github.com/penpot/penpot/pull/9511))
|
||||
- Fix internal error on margins [#9309](https://github.com/penpot/penpot/issues/9309) (PR: [#9311](https://github.com/penpot/penpot/pull/9311))
|
||||
- Remove drag-to-change when token applied on numeric input [#9313](https://github.com/penpot/penpot/issues/9313) (PR: [#9314](https://github.com/penpot/penpot/pull/9314))
|
||||
- Fix extra input on canvas background [#9359](https://github.com/penpot/penpot/issues/9359) (PR: [#9360](https://github.com/penpot/penpot/pull/9360))
|
||||
@ -194,20 +239,16 @@
|
||||
- Fix several color picker issues [#9556](https://github.com/penpot/penpot/issues/9556) (PR: [#9558](https://github.com/penpot/penpot/pull/9558))
|
||||
- Fix asset icon broken on Asset tab [#9587](https://github.com/penpot/penpot/issues/9587) (PR: [#9612](https://github.com/penpot/penpot/pull/9612))
|
||||
- Fix text fill color stops updating in multiselect with texts [#9608](https://github.com/penpot/penpot/issues/9608) (PR: [#9549](https://github.com/penpot/penpot/pull/9549))
|
||||
- Fix editing a legacy text element silently detaches its color token [#9255](https://github.com/penpot/penpot/issues/9255) (PR: [#9525](https://github.com/penpot/penpot/pull/9525))
|
||||
- Fix token application to grid paddings [#9494](https://github.com/penpot/penpot/issues/9494) (PR: [#9630](https://github.com/penpot/penpot/pull/9630))
|
||||
- Fix file crashing when switching a variant [#9259](https://github.com/penpot/penpot/issues/9259) (PR: [#9147](https://github.com/penpot/penpot/pull/9147))
|
||||
- Fix set activation after renaming [#9329](https://github.com/penpot/penpot/issues/9329) (PR: [#9545](https://github.com/penpot/penpot/pull/9545))
|
||||
- Fix font selection position hiding available fonts [#9489](https://github.com/penpot/penpot/issues/9489) (PR: [#9499](https://github.com/penpot/penpot/pull/9499))
|
||||
- Fix numeric input changes not saved when clicking on viewport [#9491](https://github.com/penpot/penpot/issues/9491) (PR: [#9548](https://github.com/penpot/penpot/pull/9548))
|
||||
- Fix resize cursor appearing on login and register buttons [#9505](https://github.com/penpot/penpot/issues/9505) (PR: [#9590](https://github.com/penpot/penpot/pull/9590))
|
||||
- Fix version restore restoring first previewed version instead of selected one [#9588](https://github.com/penpot/penpot/issues/9588) (PR: [#9626](https://github.com/penpot/penpot/pull/9626))
|
||||
- Fix incorrect error message when applying tokens while editing text [#9620](https://github.com/penpot/penpot/issues/9620) (PR: [#9708](https://github.com/penpot/penpot/pull/9708))
|
||||
- Fix standalone tokens ordering separated from token groups [#9733](https://github.com/penpot/penpot/issues/9733) (PR: [#9736](https://github.com/penpot/penpot/pull/9736))
|
||||
- Fix delete invitation modal readability in light theme [#9737](https://github.com/penpot/penpot/issues/9737) (PR: [#9747](https://github.com/penpot/penpot/pull/9747))
|
||||
- Fix team invitation not automatically accepted after account validation [#9776](https://github.com/penpot/penpot/issues/9776) (PR: [#9782](https://github.com/penpot/penpot/pull/9782))
|
||||
- Fix design tokens vanishing from the sidebar when a token name collides with a token-group prefix from another active set (e.g. `a` in one set and `a.b` in another); the colliding token is now kept and rendered as a broken pill [Github #9584](https://github.com/penpot/penpot/issues/9584)
|
||||
- Fix Plugin API addRulerGuide creating guides on page instead of board (by @girafic) [#8225](https://github.com/penpot/penpot/issues/8225) (PR: [#8632](https://github.com/penpot/penpot/pull/8632))
|
||||
- Fix text editor not swapping correctly when enabling/disabling WebGL [#10015](https://github.com/penpot/penpot/issues/10015)
|
||||
- Fix WebGL renderer focus mode leaving artefacts [#10061](https://github.com/penpot/penpot/issues/10061) (PR: [#10091](https://github.com/penpot/penpot/pull/10091))
|
||||
- Fix double click on text selecting underlying element when WebGL render is enabled [#10080](https://github.com/penpot/penpot/issues/10080) (PR: [#10123](https://github.com/penpot/penpot/pull/10123))
|
||||
- Fix publishing or unpublishing file as library failing with unexpected state found error [#10094](https://github.com/penpot/penpot/issues/10094) (PR: [#10093](https://github.com/penpot/penpot/pull/10093))
|
||||
- Fix team invitation failing when email address contains consecutive dots in domain [#10097](https://github.com/penpot/penpot/issues/10097) (PR: [#10096](https://github.com/penpot/penpot/pull/10096))
|
||||
- Add detailed error messages for unspecified import errors [#9759](https://github.com/penpot/penpot/issues/9759) (PR: [#9886](https://github.com/penpot/penpot/pull/9886))
|
||||
|
||||
|
||||
## 2.15.4
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
We take the security of this project seriously. If you have discovered
|
||||
a security vulnerability, please do **not** open a public issue.
|
||||
|
||||
Please report vulnerabilities via email to: **[support@penpot.app]**
|
||||
|
||||
Please report vulnerabilities through the [GitHub Security Advisories](https://github.com/penpot/penpot/security/advisories
|
||||
) feature in the Penpot repository.
|
||||
|
||||
### What to include:
|
||||
|
||||
|
||||
@ -7,6 +7,9 @@ list.
|
||||
|
||||
## Security
|
||||
|
||||
* Noob Researcher
|
||||
* Kirubakaran N
|
||||
* Alisher (@7megaumka7)
|
||||
* Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD)
|
||||
* [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/)
|
||||
* Vaibhav Shukla
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.12.5"}
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.1"}
|
||||
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-10"}
|
||||
|
||||
io.prometheus/simpleclient {:mvn/version "0.16.0"}
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
|
||||
|
||||
io.lettuce/lettuce-core {:mvn/version "7.5.1.RELEASE"}
|
||||
io.lettuce/lettuce-core {:mvn/version "7.6.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.4.8-jre"}
|
||||
com.google.guava/guava {:mvn/version "33.6.0-jre"}
|
||||
|
||||
funcool/yetti
|
||||
{:git/tag "v11.10"
|
||||
@ -34,13 +34,13 @@
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
com.github.seancorfield/next.jdbc
|
||||
{:mvn/version "1.3.1093"}
|
||||
{:mvn/version "1.3.1108"}
|
||||
|
||||
metosin/reitit-core {:mvn/version "0.9.1"}
|
||||
metosin/reitit-core {:mvn/version "0.10.1"}
|
||||
nrepl/nrepl {:mvn/version "1.7.0"}
|
||||
|
||||
org.postgresql/postgresql {:mvn/version "42.7.11"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
|
||||
|
||||
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.21.2"}
|
||||
org.jsoup/jsoup {:mvn/version "1.22.2"}
|
||||
org.im4java/im4java
|
||||
{:git/tag "1.4.0-penpot-2"
|
||||
:git/sha "e2b3e16"
|
||||
@ -63,18 +63,18 @@
|
||||
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.4"}
|
||||
markdown-clj/markdown-clj {:mvn/version "1.12.8"}
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.44.4"}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.44.4"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.46.7"}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.46.7"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
{:dev
|
||||
{:extra-deps
|
||||
{com.bhauman/rebel-readline {:mvn/version "0.1.5"}
|
||||
{com.bhauman/rebel-readline {:mvn/version "0.1.7"}
|
||||
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
|
||||
org.clojure/data.csv {:mvn/version "1.1.1"}
|
||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
|
||||
@ -83,7 +83,7 @@
|
||||
|
||||
:build
|
||||
{:extra-deps
|
||||
{io.github.clojure/tools.build {:mvn/version "0.10.10"}}
|
||||
{io.github.clojure/tools.build {:mvn/version "0.10.14"}}
|
||||
:ns-default build}
|
||||
|
||||
:test
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
|
||||
@ -545,6 +545,12 @@
|
||||
::audit/context {:action "email-verification"}
|
||||
::audit/profile-id (:id profile)})))))
|
||||
|
||||
;; When email verification is disabled and an inactive profile already
|
||||
;; exists, reject the registration — the email is already taken.
|
||||
(not (contains? cf/flags :email-verification))
|
||||
(ex/raise :type :validation
|
||||
:code :email-already-exists)
|
||||
|
||||
:else
|
||||
(let [elapsed? (elapsed-verify-threshold? profile)
|
||||
reports? (eml/has-reports? conn (:email profile))
|
||||
|
||||
@ -974,6 +974,12 @@
|
||||
{:id id})
|
||||
file)
|
||||
|
||||
(= (:is-shared file) (:is-shared params))
|
||||
;; File is already in the desired state (idempotent);
|
||||
;; this can happen when the frontend sends a duplicate
|
||||
;; request due to optimistic updates or race conditions.
|
||||
file
|
||||
|
||||
:else
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-shared-state
|
||||
|
||||
@ -311,11 +311,30 @@
|
||||
:object-id object-id
|
||||
:tag tag})))
|
||||
|
||||
(defn- delete-file-object-thumbnails!
|
||||
"Soft-deletes multiple object thumbnails in a single UPDATE statement
|
||||
with RETURNING, then touches all returned media objects."
|
||||
[{:keys [::db/conn ::sto/storage]} object-ids]
|
||||
(let [ids (db/create-array conn "text" (seq object-ids))
|
||||
sql (str/concat
|
||||
"UPDATE file_tagged_object_thumbnail"
|
||||
" SET deleted_at = now()"
|
||||
" WHERE object_id = ANY(?)"
|
||||
" AND deleted_at IS NULL"
|
||||
" RETURNING media_id")
|
||||
rows (db/exec! conn [sql ids])]
|
||||
(doseq [{:keys [media-id]} rows]
|
||||
(sto/touch-object! storage media-id))))
|
||||
|
||||
(def ^:private schema:delete-file-object-thumbnail
|
||||
[:map {:title "delete-file-object-thumbnail"}
|
||||
[:file-id ::sm/uuid]
|
||||
[:object-id [:string {:max 250}]]])
|
||||
|
||||
(def ^:private schema:delete-file-object-thumbnails
|
||||
[:map {:title "delete-file-object-thumbnails"}
|
||||
[:object-ids [:vector {:max 200} [:string {:max 250}]]]])
|
||||
|
||||
(sv/defmethod ::delete-file-object-thumbnail
|
||||
{::doc/added "1.19"
|
||||
::doc/module :files
|
||||
@ -329,6 +348,30 @@
|
||||
(delete-file-object-thumbnail! file-id object-id))
|
||||
nil)))
|
||||
|
||||
(sv/defmethod ::delete-file-object-thumbnails
|
||||
{::doc/added "1.19"
|
||||
::doc/module :files
|
||||
::climit/id [[:file-thumbnail-ops/by-profile ::rpc/profile-id]
|
||||
[:file-thumbnail-ops/global]]
|
||||
::sm/params schema:delete-file-object-thumbnails
|
||||
::audit/skip true}
|
||||
[cfg {:keys [::rpc/profile-id object-ids]}]
|
||||
(when (seq object-ids)
|
||||
;; Extract unique file-ids from object-ids for permission checks
|
||||
(let [file-ids (->> object-ids
|
||||
(map thc/get-file-id)
|
||||
(into #{}))]
|
||||
;; Check permissions for each unique file using a single connection
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(doseq [file-id file-ids]
|
||||
(files/check-edition-permissions! conn profile-id file-id))))
|
||||
;; Delete all matching thumbnails in one transaction
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(-> cfg
|
||||
(update ::sto/storage sto/configure conn)
|
||||
(delete-file-object-thumbnails! object-ids))
|
||||
nil)))))
|
||||
|
||||
;; --- MUTATION COMMAND: create-file-thumbnail
|
||||
|
||||
(defn- create-file-thumbnail
|
||||
|
||||
@ -89,6 +89,12 @@
|
||||
email)]
|
||||
email))
|
||||
|
||||
(defn- with-nitrate-licence
|
||||
[profile cfg]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
||||
profile))
|
||||
|
||||
;; --- QUERY: Get profile (own)
|
||||
|
||||
|
||||
@ -106,9 +112,7 @@
|
||||
(let [profile (-> (get-profile pool profile-id)
|
||||
(strip-private-attrs)
|
||||
(update :props filter-props))]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
||||
profile))
|
||||
(with-nitrate-licence profile cfg))
|
||||
|
||||
(catch Throwable cause
|
||||
(if (= :not-found (-> cause ex-data :type))
|
||||
@ -137,7 +141,7 @@
|
||||
::sm/params schema:update-profile
|
||||
::sm/result schema:profile
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id fullname lang theme] :as params}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id fullname lang theme] :as params}]
|
||||
;; NOTE: we need to retrieve the profile independently if we use
|
||||
;; it or not for explicit locking and avoid concurrent updates of
|
||||
;; the same row/object.
|
||||
@ -158,6 +162,7 @@
|
||||
(-> profile
|
||||
(strip-private-attrs)
|
||||
(d/without-nils)
|
||||
(with-nitrate-licence cfg)
|
||||
(rph/with-meta {::audit/props (audit/profile->props profile)}))))
|
||||
|
||||
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
|
||||
(def default
|
||||
{:database-uri "postgresql://postgres/penpot_test"
|
||||
:redis-uri "redis://redis/1"
|
||||
:redis-uri "redis://valkey/1"
|
||||
:auto-file-snapshot-every 1
|
||||
:file-data-backend "db"})
|
||||
|
||||
|
||||
@ -830,6 +830,49 @@
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (th/ex-of-type? error :not-found))))
|
||||
|
||||
(t/deftest set-file-shared-idempotent
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:project-id (:default-project-id profile)
|
||||
:profile-id (:id profile)})]
|
||||
|
||||
;; Share the file
|
||||
(let [data {::th/type :set-file-shared
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)
|
||||
:is-shared true}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (true? (-> out :result :is-shared))))
|
||||
|
||||
;; Calling set-file-shared with is-shared=true again should be a
|
||||
;; no-op success (idempotent), not an error.
|
||||
(let [data {::th/type :set-file-shared
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)
|
||||
:is-shared true}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (true? (-> out :result :is-shared))))
|
||||
|
||||
;; Unshare the file
|
||||
(let [data {::th/type :set-file-shared
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)
|
||||
:is-shared false}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (false? (-> out :result :is-shared))))
|
||||
|
||||
;; Calling set-file-shared with is-shared=false again should also
|
||||
;; be a no-op success (idempotent).
|
||||
(let [data {::th/type :set-file-shared
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)
|
||||
:is-shared false}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (false? (-> out :result :is-shared))))))
|
||||
|
||||
(t/deftest permissions-checks-link-to-library-1
|
||||
(let [profile1 (th/create-profile* 1)
|
||||
profile2 (th/create-profile* 2)
|
||||
|
||||
@ -380,3 +380,538 @@
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))))
|
||||
|
||||
;; --- delete-file-object-thumbnails (batch)
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-basic
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
|
||||
oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
|
||||
oid3 (thc/fmt-object-id (:id file) page-id (uuid/random) "component")]
|
||||
|
||||
;; Create three thumbnails
|
||||
(doseq [oid [oid1 oid2 oid3]]
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:object-id oid
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))))
|
||||
|
||||
;; Verify all three exist and are not soft-deleted
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{::db/remove-deleted false
|
||||
:order-by [[:created-at :asc]]})]
|
||||
(t/is (= 3 (count rows)))
|
||||
(doseq [row rows]
|
||||
(t/is (nil? (:deleted-at row)))))
|
||||
|
||||
;; Batch delete all three
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2 oid3]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; Verify all three are now soft-deleted
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{::db/remove-deleted false
|
||||
:order-by [[:created-at :asc]]})]
|
||||
(t/is (= 3 (count rows)))
|
||||
(doseq [row rows]
|
||||
(t/is (some? (:deleted-at row)))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-empty
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids []}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out)))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-non-existent
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
|
||||
oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
|
||||
|
||||
;; Batch delete non-existent object-ids (no thumbnails were created)
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-mixed-exists
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
|
||||
oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
|
||||
oid3 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
|
||||
|
||||
;; Create only one thumbnail
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:object-id oid1
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
;; Batch delete mix of existing and non-existing
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2 oid3]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; Verify oid1 is soft-deleted, others don't exist
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (= oid1 (:object-id (first rows))))
|
||||
(t/is (some? (:deleted-at (first rows)))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-already-deleted
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
|
||||
|
||||
;; Create a thumbnail
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:object-id oid
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
;; First batch delete
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; Second batch delete (idempotent — no rows match deleted_at IS NULL)
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; Verify still 1 row, still soft-deleted, not duplicated
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (= oid (:object-id (first rows))))
|
||||
(t/is (some? (:deleted-at (first rows)))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-unauthorized
|
||||
(let [profile1 (th/create-profile* 1)
|
||||
profile2 (th/create-profile* 2)
|
||||
file (th/create-file* 1 {:profile-id (:id profile1)
|
||||
:project-id (:default-project-id profile1)
|
||||
:is-shared false})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
|
||||
|
||||
;; profile1 creates a thumbnail on their file
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile1)
|
||||
:file-id (:id file)
|
||||
:object-id oid
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
;; profile2 tries to batch delete thumbnails from profile1's file
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile2)
|
||||
:object-ids [oid]}
|
||||
out (th/command! data)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out)))))
|
||||
|
||||
;; Verify the thumbnail is NOT deleted
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (nil? (:deleted-at (first rows)))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-cross-file
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
file2 (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page1-id (first (get-in file1 [:data :pages]))
|
||||
page2-id (first (get-in file2 [:data :pages]))
|
||||
oid1 (thc/fmt-object-id (:id file1) page1-id (uuid/random) "frame")
|
||||
oid2 (thc/fmt-object-id (:id file2) page2-id (uuid/random) "frame")]
|
||||
|
||||
;; Create thumbnails on both files
|
||||
(doseq [[oid fid] [[oid1 (:id file1)] [oid2 (:id file2)]]]
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id fid
|
||||
:object-id oid
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))))
|
||||
|
||||
;; Batch delete from both files in one call
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; Verify both are soft-deleted
|
||||
(let [rows1 (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file1)}
|
||||
{::db/remove-deleted false})
|
||||
rows2 (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file2)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= 1 (count rows1)))
|
||||
(t/is (some? (:deleted-at (first rows1))))
|
||||
(t/is (= 1 (count rows2)))
|
||||
(t/is (some? (:deleted-at (first rows2)))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-cross-file-unauthorized
|
||||
(let [profile1 (th/create-profile* 1)
|
||||
profile2 (th/create-profile* 2)
|
||||
file1 (th/create-file* 1 {:profile-id (:id profile1)
|
||||
:project-id (:default-project-id profile1)
|
||||
:is-shared false})
|
||||
file2 (th/create-file* 2 {:profile-id (:id profile2)
|
||||
:project-id (:default-project-id profile2)
|
||||
:is-shared false})
|
||||
page1-id (first (get-in file1 [:data :pages]))
|
||||
page2-id (first (get-in file2 [:data :pages]))
|
||||
oid1 (thc/fmt-object-id (:id file1) page1-id (uuid/random) "frame")
|
||||
oid2 (thc/fmt-object-id (:id file2) page2-id (uuid/random) "frame")]
|
||||
|
||||
;; Create thumbnails on both files (by their respective owners)
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile1)
|
||||
:file-id (:id file1)
|
||||
:object-id oid1
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile2)
|
||||
:file-id (:id file2)
|
||||
:object-id oid2
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
;; profile1 tries to batch delete thumbnails from both files
|
||||
;; (profile1 does NOT have access to file2)
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile1)
|
||||
:object-ids [oid1 oid2]}
|
||||
out (th/command! data)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out)))))
|
||||
|
||||
;; Verify NEITHER thumbnail was deleted (all-or-nothing)
|
||||
(let [rows1 (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file1)}
|
||||
{::db/remove-deleted false})
|
||||
rows2 (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file2)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= 1 (count rows1)))
|
||||
(t/is (nil? (:deleted-at (first rows1))))
|
||||
(t/is (= 1 (count rows2)))
|
||||
(t/is (nil? (:deleted-at (first rows2)))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-media-touch
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
oid1 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")
|
||||
oid2 (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
|
||||
|
||||
;; Create two thumbnails
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:object-id oid1
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:object-id oid2
|
||||
:media {:filename "sample.jpg"
|
||||
:size 312043
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
;; Get media-ids for both thumbnails
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{:order-by [[:created-at :asc]]})
|
||||
mid1 (:media-id (first rows))
|
||||
mid2 (:media-id (second rows))]
|
||||
|
||||
;; Verify storage objects exist (they are created with touched-at already set)
|
||||
(t/is (some? (th/db-get :storage-object {:id mid1})))
|
||||
(t/is (some? (th/db-get :storage-object {:id mid2})))
|
||||
|
||||
;; Batch delete both thumbnails
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; After soft-delete, storage objects should STILL exist
|
||||
;; (they are only garbage-collected later by storage-gc-touched task)
|
||||
(t/is (some? (th/db-get :storage-object {:id mid1})))
|
||||
(t/is (some? (th/db-get :storage-object {:id mid2}))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-max-batch
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
cnt 200
|
||||
oids (vec (repeatedly cnt
|
||||
#(thc/fmt-object-id (:id file) page-id
|
||||
(uuid/random) "frame")))]
|
||||
|
||||
;; Create 200 thumbnails
|
||||
(doseq [oid oids]
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:object-id oid
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))))
|
||||
|
||||
;; Verify all 200 exist
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= cnt (count rows))))
|
||||
|
||||
;; Batch delete all 200 in one call
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids oids}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; Verify all 200 are now soft-deleted
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= cnt (count rows)))
|
||||
(doseq [row rows]
|
||||
(t/is (some? (:deleted-at row)))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-single
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
|
||||
|
||||
;; Create a single thumbnail
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:object-id oid
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
;; Batch delete just one
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; Verify it's soft-deleted
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (some? (:deleted-at (first rows)))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-same-object-twice-in-batch
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
oid (thc/fmt-object-id (:id file) page-id (uuid/random) "frame")]
|
||||
|
||||
;; Create one thumbnail
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:object-id oid
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
;; Batch delete with the same object-id listed twice
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid oid]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; Verify it's soft-deleted (only one row)
|
||||
(let [rows (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (some? (:deleted-at (first rows)))))))
|
||||
|
||||
(t/deftest delete-file-object-thumbnails-keeps-other-files-intact
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
file2 (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
page1-id (first (get-in file1 [:data :pages]))
|
||||
page2-id (first (get-in file2 [:data :pages]))
|
||||
oid1 (thc/fmt-object-id (:id file1) page1-id (uuid/random) "frame")
|
||||
oid2 (thc/fmt-object-id (:id file2) page2-id (uuid/random) "frame")]
|
||||
|
||||
;; Create thumbnails on both files
|
||||
(doseq [[oid fid] [[oid1 (:id file1)] [oid2 (:id file2)]]]
|
||||
(let [data {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id fid
|
||||
:object-id oid
|
||||
:media {:filename "sample.jpg"
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))))
|
||||
|
||||
;; Delete only thumbnail from file1
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1]}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
;; Verify file1's thumbnail is deleted, file2's is not
|
||||
(let [rows1 (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file1)}
|
||||
{::db/remove-deleted false})
|
||||
rows2 (th/db-query :file-tagged-object-thumbnail
|
||||
{:file-id (:id file2)}
|
||||
{::db/remove-deleted false})]
|
||||
(t/is (= 1 (count rows1)))
|
||||
(t/is (some? (:deleted-at (first rows1))))
|
||||
(t/is (= 1 (count rows2)))
|
||||
(t/is (nil? (:deleted-at (first rows2)))))))
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.db :as db]
|
||||
[app.email.blacklist :as email.blacklist]
|
||||
[app.email.whitelist :as email.whitelist]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.tokens :as tokens]
|
||||
@ -90,17 +91,26 @@
|
||||
(t/is (not (contains? result :password))))))
|
||||
|
||||
(t/testing "update profile"
|
||||
(let [data (assoc profile
|
||||
::th/type :update-profile
|
||||
::rpc/profile-id (:id profile)
|
||||
:fullname "Full Name"
|
||||
:lang "en"
|
||||
:theme "dark")
|
||||
out (th/command! data)]
|
||||
(with-redefs [app.config/flags #{:nitrate}]
|
||||
(with-redefs [nitrate/add-nitrate-licence-to-profile
|
||||
(fn [_ profile]
|
||||
(assoc profile :subscription {:plan :pro}))]
|
||||
(let [data (assoc profile
|
||||
::th/type :update-profile
|
||||
::rpc/profile-id (:id profile)
|
||||
:fullname "Full Name"
|
||||
:lang "en"
|
||||
:theme "dark")
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))))
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))
|
||||
(t/is (= "Full Name" (get-in out [:result :fullname])))
|
||||
(t/is (= "en" (get-in out [:result :lang])))
|
||||
(t/is (= "dark" (get-in out [:result :theme])))
|
||||
(t/is (= {:plan :pro}
|
||||
(:subscription (:result out))))))))
|
||||
|
||||
(t/testing "query profile after update"
|
||||
(let [data {::th/type :get-profile
|
||||
@ -526,6 +536,65 @@
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 0 (:call-count @mock))))))))
|
||||
|
||||
(t/deftest prepare-register-and-register-profile-disable-email-verification
|
||||
;; When disable-email-verification is set and the profile is inactive
|
||||
;; (e.g. created before the flag was set), re-registering should be
|
||||
;; rejected with :email-already-exists.
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(with-redefs [app.config/flags #{:registration :login-with-password}]
|
||||
(let [current-token (atom nil)]
|
||||
;; PREPARE REGISTER: first attempt (no profile exists yet)
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "hello@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
out (th/command! data)
|
||||
token (get-in out [:result :token])]
|
||||
(t/is (th/success? out))
|
||||
(reset! current-token token))
|
||||
|
||||
;; DO REGISTRATION: creates active profile (email-verification disabled)
|
||||
(let [data {::th/type :register-profile
|
||||
:token @current-token}
|
||||
out (th/command! data)
|
||||
mdata (-> out :result meta)]
|
||||
(t/is (nil? (:error out)))
|
||||
;; No verification email sent
|
||||
(t/is (= 0 (:call-count @mock)))
|
||||
;; Session is minted
|
||||
(t/is (seq (:app.rpc/response-transform-fns mdata))))
|
||||
|
||||
;; Force the profile back to inactive to simulate the case where it was
|
||||
;; created before disable-email-verification was set
|
||||
(th/db-update! :profile
|
||||
{:is-active false}
|
||||
{:email "hello@example.com"})
|
||||
|
||||
(th/reset-mock! mock)
|
||||
|
||||
;; PREPARE REGISTER: second attempt (inactive profile exists)
|
||||
(let [data {::th/type :prepare-register-profile
|
||||
:email "hello@example.com"
|
||||
:fullname "foobar"
|
||||
:password "foobar"}
|
||||
out (th/command! data)
|
||||
token (get-in out [:result :token])]
|
||||
(t/is (th/success? out))
|
||||
(reset! current-token token))
|
||||
|
||||
;; DO REGISTRATION: second attempt should be rejected
|
||||
(let [data {::th/type :register-profile
|
||||
:token @current-token}
|
||||
out (th/command! data)
|
||||
error (:error out)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :email-already-exists))
|
||||
;; No email sent, profile remains inactive
|
||||
(t/is (= 0 (:call-count @mock)))
|
||||
(let [profile (th/db-get :profile {:email "hello@example.com"})]
|
||||
(t/is (false? (:is-active profile)))))))))
|
||||
|
||||
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
|
||||
;; With email-verification ENABLED (the default), a brand-new
|
||||
;; profile created via the invitation flow is NOT active yet, so
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
org.clojure/data.fressian {:mvn/version "1.1.1"}
|
||||
org.clojure/clojurescript {:mvn/version "1.12.42"}
|
||||
|
||||
org.apache.commons/commons-pool2 {:mvn/version "2.12.1"}
|
||||
org.apache.commons/commons-pool2 {:mvn/version "2.13.1"}
|
||||
|
||||
;; Logging
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.26.0"}
|
||||
@ -15,12 +15,12 @@
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"}
|
||||
org.slf4j/slf4j-api {:mvn/version "2.0.18"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.42"}
|
||||
|
||||
selmer/selmer {:mvn/version "1.13.1"}
|
||||
selmer/selmer {:mvn/version "1.13.4"}
|
||||
criterium/criterium {:mvn/version "0.4.6"}
|
||||
|
||||
metosin/jsonista {:mvn/version "0.3.13"}
|
||||
metosin/jsonista {:mvn/version "1.0.0"}
|
||||
metosin/malli {:mvn/version "0.19.1"}
|
||||
|
||||
expound/expound {:mvn/version "0.9.0"}
|
||||
@ -55,7 +55,7 @@
|
||||
:aliases
|
||||
{:dev
|
||||
{:extra-deps
|
||||
{org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
{org.clojure/tools.namespace {:mvn/version "1.5.1"}
|
||||
thheller/shadow-cljs {:mvn/version "3.2.0"}
|
||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
|
||||
com.bhauman/rebel-readline {:mvn/version "0.1.5"}
|
||||
@ -65,7 +65,7 @@
|
||||
|
||||
:build
|
||||
{:extra-deps
|
||||
{io.github.clojure/tools.build {:mvn/version "0.10.10"}}
|
||||
{io.github.clojure/tools.build {:mvn/version "0.10.14"}}
|
||||
:ns-default build}
|
||||
|
||||
:test
|
||||
|
||||
@ -4,21 +4,21 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "3.5.3",
|
||||
"concurrently": "^10.0.3",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "3.8.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ws": "^8.18.2"
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0"
|
||||
"date-fns": "^4.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint:clj": "clj-kondo --parallel=true --lint src/",
|
||||
|
||||
288
common/pnpm-lock.yaml
generated
288
common/pnpm-lock.yaml
generated
@ -9,48 +9,50 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
devDependencies:
|
||||
concurrently:
|
||||
specifier: ^9.1.2
|
||||
version: 9.2.1
|
||||
specifier: ^10.0.3
|
||||
version: 10.0.3
|
||||
nodemon:
|
||||
specifier: ^3.1.10
|
||||
version: 3.1.11
|
||||
specifier: ^3.1.14
|
||||
version: 3.1.14
|
||||
prettier:
|
||||
specifier: 3.5.3
|
||||
version: 3.5.3
|
||||
specifier: 3.8.4
|
||||
version: 3.8.4
|
||||
source-map-support:
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
ws:
|
||||
specifier: ^8.18.2
|
||||
version: 8.18.3
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
|
||||
packages:
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
anymatch@3.1.3:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@ -59,35 +61,25 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
chalk@5.6.2:
|
||||
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
cliui@9.0.1:
|
||||
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
concurrently@9.2.1:
|
||||
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
|
||||
engines: {node: '>=18'}
|
||||
concurrently@10.0.3:
|
||||
resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
|
||||
date-fns@4.1.0:
|
||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||
date-fns@4.4.0:
|
||||
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
@ -98,8 +90,8 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
@ -118,6 +110,10 @@ packages:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.6.0:
|
||||
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@ -126,10 +122,6 @@ packages:
|
||||
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ignore-by-default@1.0.1:
|
||||
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
|
||||
|
||||
@ -141,10 +133,6 @@ packages:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -153,14 +141,15 @@ packages:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
nodemon@3.1.11:
|
||||
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
|
||||
nodemon@3.1.14:
|
||||
resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
@ -168,12 +157,12 @@ packages:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
picomatch@2.3.2:
|
||||
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
prettier@3.5.3:
|
||||
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
|
||||
prettier@3.8.4:
|
||||
resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
@ -184,20 +173,16 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
rxjs@7.8.2:
|
||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||
|
||||
semver@7.7.3:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
semver@7.8.4:
|
||||
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
shell-quote@1.8.3:
|
||||
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
||||
shell-quote@1.8.4:
|
||||
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
@ -211,26 +196,22 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
string-width@7.2.0:
|
||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
strip-ansi@7.2.0:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
supports-color@5.5.0:
|
||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
supports-color@8.1.1:
|
||||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
@ -249,12 +230,12 @@ packages:
|
||||
undefsafe@2.0.5:
|
||||
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
wrap-ansi@9.0.2:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
@ -269,35 +250,32 @@ packages:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
yargs-parser@22.0.0:
|
||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
yargs@18.0.0:
|
||||
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||
|
||||
snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
dependencies:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
picomatch: 2.3.2
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
balanced-match: 4.0.4
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
@ -305,10 +283,7 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
chalk@5.6.2: {}
|
||||
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
@ -322,30 +297,22 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
cliui@8.0.1:
|
||||
cliui@9.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
color-convert@2.0.1:
|
||||
concurrently@10.0.3:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
concurrently@9.2.1:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
chalk: 5.6.2
|
||||
rxjs: 7.8.2
|
||||
shell-quote: 1.8.3
|
||||
supports-color: 8.1.1
|
||||
shell-quote: 1.8.4
|
||||
supports-color: 10.2.2
|
||||
tree-kill: 1.2.2
|
||||
yargs: 17.7.2
|
||||
yargs: 18.0.0
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
date-fns@4.4.0: {}
|
||||
|
||||
debug@4.4.3(supports-color@5.5.0):
|
||||
dependencies:
|
||||
@ -353,7 +320,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 5.5.0
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
@ -366,14 +333,14 @@ snapshots:
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.6.0: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
has-flag@3.0.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
ignore-by-default@1.0.1: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
@ -382,28 +349,26 @@ snapshots:
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
minimatch@3.1.2:
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
brace-expansion: 5.0.6
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nodemon@3.1.11:
|
||||
nodemon@3.1.14:
|
||||
dependencies:
|
||||
chokidar: 3.6.0
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
ignore-by-default: 1.0.1
|
||||
minimatch: 3.1.2
|
||||
minimatch: 10.2.5
|
||||
pstree.remy: 1.1.8
|
||||
semver: 7.7.3
|
||||
semver: 7.8.4
|
||||
simple-update-notifier: 2.0.0
|
||||
supports-color: 5.5.0
|
||||
touch: 3.1.1
|
||||
@ -411,29 +376,27 @@ snapshots:
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
prettier@3.5.3: {}
|
||||
prettier@3.8.4: {}
|
||||
|
||||
pstree.remy@1.1.8: {}
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
picomatch: 2.3.2
|
||||
|
||||
rxjs@7.8.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
semver@7.7.3: {}
|
||||
semver@7.8.4: {}
|
||||
|
||||
shell-quote@1.8.3: {}
|
||||
shell-quote@1.8.4: {}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
semver: 7.8.4
|
||||
|
||||
source-map-support@0.5.21:
|
||||
dependencies:
|
||||
@ -442,28 +405,22 @@ snapshots:
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
emoji-regex: 10.6.0
|
||||
get-east-asian-width: 1.6.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
strip-ansi@7.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
supports-color@5.5.0:
|
||||
dependencies:
|
||||
has-flag: 3.0.0
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-color@8.1.1:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
@ -476,24 +433,23 @@ snapshots:
|
||||
|
||||
undefsafe@2.0.5: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
ws@8.18.3: {}
|
||||
ws@8.21.0: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
yargs-parser@22.0.0: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
yargs@18.0.0:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
cliui: 9.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
string-width: 7.2.0
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
yargs-parser: 22.0.0
|
||||
|
||||
@ -0,0 +1 @@
|
||||
minimumReleaseAge: 0
|
||||
@ -159,7 +159,7 @@ goog.scope(function () {
|
||||
it1--, i++
|
||||
) {
|
||||
carry += (256 * b58[it1]) >>> 0;
|
||||
b58[it1] = carry % BASE >>> 0;
|
||||
b58[it1] = (carry % BASE) >>> 0;
|
||||
carry = (carry / BASE) >>> 0;
|
||||
}
|
||||
if (carry !== 0) {
|
||||
@ -214,7 +214,7 @@ goog.scope(function () {
|
||||
it3--, i++
|
||||
) {
|
||||
carry += (BASE * b256[it3]) >>> 0;
|
||||
b256[it3] = carry % 256 >>> 0;
|
||||
b256[it3] = (carry % 256) >>> 0;
|
||||
carry = (carry / 256) >>> 0;
|
||||
}
|
||||
if (carry !== 0) {
|
||||
|
||||
@ -169,6 +169,7 @@
|
||||
|
||||
:mcp
|
||||
:background-blur
|
||||
:available-viewer-wasm
|
||||
:stroke-path})
|
||||
|
||||
(def all-flags
|
||||
@ -194,6 +195,7 @@
|
||||
:enable-render-wasm-dpr
|
||||
:enable-token-color
|
||||
:enable-token-shadow
|
||||
:enable-token-typography-row
|
||||
:enable-inspect-styles
|
||||
:enable-feature-fdata-objects-map
|
||||
:enable-feature-render-wasm
|
||||
|
||||
@ -89,14 +89,23 @@
|
||||
([shape]
|
||||
(get-shape-filter-bounds shape false))
|
||||
([shape ignore-shadow-margin?]
|
||||
(if (or (and (cfh/svg-raw-shape? shape)
|
||||
(not= :svg (dm/get-in shape [:content :tag])))
|
||||
;; If no shadows or blur, we return the selrect as is
|
||||
(and (empty? (-> shape :shadow))
|
||||
(or (nil? (:blur shape))
|
||||
(not= :layer-blur (-> shape :blur :type))
|
||||
(zero? (-> shape :blur :value (or 0))))))
|
||||
(cond
|
||||
;; SVG raw elements (non-root) don't have proper rotated points; use selrect
|
||||
(and (cfh/svg-raw-shape? shape)
|
||||
(not= :svg (dm/get-in shape [:content :tag])))
|
||||
(dm/get-prop shape :selrect)
|
||||
|
||||
;; No shadows or blur: use the axis-aligned bounding box from the actual
|
||||
;; (possibly rotated) points. Using selrect here would be wrong for rotated
|
||||
;; shapes because selrect stores the unrotated rectangle, not the screen-space bbox.
|
||||
(and (empty? (-> shape :shadow))
|
||||
(or (nil? (:blur shape))
|
||||
(not= :layer-blur (-> shape :blur :type))
|
||||
(zero? (-> shape :blur :value (or 0)))))
|
||||
(-> (dm/get-prop shape :points)
|
||||
(grc/points->rect))
|
||||
|
||||
:else
|
||||
(let [filters (shape->filters shape)
|
||||
blur-value (case (-> shape :blur :type)
|
||||
:layer-blur (or (-> shape :blur :value) 0)
|
||||
|
||||
@ -2101,6 +2101,39 @@
|
||||
(grc/rect->center selrect)
|
||||
(or (:transform current-shape) (gmt/matrix)))))))
|
||||
|
||||
|
||||
(defn- switch-geom-change-value
|
||||
[prev-shape current-shape attr]
|
||||
;; Composite geometry stores absolute coordinates. When preserving a size
|
||||
;; override across variants, keep the target variant's position and only carry
|
||||
;; the previous dimensions; otherwise :x/:y can disagree with :selrect/:points.
|
||||
(let [prev-selrect (:selrect prev-shape)
|
||||
current-selrect (:selrect current-shape)
|
||||
final-width (:width prev-selrect)
|
||||
final-height (:height prev-selrect)
|
||||
x (:x current-selrect)
|
||||
y (:y current-selrect)
|
||||
selrect (assoc current-selrect
|
||||
:width final-width
|
||||
:height final-height
|
||||
:x x
|
||||
:y y
|
||||
:x1 x
|
||||
:y1 y
|
||||
:x2 (+ x final-width)
|
||||
:y2 (+ y final-height))]
|
||||
(case attr
|
||||
:selrect
|
||||
selrect
|
||||
|
||||
:points
|
||||
(-> selrect
|
||||
(grc/rect->points)
|
||||
(gsh/transform-points
|
||||
(grc/rect->center selrect)
|
||||
(or (:transform current-shape) (gmt/matrix)))))))
|
||||
|
||||
|
||||
(defn- equal-geometry?
|
||||
"Returns true when the value of `attr` in `shape` is considered equal
|
||||
to the corresponding value in `origin-shape`, ignoring positional
|
||||
@ -2270,6 +2303,10 @@
|
||||
(contains? #{:points :selrect :width :height} attr))
|
||||
(switch-fixed-layout-geom-change-value previous-shape current-shape origin-ref-shape attr)
|
||||
|
||||
(and (contains? #{:points :selrect} attr)
|
||||
(not path-change?))
|
||||
(switch-geom-change-value previous-shape current-shape attr)
|
||||
|
||||
:else
|
||||
(get previous-shape attr)))
|
||||
|
||||
|
||||
@ -448,18 +448,22 @@
|
||||
::oapi/type "string"
|
||||
::oapi/format "uuid"}})
|
||||
|
||||
(def email-re #"[a-zA-Z0-9_.+-\\\\]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
|
||||
;; Strict email regex aligned with app.common.spec/email-re.
|
||||
;; Local part: valid RFC chars, no leading/trailing dot, no consecutive dots.
|
||||
;; Domain: labels can't start/end with hyphen, no empty labels.
|
||||
;; TLD: at least 2 alphabetic chars.
|
||||
(def email-re
|
||||
#"[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,63}")
|
||||
|
||||
(defn parse-email
|
||||
[s]
|
||||
(if (string? s)
|
||||
(first (re-seq email-re s))
|
||||
nil))
|
||||
(when (and (string? s) (re-matches email-re s))
|
||||
s))
|
||||
|
||||
(defn email-string?
|
||||
[s]
|
||||
(and (string? s)
|
||||
(re-seq email-re s)))
|
||||
(some? (re-matches email-re s))))
|
||||
|
||||
(register!
|
||||
{:type ::email
|
||||
|
||||
@ -217,11 +217,11 @@ goog.scope(function () {
|
||||
|
||||
// Parse ........-....-....-####-............
|
||||
int8[8] = (rest = parseInt(uuid.slice(19, 23), 16)) >>> 8;
|
||||
(int8[9] = rest & 0xff),
|
||||
((int8[9] = rest & 0xff),
|
||||
// Parse ........-....-....-....-############
|
||||
// (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes)
|
||||
(int8[10] =
|
||||
((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff);
|
||||
((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff));
|
||||
int8[11] = (rest / 0x100000000) & 0xff;
|
||||
int8[12] = (rest >>> 24) & 0xff;
|
||||
int8[13] = (rest >>> 16) & 0xff;
|
||||
|
||||
@ -2866,18 +2866,14 @@
|
||||
(t/is (= (get-in rect02' [:selrect :width]) 150))))
|
||||
|
||||
|
||||
(t/deftest test-switch-when-source-master-child-has-touched-geometry
|
||||
;; Regression: when the previous-shape's geometry has sub-pixel drift
|
||||
(t/deftest test-switch-skips-composite-geometry-with-subpixel-drift
|
||||
;; Regression: when the previous-shape's geometry only has sub-pixel drift
|
||||
;; relative to its source master (a state produced by interactive transform
|
||||
;; modifiers, e.g. alt-drag duplicate of a variant whose children are
|
||||
;; component copies), the equal-geometry? guard in update-attrs-on-switch
|
||||
;; uses exact equality and fails. The :else branch then copies
|
||||
;; previous-shape's :selrect verbatim onto the freshly-instantiated target,
|
||||
;; leaving :y correct (the per-attr y skip catches that) but :selrect.y
|
||||
;; stale. The shape ends up internally inconsistent (:y disagrees with
|
||||
;; :selrect.y); the renderer reads :selrect, so the child appears at the
|
||||
;; source variant's position inside a parent that has resized to the
|
||||
;; target's dimensions — the visible "cut off" symptom.
|
||||
;; component copies), equal-geometry? must classify it as unchanged and skip
|
||||
;; copying composite geometry. Otherwise, :selrect/:points can carry stale
|
||||
;; absolute positions from the source variant onto the freshly-instantiated
|
||||
;; target, producing the visible "cut off" symptom.
|
||||
(let [;; ==== Setup
|
||||
;; A self-contained Input/Button-like component, plus a variant
|
||||
;; container whose two variants each instance that component
|
||||
@ -2911,8 +2907,8 @@
|
||||
;; The copy carries an Input/Button instance (Frame1). Introduce
|
||||
;; sub-pixel drift in its :width and :selrect.width — the kind of
|
||||
;; floating-point error produced by the alt-drag modifier path in
|
||||
;; production. This drift is what defeats equal-geometry?'s
|
||||
;; exact-equality comparison and lets the bug surface.
|
||||
;; production. The drift is small enough to be treated as unchanged
|
||||
;; geometry by equal-geometry?.
|
||||
page (thf/current-page file)
|
||||
copy01 (ths/get-shape file :copy01)
|
||||
copy-btn-id (->> (cfh/get-children-ids-with-self (:objects page) (:id copy01))
|
||||
@ -3035,3 +3031,73 @@
|
||||
(t/is (= target-rel-y actual-rel-y)
|
||||
(str "path :selrect.y should match target master layout (expected "
|
||||
target-rel-y " got " actual-rel-y ")"))))
|
||||
|
||||
|
||||
(t/deftest test-switch-preserves-size-override-at-target-position
|
||||
(let [move-to (fn [shape x y]
|
||||
(gsh/move shape (gpt/point (- x (:x shape))
|
||||
(- y (:y shape)))))
|
||||
|
||||
;; ==== Setup: each variant contains the same nested component instance.
|
||||
;; The nested instance has identical size in both variants, but a different
|
||||
;; position relative to the variant root.
|
||||
file (-> (thf/sample-file :file1)
|
||||
(tho/add-simple-component
|
||||
:nested-component :nested-main :nested-label
|
||||
:root-params {:width 100 :height 50}
|
||||
:child-params {:width 30 :height 10})
|
||||
(thv/add-variant-with-copy
|
||||
:v01 :c01 :m01 :c02 :m02 :r01 :r02 :nested-component))
|
||||
|
||||
page (thf/current-page file)
|
||||
r01 (ths/get-shape file :r01)
|
||||
r02 (ths/get-shape file :r02)
|
||||
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
|
||||
#{(:id r01) (:id r02)}
|
||||
(fn [shape]
|
||||
(cond
|
||||
(= (:id shape) (:id r01)) (move-to shape 20 100)
|
||||
(= (:id shape) (:id r02)) (move-to shape 20 70)
|
||||
:else shape))
|
||||
(:objects page)
|
||||
{})
|
||||
file (thf/apply-changes file changes)
|
||||
|
||||
file (thc/instantiate-component file :c01
|
||||
:copy01
|
||||
:children-labels [:copy-r01])
|
||||
page (thf/current-page file)
|
||||
copy01 (ths/get-shape file :copy01)
|
||||
copy-r01 (get-in page [:objects (-> copy01 :shapes first)])
|
||||
|
||||
;; This is a real geometry override, not float drift. The switch should
|
||||
;; preserve the overridden size while anchoring composite geometry to
|
||||
;; the target variant's position.
|
||||
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
|
||||
#{(:id copy-r01)}
|
||||
(fn [shape]
|
||||
(let [new-width 150
|
||||
sr (:selrect shape)
|
||||
new-sr (-> sr
|
||||
(assoc :width new-width)
|
||||
(assoc :x2 (+ (:x1 sr) new-width)))]
|
||||
(-> shape
|
||||
(assoc :width new-width)
|
||||
(assoc :selrect new-sr)
|
||||
(assoc :touched #{:geometry-group}))))
|
||||
(:objects page)
|
||||
{})
|
||||
file (thf/apply-changes file changes)
|
||||
|
||||
;; ==== Action
|
||||
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)
|
||||
rect02' (get-in page' [:objects (-> copy02' :shapes first)])]
|
||||
|
||||
;; The width override is preserved, but the target variant position remains
|
||||
;; authoritative for absolute composite geometry.
|
||||
(t/is (= 150 (:width rect02')))
|
||||
(t/is (= (+ (:y copy02') 70) (:y rect02')))
|
||||
(t/is (= (:y rect02') (get-in rect02' [:selrect :y])))))
|
||||
|
||||
@ -188,3 +188,60 @@
|
||||
(t/is (= false (decode-s "f")))
|
||||
(t/is (= true (decode-s "1")))
|
||||
(t/is (= false (decode-s "0")))))
|
||||
|
||||
(t/deftest test-email-validation
|
||||
(t/testing "accepts well-formed email addresses"
|
||||
(doseq [email ["user@domain.com"
|
||||
"user.name@domain.com"
|
||||
"user+tag@domain.com"
|
||||
"user-name@domain.com"
|
||||
"user_name@domain.com"
|
||||
"user123@domain.com"
|
||||
"USER@DOMAIN.COM"
|
||||
"u@domain.io"
|
||||
"user@sub.domain.com"
|
||||
"user@domain.co.uk"
|
||||
"user@domain.dev"
|
||||
"a@bc.co"]]
|
||||
(t/is (sm/validate ::sm/email email) (str "should accept: " email))
|
||||
(t/is (= email (sm/decode ::sm/email email sm/json-transformer)))))
|
||||
|
||||
(t/testing "rejects domain with consecutive dots (dot-dot)"
|
||||
(t/is (false? (sm/validate ::sm/email "user@gmail.com..")))
|
||||
(t/is (false? (sm/validate ::sm/email "user@sub..domain.com")))
|
||||
(t/is (false? (sm/validate ::sm/email "eissaalbothigi@gmail.com..")))
|
||||
(t/is (nil? (sm/parse-email "user@gmail.com..")))
|
||||
(t/is (nil? (sm/parse-email "eissaalbothigi@gmail.com.."))))
|
||||
|
||||
(t/testing "rejects domain ending with a dot"
|
||||
(t/is (false? (sm/validate ::sm/email "user@domain.")))
|
||||
(t/is (nil? (sm/parse-email "user@domain."))))
|
||||
|
||||
(t/testing "rejects domain starting with a dot"
|
||||
(t/is (false? (sm/validate ::sm/email "user@.domain.com")))
|
||||
(t/is (nil? (sm/parse-email "user@.domain.com"))))
|
||||
|
||||
(t/testing "rejects local part with consecutive dots"
|
||||
(t/is (false? (sm/validate ::sm/email "user..name@domain.com")))
|
||||
(t/is (nil? (sm/parse-email "user..name@domain.com"))))
|
||||
|
||||
(t/testing "rejects local part starting with a dot"
|
||||
(t/is (false? (sm/validate ::sm/email ".user@domain.com")))
|
||||
(t/is (nil? (sm/parse-email ".user@domain.com"))))
|
||||
|
||||
(t/testing "rejects label starting or ending with hyphen"
|
||||
(t/is (false? (sm/validate ::sm/email "user@-domain.com")))
|
||||
(t/is (false? (sm/validate ::sm/email "user@domain-.com"))))
|
||||
|
||||
(t/testing "rejects TLD shorter than 2 chars"
|
||||
(t/is (false? (sm/validate ::sm/email "user@domain.c"))))
|
||||
|
||||
(t/testing "rejects domain without a dot"
|
||||
(t/is (false? (sm/validate ::sm/email "user@domain"))))
|
||||
|
||||
(t/testing "rejects empty or malformed emails"
|
||||
(t/is (false? (sm/validate ::sm/email "")))
|
||||
(t/is (false? (sm/validate ::sm/email "@domain.com")))
|
||||
(t/is (false? (sm/validate ::sm/email "user@")))
|
||||
(t/is (false? (sm/validate ::sm/email "userdomain.com")))
|
||||
(t/is (false? (sm/validate ::sm/email "user@@domain.com")))))
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM ubuntu:24.04 AS base
|
||||
FROM ubuntu:26.04 AS base
|
||||
|
||||
ENV LANG='C.UTF-8' \
|
||||
LC_ALL='C.UTF-8' \
|
||||
@ -32,7 +32,7 @@ RUN set -ex; \
|
||||
|
||||
FROM base AS setup-node
|
||||
|
||||
ENV NODE_VERSION=v24.15.0 \
|
||||
ENV NODE_VERSION=v24.16.0 \
|
||||
PATH=/opt/node/bin:$PATH
|
||||
|
||||
RUN set -eux; \
|
||||
@ -416,7 +416,7 @@ RUN set -ex; \
|
||||
libfreetype6 \
|
||||
libfontconfig1 \
|
||||
libglib2.0-0 \
|
||||
libxml2 \
|
||||
libxml2-16 \
|
||||
liblcms2-2 \
|
||||
libheif1 \
|
||||
libopenjp2-7 \
|
||||
@ -425,7 +425,7 @@ RUN set -ex; \
|
||||
libgomp1 \
|
||||
libwebpmux3 \
|
||||
libwebpdemux2 \
|
||||
libzip4t64 \
|
||||
libzip5 \
|
||||
; \
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
@ -458,7 +458,7 @@ ENV LANG='C.UTF-8' \
|
||||
SERENA_CONTEXT="claude-code" \
|
||||
PATH="/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
|
||||
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-24 /opt/imagick /opt/imagick
|
||||
COPY --from=setup-jvm /opt/jdk /opt/jdk
|
||||
COPY --from=setup-jvm /opt/clojure /opt/clojure
|
||||
COPY --from=setup-node /opt/node /opt/node
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM ubuntu:24.04
|
||||
FROM ubuntu:26.04
|
||||
LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
ENV LANG='C.UTF-8' \
|
||||
@ -6,7 +6,7 @@ ENV LANG='C.UTF-8' \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
TZ=Etc/UTC
|
||||
|
||||
ARG IMAGEMAGICK_VERSION=7.1.2-13
|
||||
ARG IMAGEMAGICK_VERSION=7.1.2-24
|
||||
|
||||
RUN set -e; \
|
||||
apt-get -qq update; \
|
||||
@ -79,13 +79,12 @@ RUN set -e; \
|
||||
libopenjp2-7 \
|
||||
libpng16-16 \
|
||||
librsvg2-2 \
|
||||
libxml2 \
|
||||
libtiff6 \
|
||||
libwebp7 \
|
||||
libwebpdemux2 \
|
||||
libwebpmux3 \
|
||||
libxml2 \
|
||||
libzip4t64 \
|
||||
libxml2-16 \
|
||||
libzip5 \
|
||||
libzstd1 \
|
||||
;\
|
||||
apt-get -qqy clean; \
|
||||
|
||||
@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
|
||||
LC_ALL='C.UTF-8' \
|
||||
JAVA_HOME="/opt/jdk" \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_VERSION=v24.15.0 \
|
||||
NODE_VERSION=v24.16.0 \
|
||||
TZ=Etc/UTC
|
||||
|
||||
RUN set -ex; \
|
||||
@ -16,6 +16,7 @@ RUN set -ex; \
|
||||
ca-certificates \
|
||||
curl \
|
||||
; \
|
||||
apt-get clean; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN set -eux; \
|
||||
@ -115,13 +116,13 @@ RUN set -ex; \
|
||||
woff2 \
|
||||
; \
|
||||
find tmp/usr/share/zoneinfo/* -type d ! -name 'Etc' |xargs rm -rf; \
|
||||
apt-get clean; \
|
||||
rm -rf /var/lib /var/cache; \
|
||||
rm -rf /usr/include; \
|
||||
mkdir -p /opt/data/assets; \
|
||||
mkdir -p /opt/penpot; \
|
||||
chown -R penpot:penpot /opt/penpot; \
|
||||
chown -R penpot:penpot /opt/data; \
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
chown -R penpot:penpot /opt/data;
|
||||
|
||||
COPY --from=build /opt/jre /opt/jre
|
||||
COPY --from=build /opt/node /opt/node
|
||||
|
||||
@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
ENV LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
NODE_VERSION=v24.15.0 \
|
||||
NODE_VERSION=v24.16.0 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PATH=/opt/node/bin:/opt/imagick/bin:$PATH \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers
|
||||
@ -13,12 +13,14 @@ RUN set -ex; \
|
||||
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 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; \
|
||||
@ -80,6 +82,7 @@ RUN set -ex; \
|
||||
libzip4t64 \
|
||||
libzstd1 \
|
||||
; \
|
||||
apt-get clean; \
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
RUN set -eux; \
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
FROM nginxinc/nginx-unprivileged:1.30.0
|
||||
FROM nginxinc/nginx-unprivileged:1.30.2-alpine
|
||||
LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
USER root
|
||||
|
||||
RUN set -ex; \
|
||||
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
|
||||
apk update; \
|
||||
apk upgrade; \
|
||||
apk add --no-cache bash gettext; \
|
||||
rm -rf /var/cache/apk/*; \
|
||||
addgroup -g 1001 penpot; \
|
||||
adduser -D -H -u 1001 -s /bin/false -h /opt/penpot -G penpot penpot; \
|
||||
mkdir -p /opt/data/assets; \
|
||||
chown -R penpot:penpot /opt/data; \
|
||||
mkdir -p /etc/nginx/overrides/main.d/; \
|
||||
|
||||
@ -13,12 +13,14 @@ RUN set -ex; \
|
||||
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 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; \
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
FROM nginxinc/nginx-unprivileged:1.30.0
|
||||
FROM nginxinc/nginx-unprivileged:1.30.2-alpine
|
||||
LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
USER root
|
||||
|
||||
RUN set -ex; \
|
||||
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot;
|
||||
apk update; \
|
||||
apk upgrade; \
|
||||
rm -rf /var/cache/apk/*; \
|
||||
addgroup -g 1001 penpot; \
|
||||
adduser -D -H -u 1001 -s /bin/false -h /opt/penpot -G penpot penpot;
|
||||
|
||||
ARG BUNDLE_PATH="./bundle-storybook/"
|
||||
COPY $BUNDLE_PATH /var/www/
|
||||
|
||||
@ -78,7 +78,7 @@ services:
|
||||
# - "443:443"
|
||||
|
||||
penpot-frontend:
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.15}"
|
||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
|
||||
restart: always
|
||||
ports:
|
||||
- 9001:8080
|
||||
@ -111,7 +111,7 @@ services:
|
||||
# PENPOT_DISABLE_IPV6_LISTEN: "true"
|
||||
|
||||
penpot-backend:
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.15}"
|
||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
@ -180,13 +180,13 @@ services:
|
||||
PENPOT_SMTP_SSL: "false"
|
||||
|
||||
penpot-mcp:
|
||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.15}"
|
||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
|
||||
restart: always
|
||||
networks:
|
||||
- penpot
|
||||
|
||||
penpot-exporter:
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.15}"
|
||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
|
||||
restart: always
|
||||
|
||||
depends_on:
|
||||
|
||||
@ -848,6 +848,9 @@ a[href].post-tag:visited {
|
||||
.illus-techguide {
|
||||
background-image: url(/img/home-technical-guide.webp);
|
||||
}
|
||||
.illus-mcp {
|
||||
background-image: url(/img/home-mcp-server.webp);
|
||||
}
|
||||
.illus-plugins {
|
||||
background-image: url(/img/home-plugins.webp);
|
||||
}
|
||||
|
||||
@ -18,24 +18,30 @@ eleventyNavigation:
|
||||
<p>Everything you need to know about how Penpot works.</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="illus illus-contributing">
|
||||
<a href="/contributing-guide/">
|
||||
<h2>Contributing guide →</h2>
|
||||
<p>How to report bugs, add translations and more.</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="illus illus-techguide">
|
||||
<a href="/technical-guide/">
|
||||
<h2>Technical guide →</h2>
|
||||
<p>Installation, configuration and architecture.</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="illus illus-mcp">
|
||||
<a href="/mcp/">
|
||||
<h2>MCP server →</h2>
|
||||
<p>Connect AI agents to your Penpot files for design and development workflows.</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="illus illus-plugins">
|
||||
<a href="/plugins/">
|
||||
<h2>Plugins →</h2>
|
||||
<p>All about Penpot plugins.</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="illus illus-contributing">
|
||||
<a href="/contributing-guide/">
|
||||
<h2>Contributing guide →</h2>
|
||||
<p>How to report bugs, add translations and more.</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="illus illus-faq">
|
||||
<a href="https://community.penpot.app/c/faq/17">
|
||||
<h2>FAQs →</h2>
|
||||
|
||||
@ -309,6 +309,38 @@ For client-specific setup, use the shared section **Connect your MCP client**.
|
||||
|
||||
For remote mode, use the URL shown in **Your account → Integrations → MCP Server**, which includes your `userToken`.
|
||||
|
||||
### Setup videos
|
||||
|
||||
<figure>
|
||||
<iframe
|
||||
width="672px"
|
||||
height="378px"
|
||||
src="https://www.youtube.com/embed/hBn1iutWSq4?rel=0"
|
||||
title="Penpot MCP Server: Remote Setup in 5 Min | OpenCode + OpenRouter"
|
||||
frameborder="0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
<figcaption>Penpot MCP Server – Remote setup with OpenCode and OpenRouter</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure>
|
||||
<iframe
|
||||
width="672px"
|
||||
height="378px"
|
||||
src="https://www.youtube.com/embed/QeSFO0h7GGY?rel=0"
|
||||
title="Penpot MCP Server Remote Setup in 1 Minute | Cursor"
|
||||
frameborder="0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
<figcaption>Penpot MCP Server – Remote setup with Cursor</figcaption>
|
||||
</figure>
|
||||
|
||||
<a id="use-remote"></a>
|
||||
### Use
|
||||
|
||||
@ -391,6 +423,23 @@ Leave this terminal running while you use MCP.
|
||||
|
||||
For advanced or repository-based workflows, see the [MCP README](https://github.com/penpot/penpot/blob/main/mcp/README.md) in the Penpot repository.
|
||||
|
||||
### Setup video
|
||||
|
||||
<figure>
|
||||
<iframe
|
||||
width="672px"
|
||||
height="378px"
|
||||
src="https://www.youtube.com/embed/xfgf0kKGOoc?rel=0"
|
||||
title="Penpot MCP Server Local Setup"
|
||||
frameborder="0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
<figcaption>Penpot MCP Server – Local setup</figcaption>
|
||||
</figure>
|
||||
|
||||
<a id="connect-local"></a>
|
||||
### Connect
|
||||
|
||||
|
||||
@ -39,5 +39,5 @@
|
||||
"markdown-it-anchor": "^9.0.1",
|
||||
"markdown-it-plantuml": "^1.4.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
}
|
||||
|
||||
711
docs/pnpm-lock.yaml
generated
711
docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
0
docs/pnpm-workspace.yaml
Normal file
0
docs/pnpm-workspace.yaml
Normal file
@ -206,6 +206,12 @@ server {
|
||||
proxy_pass http://localhost:9001/ws/notifications;
|
||||
}
|
||||
|
||||
location /mcp/ws {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_pass http://localhost:9001/mcp/ws;
|
||||
}
|
||||
|
||||
# Proxy pass
|
||||
location / {
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
@ -4,29 +4,29 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"packageManager": "pnpm@11.5.3+sha512.7ac1c919341c213a34dc0d02afb7143c5c26ac26ee8c4782deea821b8ac64d2134a081fd8941dae6e29bbb48f58dfc2b7fbceeccc07cb2f09d219d342a4969ed",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"archiver": "7.0.1",
|
||||
"archiver": "8.0.0",
|
||||
"cookies": "^0.9.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"generic-pool": "^3.9.0",
|
||||
"inflation": "^2.1.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"ioredis": "^5.11.1",
|
||||
"playwright": "^1.60.0",
|
||||
"raw-body": "^3.0.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"svgo": "penpot/svgo#v3.1",
|
||||
"undici": "^8.2.0",
|
||||
"@penpot/svgo": "penpot/svgo#3.3.0",
|
||||
"undici": "^8.4.1",
|
||||
"xml-js": "^1.6.11",
|
||||
"xregexp": "^5.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ws": "^8.20.1"
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target",
|
||||
|
||||
669
exporter/pnpm-lock.yaml
generated
669
exporter/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,9 @@
|
||||
allowBuilds:
|
||||
core-js-pure: false
|
||||
minimumReleaseAgeExclude:
|
||||
- lodash@4.17.24
|
||||
- lodash@4.17.23
|
||||
overrides:
|
||||
lodash@<=4.17.23: ^4.17.24
|
||||
lodash@>=4.0.0 <=4.17.22: ^4.17.23
|
||||
lodash@>=4.0.0 <=4.17.23: ^4.17.24
|
||||
@ -14,6 +14,7 @@ rm -rf target
|
||||
pnpm run build;
|
||||
|
||||
cp pnpm-lock.yaml target/;
|
||||
cp pnpm-workspace.yaml target/;
|
||||
cp package.json target/;
|
||||
touch target/pnpm-workspace.yaml;
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
(ns app.handlers.resources
|
||||
"Temporal resources management."
|
||||
(:require
|
||||
["archiver$default" :as arc]
|
||||
["archiver" :as arc]
|
||||
["node:fs" :as fs]
|
||||
["node:fs/promises" :as fsp]
|
||||
["node:path" :as path]
|
||||
@ -41,7 +41,7 @@
|
||||
|
||||
(defn create-zip
|
||||
[& {:keys [resource on-complete on-progress on-error]}]
|
||||
(let [^js zip (arc/create "zip")
|
||||
(let [^js zip (new arc/ZipArchive)
|
||||
^js out (fs/createWriteStream (:path resource))
|
||||
on-complete (or on-complete (constantly nil))
|
||||
progress (atom 0)]
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
(ns app.renderer.svg
|
||||
(:require
|
||||
["svgo" :as svgo]
|
||||
["@penpot/svgo" :as svgo]
|
||||
["xml-js" :as xml]
|
||||
[app.browser :as bw]
|
||||
[app.common.data :as d]
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
|
||||
"browserslist": [
|
||||
"defaults"
|
||||
],
|
||||
@ -17,8 +17,8 @@
|
||||
"build:app:assets": "node ./scripts/build-app-assets.js",
|
||||
"build:storybook": "pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
|
||||
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
|
||||
"build:wasm": "../render-wasm/build",
|
||||
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
|
||||
"build:wasm": "../render-wasm/build",
|
||||
"build:app:libs": "node ./scripts/build-libs.js",
|
||||
"build:app:main": "clojure -M:dev:shadow-cljs release main worker",
|
||||
"build:app:worker": "clojure -M:dev:shadow-cljs release worker",
|
||||
@ -46,34 +46,35 @@
|
||||
"clear:wasm": "cargo clean --manifest-path ../render-wasm/Cargo.toml",
|
||||
"watch": "exit 0",
|
||||
"watch:app": "pnpm run clear:shadow-cache && pnpm run clear:wasm && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"",
|
||||
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"",
|
||||
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 -h 0.0.0.0 --no-open\" \"node ./scripts/watch-storybook.js\"",
|
||||
"postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@penpot/draft-js": "workspace:./packages/draft-js",
|
||||
"@penpot/mousetrap": "workspace:./packages/mousetrap",
|
||||
"@penpot/draft-js": "link:packages/draft-js",
|
||||
"@penpot/mousetrap": "link:packages/mousetrap",
|
||||
"@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime",
|
||||
"@penpot/svgo": "penpot/svgo#v3.2",
|
||||
"@penpot/text-editor": "workspace:./text-editor",
|
||||
"@penpot/tokenscript": "workspace:./packages/tokenscript",
|
||||
"@penpot/ui": "workspace:./packages/ui",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@storybook/addon-docs": "10.3.5",
|
||||
"@storybook/addon-themes": "10.3.5",
|
||||
"@storybook/addon-vitest": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@tokens-studio/sd-transforms": "1.2.11",
|
||||
"@types/node": "^25.5.2",
|
||||
"@vitest/browser": "4.1.3",
|
||||
"@vitest/browser-playwright": "^4.1.3",
|
||||
"@vitest/coverage-v8": "4.1.3",
|
||||
"@penpot/svgo": "penpot/svgo#3.3.0",
|
||||
"@penpot/text-editor": "link:text-editor",
|
||||
"@penpot/tokenscript": "link:packages/tokenscript",
|
||||
"@penpot/ua-parser": "penpot/ua-parser#1.0.0",
|
||||
"@penpot/ui": "link:packages/ui",
|
||||
"@playwright/test": "1.61.0",
|
||||
"@storybook/addon-docs": "10.4.5",
|
||||
"@storybook/addon-themes": "10.4.5",
|
||||
"@storybook/addon-vitest": "10.4.5",
|
||||
"@storybook/react-vite": "10.4.5",
|
||||
"@tokens-studio/sd-transforms": "2.0.3",
|
||||
"@types/node": "^25.9.3",
|
||||
"@vitest/browser": "4.1.9",
|
||||
"@vitest/browser-playwright": "^4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@zip.js/zip.js": "2.8.26",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"compression": "^1.8.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"eventsource-parser": "^3.0.8",
|
||||
"concurrently": "^10.0.3",
|
||||
"date-fns": "^4.4.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eventsource-parser": "^3.1.0",
|
||||
"express": "^5.1.0",
|
||||
"fancy-log": "^2.0.0",
|
||||
"getopts": "^2.3.0",
|
||||
@ -84,48 +85,46 @@
|
||||
"lodash": "^4.18.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"map-stream": "0.0.7",
|
||||
"marked": "^17.0.5",
|
||||
"marked": "^18.0.5",
|
||||
"mkdirp": "^3.0.1",
|
||||
"mustache": "^4.2.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"opentype.js": "^1.3.4",
|
||||
"opentype.js": "^2.0.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"playwright": "1.60.0",
|
||||
"postcss": "^8.5.8",
|
||||
"playwright": "1.61.0",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss-clean": "^1.2.2",
|
||||
"postcss-modules": "^6.0.1",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.4",
|
||||
"pretty-time": "^1.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"randomcolor": "^0.6.2",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"rimraf": "^6.1.3",
|
||||
"rxjs": "8.0.0-alpha.14",
|
||||
"sass": "^1.98.0",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"sax": "^1.4.1",
|
||||
"sass": "^1.100.0",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"sax": "^1.6.0",
|
||||
"scheduler": "^0.27.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"storybook": "10.3.5",
|
||||
"style-dictionary": "5.0.0-rc.1",
|
||||
"stylelint": "^17.4.0",
|
||||
"storybook": "10.4.5",
|
||||
"style-dictionary": "5.4.4",
|
||||
"stylelint": "^17.13.0",
|
||||
"stylelint-config-standard-scss": "^17.0.0",
|
||||
"stylelint-scss": "^7.0.0",
|
||||
"stylelint-scss": "^7.2.0",
|
||||
"stylelint-use-logical-spec": "^5.0.1",
|
||||
"svg-sprite": "^2.0.4",
|
||||
"tdigest": "^0.1.2",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"typescript": "^6.0.2",
|
||||
"ua-parser-js": "2.0.9",
|
||||
"vite": "^8.0.7",
|
||||
"vitest": "^4.1.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9",
|
||||
"wait-on": "^9.0.4",
|
||||
"wasm-pack": "^0.13.1",
|
||||
"watcher": "^2.3.1",
|
||||
"workerpool": "^10.0.1",
|
||||
"xregexp": "^5.1.2"
|
||||
|
||||
@ -4,18 +4,18 @@
|
||||
"description": "Penpot Draft-JS Wrapper",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
|
||||
"author": "Andrey Antukh",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0",
|
||||
"immutable": "^5.1.4"
|
||||
"immutable": "^5.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=0.17.0",
|
||||
"react-dom": ">=0.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.2"
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"description": "Simple library for handling keyboard shortcuts",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
|
||||
"author": "Craig Campbell",
|
||||
"license": "Apache-2.0 WITH LLVM-exception"
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
||||
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
|
||||
"author": "Andrey Antukh",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
|
||||
@ -14,23 +14,23 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.5",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@storybook/react": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@storybook/react": "10.4.5",
|
||||
"@storybook/react-vite": "10.4.5",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"react-compiler-runtime": "^1.0.0",
|
||||
"storybook": "10.3.5",
|
||||
"vite-plugin-dts": "^4.5.4"
|
||||
"storybook": "10.4.5",
|
||||
"vite-plugin-dts": "^5.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19.2",
|
||||
|
||||
@ -0,0 +1,195 @@
|
||||
{
|
||||
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77dfae2d9e7c",
|
||||
"~:file-id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1",
|
||||
"~:created-at": "~m1717759268004",
|
||||
"~:data": {
|
||||
"~:options": {},
|
||||
"~:objects": {
|
||||
"~u00000000-0000-0000-0000-000000000000": {
|
||||
"~#shape": {
|
||||
"~:y": 0,
|
||||
"~:hide-fill-on-export": false,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:name": "Root Frame",
|
||||
"~:width": 0.01,
|
||||
"~:type": "~:frame",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0,
|
||||
"~:y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.01,
|
||||
"~:y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.01,
|
||||
"~:y": 0.01
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0,
|
||||
"~:y": 0.01
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:proportion-lock": false,
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:strokes": [],
|
||||
"~:x": 0,
|
||||
"~:proportion": 1.0,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 0,
|
||||
"~:y": 0,
|
||||
"~:width": 0.01,
|
||||
"~:height": 0.01,
|
||||
"~:x1": 0,
|
||||
"~:y1": 0,
|
||||
"~:x2": 0.01,
|
||||
"~:y2": 0.01
|
||||
}
|
||||
},
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#FFFFFF",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:flip-x": null,
|
||||
"~:height": 0.01,
|
||||
"~:flip-y": null,
|
||||
"~:shapes": [
|
||||
"~ubc508673-9e3b-80bf-8004-77dfa30a2b13"
|
||||
]
|
||||
}
|
||||
},
|
||||
"~ubc508673-9e3b-80bf-8004-77dfa30a2b13": {
|
||||
"~#shape": {
|
||||
"~:y": 100,
|
||||
"~:hide-fill-on-export": false,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 0.5735764363510460,
|
||||
"~:b": 0.8191520442889918,
|
||||
"~:c": -0.8191520442889918,
|
||||
"~:d": 0.5735764363510460,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:rotation": 55,
|
||||
"~:grow-type": "~:fixed",
|
||||
"~:hide-in-viewer": false,
|
||||
"~:name": "Board",
|
||||
"~:width": 80,
|
||||
"~:type": "~:frame",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 166.208,
|
||||
"~:y": 92.816
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 212.096,
|
||||
"~:y": 158.352
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 113.792,
|
||||
"~:y": 227.184
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 67.904,
|
||||
"~:y": 161.648
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:proportion-lock": false,
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 0.5735764363510460,
|
||||
"~:b": -0.8191520442889918,
|
||||
"~:c": 0.8191520442889918,
|
||||
"~:d": 0.5735764363510460,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:id": "~ubc508673-9e3b-80bf-8004-77dfa30a2b13",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:strokes": [
|
||||
{
|
||||
"~:stroke-color": "#FF0000",
|
||||
"~:stroke-opacity": 1,
|
||||
"~:stroke-style": "~:solid",
|
||||
"~:stroke-alignment": "~:outer",
|
||||
"~:stroke-width": 20
|
||||
}
|
||||
],
|
||||
"~:x": 100,
|
||||
"~:proportion": 1,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 100,
|
||||
"~:y": 100,
|
||||
"~:width": 80,
|
||||
"~:height": 120,
|
||||
"~:x1": 100,
|
||||
"~:y1": 100,
|
||||
"~:x2": 180,
|
||||
"~:y2": 220
|
||||
}
|
||||
},
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#FFFFFF",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:flip-x": null,
|
||||
"~:height": 120,
|
||||
"~:flip-y": null,
|
||||
"~:shapes": [],
|
||||
"~:show-content": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2",
|
||||
"~:name": "Page 1"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
{
|
||||
"~:users": [
|
||||
{
|
||||
"~:id": "~u0515a066-e303-8169-8004-73eb4018f4e0",
|
||||
"~:email": "leia@example.com",
|
||||
"~:name": "Princesa Leia",
|
||||
"~:fullname": "Princesa Leia",
|
||||
"~:is-active": true
|
||||
}
|
||||
],
|
||||
"~:fonts": [],
|
||||
"~:project": {
|
||||
"~:id": "~u0515a066-e303-8169-8004-73eb401b5d55",
|
||||
"~:name": "Drafts",
|
||||
"~:team-id": "~u0515a066-e303-8169-8004-73eb401977a6"
|
||||
},
|
||||
"~:share-links": [],
|
||||
"~:libraries": [],
|
||||
"~:file": {
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "Rotated Board Stroke Test",
|
||||
"~:revn": 1,
|
||||
"~:modified-at": "~m1717759268010",
|
||||
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1",
|
||||
"~:is-shared": false,
|
||||
"~:version": 48,
|
||||
"~:project-id": "~u0515a066-e303-8169-8004-73eb401b5d55",
|
||||
"~:created-at": "~m1717759250257",
|
||||
"~:data": {
|
||||
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1",
|
||||
"~:options": {
|
||||
"~:components-v2": true
|
||||
},
|
||||
"~:pages": [
|
||||
"~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2"
|
||||
],
|
||||
"~:pages-index": {
|
||||
"~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2": {
|
||||
"~#penpot/pointer": [
|
||||
"~uaa5cc0bb-91ff-81b9-8004-77dfae2d9e7c",
|
||||
{
|
||||
"~:created-at": "~m1717759268024"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"~:team": {
|
||||
"~:id": "~u0515a066-e303-8169-8004-73eb401977a6",
|
||||
"~:created-at": "~m1717493865581",
|
||||
"~:modified-at": "~m1717493865581",
|
||||
"~:name": "Default",
|
||||
"~:is-default": true,
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true,
|
||||
"~:in-team": true
|
||||
}
|
||||
}
|
||||
@ -71,6 +71,21 @@ export class ViewerPage extends BaseWebSocketPage {
|
||||
);
|
||||
}
|
||||
|
||||
async setupFileWithRotatedBoardStroke() {
|
||||
await this.mockRPC(
|
||||
/get\-view\-only\-bundle\?/,
|
||||
"viewer/get-view-only-bundle-rotated-board-stroke.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-comment-threads?file-id=*",
|
||||
"workspace/get-comment-threads-empty.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-file-fragment?file-id=*&fragment-id=*",
|
||||
"viewer/get-file-fragment-rotated-board-stroke.json",
|
||||
);
|
||||
}
|
||||
|
||||
async setupFileWithComments() {
|
||||
await this.mockRPC(
|
||||
/get\-view\-only\-bundle\?/,
|
||||
|
||||
@ -318,4 +318,18 @@ test("Color picker color list", async ({ page }) => {
|
||||
await expect(
|
||||
colorpicker.getByRole("listitem", { name: "First color" }),
|
||||
).toBeVisible();
|
||||
|
||||
//test show and hide color palette
|
||||
const paletteToggle = workspacePage.page.getByRole('button', { name: 'Toggle color palette' });
|
||||
await paletteToggle.click();
|
||||
const paletteBar = workspacePage.page.getByRole('region', { name: 'Palette bar' });
|
||||
await expect(paletteBar).toBeVisible();
|
||||
|
||||
// Check that color palette is open by checking the presence of a color swatch in the palette
|
||||
const paletteSwatch = paletteBar.getByRole('button', { name: 'first color' });
|
||||
await expect(paletteSwatch).toBeVisible();
|
||||
|
||||
// Close the color palette
|
||||
await paletteToggle.click();
|
||||
await expect(paletteSwatch).not.toBeVisible();
|
||||
});
|
||||
|
||||
@ -271,17 +271,15 @@ test("Multiselection of text and typographies", async ({ page }) => {
|
||||
await expect(textSection.getByText("Mixed assets")).toBeVisible();
|
||||
|
||||
// Select token typography text layer
|
||||
// TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY
|
||||
await tokenTypographyTextLayerOne.click();
|
||||
await expect(textSection).toBeVisible();
|
||||
await expect(textSection.getByText("Metrophobic")).toBeVisible();
|
||||
await expect(textSection.getByLabel('token-typo-one')).toBeVisible();
|
||||
|
||||
// Select two token typography text layer with different token typography
|
||||
// TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY
|
||||
await tokenTypographyTextLayerTwo.click({ modifiers: ["Control"] });
|
||||
await expect(textSection).toBeVisible();
|
||||
await expect(
|
||||
textSection.getByTitle("Font family").getByText("Mixed Font Families"),
|
||||
textSection.getByText('Mixed tokens'),
|
||||
).toBeVisible();
|
||||
|
||||
//Select plain text layer and typography text layer together
|
||||
|
||||
@ -186,11 +186,8 @@ test.describe("Tokens: Apply token", () => {
|
||||
|
||||
await tokensSidebar.getByRole("button", { name: "Full" }).click();
|
||||
|
||||
const fontSizeInput = workspacePage.rightSidebar.getByRole("textbox", {
|
||||
name: "Font Size",
|
||||
});
|
||||
await expect(fontSizeInput).toBeVisible();
|
||||
await expect(fontSizeInput).toHaveValue("100");
|
||||
const tokenRow = workspacePage.rightSidebar.getByLabel('Full');
|
||||
await expect(tokenRow).toBeVisible();
|
||||
});
|
||||
|
||||
test("User adds shadow token with multiple shadows and applies it to shape", async ({
|
||||
|
||||
@ -418,13 +418,9 @@ test.describe("Remapping a single token", () => {
|
||||
.filter({ hasText: "Some Text" })
|
||||
.click();
|
||||
|
||||
// Verify the shape shows the updated font size value (18)
|
||||
// This proves the remapping worked and the value update propagated through the reference
|
||||
const fontSizeInput = workspacePage.rightSidebar.getByRole("textbox", {
|
||||
name: "Font Size",
|
||||
});
|
||||
await expect(fontSizeInput).toBeVisible();
|
||||
await expect(fontSizeInput).toHaveValue("18");
|
||||
const tokenPillSidebar = workspacePage.rightSidebar.getByLabel('paragraph-style')
|
||||
await expect(tokenPillSidebar).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
49
frontend/playwright/ui/specs/viewer-inspect-exports.spec.js
Normal file
49
frontend/playwright/ui/specs/viewer-inspect-exports.spec.js
Normal file
@ -0,0 +1,49 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { ViewerPage } from "../pages/ViewerPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await ViewerPage.init(page);
|
||||
});
|
||||
|
||||
const multipleBoardsFileId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb0";
|
||||
const multipleBoardsPageId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb3";
|
||||
|
||||
test("[View mode] Export presets are preserved when navigating between boards in inspect mode", async ({
|
||||
page,
|
||||
}) => {
|
||||
const viewer = new ViewerPage(page);
|
||||
await viewer.setupLoggedInUser();
|
||||
await viewer.setupFileWithMultipleBoards();
|
||||
|
||||
await viewer.goToViewer({
|
||||
fileId: multipleBoardsFileId,
|
||||
pageId: multipleBoardsPageId,
|
||||
});
|
||||
|
||||
// Enter inspect (code) mode
|
||||
await viewer.showCode();
|
||||
|
||||
// Wait for the inspect panel to load
|
||||
await page.waitForSelector(".main_ui_inspect_exports__add-export");
|
||||
|
||||
// Add an export preset via the "+" button in the Export section
|
||||
const addExportButton = page.locator(".main_ui_inspect_exports__add-export");
|
||||
await addExportButton.click();
|
||||
|
||||
// Verify the "Export 1 element" button appears, confirming the preset was added
|
||||
const exportButton = page.getByRole("button", { name: "Export 1 element" });
|
||||
await expect(exportButton).toBeVisible();
|
||||
|
||||
// Navigate to another board
|
||||
const nextButton = page.getByRole("button", { name: "Next" });
|
||||
await nextButton.click();
|
||||
await expect(page).toHaveURL(/&index=1/);
|
||||
|
||||
// Navigate back to the first board
|
||||
const prevButton = page.locator(".main_ui_viewer__viewer-go-prev");
|
||||
await prevButton.click();
|
||||
await expect(page).toHaveURL(/&index=0/);
|
||||
|
||||
// Export preset should still be visible after returning to the first board
|
||||
await expect(exportButton).toBeVisible();
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { ViewerPage } from "../pages/ViewerPage";
|
||||
|
||||
// Issue 8257: outer stroke of a rotated board is cropped in View Mode.
|
||||
// The SVG viewport must be large enough to contain the stroke of a rotated board.
|
||||
// A 55° rotated board (80×120) with a 20px outer stroke has a rotated bounding box
|
||||
// of ~144×134px. The viewport must be at least ~202×192px (bbox + stroke margin).
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await ViewerPage.init(page);
|
||||
});
|
||||
|
||||
const rotatedBoardFileId = "aa5cc0bb-91ff-81b9-8004-77df9cd3edb1";
|
||||
const rotatedBoardPageId = "aa5cc0bb-91ff-81b9-8004-77df9cd3edb2";
|
||||
|
||||
test("Viewer shows full outer stroke of a rotated board without clipping", async ({
|
||||
page,
|
||||
}) => {
|
||||
const viewer = new ViewerPage(page);
|
||||
await viewer.setupLoggedInUser();
|
||||
await viewer.setupFileWithRotatedBoardStroke();
|
||||
|
||||
await viewer.goToViewer({
|
||||
fileId: rotatedBoardFileId,
|
||||
pageId: rotatedBoardPageId,
|
||||
});
|
||||
|
||||
// Wait for the viewer SVG to be rendered
|
||||
const svg = page.locator("svg[class*='not-fixed']").first();
|
||||
await expect(svg).toBeVisible();
|
||||
|
||||
// The SVG viewBox must be large enough to contain the rotated board plus its
|
||||
// 20px outer stroke. For a 55° rotated board (80×120):
|
||||
// - The axis-aligned bounding box of the rotated frame is ~144×134px
|
||||
// - The outer stroke (20px) adds sqrt(2)*20 ≈ 29px margin on each side
|
||||
// - So the viewport must be at least ~202×192px
|
||||
//
|
||||
// Before the fix, the viewer used the unrotated selrect (80×120) as the viewport,
|
||||
// causing the stroke to be heavily clipped.
|
||||
const viewBox = await svg.getAttribute("viewBox");
|
||||
const [, , vbWidth, vbHeight] = viewBox.split(" ").map(Number);
|
||||
|
||||
// The unrotated selrect is 80×120. If the viewport is close to those dimensions,
|
||||
// the stroke is being clipped (bug). The fixed viewport should be much larger.
|
||||
expect(vbWidth).toBeGreaterThan(150);
|
||||
expect(vbHeight).toBeGreaterThan(150);
|
||||
});
|
||||
6583
frontend/pnpm-lock.yaml
generated
6583
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -9,3 +9,25 @@ patchedDependencies:
|
||||
'@zip.js/zip.js@2.8.26': patches/@zip.js__zip.js@2.8.26.patch
|
||||
|
||||
shamefullyHoist: true
|
||||
|
||||
minimumReleaseAge: 0
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
canvas: true
|
||||
core-js-pure: true
|
||||
esbuild: true
|
||||
|
||||
overrides:
|
||||
ajv@>=7.0.0-alpha.0 <8.18.0: ^8.18.0
|
||||
immutable@<3.8.3: ^3.8.3
|
||||
lodash@<=4.17.23: ^4.17.24
|
||||
lodash@>=4.0.0 <=4.17.23: ^4.17.24
|
||||
minimatch@>=10.0.0 <10.2.1: ^10.2.1
|
||||
minimatch@>=10.0.0 <10.2.3: ^10.2.3
|
||||
minimatch@>=9.0.0 <9.0.6: ^9.0.6
|
||||
minimatch@>=9.0.0 <9.0.7: ^9.0.7
|
||||
postcss@<7.0.36: ^7.0.36
|
||||
postcss@<8.4.31: ^8.4.31
|
||||
postcss@<8.5.10: ^8.5.10
|
||||
yaml@>=2.0.0 <2.8.3: ^2.8.3
|
||||
|
||||
@ -9,14 +9,12 @@
|
||||
{:target :esm
|
||||
:output-dir "resources/public/js/"
|
||||
:asset-path "/js"
|
||||
;; :devtools is dev-only, so it lives under :dev -- shadow merges that map
|
||||
;; for `watch`/`compile` but not `release`, keeping :devtools-url out of
|
||||
;; release entirely (shadow spec-checks it as non-empty-string? whenever the
|
||||
;; key is present, even in release). In the devenv SHADOW_SERVER_URL is
|
||||
;; always set per workspace (see defaults.env / manage.sh).
|
||||
:dev {:devtools {:watch-dir "resources/public"
|
||||
:reload-strategy :full
|
||||
:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]}}
|
||||
:devtools {:watch-dir "resources/public"
|
||||
:reload-strategy :full}
|
||||
|
||||
:dev {;; allows remote-relay per parallel environment
|
||||
;; inside :dev so the integration tests won't use it
|
||||
:devtools {:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]}}
|
||||
:build-options {:manifest-name "manifest.json"}
|
||||
:modules
|
||||
{:shared
|
||||
@ -92,11 +90,12 @@
|
||||
{:target :browser
|
||||
:output-dir "resources/public/js/worker/"
|
||||
:asset-path "/js/worker"
|
||||
;; Dev-only; see the :main build above for why :devtools lives under :dev.
|
||||
:dev {:devtools {:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]
|
||||
:browser-inject :main
|
||||
:watch-dir "resources/public"
|
||||
:reload-strategy :full}}
|
||||
:devtools {:watch-dir "resources/public"
|
||||
:reload-strategy :full
|
||||
:browser-inject :main}
|
||||
:dev {;; allows remote-relay per parallel environment
|
||||
;; inside :dev so the integration tests won't use it
|
||||
:devtools {:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]}}
|
||||
:build-options {:manifest-name "manifest.json"}
|
||||
:modules
|
||||
{:main
|
||||
|
||||
@ -92,15 +92,17 @@
|
||||
(effect [_ state _]
|
||||
(when (and wasm/context-initialized?
|
||||
(not @wasm/context-lost?))
|
||||
(let [objects (dsh/lookup-page-objects state)]
|
||||
(doseq [{:keys [type id parent-id]} redo-changes
|
||||
:when (contains? wasm-structural-change-types type)
|
||||
:let [shape-id (case type
|
||||
:add-obj id
|
||||
:mov-objects parent-id)
|
||||
shape (get objects shape-id)]
|
||||
:when shape]
|
||||
(wasm.api/process-object shape))
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shapes
|
||||
(into []
|
||||
(keep (fn [{:keys [type id parent-id]}]
|
||||
(when (contains? wasm-structural-change-types type)
|
||||
(get objects (case type
|
||||
:add-obj id
|
||||
:mov-objects parent-id)))))
|
||||
redo-changes)]
|
||||
|
||||
(wasm.api/process-objects shapes)
|
||||
(wasm.api/request-render "sync-wasm-structural-changes"))))))
|
||||
|
||||
(defn- apply-changes-localy
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
(ns app.main.data.event
|
||||
(:require
|
||||
["ua-parser-js" :as ua]
|
||||
["@penpot/ua-parser" :as ua]
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.json :as json]
|
||||
@ -66,22 +66,22 @@
|
||||
|
||||
(defn- collect-context
|
||||
[]
|
||||
(let [uagent (new ua/UAParser)]
|
||||
(let [result (ua/parse)]
|
||||
(merge
|
||||
{:version (:full cf/version)
|
||||
:locale i18n/*current-locale*}
|
||||
(let [browser (.getBrowser uagent)]
|
||||
(let [browser (.getBrowser result)]
|
||||
{:browser (obj/get browser "name")
|
||||
:browser-version (obj/get browser "version")})
|
||||
(let [engine (.getEngine uagent)]
|
||||
(let [engine (.getEngine result)]
|
||||
{:engine (obj/get engine "name")
|
||||
:engine-version (obj/get engine "version")})
|
||||
(let [os (.getOS uagent)
|
||||
(let [os (.getOS result)
|
||||
name (obj/get os "name")
|
||||
version (obj/get os "version")]
|
||||
{:os (str name " " version)
|
||||
:os-version version})
|
||||
(let [device (.getDevice uagent)]
|
||||
(let [device (.getDevice result)]
|
||||
(if-let [type (obj/get device "type")]
|
||||
{:device-type type
|
||||
:device-vendor (obj/get device "vendor")
|
||||
@ -93,7 +93,7 @@
|
||||
:screen-height (obj/get screen "height")
|
||||
:screen-color-depth (obj/get screen "colorDepth")
|
||||
:screen-orientation (obj/get orientation "type")})
|
||||
(let [cpu (.getCPU uagent)]
|
||||
(let [cpu (.getCPU result)]
|
||||
{:device-arch (obj/get cpu "architecture")}))))
|
||||
|
||||
(def context
|
||||
|
||||
@ -163,31 +163,46 @@
|
||||
(when (= status "ended")
|
||||
(dom/trigger-download-uri filename mtype resource-uri)))))
|
||||
|
||||
;; TODO: Remove once we support WASM SVG export
|
||||
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf})
|
||||
|
||||
(defn- wasm-export-enabled?
|
||||
"WASM export is available: the flag is set AND render-wasm is active for the
|
||||
current file. When render-wasm is inactive its shape tree isn't loaded, so a
|
||||
client-side WASM render would crash."
|
||||
[state]
|
||||
(and (contains? cf/flags :wasm-export)
|
||||
(features/active-feature? state "render-wasm/v1")))
|
||||
|
||||
(defn- use-wasm-export?
|
||||
"Whether to take the client-side WASM export path for `export`."
|
||||
[state export]
|
||||
(and (wasm-export-enabled? state)
|
||||
(contains? wasm-export-types (:type export))))
|
||||
|
||||
(defn request-simple-export
|
||||
[{:keys [export]}]
|
||||
(if (and (contains? cf/flags :wasm-export)
|
||||
(contains? #{:jpeg :webp :png} (:type export)))
|
||||
(ptk/reify ::request-simple-export-wasm
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
(wasm.exports/export-image export)))
|
||||
(ptk/reify ::request-simple-export
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(cond-> state
|
||||
(not (use-wasm-export? state export))
|
||||
(update :export assoc :in-progress true :id uuid/zero)))
|
||||
|
||||
(ptk/reify ::request-simple-export
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :export assoc :in-progress true :id uuid/zero))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(if (use-wasm-export? state export)
|
||||
(do
|
||||
(case (:type export)
|
||||
:pdf (wasm.exports/export-pdf export)
|
||||
(wasm.exports/export-image export))
|
||||
(rx/empty))
|
||||
(let [profile-id (:profile-id state)
|
||||
params {:exports [export]
|
||||
:profile-id profile-id
|
||||
:cmd :export-shapes
|
||||
:wait true
|
||||
:is-wasm
|
||||
(and
|
||||
(features/active-feature? state "render-wasm/v1")
|
||||
(contains? cf/flags :wasm-export))}]
|
||||
:is-wasm (wasm-export-enabled? state)}]
|
||||
(rx/concat
|
||||
(rx/of ::dwp/force-persist)
|
||||
|
||||
@ -221,10 +236,7 @@
|
||||
:cmd cmd
|
||||
:profile-id profile-id
|
||||
:force-multiple true
|
||||
:is-wasm
|
||||
(and
|
||||
(features/active-feature? state "render-wasm/v1")
|
||||
(contains? cf/flags :wasm-export))}
|
||||
:is-wasm (wasm-export-enabled? state)}
|
||||
(some? name)
|
||||
(assoc :name name))
|
||||
|
||||
|
||||
@ -26,3 +26,18 @@
|
||||
(dom/trigger-download-uri filename mtype url)
|
||||
(wapi/revoke-uri url)
|
||||
nil))
|
||||
|
||||
(defn export-pdf-uri
|
||||
[{:keys [scale object-id]}]
|
||||
(let [bytes (wasm.api/render-shape-pdf object-id (or scale 1))
|
||||
blob (wapi/create-blob bytes "application/pdf")]
|
||||
(wapi/create-uri blob)))
|
||||
|
||||
(defn export-pdf
|
||||
[{:keys [suffix name] :as params}]
|
||||
(let [url (export-pdf-uri params)
|
||||
filename (str name (or suffix "") ".pdf")]
|
||||
(dom/trigger-download-uri filename "application/pdf" url)
|
||||
(js/queueMicrotask #(wapi/revoke-uri url))
|
||||
nil))
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.logging :as log]
|
||||
[app.common.time :as ct]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.event :as ev]
|
||||
@ -57,45 +58,57 @@
|
||||
|
||||
(defn start-plugin!
|
||||
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
|
||||
(-> (.ɵloadPlugin
|
||||
^js ug/global
|
||||
#js {:pluginId plugin-id
|
||||
:name name
|
||||
:version version
|
||||
:description description
|
||||
:host host
|
||||
:code code
|
||||
:allowBackground (boolean allow-background)
|
||||
:permissions (apply array permissions)}
|
||||
nil
|
||||
extensions)
|
||||
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
|
||||
(if (fn? load-plugin)
|
||||
(-> (load-plugin
|
||||
#js {:pluginId plugin-id
|
||||
:name name
|
||||
:version version
|
||||
:description description
|
||||
:host host
|
||||
:code code
|
||||
:allowBackground (boolean allow-background)
|
||||
:permissions (apply array permissions)}
|
||||
nil
|
||||
extensions)
|
||||
|
||||
(p/catch (fn [cause]
|
||||
(ex/print-throwable cause :prefix "Plugin Error")
|
||||
(errors/flash :cause cause :type :handled)))))
|
||||
(p/catch (fn [cause]
|
||||
(ex/print-throwable cause :prefix "Plugin Error")
|
||||
(errors/flash :cause cause :type :handled))))
|
||||
|
||||
(log/warn :hint "Plugin runtime not initialized yet"
|
||||
:plugin-id plugin-id
|
||||
:action "start-plugin!"))))
|
||||
|
||||
(defn- load-plugin!
|
||||
[{:keys [plugin-id name version description host code icon permissions]}]
|
||||
(st/emit! (pflag/clear plugin-id)
|
||||
(save-current-plugin plugin-id))
|
||||
|
||||
(-> (.ɵloadPlugin
|
||||
^js ug/global
|
||||
#js {:pluginId plugin-id
|
||||
:name name
|
||||
:description description
|
||||
:version version
|
||||
:host host
|
||||
:code code
|
||||
:icon icon
|
||||
:permissions (apply array permissions)}
|
||||
(fn []
|
||||
(st/emit! (remove-current-plugin plugin-id))))
|
||||
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
|
||||
(if (fn? load-plugin)
|
||||
(-> (load-plugin
|
||||
#js {:pluginId plugin-id
|
||||
:name name
|
||||
:description description
|
||||
:version version
|
||||
:host host
|
||||
:code code
|
||||
:icon icon
|
||||
:permissions (apply array permissions)}
|
||||
(fn []
|
||||
(st/emit! (remove-current-plugin plugin-id))))
|
||||
|
||||
(p/catch (fn [cause]
|
||||
(st/emit! (remove-current-plugin plugin-id))
|
||||
(ex/print-throwable cause :prefix "Plugin Error")
|
||||
(errors/flash :cause cause :type :handled)))))
|
||||
(p/catch (fn [cause]
|
||||
(st/emit! (remove-current-plugin plugin-id))
|
||||
(ex/print-throwable cause :prefix "Plugin Error")
|
||||
(errors/flash :cause cause :type :handled))))
|
||||
|
||||
(do
|
||||
(log/warn :hint "Plugin runtime not initialized yet"
|
||||
:plugin-id plugin-id
|
||||
:action "load-plugin!")
|
||||
(st/emit! (remove-current-plugin plugin-id))))))
|
||||
|
||||
(defn open-plugin!
|
||||
[{:keys [url] :as manifest} user-can-edit?]
|
||||
@ -135,10 +148,15 @@
|
||||
|
||||
(defn close-plugin!
|
||||
[{:keys [plugin-id]}]
|
||||
(try
|
||||
(.ɵunloadPlugin ^js ug/global plugin-id)
|
||||
(catch :default e
|
||||
(.error js/console "Error" e))))
|
||||
(let [unload-plugin (unchecked-get ug/global "ɵunloadPlugin")]
|
||||
(if (fn? unload-plugin)
|
||||
(try
|
||||
(unload-plugin plugin-id)
|
||||
(catch :default e
|
||||
(.error js/console "Error" e)))
|
||||
(log/warn :hint "Plugin runtime not initialized yet"
|
||||
:plugin-id plugin-id
|
||||
:action "close-plugin!"))))
|
||||
|
||||
(defn close-current-plugin
|
||||
[& {:keys [close-only-edition-plugins?]}]
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
[app.common.types.shape-tree :as ctt]
|
||||
[app.common.types.shape.interactions :as ctsi]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.data.comments :as dcmt]
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.data.event :as ev]
|
||||
@ -219,7 +220,8 @@
|
||||
(ptk/reify ::update-page-position-data
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(if (and (features/active-feature? state "render-wasm/v1")
|
||||
(contains? cf/flags :available-viewer-wasm))
|
||||
(let [objects (dsh/lookup-page-objects state file-id page-id)
|
||||
|
||||
shapes
|
||||
@ -581,6 +583,13 @@
|
||||
(update [_ state]
|
||||
(d/dissoc-in state [:viewer-local :nav-scroll]))))
|
||||
|
||||
(defn update-exports-cache
|
||||
[shapes-key exports]
|
||||
(ptk/reify ::update-exports-cache
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc-in state [:inspect-exports-cache shapes-key] exports))))
|
||||
|
||||
(defn complete-animation
|
||||
[]
|
||||
(ptk/reify ::complete-animation
|
||||
|
||||
@ -504,8 +504,7 @@
|
||||
(rx/of (dwu/append-undo entry stack-undo?)))
|
||||
(rx/empty))))))
|
||||
|
||||
(rx/take-until stoper-s))
|
||||
(rx/of (mcp/notify-other-tabs-disconnect)))))
|
||||
(rx/take-until stoper-s)))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
|
||||
@ -743,7 +743,8 @@
|
||||
(update :fills translate-fills)
|
||||
(update :strokes translate-strokes)
|
||||
(d/update-when :content #(txt/transform-nodes process-text-node %))
|
||||
(d/update-when :position-data #(mapv process-text-node %)))))
|
||||
;; Removes the position-data so it's regenerated
|
||||
(dissoc :position-data))))
|
||||
|
||||
;; Analyze the rchange and replace staled media and
|
||||
;; references to the new uploaded media-objects.
|
||||
|
||||
@ -47,6 +47,20 @@
|
||||
(let [wglobal (:workspace-global state)]
|
||||
(layout/persist-layout-state! wglobal)))))
|
||||
|
||||
(defn toggle-palette
|
||||
"Toggle the palette tool and change the library it uses"
|
||||
[selected]
|
||||
(ptk/reify ::toggle-palette
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (layout/toggle-layout-flag :colorpalette)
|
||||
(mbc/event colorpalette-selected-broadcast-key selected)))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(let [wglobal (:workspace-global state)]
|
||||
(layout/persist-layout-state! wglobal)))))
|
||||
|
||||
(defn start-picker
|
||||
[]
|
||||
(ptk/reify ::start-picker
|
||||
|
||||
@ -10,13 +10,10 @@
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.main.broadcast :as mbc]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.plugins :as dp]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.register :refer [mcp-plugin-id]]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.plugins.register :as preg]
|
||||
[app.util.timers :as ts]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
@ -29,7 +26,7 @@
|
||||
{:code "plugin.js"
|
||||
:name "Penpot MCP Plugin"
|
||||
:version 2
|
||||
:plugin-id mcp-plugin-id
|
||||
:plugin-id preg/mcp-plugin-id
|
||||
:description "This plugin enables interaction with the Penpot MCP server"
|
||||
:allow-background true
|
||||
:permissions
|
||||
@ -39,6 +36,14 @@
|
||||
|
||||
(defonce interval-sub (atom nil))
|
||||
|
||||
(defn connect-mcp
|
||||
[]
|
||||
(ptk/reify ::connect-mcp
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (mbc/event :mcp/force-disconnect {})
|
||||
(ptk/data-event ::connect)))))
|
||||
|
||||
(defn finalize-workspace?
|
||||
[event]
|
||||
(= (ptk/type event) :app.main.data.workspace/finalize-workspace))
|
||||
@ -72,45 +77,6 @@
|
||||
(rx/dispose! @interval-sub)
|
||||
(reset! interval-sub nil)))
|
||||
|
||||
(declare manage-mcp-notification)
|
||||
|
||||
(defn handle-pong
|
||||
[{:keys [id data]}]
|
||||
(ptk/reify ::handle-pong
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [mcp-state (get state :mcp)]
|
||||
(cond
|
||||
(= "connected" (:connection-status data))
|
||||
(update state :mcp assoc :connected-tab id)
|
||||
|
||||
(and (= "disconnected" (:connection-status data))
|
||||
(= id (:connected-tab mcp-state)))
|
||||
(update state :mcp dissoc :connected-tab)
|
||||
|
||||
:else
|
||||
state)))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (manage-mcp-notification)))))
|
||||
|
||||
;; This event will arrive when a new workspace is open in another tab
|
||||
(defn handle-ping
|
||||
[]
|
||||
(ptk/reify ::handle-ping
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [conn-status (get-in state [:mcp :connection-status])]
|
||||
(rx/of (mbc/event :mcp/pong {:connection-status conn-status}))))))
|
||||
|
||||
(defn notify-other-tabs-disconnect
|
||||
[]
|
||||
(ptk/reify ::notify-other-tabs-disconnect
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (mbc/event :mcp/pong {:connection-status "disconnected"})))))
|
||||
|
||||
;; This event will arrive when the mcp is enabled in the dashboard
|
||||
(defn update-mcp-status
|
||||
[value]
|
||||
@ -121,12 +87,10 @@
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/merge
|
||||
(rx/of (manage-mcp-notification))
|
||||
(case value
|
||||
true (rx/of (ptk/data-event ::connect))
|
||||
false (rx/of (ptk/data-event ::disconnect))
|
||||
nil)))))
|
||||
(case value
|
||||
true (rx/of (connect-mcp))
|
||||
false (rx/of (ptk/data-event ::disconnect))
|
||||
nil))))
|
||||
|
||||
(defn update-mcp-connection-status
|
||||
[value]
|
||||
@ -137,20 +101,13 @@
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (manage-mcp-notification)
|
||||
(mbc/event :mcp/pong {:connection-status value})))))
|
||||
|
||||
(defn connect-mcp
|
||||
[]
|
||||
(ptk/reify ::connect-mcp
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :mcp assoc :connected-tab (:session-id state)))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (mbc/event :mcp/force-disconect {})
|
||||
(ptk/data-event ::connect)))))
|
||||
;; Only one MCP plugin instance may be active across browser tabs.
|
||||
;; When this tab becomes connected, tell every other tab to
|
||||
;; disconnect (which also stops their reconnect watcher). Otherwise
|
||||
;; several tabs stay connected at once and the MCP server reports
|
||||
;; "multiple instances connected" and the agent fails.
|
||||
(when (= "connected" value)
|
||||
(rx/of (mbc/event :mcp/force-disconnect {}))))))
|
||||
|
||||
;; This event will arrive when the user selects disconnect on the menu
|
||||
;; or there is a broadcast message for disconnection
|
||||
@ -166,77 +123,54 @@
|
||||
(effect [_ _ _]
|
||||
(stop-reconnect-watcher!))))
|
||||
|
||||
(defn- manage-mcp-notification
|
||||
[]
|
||||
(ptk/reify ::manage-mcp-notification
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [mcp-state (get state :mcp)
|
||||
|
||||
mcp-enabled? (-> state :profile :props :mcp-enabled)
|
||||
|
||||
current-tab-id (get state :session-id)
|
||||
connected-tab-id (get mcp-state :connected-tab)]
|
||||
|
||||
(if mcp-enabled?
|
||||
(if (= connected-tab-id current-tab-id)
|
||||
(rx/of (ntf/hide))
|
||||
(rx/of (ntf/dialog
|
||||
{:content (tr "notifications.mcp.active-in-another-tab")
|
||||
:cancel {:label (tr "labels.dismiss")
|
||||
:callback #(st/emit! (ntf/hide)
|
||||
(ev/event {::ev/name "dismiss-mcp-tab-switch"
|
||||
::ev/origin "workspace-notification"}))}
|
||||
:accept {:label (tr "labels.switch")
|
||||
:callback #(st/emit! (connect-mcp)
|
||||
(ev/event {::ev/name "confirm-mcp-tab-switch"
|
||||
::ev/origin "workspace-notification"}))}})))
|
||||
(rx/of (ntf/hide)))))))
|
||||
|
||||
(defn init-mcp
|
||||
[stream]
|
||||
(->> (rp/cmd! :get-current-mcp-token)
|
||||
(rx/tap
|
||||
(fn [{:keys [token]}]
|
||||
(when token
|
||||
(dp/start-plugin!
|
||||
(assoc default-manifest
|
||||
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
|
||||
:host (str (u/join cf/public-uri "plugins/mcp/")))
|
||||
;; Wait for plugins runtime to be initialized before starting the MCP plugin.
|
||||
;; This ensures global.ɵloadPlugin is available when start-plugin! is called.
|
||||
(->> (rx/from (preg/wait-for-runtime))
|
||||
(rx/mapcat
|
||||
(fn [_]
|
||||
(->> (rp/cmd! :get-current-mcp-token)
|
||||
(rx/tap
|
||||
(fn [{:keys [token]}]
|
||||
(when token
|
||||
(dp/start-plugin!
|
||||
(assoc default-manifest
|
||||
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
|
||||
:host (str (u/join cf/public-uri "plugins/mcp/")))
|
||||
|
||||
;; API extension for MCP server
|
||||
#js {:mcp
|
||||
#js
|
||||
{:getToken (constantly token)
|
||||
:getServerUrl #(str cf/mcp-ws-uri)
|
||||
:setMcpStatus
|
||||
(fn [status]
|
||||
(when (= status "connected")
|
||||
(start-reconnect-watcher!))
|
||||
(st/emit! (update-mcp-connection-status status))
|
||||
(log/info :hint "MCP STATUS" :status status))
|
||||
;; API extension for MCP server
|
||||
#js {:mcp
|
||||
#js
|
||||
{:getToken (constantly token)
|
||||
:getServerUrl #(str cf/mcp-ws-uri)
|
||||
:setMcpStatus
|
||||
(fn [status]
|
||||
(when (= status "connected")
|
||||
(start-reconnect-watcher!))
|
||||
(st/emit! (update-mcp-connection-status status))
|
||||
(log/info :hint "MCP STATUS" :status status))
|
||||
|
||||
:on
|
||||
(fn [event cb]
|
||||
(when-let [event
|
||||
(case event
|
||||
"disconnect" ::disconnect
|
||||
"connect" ::connect
|
||||
nil)]
|
||||
:on
|
||||
(fn [event cb]
|
||||
(when-let [event
|
||||
(case event
|
||||
"disconnect" ::disconnect
|
||||
"connect" ::connect
|
||||
nil)]
|
||||
|
||||
(let [stopper (rx/filter finalize-workspace? stream)]
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? event))
|
||||
(rx/take-until stopper)
|
||||
(rx/subs! #(cb))))))}}))))
|
||||
(rx/ignore)))
|
||||
(let [stopper (rx/filter finalize-workspace? stream)]
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? event))
|
||||
(rx/take-until stopper)
|
||||
(rx/subs! #(cb))))))}})))))))))
|
||||
|
||||
(defn init
|
||||
[]
|
||||
(ptk/reify ::init
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :mcp assoc :connected-tab (:session-id state) :active true))
|
||||
(update state :mcp assoc :active true))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
@ -251,22 +185,8 @@
|
||||
(rx/merge
|
||||
(init-mcp stream)
|
||||
|
||||
(rx/of (mbc/event :mcp/ping {}))
|
||||
|
||||
(->> mbc/stream
|
||||
(rx/filter (mbc/type? :mcp/ping))
|
||||
(rx/filter (fn [{:keys [id]}]
|
||||
(not= session-id id)))
|
||||
(rx/map handle-ping))
|
||||
|
||||
(->> mbc/stream
|
||||
(rx/filter (mbc/type? :mcp/pong))
|
||||
(rx/filter (fn [{:keys [id]}]
|
||||
(not= session-id id)))
|
||||
(rx/map handle-pong))
|
||||
|
||||
(->> mbc/stream
|
||||
(rx/filter (mbc/type? :mcp/force-disconect))
|
||||
(rx/filter (mbc/type? :mcp/force-disconnect))
|
||||
(rx/filter (fn [{:keys [id]}]
|
||||
(not= session-id id)))
|
||||
(rx/map deref)
|
||||
@ -276,9 +196,9 @@
|
||||
(->> mbc/stream
|
||||
(rx/filter (mbc/type? :mcp/enable))
|
||||
(rx/mapcat (fn [_]
|
||||
;; NOTE: we don't need an explicit
|
||||
;; connect because the plugin has
|
||||
;; auto-connect
|
||||
;; Re-init so the force-disconnect
|
||||
;; listener is set up now that MCP
|
||||
;; is enabled.
|
||||
(rx/of (update-mcp-status true)
|
||||
(init)))))
|
||||
|
||||
@ -286,7 +206,6 @@
|
||||
(rx/filter (mbc/type? :mcp/disable))
|
||||
(rx/mapcat (fn [_]
|
||||
(rx/of (update-mcp-status false)
|
||||
(init)
|
||||
(user-disconnect-mcp))))))
|
||||
|
||||
(rx/take-until stoper-s))))))
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
(let [page (dsh/lookup-page state file-id page-id)
|
||||
uris (into #{} xf:collect-file-media (:objects page))]
|
||||
(rx/merge
|
||||
(rx/of (ptk/data-event ::initialized page-id))
|
||||
(->> (rx/from uris)
|
||||
(rx/map #(http/fetch-data-uri % false))
|
||||
(rx/ignore))
|
||||
|
||||
@ -957,10 +957,10 @@
|
||||
txt/text-transform-attrs)))
|
||||
values (cond-> values
|
||||
(number? (:line-height values))
|
||||
(update :line-height str)
|
||||
(update :line-height #(str (mth/precision % 2)))
|
||||
|
||||
(number? (:letter-spacing values))
|
||||
(update :letter-spacing str))
|
||||
(update :letter-spacing #(str (mth/precision % 2))))
|
||||
|
||||
typ-id (uuid/next)
|
||||
typ (-> (if multiple?
|
||||
|
||||
@ -85,40 +85,70 @@
|
||||
(let [request (create-request file-id page-id shape-id tag)]
|
||||
(q/enqueue-unique queue request (partial render-thumbnail state file-id page-id shape-id tag))))
|
||||
|
||||
(defn- clear-thumbnail-batch
|
||||
[]
|
||||
(let [pending (volatile! nil)]
|
||||
(ptk/reify ::clear-thumbnail-batch
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [items (get state ::thumbnails-deletion-queue)]
|
||||
(when (seq items)
|
||||
(vreset! pending items))
|
||||
(dissoc state ::thumbnails-deletion-queue)))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(let [items (reduce-kv (fn [acc object-id uri]
|
||||
(when (str/starts-with? uri "blob:")
|
||||
(tm/schedule-on-idle (partial wapi/revoke-uri uri)))
|
||||
(conj acc object-id))
|
||||
[]
|
||||
@pending)]
|
||||
(l/dbg :hint "clear-thumbnail-batch" :total (count items))
|
||||
(->> (rx/from (partition-all 200 items))
|
||||
(rx/mapcat
|
||||
(fn [batch]
|
||||
(l/dbg :hint "clear-thumbnail-batch" :batch-size (count batch))
|
||||
(->> (rp/cmd! :delete-file-object-thumbnails
|
||||
{:object-ids (vec batch)})
|
||||
(rx/catch rx/empty)
|
||||
(rx/ignore))))))))))
|
||||
|
||||
(defn remove-from-deletion-queue
|
||||
"Removes an object-id from the pending deletion queue in state.
|
||||
Used by update-thumbnail to cancel a pending batched delete before
|
||||
creating a new thumbnail for the same object."
|
||||
[object-id]
|
||||
(ptk/reify ::remove-from-deletion-queue
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state ::thumbnails-deletion-queue dissoc object-id))))
|
||||
|
||||
(defn clear-thumbnail
|
||||
([file-id page-id frame-id tag]
|
||||
(clear-thumbnail file-id (thc/fmt-object-id file-id page-id frame-id tag)))
|
||||
([file-id object-id]
|
||||
(let [pending (volatile! false)]
|
||||
(ptk/reify ::clear-thumbnail
|
||||
cljs.core/IDeref
|
||||
(-deref [_] object-id)
|
||||
([_file-id object-id]
|
||||
(ptk/reify ::clear-thumbnail
|
||||
cljs.core/IDeref
|
||||
(-deref [_] object-id)
|
||||
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [uri (dm/get-in state [:thumbnails object-id])]
|
||||
(l/dbg :hint "clear-thumbnail" :object-id object-id :uri uri)
|
||||
(-> state
|
||||
(update :thumbnails
|
||||
(fn [thumbs]
|
||||
(if-let [uri (get thumbs object-id)]
|
||||
(do (vreset! pending uri)
|
||||
(dissoc thumbs object-id))
|
||||
thumbs)))
|
||||
(update :thumbnails-meta dissoc object-id)))
|
||||
(update ::thumbnails-deletion-queue assoc object-id uri)
|
||||
(update :thumbnails dissoc object-id)
|
||||
(update :thumbnails-meta dissoc object-id))))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(if-let [uri @pending]
|
||||
(do
|
||||
(l/trc :hint "clear-thumbnail" :uri uri)
|
||||
(when (str/starts-with? uri "blob:")
|
||||
(tm/schedule-on-idle (partial wapi/revoke-uri uri)))
|
||||
|
||||
(let [params {:file-id file-id
|
||||
:object-id object-id}]
|
||||
(->> (rp/cmd! :delete-file-object-thumbnail params)
|
||||
(rx/catch rx/empty)
|
||||
(rx/ignore))))
|
||||
(rx/empty)))))))
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ stream]
|
||||
(let [stopper-s (->> stream
|
||||
(rx/filter (ptk/type? ::clear-thumbnail)))]
|
||||
(->> (rx/timer 200)
|
||||
(rx/take 1)
|
||||
(rx/map (fn [_] (clear-thumbnail-batch)))
|
||||
(rx/take-until stopper-s)))))))
|
||||
|
||||
(defn assoc-thumbnail
|
||||
[object-id uri]
|
||||
@ -173,7 +203,8 @@
|
||||
:tag (or tag "frame")}]
|
||||
|
||||
(rx/merge
|
||||
(rx/of (assoc-thumbnail object-id uri))
|
||||
(rx/of (assoc-thumbnail object-id uri)
|
||||
(remove-from-deletion-queue object-id))
|
||||
(->> (rp/cmd! :create-file-object-thumbnail params)
|
||||
(rx/catch rx/empty)
|
||||
(rx/ignore))))))
|
||||
@ -305,6 +336,14 @@
|
||||
;; and interrupt any ongoing update-thumbnail process
|
||||
;; related to current frame-id
|
||||
(->> all-commits-s
|
||||
;; Ensure each clear-thumbnail event is dispatched in its
|
||||
;; own macrotask tick. Without this, multiple changes
|
||||
;; arriving on the same synchronous tick would emit
|
||||
;; several clear-thumbnail events back-to-back, causing
|
||||
;; their debounce timers (rx/take-until stopper-s) to
|
||||
;; race and potentially leave multiple clear-thumbnail-batch
|
||||
;; timers alive simultaneously.
|
||||
(rx/observe-on :async)
|
||||
(rx/mapcat (fn [[tag frame-id]]
|
||||
(rx/of (clear-thumbnail file-id page-id frame-id tag)))))
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cph]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.shape-tree :as ctt]
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
@ -155,6 +156,14 @@
|
||||
(def mcp
|
||||
(l/derived :mcp st/state))
|
||||
|
||||
(def mcp-key-expired?
|
||||
(l/derived (fn [state]
|
||||
(when-let [expires-at (some->> (:access-tokens state)
|
||||
(some #(when (= (:type %) "mcp") %))
|
||||
:expires-at)]
|
||||
(> (ct/now) expires-at)))
|
||||
st/state))
|
||||
|
||||
(def workspace-drawing
|
||||
(l/derived :workspace-drawing st/state))
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.config :as cfg]
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.render-viewer-wasm :as rwv]
|
||||
[app.main.ui.context :as muc]
|
||||
[app.main.ui.shapes.bool :as bool]
|
||||
[app.main.ui.shapes.circle :as circle]
|
||||
@ -524,32 +525,6 @@
|
||||
(for [shape shapes]
|
||||
[:& shape-wrapper {:key (dm/str (:id shape)) :shape shape}])]]]))
|
||||
|
||||
(defn render-to-canvas
|
||||
[objects canvas bounds scale object-id on-render]
|
||||
(let [width (.-width canvas)
|
||||
height (.-height canvas)
|
||||
os-canvas (js/OffscreenCanvas. width height)]
|
||||
(try
|
||||
(when (wasm.api/init-canvas-context os-canvas)
|
||||
(wasm.api/initialize-viewport
|
||||
objects scale bounds
|
||||
:background-opacity 0
|
||||
:on-render
|
||||
(fn []
|
||||
(wasm.api/render-sync-shape object-id)
|
||||
(ts/raf
|
||||
(fn []
|
||||
(let [bitmap (.transferToImageBitmap os-canvas)
|
||||
ctx2d (.getContext canvas "2d")]
|
||||
(.clearRect ctx2d 0 0 width height)
|
||||
(.drawImage ctx2d bitmap 0 0)
|
||||
(dom/set-attribute! canvas "id" (dm/str "screenshot-" object-id))
|
||||
(wasm.api/clear-canvas)
|
||||
(on-render)))))))
|
||||
(catch :default e
|
||||
(js/console.error "Error initializing canvas context:" e)
|
||||
false))))
|
||||
|
||||
(mf/defc object-wasm
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [objects object-id skip-children scale on-render] :as props}]
|
||||
@ -574,7 +549,7 @@
|
||||
(p/fmap
|
||||
(fn [ready?]
|
||||
(when ready?
|
||||
(render-to-canvas objects canvas bounds scale object-id on-render))))))))
|
||||
(rwv/render-to-canvas objects canvas bounds scale object-id on-render))))))))
|
||||
|
||||
[:canvas {:ref canvas-ref
|
||||
:width (* scale width)
|
||||
|
||||
269
frontend/src/app/main/render_viewer_wasm.cljs
Normal file
269
frontend/src/app/main/render_viewer_wasm.cljs
Normal file
@ -0,0 +1,269 @@
|
||||
;; 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.main.render-viewer-wasm
|
||||
"WASM offscreen rendering for the shared viewer (snapshot + fixed-scroll)."
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.timers :as ts]
|
||||
[app.util.webapi :as webapi]
|
||||
[goog.events :as events]
|
||||
[promesa.core :as p]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; The WASM module is a single global instance; serialize offscreen work.
|
||||
(defonce ^:private wasm-render-queue (atom (p/resolved nil)))
|
||||
|
||||
(defn- enqueue-wasm-render!
|
||||
[task]
|
||||
(let [next-p (-> @wasm-render-queue
|
||||
(p/handle (fn [_ _] (task))))]
|
||||
(reset! wasm-render-queue (p/handle next-p (fn [_ _] nil)))
|
||||
next-p))
|
||||
|
||||
(defonce ^:private viewer-snapshot
|
||||
(atom {:os-canvas nil
|
||||
:page-key nil
|
||||
:canvas-w 0
|
||||
:canvas-h 0
|
||||
:dpr 1}))
|
||||
|
||||
(defn- reset-viewer-snapshot! []
|
||||
(reset! viewer-snapshot
|
||||
{:os-canvas nil
|
||||
:page-key nil
|
||||
:canvas-w 0
|
||||
:canvas-h 0
|
||||
:dpr 1}))
|
||||
|
||||
(defn- draw-bitmap!
|
||||
[canvas os-canvas object-id vis-w vis-h finish]
|
||||
(ts/raf
|
||||
(fn []
|
||||
(let [ctx2d (.getContext canvas "2d")]
|
||||
(.clearRect ctx2d 0 0 vis-w vis-h)
|
||||
;; Draw directly from OffscreenCanvas so it can be reused across passes.
|
||||
(.drawImage ctx2d os-canvas 0 0 vis-w vis-h)
|
||||
(dom/set-attribute! canvas "id" (str "screenshot-" object-id))
|
||||
(finish)))))
|
||||
|
||||
(defn- viewer-disable-wasm-ui-overlay!
|
||||
"Workspace WASM UI (rulers + rounded viewport frame) is composited in
|
||||
`present_frame`; the viewer must not show that chrome."
|
||||
[]
|
||||
(wasm.api/set-rulers-frame-visible! false)
|
||||
(wasm.api/set-rulers-visible! false))
|
||||
|
||||
(defn- viewer-apply-layer-mask!
|
||||
[include-ids clear-fills-ids]
|
||||
(wasm.api/clear-render-include-filter!)
|
||||
(when (seq include-ids)
|
||||
(wasm.api/set-render-include-filter! include-ids))
|
||||
(doseq [id clear-fills-ids]
|
||||
(wasm.api/use-shape id)
|
||||
(wasm.api/clear-shape-fills!)))
|
||||
|
||||
(defn- viewer-restore-layer-mask!
|
||||
[page-objects clear-fills-ids]
|
||||
(wasm.api/clear-render-include-filter!)
|
||||
(doseq [id clear-fills-ids]
|
||||
(wasm.api/use-shape id)
|
||||
(wasm.api/set-shape-fills id (get-in page-objects [id :fills] []) false)))
|
||||
|
||||
(defn- viewer-do-render!
|
||||
[page-objects canvas os-canvas object-id vis-w vis-h scale size
|
||||
include-ids clear-fills-ids finish]
|
||||
(viewer-disable-wasm-ui-overlay!)
|
||||
(viewer-apply-layer-mask! include-ids clear-fills-ids)
|
||||
(wasm.api/set-viewer-viewport! scale size)
|
||||
(wasm.api/render-sync-shape object-id)
|
||||
(viewer-restore-layer-mask! page-objects clear-fills-ids)
|
||||
(draw-bitmap! canvas os-canvas object-id vis-w vis-h finish))
|
||||
|
||||
(defn- render-to-canvas*
|
||||
[objects canvas bounds scale object-id on-render]
|
||||
(p/create
|
||||
(fn [resolve _reject]
|
||||
(let [width (.-width canvas)
|
||||
height (.-height canvas)
|
||||
prev-disable @wasm/disable-request-render?
|
||||
finish (fn []
|
||||
(reset! wasm/disable-request-render? prev-disable)
|
||||
(when (fn? on-render) (on-render))
|
||||
(resolve nil))]
|
||||
(try
|
||||
(reset! wasm/disable-request-render? true)
|
||||
(let [os-canvas (js/OffscreenCanvas. width height)]
|
||||
(if (wasm.api/init-canvas-context os-canvas)
|
||||
(wasm.api/initialize-viewport
|
||||
objects scale bounds
|
||||
:background-opacity 0
|
||||
:force-sync true
|
||||
:on-render
|
||||
(fn []
|
||||
(viewer-disable-wasm-ui-overlay!)
|
||||
(wasm.api/render-sync-shape object-id)
|
||||
(draw-bitmap! canvas os-canvas object-id width height
|
||||
(fn []
|
||||
(wasm.api/clear-canvas {:lose-browser-context? false})
|
||||
(reset-viewer-snapshot!)
|
||||
(finish)))))
|
||||
(finish)))
|
||||
(catch :default e
|
||||
(js/console.error "Error initializing canvas context:" e)
|
||||
(finish)))))))
|
||||
|
||||
(defn render-to-canvas
|
||||
"One-shot WASM render into `canvas` (exports, thumbnails). Serialized globally."
|
||||
[objects canvas bounds scale object-id on-render]
|
||||
(enqueue-wasm-render!
|
||||
(fn []
|
||||
(render-to-canvas* objects canvas bounds scale object-id on-render))))
|
||||
|
||||
(defn- render-viewer-frame*
|
||||
[page-key page-objects canvas size scale object-id on-render
|
||||
{:keys [include-ids clear-fills-ids] :or {clear-fills-ids #{}}}]
|
||||
(p/create
|
||||
(fn [resolve _reject]
|
||||
(let [prev-disable @wasm/disable-request-render?
|
||||
finish (fn []
|
||||
(reset! wasm/disable-request-render? prev-disable)
|
||||
(when (fn? on-render) (on-render))
|
||||
(resolve nil))
|
||||
vis-w (.-width canvas)
|
||||
vis-h (.-height canvas)
|
||||
dpr (wasm.api/get-dpr)
|
||||
snap @viewer-snapshot
|
||||
same-page? (and (some? page-key) (identical? page-key (:page-key snap)))
|
||||
same-size? (and (= vis-w (:canvas-w snap))
|
||||
(= vis-h (:canvas-h snap))
|
||||
(= dpr (:dpr snap)))
|
||||
os (:os-canvas snap)
|
||||
do-render! (fn [os-canvas]
|
||||
(viewer-do-render! page-objects canvas os-canvas object-id
|
||||
vis-w vis-h scale size include-ids
|
||||
clear-fills-ids finish))]
|
||||
|
||||
(reset! wasm/disable-request-render? true)
|
||||
|
||||
(try
|
||||
(if (and same-page? (wasm.api/initialized?) os)
|
||||
(do
|
||||
(when-not same-size?
|
||||
(wasm.api/resize-offscreen-canvas! os vis-w vis-h)
|
||||
(swap! viewer-snapshot assoc :canvas-w vis-w :canvas-h vis-h :dpr dpr))
|
||||
(do-render! os))
|
||||
(let [os-canvas (js/OffscreenCanvas. vis-w vis-h)]
|
||||
(when (wasm.api/initialized?)
|
||||
(wasm.api/clear-canvas {:lose-browser-context? false}))
|
||||
(if (wasm.api/init-canvas-context os-canvas)
|
||||
(do
|
||||
(reset! viewer-snapshot
|
||||
{:os-canvas os-canvas
|
||||
:page-key page-key
|
||||
:canvas-w vis-w
|
||||
:canvas-h vis-h
|
||||
:dpr dpr})
|
||||
(wasm.api/initialize-viewport
|
||||
page-objects scale size
|
||||
:background-opacity 0
|
||||
:force-sync true
|
||||
:on-render #(do-render! os-canvas)))
|
||||
(finish))))
|
||||
(catch :default e
|
||||
(js/console.error "viewer-snapshot: render error" e)
|
||||
(finish)))))))
|
||||
|
||||
(defn- use-fixed-scroll-sync!
|
||||
[enabled? layer-ref]
|
||||
(mf/use-layout-effect
|
||||
(mf/deps enabled?)
|
||||
(fn []
|
||||
(when enabled?
|
||||
(let [section (dom/get-element "viewer-section")
|
||||
sync!
|
||||
(fn []
|
||||
(when-let [layer (mf/ref-val layer-ref)]
|
||||
(dom/set-style! layer "transform"
|
||||
(dm/str "translate("
|
||||
(or (dom/get-h-scroll-pos section) 0) "px, "
|
||||
(or (dom/get-scroll-pos section) 0) "px)"))))]
|
||||
(when section
|
||||
(sync!)
|
||||
(let [key (events/listen section "scroll" (fn [_] (sync!)))]
|
||||
#(events/unlistenByKey key))))))))
|
||||
|
||||
(defn- use-viewer-dpr-key
|
||||
"Bump a counter when browser zoom changes devicePixelRatio so WASM canvases
|
||||
are resized like the workspace viewport."
|
||||
[]
|
||||
(let [dpr-key (mf/use-state 0)]
|
||||
(mf/use-effect
|
||||
(mf/deps [])
|
||||
(fn []
|
||||
(webapi/on-dpr-change (fn [_] (swap! dpr-key inc)))))
|
||||
@dpr-key))
|
||||
|
||||
(defn- use-viewer-wasm-layers!
|
||||
[page-id page-objects size scale frame-id not-fixed-ref fixed-ref
|
||||
not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids delta dpr-key]
|
||||
;; The hot-areas SVG shifts every object by `-(size + delta)` so the frame
|
||||
;; `selrect` lands flush against the overlay snap side, ignoring the extra
|
||||
;; padding reserved for shadows/blur/strokes. Bake the same `delta` into the
|
||||
;; WASM view origin so the rendered canvas aligns with that SVG (otherwise it
|
||||
;; appears offset by the shadow margin).
|
||||
(let [render-size (-> size
|
||||
(update :x + (:x delta 0))
|
||||
(update :y + (:y delta 0)))]
|
||||
(mf/use-layout-effect
|
||||
(mf/deps page-id page-objects render-size scale frame-id dpr-key
|
||||
not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids)
|
||||
(fn []
|
||||
(when (get page-objects frame-id)
|
||||
(->> @wasm.api/module
|
||||
(p/fmap
|
||||
(fn [ready?]
|
||||
(when ready?
|
||||
(let [not-fixed-canvas (mf/ref-val not-fixed-ref)
|
||||
fixed-canvas (mf/ref-val fixed-ref)
|
||||
passes
|
||||
(cond-> []
|
||||
not-fixed-canvas
|
||||
(conj {:canvas not-fixed-canvas
|
||||
:opts (cond-> {}
|
||||
(seq not-fixed-include-ids)
|
||||
(assoc :include-ids not-fixed-include-ids))})
|
||||
|
||||
(and fixed-canvas (seq fixed-include-ids))
|
||||
(conj {:canvas fixed-canvas
|
||||
:opts (cond-> {:include-ids fixed-include-ids}
|
||||
(seq fixed-clear-fills-ids)
|
||||
(assoc :clear-fills-ids fixed-clear-fills-ids))}))]
|
||||
(when (seq passes)
|
||||
(enqueue-wasm-render!
|
||||
(fn []
|
||||
(reduce (fn [chain {:keys [canvas opts]}]
|
||||
(p/then chain
|
||||
#(render-viewer-frame* page-id page-objects
|
||||
canvas render-size scale frame-id
|
||||
nil opts)))
|
||||
(p/resolved nil)
|
||||
passes))))))))))))))
|
||||
|
||||
(defn use-viewer-wasm-viewport!
|
||||
"WASM render passes and fixed-scroll DOM sync for the viewer viewport."
|
||||
[page-id page-objects size scale frame-id
|
||||
not-fixed-ref fixed-ref fixed-scroll-layer-ref
|
||||
not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids delta]
|
||||
(use-fixed-scroll-sync! (some? fixed-scroll-layer-ref) fixed-scroll-layer-ref)
|
||||
(let [dpr-key (use-viewer-dpr-key)]
|
||||
(use-viewer-wasm-layers! page-id page-objects size scale frame-id
|
||||
not-fixed-ref fixed-ref
|
||||
not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids
|
||||
delta dpr-key)))
|
||||
@ -27,7 +27,7 @@
|
||||
[app.main.ui.nitrate.entry :as nitrate-entry]
|
||||
[app.main.ui.notifications :as notifications]
|
||||
[app.main.ui.onboarding.questions :refer [questions-modal]]
|
||||
[app.main.ui.onboarding.team-choice :refer [onboarding-team-modal]]
|
||||
[app.main.ui.onboarding.team-choice :refer [onboarding-team-modal*]]
|
||||
[app.main.ui.releases :refer [release-notes-modal]]
|
||||
[app.main.ui.static :as static]
|
||||
[app.util.dom :as dom]
|
||||
@ -238,14 +238,14 @@
|
||||
#_[:& app.main.ui.releases/release-notes-modal {:version "2.5"}]
|
||||
#_[:& app.main.ui.onboarding/onboarding-templates-modal]
|
||||
#_[:& app.main.ui.onboarding/onboarding-modal]
|
||||
#_[:& app.main.ui.onboarding.team-choice/onboarding-team-modal]
|
||||
#_[:> app.main.ui.onboarding.team-choice/onboarding-team-modal*]
|
||||
|
||||
(cond
|
||||
show-question-modal?
|
||||
[:& questions-modal]
|
||||
|
||||
show-team-modal?
|
||||
[:& onboarding-team-modal {:go-to-team true}]
|
||||
[:> onboarding-team-modal* {:go-to-team true}]
|
||||
|
||||
show-release-modal?
|
||||
[:& release-notes-modal {:version (:main cf/version)}])
|
||||
@ -272,7 +272,7 @@
|
||||
[:& questions-modal]
|
||||
|
||||
show-team-modal?
|
||||
[:& onboarding-team-modal {:go-to-team false}]
|
||||
[:> onboarding-team-modal* {:go-to-team false}]
|
||||
|
||||
show-release-modal?
|
||||
[:& release-notes-modal {:version (:main cf/version)}]))
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
[app.main.data.auth :as da]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.auth.login :refer [login-page]]
|
||||
[app.main.ui.auth.recovery :refer [recovery-page]]
|
||||
[app.main.ui.auth.recovery :refer [recovery-page*]]
|
||||
[app.main.ui.auth.recovery-request :refer [recovery-request-page]]
|
||||
[app.main.ui.auth.register :refer [register-page* register-success-page* register-validate-page* terms-register*]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
@ -69,7 +69,7 @@
|
||||
[:& recovery-request-page]
|
||||
|
||||
:auth-recovery
|
||||
[:& recovery-page {:params params}])
|
||||
[:> recovery-page* {:params params}])
|
||||
|
||||
(when (= section :auth-register)
|
||||
[:> terms-register*])]]))
|
||||
|
||||
@ -44,8 +44,8 @@
|
||||
:password (get-in @form [:clean-data :password-2])}]
|
||||
(st/emit! (du/recover-profile (with-meta params mdata)))))
|
||||
|
||||
(mf/defc recovery-form
|
||||
[{:keys [params] :as props}]
|
||||
(mf/defc recovery-form*
|
||||
[{:keys [params]}]
|
||||
(let [form (fm/use-form :schema schema:recovery-form
|
||||
:initial params)]
|
||||
|
||||
@ -73,13 +73,13 @@
|
||||
|
||||
;; --- Recovery Request Page
|
||||
|
||||
(mf/defc recovery-page
|
||||
[{:keys [params] :as props}]
|
||||
(mf/defc recovery-page*
|
||||
[{:keys [params]}]
|
||||
[:div {:class (stl/css :auth-form-wrapper)}
|
||||
[:h1 {:class (stl/css :auth-title)} "Forgot your password?"]
|
||||
[:div {:class (stl/css :auth-subtitle)} "Please enter your new password"]
|
||||
[:hr {:class (stl/css :separator)}]
|
||||
[:& recovery-form {:params params}]
|
||||
[:> recovery-form* {:params params}]
|
||||
|
||||
[:div {:class (stl/css :links)}
|
||||
[:div {:class (stl/css :go-back)}
|
||||
|
||||
@ -557,6 +557,32 @@
|
||||
(dom/stop-propagation event)
|
||||
(swap! items (fn [items] (if (c/empty? items) items (pop items)))))))))
|
||||
|
||||
on-paste
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(let [paste-data (-> event .-clipboardData (.getData "text"))]
|
||||
(when (and (string? paste-data)
|
||||
(re-find #"[,\s]" paste-data))
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
|
||||
;; Mark as touched
|
||||
(swap! form assoc-in [:touched input-name] true)
|
||||
|
||||
;; Split pasted text by commas and/or whitespace, add each valid part
|
||||
(let [parts (->> (str/split paste-data #",|\s+")
|
||||
(map str/trim)
|
||||
(remove str/empty?))]
|
||||
(doseq [part parts]
|
||||
(when (valid-item-fn part)
|
||||
(swap! items conj-dedup {:text part
|
||||
:valid true
|
||||
:caution (caution-item-fn part)})))
|
||||
|
||||
;; Reset input value and mark as untouched after successful paste
|
||||
(reset! value "")
|
||||
(swap! form assoc-in [:touched input-name] false))))))
|
||||
|
||||
on-blur
|
||||
(mf/use-fn
|
||||
(fn [_]
|
||||
@ -590,6 +616,7 @@
|
||||
:on-focus on-focus
|
||||
:on-blur on-blur
|
||||
:on-key-down on-key-down
|
||||
:on-paste on-paste
|
||||
:value @value
|
||||
:on-change on-change
|
||||
:placeholder (when empty? label)}]
|
||||
|
||||
@ -12,10 +12,12 @@
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.data.dashboard :as dd]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.nitrate :as dnt]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
|
||||
[app.main.ui.dashboard.grid :refer [grid*]]
|
||||
[app.main.ui.dashboard.subscription :refer [get-subscription-type]]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
@ -239,13 +241,15 @@
|
||||
|
||||
;; Calculate deletion days based on team subscription
|
||||
deletion-days
|
||||
(let [subscription (get team :subscription)
|
||||
sub-type (get subscription :type)
|
||||
sub-status (get subscription :status)
|
||||
canceled? (contains? #{"canceled" "unpaid"} sub-status)]
|
||||
(let [profile (mf/deref refs/profile)
|
||||
subscription-type (get-subscription-type (:subscription team))
|
||||
nitrate-type (get-in profile [:subscription :type])
|
||||
nitrate-active? (dnt/is-valid-license? profile)]
|
||||
(cond
|
||||
(and (= "unlimited" sub-type) (not canceled?)) 30
|
||||
(and (= "enterprise" sub-type) (not canceled?)) 90
|
||||
(and nitrate-active?
|
||||
(contains? #{"enterprise" "nitrate"} nitrate-type)) 90
|
||||
(= subscription-type "unlimited") 30
|
||||
(= subscription-type "enterprise") 90
|
||||
:else 7))
|
||||
|
||||
on-delete-all
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user