mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 13:20:03 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d8f0bb125 | ||
|
|
ea8a7dd3d8 | ||
|
|
65b578a549 |
No files matched your search
@@ -20,17 +20,6 @@ Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
|
||||
is the authoritative source for the commit message format, the emoji menu,
|
||||
subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
|
||||
|
||||
## Iron Rules (non-negotiable)
|
||||
|
||||
1. **Wrap every body line at 76 characters or fewer.** Count characters, do
|
||||
not eyeball. Exceptions: `Signed-off-by:` / `AI-assisted-by:` trailers and
|
||||
lines carrying a URL. This is the rule agents skip most often.
|
||||
2. **Subject ≤70 chars**, imperative, capitalized, no trailing period.
|
||||
3. **Blank line between subject and body.**
|
||||
4. **Run `./scripts/check-commit` and require exit code 0.** It mechanically
|
||||
checks rules 1–3. A non-zero exit is a hard blocker: fix the message and
|
||||
re-commit. Never report the commit as done with a failing checker.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Stage the files** specified by the calling context. Do not ask for
|
||||
@@ -40,18 +29,12 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
|
||||
that does not match the stated intent, **STOP** and tell the user before
|
||||
committing.
|
||||
3. Draft the message following the format in the memory doc, wrapping the body
|
||||
at 76 characters per line, and run:
|
||||
at 72 characters per line, and run:
|
||||
```bash
|
||||
git commit -m "<subject>" -m "<body>"
|
||||
```
|
||||
(or `git commit -F -` if the body has unusual characters).
|
||||
4. **Verify the message with the checker**:
|
||||
```bash
|
||||
./scripts/check-commit
|
||||
```
|
||||
If it fails, amend the message (`git commit --amend`) until it passes. Do
|
||||
not finish with a failing checker.
|
||||
5. The `AI-assisted-by` trailer value is provided by the calling context — use
|
||||
4. The `AI-assisted-by` trailer value is provided by the calling context — use
|
||||
it verbatim.
|
||||
|
||||
## Constraints
|
||||
@@ -62,4 +45,3 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
|
||||
- Do not amend a commit you did not create in this session, unless explicitly asked.
|
||||
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
|
||||
- Do not add untracked files that were not created in this session.
|
||||
- Do not skip the `scripts/check-commit` verification step (Iron Rule 4).
|
||||
+237
-62
@@ -1,11 +1,13 @@
|
||||
---
|
||||
name: planner
|
||||
description: Read-only planning and architecture analysis — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan, suggested save path and the next steps.
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps.
|
||||
---
|
||||
|
||||
# Planner
|
||||
|
||||
Produce a plan that another engineer or agent can execute without guessing.
|
||||
Read-only senior software architect role for Penpot. Produces structured
|
||||
implementation plans with task breakdowns that engineers or other agents can
|
||||
execute. Never writes or modifies code.
|
||||
|
||||
## When to Use
|
||||
|
||||
@@ -19,7 +21,24 @@ Produce a plan that another engineer or agent can execute without guessing.
|
||||
- A task feels too large or vague to start.
|
||||
- Work needs to be parallelized across multiple agents or sessions.
|
||||
|
||||
Do not use for a small change with obvious scope or an existing executable plan.
|
||||
Do **not** use this skill to actually implement anything — it is read-only.
|
||||
|
||||
**When NOT to use:** Single-file changes with obvious scope, or when the spec
|
||||
already contains well-defined tasks.
|
||||
|
||||
## Role
|
||||
|
||||
You help users understand the Penpot codebase, design solutions, and produce
|
||||
implementation plans that other agents or developers can execute. The plan
|
||||
tells them what to build and how to verify it, task by task.
|
||||
|
||||
The implementer reads the project's agent docs (`AGENTS.md`, project memories
|
||||
such as `mem:critical-info`, `mem:testing`, and each module's core memory)
|
||||
before working. Reference those memories instead of re-explaining tooling,
|
||||
conventions, or test design — explain in the plan only what they do not cover.
|
||||
|
||||
Do **not** suggest commit messages or commit names anywhere in your plans or
|
||||
responses — committing is the implementer's responsibility.
|
||||
|
||||
## CRITICAL: Required Reading Before Planning
|
||||
|
||||
@@ -36,36 +55,67 @@ Before drafting any plan, work through the project's own guidance:
|
||||
|
||||
Skipping this step is the #1 cause of incorrect or incomplete plans.
|
||||
|
||||
## Constraints
|
||||
---
|
||||
|
||||
- You are **analysis-only** — never create, edit, or delete source code. The
|
||||
only file you may write is the plan itself, and only when the command or
|
||||
user explicitly instructs you to save it.
|
||||
- You do **not** run builds, tests, linters, or any commands that modify state.
|
||||
- You do **not** create git commits or interact with version control.
|
||||
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||
`find`, `cat`, `bat`).
|
||||
- Your output is a structured plan or analysis, ready for handoff to an
|
||||
engineer agent or developer.
|
||||
## The Planning Process
|
||||
|
||||
## Planning Process
|
||||
### Phase 1: Architecture Analysis
|
||||
|
||||
1. Define the problem, desired outcome, constraints, and exclusions.
|
||||
2. Trace the current behavior through the affected modules.
|
||||
3. Map dependencies and choose an implementation order that builds foundations
|
||||
before their consumers.
|
||||
4. Identify open product or architecture decisions. Resolve implementation
|
||||
details from existing conventions when they do not affect public behavior.
|
||||
5. Identify edge cases, security and data risks, performance bounds, breaking
|
||||
changes, and external dependencies.
|
||||
6. Split the work into small, ordered tasks. Prefer complete testable slices
|
||||
over unrelated layer-wide batches. Apply DRY and KISS to the proposed
|
||||
implementation.
|
||||
7. Define exact acceptance criteria and verification for every task.
|
||||
8. Add a checkpoint after every two or three tasks in a longer plan.
|
||||
9. State which tasks can run in parallel and which must remain sequential.
|
||||
1. Read the spec, requirements, or feature request.
|
||||
2. Analyze the codebase architecture and identify affected modules.
|
||||
3. Read project conventions (starting with `critical-info` and module core
|
||||
memories) before drafting.
|
||||
4. Map dependencies between components (see the dependency graph in
|
||||
`critical-info`).
|
||||
5. Identify risks, edge cases, performance implications, and breaking changes.
|
||||
|
||||
## Task Format
|
||||
### Phase 2: Task Breakdown
|
||||
|
||||
#### Identify the Dependency Graph
|
||||
|
||||
Map what depends on what, following the monorepo's module dependency graph:
|
||||
|
||||
```
|
||||
common (shared types, schemas — no deps)
|
||||
│
|
||||
├── backend (depends common)
|
||||
│ ├── RPC handlers
|
||||
│ └── persistence / migrations
|
||||
│
|
||||
├── frontend (depends common, render-wasm)
|
||||
│ ├── UI components
|
||||
│ └── state / API integration
|
||||
│
|
||||
├── exporter (depends common)
|
||||
│
|
||||
└── render-wasm (consumed by frontend)
|
||||
```
|
||||
|
||||
Implementation order follows the dependency graph bottom-up: build shared
|
||||
foundations first, then layer consumers on top.
|
||||
|
||||
#### Slice Vertically
|
||||
|
||||
Instead of building all of common, then all of backend, then all of frontend —
|
||||
build one complete feature path at a time:
|
||||
|
||||
**Bad (horizontal slicing):**
|
||||
```
|
||||
Task 1: Build all common types
|
||||
Task 2: Build all backend handlers
|
||||
Task 3: Build all frontend components
|
||||
```
|
||||
|
||||
**Good (vertical slicing):**
|
||||
```
|
||||
Task 1: common data types + schema ← foundation
|
||||
Task 2: backend RPC handler + persistence
|
||||
Task 3: frontend UI component + API integration
|
||||
```
|
||||
|
||||
Each vertical slice delivers working, testable functionality.
|
||||
|
||||
#### Write Tasks
|
||||
|
||||
Each task follows this structure:
|
||||
|
||||
@@ -102,16 +152,17 @@ implementation. Omit when the task is mechanical.
|
||||
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
|
||||
```
|
||||
|
||||
Use commands from `mem:testing` and affected module memories. Never substitute
|
||||
generic text such as "run the tests" when the project documents an exact
|
||||
command.
|
||||
Replace "module-specific test command" with the actual commands for the module
|
||||
(e.g. `clojure -M:dev:test` for backend/common,
|
||||
`npx shadow-cljs compile test && npx karma start` for frontend, or the
|
||||
commands noted in the module's core memory).
|
||||
|
||||
When possible, design each task with TDD in mind: acceptance criteria double as a test
|
||||
list, and the natural first step of the task is writing those tests before the
|
||||
implementation. Some tasks resist this (config, migrations, pure wiring) — for those, keep
|
||||
the usual verification steps.
|
||||
When possible, design each task with TDD in mind: acceptance criteria double
|
||||
as a test list, and the natural first step of the task is writing those tests
|
||||
before the implementation. Some tasks resist this (config, migrations, pure
|
||||
wiring) — for those, keep the usual verification steps.
|
||||
|
||||
## Task Sizing
|
||||
#### Estimate Scope
|
||||
|
||||
| Size | Files | Scope | Example |
|
||||
|------|-------|-------|---------|
|
||||
@@ -121,11 +172,16 @@ the usual verification steps.
|
||||
| **L** | 5-8 | Multi-component feature | Search with filtering and pagination |
|
||||
| **XL** | 8+ | **Too large — break it down further** | — |
|
||||
|
||||
Split a task when it contains independent outcomes, spans unrelated systems, or cannot be
|
||||
completed and verified in one focused session (if a task is XL, it should be broken into
|
||||
smaller tasks; agents perform best on S and M tasks).
|
||||
If a task is XL, it should be broken into smaller tasks. Agents perform best
|
||||
on S and M tasks.
|
||||
|
||||
## Task order and checkpoints
|
||||
**When to break a task down further:**
|
||||
- It would take more than one focused session
|
||||
- You cannot describe the acceptance criteria in 3 or fewer bullet points
|
||||
- It touches two or more independent subsystems
|
||||
- You find yourself writing "and" in the task title (a sign it is two tasks)
|
||||
|
||||
#### Order and Checkpoints
|
||||
|
||||
Arrange tasks so that:
|
||||
|
||||
@@ -141,42 +197,152 @@ Add explicit checkpoints with the relevant module commands:
|
||||
- [ ] Relevant tests pass (module-specific command).
|
||||
- [ ] The relevant build or compilation passes, if applicable.
|
||||
- [ ] The core flow works end-to-end.
|
||||
- [ ] Review with human before proceeding.
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Analyze the codebase architecture and identify affected modules.
|
||||
- Read project conventions before drafting (start with `critical-info` and
|
||||
affected module core memories).
|
||||
- Break down complex features or bugs into atomic, actionable steps.
|
||||
- Propose solutions with clear rationale, trade-offs, and sequencing.
|
||||
- Identify risks, edge cases, performance implications, and breaking changes.
|
||||
- Apply DRY and KISS principles to the proposed implementation.
|
||||
- Define a testing strategy aligned with each affected module's tooling.
|
||||
- Every task must have acceptance criteria and verification steps.
|
||||
- Checkpoints must exist after every 2-3 tasks.
|
||||
|
||||
## Constraints
|
||||
|
||||
- You are **analysis-only** — never create, edit, or delete source code. The
|
||||
only file you may write is the plan itself, and only when the command or
|
||||
user explicitly instructs you to save it.
|
||||
- You do **not** run builds, tests, linters, or any commands that modify state.
|
||||
- You do **not** create git commits or interact with version control.
|
||||
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||
`find`, `cat`, `bat`).
|
||||
- Your output is a structured plan or analysis, ready for handoff to an
|
||||
engineer agent or developer.
|
||||
|
||||
## Output Format
|
||||
|
||||
The plan is always delivered in the response so the user sees it regardless
|
||||
of which agent is running the skill. File writes follow `Constraints` —
|
||||
by default announce the path instead of writing.
|
||||
of which agent is running the skill. By default you never write the plan file;
|
||||
announce the path instead. Write the file only when the command or user
|
||||
explicitly instructs you to save it — and then only that file.
|
||||
|
||||
Announce the save path `.agents/plans/YYYY-MM-DD-<slug>.md` (today's date,
|
||||
lowercase hyphen-separated slug, e.g. `2026-09-10-add-batch-get-profiles`;
|
||||
an explicit user path wins).
|
||||
Announce the suggested save path:
|
||||
|
||||
```
|
||||
.agents/plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||
```
|
||||
|
||||
Use today's date in the user's local timezone. The `<plan-one-line-title>`
|
||||
slug is lowercase, hyphen-separated, and a short summary of the task
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly
|
||||
provides a target file path, announce that path instead of the default.
|
||||
|
||||
End the response by suggesting the next steps: `/review-plan` to get a second
|
||||
opinion on the plan and `/implement-plan` to execute it.
|
||||
|
||||
### Plan Structure
|
||||
|
||||
Use this document shape:
|
||||
### Plan Document Template
|
||||
|
||||
```markdown
|
||||
# Plan: Title
|
||||
# Plan: [Feature/Project Name]
|
||||
|
||||
## Context
|
||||
## Affected Modules
|
||||
## Architecture Decisions
|
||||
## Risks and Considerations
|
||||
## Approach
|
||||
## Task List
|
||||
## Verification and Testing
|
||||
## Parallelization
|
||||
## Open Questions
|
||||
```
|
||||
[One paragraph: what is the problem or feature request? Why is it needed?]
|
||||
|
||||
Omit empty sections only when they do not apply. Every implementation task
|
||||
still requires acceptance criteria, verification, dependencies, likely files,
|
||||
and scope.
|
||||
## Affected Modules
|
||||
[Which modules of the monorepo are involved? Reference module paths and any
|
||||
`mem:` memories that were consulted.]
|
||||
|
||||
## Architecture Decisions
|
||||
- [Key decision 1 and rationale]
|
||||
- [Key decision 2 and rationale]
|
||||
|
||||
## Risks & Considerations
|
||||
[Edge cases, performance implications, breaking changes, migration concerns,
|
||||
security implications.]
|
||||
|
||||
## Approach
|
||||
[A short strategy summary: 3-5 sentences describing the overall approach and
|
||||
the shape of the dependency graph (what depends on what, what gets built
|
||||
first). High-level only — the task-by-task detail lives in the Task List.]
|
||||
|
||||
## Task List
|
||||
|
||||
Each task uses the full task structure defined in
|
||||
[Write Tasks](#write-tasks) — description, rationale, acceptance criteria,
|
||||
verification, dependencies, files, estimated scope, and optional code sketch.
|
||||
Never reduce a task to a one-line checkbox; the plan must be self-contained
|
||||
and executable without other context.
|
||||
|
||||
Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks
|
||||
into phases, milestones, or sprints; ordering and dependencies are already
|
||||
captured per task. Insert a checkpoint after every 2-3 tasks.
|
||||
|
||||
## Task 1: [Short descriptive title]
|
||||
|
||||
**Description:** [What this task accomplishes.]
|
||||
|
||||
**Rationale:** [Why this approach over the alternatives.]
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] [Specific, testable condition]
|
||||
|
||||
**Verification:**
|
||||
- [ ] Relevant tests pass (module-specific command).
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files likely touched:**
|
||||
- `path/to/file`
|
||||
|
||||
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
|
||||
|
||||
**Code sketch (optional):** [Short contract-level example, only if the shape
|
||||
is non-obvious.]
|
||||
|
||||
## Task 2: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
## Task 3: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
### Checkpoint: After Tasks 1-3
|
||||
- [ ] Relevant tests pass (module-specific command).
|
||||
- [ ] The relevant build or compilation passes, if applicable.
|
||||
- [ ] The core flow works end-to-end.
|
||||
- [ ] Review with human before proceeding.
|
||||
|
||||
## Task 4: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
## Task 5: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
## Verification & Testing
|
||||
[How to verify each task and the whole plan: the project's real test, lint,
|
||||
build, and run commands (extracted during Required Reading), coverage
|
||||
expectations, and manual checks. Consult each module's core memory for the
|
||||
exact commands.]
|
||||
|
||||
## Parallelization Opportunities
|
||||
- **Safe to parallelize:** Independent feature slices across separate
|
||||
modules, tests for already-implemented features, documentation
|
||||
- **Must be sequential:** Shared common schema changes, database migrations
|
||||
- **Needs coordination:** Features that share a contract (define the contract
|
||||
first, then parallelize)
|
||||
|
||||
## Open Questions
|
||||
- [Question needing human input]
|
||||
```
|
||||
|
||||
When the plan is purely analytical (e.g. a code review or feasibility study
|
||||
with no implementation), skip the **Approach** and **Task List** sections and
|
||||
@@ -191,6 +357,15 @@ lead with **Findings** instead, keeping the rest of the structure.
|
||||
| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. |
|
||||
| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Delivering prose without a task breakdown
|
||||
- Tasks that say "implement the feature" without acceptance criteria
|
||||
- No verification steps in the plan
|
||||
- All tasks are XL-sized
|
||||
- No checkpoints between tasks
|
||||
- Dependency order isn't considered
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before delivering the plan, confirm:
|
||||
|
||||
@@ -357,39 +357,6 @@ Insert the new version section right after the `# CHANGELOG` header (before
|
||||
the previous version entry). Use the `edit` tool with enough context to make
|
||||
a unique match.
|
||||
|
||||
### 8b. Propose and populate the `:rocket: Epics and highlights` subsection
|
||||
|
||||
After inserting the version section, proactively create or populate the
|
||||
`### :rocket: Epics and highlights` subsection. This section surfaces the
|
||||
most impactful changes for self-hosted users checking for updates.
|
||||
|
||||
**When to create:** If the version section does not already have a
|
||||
`### :rocket: Epics and highlights` subsection, create one. Place it before
|
||||
`### :sparkles:` (matching existing order in CHANGES.md).
|
||||
|
||||
**How to identify highlights:** Review the `:sparkles:` entries for the
|
||||
version and select 2–5 of the most impactful/user-visible ones. Criteria:
|
||||
- New user-visible features (not internal refactors)
|
||||
- Significant capability additions
|
||||
- Items that create "FOMO" for self-hosted users on older versions
|
||||
|
||||
**Use release notes as hints:** Check
|
||||
`frontend/src/app/main/ui/releases/v2_<MINOR>.cljs` for the corresponding
|
||||
version. The slide titles and feature descriptions there are curated
|
||||
marketing content indicating what the team considers highlight-worthy. Match
|
||||
those themes to changelog entries. Treat these files as optional hints — they
|
||||
may not exist for every version.
|
||||
|
||||
**Format requirement:** Every `:rocket:` entry MUST follow the standard
|
||||
changelog format with issue/PR references:
|
||||
```
|
||||
- <description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
```
|
||||
An entry without issue AND PR references is a highlight gap (warning, not an anomaly).
|
||||
|
||||
**Preserve existing entries:** If the `:rocket:` section already exists from
|
||||
a prior run, preserve its entries. Do not remove or rewrite them.
|
||||
|
||||
### 9. Verify
|
||||
|
||||
Read the top of `CHANGES.md` and confirm:
|
||||
@@ -501,8 +468,9 @@ Markdown viewer.
|
||||
## What is an anomaly
|
||||
|
||||
**An anomaly is a milestone-mismatch between an issue and its referenced
|
||||
PR.** There are two anomaly types, plus two highlight gaps (warnings that
|
||||
do not count toward the anomaly total):
|
||||
PR.** It indicates that the changelog claim "this issue is fixed by this PR,
|
||||
all in milestone M" is inconsistent with the actual milestone assignments.
|
||||
There are exactly two types:
|
||||
|
||||
1. **Issue is in the milestone, but its referenced PR is in a different
|
||||
milestone (or has no milestone).** The changelog claims a fix in this
|
||||
@@ -518,13 +486,6 @@ do not count toward the anomaly total):
|
||||
PR that closes an issue with no milestone references an issue from
|
||||
another (probably private) project; that is expected and the issue is
|
||||
not part of this changelog. Do not report it.
|
||||
3. **missing-highlights (gap):** A released X.Y.0 version section has no
|
||||
`### :rocket: Epics and highlights` subsection. Patches (X.Y.Z) never
|
||||
carry highlights, so only minors/majors are checked.
|
||||
4. **missing-highlight-reference (gap):** A `:rocket:` entry lacks the
|
||||
required issue AND PR references. Every highlight entry must follow the
|
||||
standard changelog format with `[#ISSUE]` and `(PR: [#PR])` links
|
||||
(multi-PR `(PR: [#A](...), [#B](...))` accepted).
|
||||
|
||||
**Anything else is not an anomaly.** Other discrepancies (exclusion
|
||||
labels on in-changelog issues, missing valid issues, unmerged PR
|
||||
@@ -692,40 +653,6 @@ for pr_num in sorted(changelog_prs):
|
||||
'issue_milestone': issue_ms, # may be None
|
||||
})
|
||||
|
||||
# --- Type C: released X.Y.0 version sections without :rocket: subsection ---
|
||||
# Patches (X.Y.Z with Z != 0) never carry :rocket: by design — only minors/majors (X.Y.0).
|
||||
anomalies_c = [] # list of version strings
|
||||
rocket_heading_re = re.compile(r'^### :rocket:', re.MULTILINE)
|
||||
version_sections = re.split(r'(?=^## \d+\.\d+\.\d+)', content, flags=re.MULTILINE)
|
||||
for vs in version_sections:
|
||||
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
|
||||
if not m: continue
|
||||
ver, suffix = m.group(1), m.group(2)
|
||||
if 'unreleased' in suffix.lower(): continue
|
||||
if ver.split('.')[2] != '0': continue
|
||||
if not rocket_heading_re.search(vs):
|
||||
anomalies_c.append(ver)
|
||||
|
||||
# --- Type D: :rocket: entries without issue AND PR references ---
|
||||
# Both are required: `[#ISSUE](.../issues/N)` and `(PR: [#PR](.../pull/M))`.
|
||||
# Multi-PR entries `(PR: [#A](...), [#B](...))` are accepted.
|
||||
anomalies_d = [] # list of dicts: {version, line}
|
||||
issue_ref_re = re.compile(r'\[#\d+\]\(https://github\.com/penpot/penpot/issues/\d+\)')
|
||||
pr_ref_re = re.compile(r'\(PR:\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\)(\s*,\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\))*\)')
|
||||
for vs in version_sections:
|
||||
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
|
||||
if not m: continue
|
||||
ver = m.group(1)
|
||||
rocket_match = rocket_heading_re.search(vs)
|
||||
if not rocket_match: continue
|
||||
# Extract the :rocket: subsection body (up to next ### or ##)
|
||||
rocket_body = vs[rocket_match.end():]
|
||||
rocket_body = re.split(r'(?m)^#{2,3}\s', rocket_body)[0]
|
||||
for line in rocket_body.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('- ') and not (issue_ref_re.search(line) and pr_ref_re.search(line)):
|
||||
anomalies_d.append({'version': ver, 'line': line[:100]})
|
||||
|
||||
# --- Write report ---
|
||||
def fmt_ms(ms):
|
||||
return ms if ms else "_none_"
|
||||
@@ -737,17 +664,13 @@ with open(OUTPUT, 'w') as f:
|
||||
|
||||
n_a = len(anomalies_a)
|
||||
n_b = len(anomalies_b)
|
||||
n_c = len(anomalies_c)
|
||||
n_d = len(anomalies_d)
|
||||
|
||||
f.write('## Summary\n\n')
|
||||
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
|
||||
f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n')
|
||||
f.write(f'- **Total anomalies:** {n_a + n_b}\n')
|
||||
f.write(f'- **Released X.Y.0 version missing :rocket: section (gap):** {n_c}\n')
|
||||
f.write(f'- **:rocket: entry without issue AND PR references (gap):** {n_d}\n\n')
|
||||
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
|
||||
|
||||
# --- Anomalies section (milestone mismatches only) ---
|
||||
# --- Anomalies section ---
|
||||
if n_a or n_b:
|
||||
f.write('## Anomalies\n\n')
|
||||
f.write('These are milestone mismatches between an issue in the changelog '
|
||||
@@ -786,37 +709,9 @@ with open(OUTPUT, 'w') as f:
|
||||
badge = '🔴' if e['issue_milestone'] is None else '⚠️'
|
||||
f.write(f' - {badge} Closing {issue_link(e["issue"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
|
||||
f.write('\n')
|
||||
|
||||
else:
|
||||
f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n')
|
||||
|
||||
# --- Highlight gaps (warnings, not anomalies) ---
|
||||
if n_c or n_d:
|
||||
f.write('## Highlight gaps\n\n')
|
||||
f.write('These are warnings, not anomalies: they do not affect the '
|
||||
'milestone-mismatch total above. They track `:rocket:` coverage '
|
||||
'across all released X.Y.0 versions. Historical entries (e.g. '
|
||||
'Taiga links) predate the current reference convention and are '
|
||||
'expected to appear here.\n\n')
|
||||
|
||||
if n_c:
|
||||
f.write(f'### Released X.Y.0 version missing :rocket: section\n\n')
|
||||
f.write('These released minors/majors have no `### :rocket: Epics and highlights` subsection. '
|
||||
'Add highlights to help self-hosted users understand what they are missing.\n\n')
|
||||
for ver in anomalies_c:
|
||||
f.write(f'- Version **{ver}**\n')
|
||||
f.write('\n')
|
||||
|
||||
if n_d:
|
||||
f.write(f'### :rocket: entry without issue AND PR references\n\n')
|
||||
f.write('These highlight entries lack the required issue AND PR references. '
|
||||
'Add `[#ISSUE](...)` and `(PR: [#PR](...))` links.\n\n')
|
||||
for d in anomalies_d:
|
||||
f.write(f'- **{d["version"]}**: `{d["line"]}`\n')
|
||||
f.write('\n')
|
||||
elif not (n_a or n_b):
|
||||
f.write('✅ No highlight gaps found. All released X.Y.0 versions have properly referenced :rocket: entries.\n\n')
|
||||
|
||||
# --- Context ---
|
||||
f.write('---\n\n')
|
||||
f.write('## Context\n\n')
|
||||
@@ -831,7 +726,8 @@ print(f"Anomaly report written to {OUTPUT}")
|
||||
PYEOF
|
||||
```
|
||||
|
||||
This generates `CHANGES-ISSUES.md` containing anomalies and highlight gaps:
|
||||
This generates `CHANGES-ISSUES.md` containing **only the anomalies** —
|
||||
milestone mismatches between issues and their referenced PRs:
|
||||
|
||||
1. **Issue in milestone, referenced PR in different milestone or no milestone** —
|
||||
the changelog claims a fix here, but the PR is released elsewhere.
|
||||
@@ -840,13 +736,6 @@ This generates `CHANGES-ISSUES.md` containing anomalies and highlight gaps:
|
||||
(An issue with *no* milestone belongs to another, probably private,
|
||||
project — milestones are only required on the "Main" project — so it is
|
||||
neither an anomaly nor a changelog candidate.)
|
||||
3. **missing-highlights (gap, warning)** — a released X.Y.0 version section
|
||||
has no `### :rocket: Epics and highlights` subsection. Patches (X.Y.Z)
|
||||
never carry highlights.
|
||||
4. **missing-highlight-reference (gap, warning)** — a `:rocket:` entry lacks
|
||||
the required issue AND PR references.
|
||||
|
||||
Gaps do not count toward the anomaly total.
|
||||
|
||||
**Rule violations are not in the report** — they are workflow errors the
|
||||
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
|
||||
@@ -920,13 +809,10 @@ self-contained and clickable in any Markdown viewer.
|
||||
issue from a different project or context. If the PR title and issue title
|
||||
are clearly unrelated, or the PR predates the issue by years, treat it as a
|
||||
data glitch and skip it.
|
||||
- **Anomaly = milestone mismatch only; gaps are warnings.** The report's
|
||||
anomaly total counts only milestone mismatches: (1) the issue is in this
|
||||
milestone but the referenced PR is in a different milestone (or unassigned),
|
||||
and (2) the PR is in this milestone but the issue it closes is in a
|
||||
different milestone. `:rocket:` highlight gaps (missing section on a
|
||||
released X.Y.0, entry without issue AND PR references) are reported in a
|
||||
separate `Highlight gaps` section and never count toward the anomaly total. An
|
||||
- **Anomaly = milestone mismatch only.** The report contains only milestone
|
||||
mismatches: (1) the issue is in this milestone but the referenced PR is
|
||||
in a different milestone (or unassigned), and (2) the PR is in this
|
||||
milestone but the issue it closes is in a different milestone. An
|
||||
*unassigned* (milestone-less) issue closed by a milestone PR is **not**
|
||||
an anomaly: milestones are required only for the "Main" project, so such
|
||||
issues come from another (probably private) project and are not changelog
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
runs-on: penpot-standar-runner
|
||||
runs-on: penpot-extended-runner
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
# ── 1. Resolve the build key and check the whole set at once ───────────
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: penpot-standar-runner
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
# ── 2. One build per image, in parallel, only when needed ──────────────
|
||||
build:
|
||||
name: Build ${{ matrix.image }}
|
||||
runs-on: penpot-standar-runner
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 60
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.exists == 'false'
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
# the S3 marker guarantees the branch tags were already moved.
|
||||
promote:
|
||||
name: Promote image set
|
||||
runs-on: penpot-standar-runner
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 10
|
||||
needs: [prepare, build]
|
||||
|
||||
@@ -302,7 +302,7 @@ jobs:
|
||||
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-standar-runner
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 5
|
||||
needs: [prepare, build, promote]
|
||||
if: failure()
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
notify:
|
||||
name: Notifications
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-docker
|
||||
- build-docker-admin-console
|
||||
|
||||
@@ -19,7 +19,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
version: ${{ steps.vars.outputs.gh_ref }}
|
||||
release_notes: ${{ steps.extract_release_notes.outputs.release_notes }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
test-exporter:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Exporter Tests"
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -24,6 +24,7 @@ opencode.json
|
||||
!AGENTS.md
|
||||
!CODE_OF_CONDUCT.md
|
||||
!SECURITY.md
|
||||
!HIGHLIGHTS.md
|
||||
/*.png
|
||||
/*.svg
|
||||
/*.sql
|
||||
|
||||
@@ -86,8 +86,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `
|
||||
| `file-thumbnail` | File grid thumbnails in `file_thumbnail.media_id`. | Yes | Authentication required | Reference scan. |
|
||||
| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. |
|
||||
| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. |
|
||||
| `tempfile` | Export files and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
|
||||
| `upload-session` | Chunked-upload chunks. References: `upload_session_chunk.object_id` and `upload_session_chunk.session_id` (both NO ACTION DEFERRABLE: restrict semantics, procedural deletion). | No | Authentication required | No reference scan. A touched object is deleted after the delay; `gc-deleted` removes mappings before rows. |
|
||||
| `tempfile` | Export files, chunked-upload chunks, and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
|
||||
| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. |
|
||||
| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. |
|
||||
| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. |
|
||||
@@ -96,7 +95,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `
|
||||
- `file-media-object` is the default bucket for old rows without bucket metadata.
|
||||
- Do not assign a new bucket without adding its access and cleanup behavior.
|
||||
- The touched-object collector raises an internal error for an unknown bucket.
|
||||
- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, `upload-session`, and `organization`.
|
||||
- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`.
|
||||
- It does not support `file-data-fragment` or `file-change`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
@@ -11,7 +11,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
# Development workflow
|
||||
|
||||
- Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples:
|
||||
- Before `git commit` → `mem:workflow/creating-commits` (subject/body format, 76-char body wrapping enforced by `scripts/check-commit`, `AI-assisted-by: model-name` trailer)
|
||||
- Before `git commit` → `mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
|
||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
|
||||
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line)
|
||||
- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace
|
||||
|
||||
@@ -14,32 +14,12 @@ automatically pull the identity from the local git config `user.name` and `user.
|
||||
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
||||
|
||||
Body explaining what changed and why.
|
||||
Wrap lines at 76 characters — git log adds a
|
||||
four-space indent, so 76 + 4 fits an 80-column
|
||||
terminal. Keep each line concise.
|
||||
Wrap lines at 72 characters — git log and tooling
|
||||
render long lines poorly. Keep each line concise.
|
||||
|
||||
AI-assisted-by: model-name
|
||||
```
|
||||
|
||||
## HARD RULES (inexcusable)
|
||||
|
||||
These rules are not advisory. Do not commit until every one holds. A commit
|
||||
that breaks them is wrong, even if the code is right.
|
||||
|
||||
- **Body lines MUST wrap at 76 characters or fewer.** Measure every line; do
|
||||
not eyeball it. This is the rule most often skipped. Rationale: `git log`
|
||||
indents the body four spaces, so 76 + 4 fits an 80-column terminal.
|
||||
- **Subject MUST be ≤70 chars**, imperative, capitalized, no trailing period.
|
||||
- **MUST be a blank line** between subject and body.
|
||||
- **MUST run `scripts/check-commit` and get exit code 0 before finishing.**
|
||||
It mechanically validates the rules above; a failing run is a blocker.
|
||||
- It checks `HEAD` by default: `./scripts/check-commit`
|
||||
- For another commit: `./scripts/check-commit -c <ref>`
|
||||
- **NEVER** hand-wave the body as "one long line". If a line exceeds 76,
|
||||
break it at a space.
|
||||
- Exceptions inside the body (do not wrap these): `Signed-off-by:`,
|
||||
`Co-authored-by:`, `AI-assisted-by:` trailers, and lines carrying a URL.
|
||||
|
||||
**AI-assisted-by trailer rules:**
|
||||
- Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash`
|
||||
- Do NOT add prefixes like `opencode-go/` — use the bare model name
|
||||
|
||||
@@ -52,12 +52,10 @@ file (never pipe tool output through filters).
|
||||
then re-run `corepack use pnpm@<tag>` in that directory.
|
||||
- A workspace may fail with `ERR_PNPM_IGNORED_BUILDS`, and pnpm then writes
|
||||
a placeholder scaffold into its `pnpm-workspace.yaml`:
|
||||
`allowBuilds: esbuild: set this to true or false`. Current pnpm writes
|
||||
only the `allowBuilds` placeholder; any legacy key still present
|
||||
(`ignoredBuiltDependencies`, `onlyBuiltDependencies`,
|
||||
`neverBuiltDependencies`) is ignored since pnpm 11. Repo convention is
|
||||
`allowBuilds: esbuild: true`. Replace the placeholder and drop the
|
||||
legacy entry, then re-run.
|
||||
`allowBuilds: esbuild: set this to true or false` plus
|
||||
`ignoredBuiltDependencies`. Repo convention is `allowBuilds: esbuild: true`.
|
||||
Replace the placeholder and drop the `ignoredBuiltDependencies` entry,
|
||||
then re-run.
|
||||
- `plugins/apps/composable-test-suite` once had its own
|
||||
`pnpm-workspace.yaml` and acted as a nested workspace root. That state is
|
||||
gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up,
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
|
||||
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
|
||||
This prevents hiding test failures. See `mem:testing` for details.
|
||||
- **`.claude/skills` is a symlink to `.agents/skills`.**
|
||||
Edit skills only in their canonical location (`.agents/skills`); never edit
|
||||
through `.claude/skills`.
|
||||
- **Commit message body lines MUST wrap at ≤76 chars** (subject ≤70 chars) and
|
||||
the commit MUST pass `./scripts/check-commit` with exit code 0 before you
|
||||
consider it done. This is mechanically checked — do not eyeball it.
|
||||
- **Read the workflow memory BEFORE the corresponding action**:
|
||||
- Before `git commit` → `mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
|
||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, Issue Type)
|
||||
|
||||
-23
@@ -156,21 +156,6 @@
|
||||
- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290))
|
||||
- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317))
|
||||
- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359))
|
||||
- Fix missing text in legacy SVG board thumbnails [#10182](https://github.com/penpot/penpot/issues/10182) (PR: [#11552](https://github.com/penpot/penpot/pull/11552))
|
||||
- Fix workspace crash when applying transform modifiers in the WASM renderer [#10894](https://github.com/penpot/penpot/issues/10894) (PR: [#10896](https://github.com/penpot/penpot/pull/10896))
|
||||
- Limit ZIP entry count and object size on V3 binfile import [#11021](https://github.com/penpot/penpot/issues/11021) (PR: [#11022](https://github.com/penpot/penpot/pull/11022))
|
||||
- Block plugin UI iframe URLs targeting the Penpot domain [#11271](https://github.com/penpot/penpot/issues/11271) (PR: [#11273](https://github.com/penpot/penpot/pull/11273))
|
||||
- Restrict the MCP REPL code execution endpoint to development environments [#11283](https://github.com/penpot/penpot/issues/11283) (PR: [#11282](https://github.com/penpot/penpot/pull/11282))
|
||||
- Filter share-link tokens from the get-view-only-bundle response [#11285](https://github.com/penpot/penpot/issues/11285) (PR: [#11286](https://github.com/penpot/penpot/pull/11286))
|
||||
- Disable MCP developer tools in multi-user mode [#11291](https://github.com/penpot/penpot/issues/11291) (PR: [#11310](https://github.com/penpot/penpot/pull/11310))
|
||||
- Fix Hide comments setting being ignored after opening the Comments section [#11308](https://github.com/penpot/penpot/issues/11308) (PR: [#11492](https://github.com/penpot/penpot/pull/11492))
|
||||
- Block NAT64/6to4/Teredo IPv6 transition addresses in the SSRF guard [#11319](https://github.com/penpot/penpot/issues/11319) (PR: [#11320](https://github.com/penpot/penpot/pull/11320))
|
||||
- Prevent team admins from removing the team owner [#11367](https://github.com/penpot/penpot/issues/11367) (PR: [#11368](https://github.com/penpot/penpot/pull/11368))
|
||||
- Enforce share-link comment permissions and page scope [#11370](https://github.com/penpot/penpot/issues/11370) (PR: [#11371](https://github.com/penpot/penpot/pull/11371))
|
||||
- Clean up orphaned teams, projects and files on profile deletion [#11394](https://github.com/penpot/penpot/issues/11394) (PR: [#11395](https://github.com/penpot/penpot/pull/11395))
|
||||
- Fix crash when pressing Ctrl+D with no shape selected [#11448](https://github.com/penpot/penpot/issues/11448) (PR: [#11491](https://github.com/penpot/penpot/pull/11491))
|
||||
- Fix text layout not updating when auto-width is set by double-clicking the bounding box [#11480](https://github.com/penpot/penpot/issues/11480) (PR: [#11541](https://github.com/penpot/penpot/pull/11541))
|
||||
- Fix boolean shapes rendering deformed in the WASM renderer and exports [#11482](https://github.com/penpot/penpot/issues/11482) (PR: [#11551](https://github.com/penpot/penpot/pull/11551))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@@ -233,10 +218,6 @@
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
- Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038))
|
||||
- Add layer blur effect for visual depth and styling [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))
|
||||
- Render guides in WebGL for consistent viewer performance [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
|
||||
- Add concurrency limiter and status indicators for MCP server communications [#9493](https://github.com/penpot/penpot/issues/9493) (PR: [#9748](https://github.com/penpot/penpot/pull/9748))
|
||||
- Add typography token row to multiselected texts for better token visibility [#9336](https://github.com/penpot/penpot/issues/9336) (PR: [#9128](https://github.com/penpot/penpot/pull/9128))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@@ -591,10 +572,6 @@
|
||||
|
||||
## 2.15.0
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
- Add MCP server integration for AI-assisted design workflows [#9174](https://github.com/penpot/penpot/issues/9174) (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add MCP server integration [GH #9174](https://github.com/penpot/penpot/issues/9174)
|
||||
|
||||
@@ -188,11 +188,8 @@ Commit messages must follow this format:
|
||||
- Add clear and concise description on the body
|
||||
- Do not end the subject with a period
|
||||
- Keep the subject to **70 characters** or fewer
|
||||
- **Wrap body lines at 76 characters or fewer** (trailers and URLs excepted)
|
||||
- Separate the subject from the body with a **blank line**
|
||||
|
||||
You can check a commit against these rules with `./scripts/check-commit`.
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# HIGHLIGHTS
|
||||
|
||||
## 2.17.0
|
||||
|
||||
- Background blur is here
|
||||
- WebGL rendering gets stronger
|
||||
- MCP connection status and more
|
||||
- Design tokens: more visible, more user-friendly
|
||||
|
||||
|
||||
## 2.16.0
|
||||
|
||||
- Design tokens in the design panel
|
||||
- Major community contributions
|
||||
- WebGL rendering (beta)
|
||||
|
||||
|
||||
## 2.15.0
|
||||
|
||||
- AI connected to real design context
|
||||
- Multi-directional workflow
|
||||
- Your stack, your model, your decision
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ If your organization is scaling and needs extra support, we’re here to help. [
|
||||
|
||||
- [Why Penpot](#why-penpot)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Penpot Enterprise](#penpot-enterprise)
|
||||
- [Community](#community)
|
||||
- [Contributing](#contributing)
|
||||
- [Resources](#resources)
|
||||
@@ -94,12 +93,6 @@ Penpot is the only design & prototype platform that is deployment agnostic. You
|
||||
|
||||
Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host).
|
||||
|
||||
<img width="100%" height="1010" alt="2" src="https://github.com/user-attachments/assets/243e796e-a140-481a-b68f-b24be6a70e37" />
|
||||
|
||||
## Penpot Enterprise ##
|
||||
|
||||
Penpot Enterprise is our paid plan for organizations that need to scale their design work across multiple teams with advanced governance, security, and administration. Manage teams and access from a centralized **Admin Console**, configure advanced permissions, and connect your **identity provider through SSO**. Available for cloud and self-hosted environments, it combines enterprise controls with Penpot’s open-source foundation and open standards.
|
||||
|
||||
## Community ##
|
||||
|
||||
We love the Open Source software community. Contributing is our passion and if it’s yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
|
||||
|
||||
@@ -31,7 +31,7 @@ export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-login-with-password \
|
||||
enable-login-with-ldap \
|
||||
disable-login-with-ldap \
|
||||
disable-login-with-oidc \
|
||||
disable-login-with-google \
|
||||
disable-login-with-github \
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[clj-ldap.client :as ldap]
|
||||
[cuerdas.core :as str]
|
||||
[clojure.string]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(defn- prepare-params
|
||||
@@ -36,22 +36,11 @@
|
||||
:cause cause))))
|
||||
|
||||
(defn- replace-several [s & {:as replacements}]
|
||||
(reduce-kv str/replace s replacements))
|
||||
|
||||
(defn- escape-ldap-filter-value
|
||||
"Escapes special characters in a string for use in LDAP filter values,
|
||||
per RFC 4515 section 3."
|
||||
[s]
|
||||
(-> s
|
||||
(str/replace "\\" "\\5c")
|
||||
(str/replace "*" "\\2a")
|
||||
(str/replace "(" "\\28")
|
||||
(str/replace ")" "\\29")
|
||||
(str/replace "\u0000" "\\00")))
|
||||
(reduce-kv clojure.string/replace s replacements))
|
||||
|
||||
(defn- search-user
|
||||
[{:keys [::conn base-dn] :as cfg} email]
|
||||
(let [query (replace-several (:query cfg) ":username" (escape-ldap-filter-value email))
|
||||
(let [query (replace-several (:query cfg) ":username" email)
|
||||
attrs [(:attrs-username cfg)
|
||||
(:attrs-email cfg)
|
||||
(:attrs-fullname cfg)]
|
||||
@@ -60,19 +49,12 @@
|
||||
:attributes attrs}]
|
||||
(first (ldap/search conn base-dn params))))
|
||||
|
||||
(defn- get-attr
|
||||
"Retrieves an attribute from an LDAP entry. Handles multi-valued
|
||||
attributes by returning the first value."
|
||||
[entry attr-key]
|
||||
(let [v (get entry attr-key)]
|
||||
(if (coll? v) (first v) v)))
|
||||
|
||||
(defn- retrieve-user
|
||||
[{:keys [::conn] :as cfg} {:keys [email password]}]
|
||||
(when-let [{:keys [dn] :as user} (search-user cfg email)]
|
||||
(when (ldap/bind? conn dn password)
|
||||
{:fullname (get-attr user (-> cfg :attrs-fullname keyword))
|
||||
:email (get-attr user (-> cfg :attrs-email keyword))
|
||||
{:fullname (get user (-> cfg :attrs-fullname keyword))
|
||||
:email email
|
||||
:backend "ldap"})))
|
||||
|
||||
(def ^:private schema:info-data
|
||||
@@ -97,7 +79,7 @@
|
||||
(l/warn :hint "invalid response from ldap, looks like ldap is not configured correctly" :data user)
|
||||
(ex/raise :type :restriction
|
||||
:code :wrong-ldap-response
|
||||
::sm/explain explain)))
|
||||
:explain explain)))
|
||||
user)))
|
||||
|
||||
(defn- try-connectivity
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
|
||||
:quotes-upload-sessions-per-profile 5
|
||||
:quotes-upload-chunks-per-session 20
|
||||
:upload-max-chunk-size (* 1024 1024 30) ; 30MiB
|
||||
|
||||
;; SSRF protection
|
||||
:ssrf-allowed-hosts #{}
|
||||
@@ -204,7 +203,6 @@
|
||||
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
|
||||
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
|
||||
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
|
||||
[:upload-max-chunk-size {:optional true} ::sm/int]
|
||||
[:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
|
||||
|
||||
[:auth-token-cookie-name {:optional true} :string]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.arrow
|
||||
"Bulk Ladybug ingest through in-memory Arrow.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.debug
|
||||
"In-memory Ladybug sessions for the debug graph console."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.ingest
|
||||
"Penpot file -> Ladybug graph projection."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.ladybug
|
||||
"Ladybug access layer for graph-backed Penpot.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.meta
|
||||
"`GraphMeta`: the graph's own account of who built it and from what.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.projection.document
|
||||
"Project a Penpot file-data map into Ladybug nodes and structural edges.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.projection.transforms
|
||||
"Derived graph links: edges a reader could compute from the projected
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.report
|
||||
(:require
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.schema
|
||||
"Ladybug DDL facade for the graph-backed Penpot vertical slice.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.schema.contract
|
||||
"Deliberate choices in Penpot's graph schema, recorded as data.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.schema.nodes
|
||||
"Single source of truth for graph node tables.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.schema.projection
|
||||
"Derive Ladybug node column schemas from Penpot Malli sources.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.schema.types
|
||||
"Map Malli schemas to Ladybug column types.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.schema.values
|
||||
"Shape a Penpot value into the plain data its Ladybug column type wants.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.stats
|
||||
(:require
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.sync
|
||||
"Incremental Ladybug graph updates from Penpot file-change events."
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.util.ssrf :as ssrf]
|
||||
[app.worker :as-alias wrk]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[java-http-clj.core :as http])
|
||||
@@ -24,8 +23,6 @@
|
||||
java.net.URI))
|
||||
|
||||
(def default-max-redirects 5)
|
||||
(def default-connect-timeout 30000)
|
||||
(def default-request-timeout 30000)
|
||||
|
||||
(defn client?
|
||||
[o]
|
||||
@@ -36,17 +33,15 @@
|
||||
:pred client?})
|
||||
|
||||
(defmethod ig/init-key ::client
|
||||
[_ {:keys [::wrk/executor]}]
|
||||
(http/build-client {:connect-timeout default-connect-timeout
|
||||
:executor executor
|
||||
[_ _]
|
||||
(http/build-client {:connect-timeout 30000
|
||||
:follow-redirects :never}))
|
||||
|
||||
(defn send!
|
||||
([client req] (send! client req {}))
|
||||
([client req {:keys [response-type] :or {response-type :string}}]
|
||||
(assert (client? client) "expected valid http client")
|
||||
(http/send (merge {:timeout default-request-timeout} req)
|
||||
{:client client :as response-type})))
|
||||
(http/send req {:client client :as response-type})))
|
||||
|
||||
(defn- resolve-client
|
||||
[params]
|
||||
|
||||
@@ -60,13 +60,7 @@
|
||||
|
||||
(defmethod handle-error :restriction
|
||||
[err request _]
|
||||
(let [data (ex-data err)
|
||||
code (get data :code)
|
||||
explain (ex/explain data)
|
||||
data (-> data
|
||||
(dissoc ::sm/explain)
|
||||
(cond-> explain (assoc :explain explain)))]
|
||||
|
||||
(let [{:keys [code] :as data} (ex-data err)]
|
||||
(if (= code :method-not-allowed)
|
||||
{::yres/status 405
|
||||
::yres/body data}
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
::http.client/client
|
||||
{::wrk/executor (ig/ref ::wrk/executor)}
|
||||
{}
|
||||
|
||||
::session/manager
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
@@ -390,6 +390,7 @@
|
||||
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
|
||||
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
|
||||
:telemetry (ig/ref :app.tasks.telemetry/handler)
|
||||
:upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler)
|
||||
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
|
||||
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
|
||||
:storage-pending-gc (ig/ref ::sto.pending-gc/handler)
|
||||
@@ -428,6 +429,9 @@
|
||||
:app.tasks.tasks-gc/handler
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.tasks.upload-session-gc/handler
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.tasks.objects-gc/handler
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
@@ -560,6 +564,9 @@
|
||||
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
||||
:task :tasks-gc}
|
||||
|
||||
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
|
||||
:task :upload-session-gc}
|
||||
|
||||
{:cron #penpot/cron "0 0 2 * * ?" ;; daily
|
||||
:task :file-gc-scheduler}
|
||||
|
||||
|
||||
@@ -75,10 +75,10 @@
|
||||
{:method method
|
||||
:uri uri
|
||||
:body body
|
||||
:headers headers
|
||||
:timeout timeout}
|
||||
:headers headers}
|
||||
{:response-type :input-stream
|
||||
:skip-ssrf-check? true})
|
||||
:skip-ssrf-check? true
|
||||
:timeout timeout})
|
||||
status (:status resp)]
|
||||
(when (not (<= 200 status 299))
|
||||
(let [body (:body resp)]
|
||||
|
||||
@@ -502,10 +502,7 @@
|
||||
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}
|
||||
|
||||
{:name "0153-add-storage-object-status-and-deletion-attempts"
|
||||
:fn (mg/resource "app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql")}
|
||||
|
||||
{:name "0154-add-upload-session-chunk-table"
|
||||
:fn (mg/resource "app/migrations/sql/0154-add-upload-session-chunk-table.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
--- Add the upload_session_chunk table, a deleted_at marker to upload_session,
|
||||
--- and make the upload_session.profile_id foreign key non-deleting.
|
||||
|
||||
--- Each row maps one chunk of a chunked-upload session to the storage_object
|
||||
--- row that holds its bytes. Both foreign keys are ON DELETE NO ACTION
|
||||
--- DEFERRABLE on purpose: neither the session nor the storage object can be
|
||||
--- removed while a mapping row exists. Only objects-gc removes mappings
|
||||
--- (for consumed, stalled and profile-purge sessions), always before the
|
||||
--- session row, touching the chunk objects so storage GC reclaims them.
|
||||
--- NO ACTION is identical to RESTRICT in normal
|
||||
--- (immediate) operation; only the deferrability differs, which tooling
|
||||
--- such as the backend test fixture relies on
|
||||
--- (SET CONSTRAINTS ALL DEFERRED).
|
||||
---
|
||||
--- object_id is nullable: the mapping row is inserted first (reserving the
|
||||
--- slot under the UNIQUE(session_id, chunk_index) constraint inside a
|
||||
--- transaction that locks the session), and object_id is set once the blob
|
||||
--- has been written outside the transaction. A mapping with NULL object_id
|
||||
--- and no in-flight upload behind it means that upload died mid-flight; the
|
||||
--- client then starts a new session (sessions are ephemeral).
|
||||
|
||||
CREATE TABLE upload_session_chunk (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
session_id uuid NOT NULL REFERENCES upload_session(id) ON DELETE NO ACTION DEFERRABLE,
|
||||
object_id uuid NULL REFERENCES storage_object(id) ON DELETE NO ACTION DEFERRABLE,
|
||||
chunk_index integer NOT NULL,
|
||||
|
||||
UNIQUE (session_id, chunk_index)
|
||||
);
|
||||
|
||||
--- No standalone index on session_id: the UNIQUE(session_id, chunk_index)
|
||||
--- btree already serves session_id-only lookups and the session FK check
|
||||
--- via its leftmost column.
|
||||
|
||||
CREATE INDEX upload_session_chunk__object_id__idx
|
||||
ON upload_session_chunk(object_id);
|
||||
|
||||
--- The deleted_at column marks a session as consumed: assemble-chunks sets it
|
||||
--- instead of deleting the row, and objects-gc physically purges consumed
|
||||
--- sessions (as well as stalled ones that were never assembled).
|
||||
|
||||
ALTER TABLE upload_session
|
||||
ADD COLUMN deleted_at timestamptz NULL DEFAULT NULL;
|
||||
|
||||
CREATE INDEX upload_session__deleted_at_created_at__idx
|
||||
ON upload_session(deleted_at, created_at);
|
||||
|
||||
--- The profile foreign key moves from CASCADE to NO ACTION DEFERRABLE:
|
||||
--- sessions must be purged procedurally (objects-gc drains the sessions of
|
||||
--- profiles pending purge before the profile row is deleted), so a profile
|
||||
--- can no longer disappear with live chunk mappings behind it.
|
||||
|
||||
ALTER TABLE upload_session
|
||||
DROP CONSTRAINT upload_session_profile_id_fkey;
|
||||
|
||||
ALTER TABLE upload_session
|
||||
ADD CONSTRAINT upload_session_profile_id_fkey
|
||||
FOREIGN KEY (profile_id) REFERENCES profile(id) ON DELETE NO ACTION DEFERRABLE;
|
||||
@@ -390,26 +390,18 @@
|
||||
|
||||
(def ^:private sql:file-comment-users
|
||||
"WITH available_profiles AS (
|
||||
SELECT DISTINCT c.owner_id AS id
|
||||
FROM comment c
|
||||
JOIN comment_thread ct
|
||||
ON ct.id = c.thread_id
|
||||
WHERE ct.file_id = ?::uuid
|
||||
),
|
||||
profile_ids AS (
|
||||
SELECT id FROM available_profiles
|
||||
UNION
|
||||
SELECT ?::uuid
|
||||
SELECT DISTINCT owner_id AS id
|
||||
FROM comment
|
||||
WHERE thread_id IN (SELECT id FROM comment_thread WHERE file_id=?)
|
||||
)
|
||||
SELECT p.id,
|
||||
p.email,
|
||||
p.fullname AS name,
|
||||
p.fullname,
|
||||
p.fullname AS fullname,
|
||||
p.photo_id,
|
||||
p.is_active
|
||||
FROM profile p
|
||||
JOIN profile_ids AS x
|
||||
ON x.id = p.id;")
|
||||
FROM profile AS p
|
||||
WHERE p.id IN (SELECT id FROM available_profiles) OR p.id=?")
|
||||
|
||||
(defn get-file-comments-users
|
||||
[conn file-id profile-id]
|
||||
|
||||
@@ -339,8 +339,6 @@
|
||||
|
||||
;; --- Chunked Upload: Upload a single chunk
|
||||
|
||||
(declare ^:private check-upload-chunk-slot)
|
||||
|
||||
(def ^:private schema:upload-chunk
|
||||
[:map {:title "upload-chunk"}
|
||||
[:session-id ::sm/uuid]
|
||||
@@ -352,75 +350,13 @@
|
||||
[:session-id ::sm/uuid]
|
||||
[:index ::sm/int]])
|
||||
|
||||
(def ^:private sql:link-upload-session-chunk
|
||||
"UPDATE upload_session_chunk
|
||||
SET object_id = ?
|
||||
WHERE session_id = ?
|
||||
AND chunk_index = ?
|
||||
AND object_id IS NULL")
|
||||
|
||||
(sv/defmethod ::upload-chunk
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:upload-chunk
|
||||
::sm/result schema:upload-chunk-result}
|
||||
[{:keys [::db/pool] :as cfg}
|
||||
{:keys [::rpc/profile-id session-id index content] :as _params}]
|
||||
(let [session (db/tx-run! cfg check-upload-chunk-slot session-id profile-id index content)]
|
||||
(l/trc :hint "upload-chunk"
|
||||
:session-id session-id
|
||||
:chunk (str index "/" (:total-chunks session))
|
||||
:size (:size content)
|
||||
:path (:path content))
|
||||
|
||||
;; NOTE: the blob is written outside any transaction on purpose (see
|
||||
;; mem:backend/storage): a failed write must never mingle with the
|
||||
;; mapping transaction. If the write fails, the reserved mapping is
|
||||
;; removed and the error propagates, so the client retries the index
|
||||
;; in the same session. If the process dies between the reserve and
|
||||
;; the link below, a NULL mapping is left behind and the client starts
|
||||
;; a new session (sessions are ephemeral).
|
||||
(let [storage (sto/resolve cfg)
|
||||
data (sto/content (:path content))
|
||||
object (try
|
||||
(sto/put-object! storage
|
||||
{::sto/content data
|
||||
::sto/deduplicate? false
|
||||
::sto/touched-at (ct/in-future {:hours 1})
|
||||
:content-type (:mtype content)
|
||||
:bucket sto/upload-session-bucket})
|
||||
(catch Throwable cause
|
||||
(db/delete! pool :upload-session-chunk
|
||||
{:session-id session-id :chunk-index index})
|
||||
(throw cause)))
|
||||
linked (-> (db/exec-one! pool [sql:link-upload-session-chunk
|
||||
(:id object) session-id index])
|
||||
(db/get-update-count))]
|
||||
(when (zero? linked)
|
||||
;; The mapping vanished concurrently (session consumed or purged
|
||||
;; after the reserve); the orphaned object stays touched so
|
||||
;; touched-gc reclaims it.
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "upload session no longer available"
|
||||
:session-id session-id))))
|
||||
|
||||
{:session-id session-id
|
||||
:index index})
|
||||
|
||||
(defn- check-upload-chunk-slot
|
||||
"Reserves the (session, index) slot: locks the session row, runs all
|
||||
validations and inserts the mapping with a NULL object_id, all in one
|
||||
transaction. Concurrent uploads of the same session serialize on the
|
||||
session lock, so the UNIQUE(session_id, chunk_index) constraint can
|
||||
never fire."
|
||||
[{:keys [::db/conn]} session-id profile-id index content]
|
||||
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id} {::db/for-update true})]
|
||||
(when (:deleted-at session)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "upload session already consumed"
|
||||
:session-id session-id))
|
||||
|
||||
(let [session (db/get pool :upload-session {:id session-id :profile-id profile-id})]
|
||||
(when (or (neg? index) (>= index (:total-chunks session)))
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-chunk-index
|
||||
@@ -429,46 +365,40 @@
|
||||
:total-chunks (:total-chunks session)
|
||||
:index index))
|
||||
|
||||
(when (> (:size content) (cf/get :upload-max-chunk-size))
|
||||
(ex/raise :type :validation
|
||||
:code :chunk-too-large
|
||||
:hint "chunk size exceeds the maximum allowed"
|
||||
:session-id session-id
|
||||
:index index
|
||||
:size (:size content)
|
||||
:max-size (cf/get :upload-max-chunk-size)))
|
||||
|
||||
;; NOTE: a mapping with NULL object_id also counts as occupied: either
|
||||
;; its upload is still in flight, or it died mid-flight and the client
|
||||
;; must start a new session.
|
||||
(when (db/get* conn :upload-session-chunk {:session-id session-id :chunk-index index})
|
||||
(ex/raise :type :validation
|
||||
:code :chunk-already-exists
|
||||
:hint "chunk already uploaded for this session and index"
|
||||
:session-id session-id
|
||||
:index index))
|
||||
(l/trc :hint "upload-chunk"
|
||||
:session-id session-id
|
||||
:chunk (str index "/" (:total-chunks session))
|
||||
:size (:size content)
|
||||
:path (:path content)))
|
||||
|
||||
(db/insert! conn :upload-session-chunk
|
||||
{:session-id session-id
|
||||
:object-id nil
|
||||
:chunk-index index})
|
||||
(let [storage (sto/resolve cfg)
|
||||
data (sto/content (:path content))]
|
||||
(sto/put-object! storage
|
||||
{::sto/content data
|
||||
::sto/deduplicate? false
|
||||
::sto/touched-at (ct/in-future {:hours 1})
|
||||
:content-type (:mtype content)
|
||||
:bucket sto/tempfile-bucket
|
||||
:upload-id (str session-id)
|
||||
:chunk-index index}))
|
||||
|
||||
session))
|
||||
{:session-id session-id
|
||||
:index index})
|
||||
|
||||
;; --- Chunked Upload: shared helpers
|
||||
|
||||
(def ^:private sql:get-upload-session-chunks
|
||||
"SELECT so.id, so.size
|
||||
FROM upload_session_chunk AS usc
|
||||
JOIN storage_object AS so ON (so.id = usc.object_id)
|
||||
WHERE usc.session_id = ?
|
||||
AND so.deleted_at IS NULL
|
||||
AND so.status = 'valid'
|
||||
ORDER BY usc.chunk_index ASC")
|
||||
(def ^:private sql:get-upload-chunks
|
||||
"SELECT id, size, (metadata->>'~:chunk-index')::integer AS chunk_index
|
||||
FROM storage_object
|
||||
WHERE (metadata->>'~:upload-id') = ?::text
|
||||
AND deleted_at IS NULL
|
||||
AND status = 'valid'
|
||||
ORDER BY (metadata->>'~:chunk-index')::integer ASC")
|
||||
|
||||
(defn- get-upload-chunks
|
||||
[conn session-id]
|
||||
(db/exec! conn [sql:get-upload-session-chunks session-id]))
|
||||
(db/exec! conn [sql:get-upload-chunks (str session-id)]))
|
||||
|
||||
(defn- concat-chunks
|
||||
"Reads all chunk storage objects in order and writes them to a single
|
||||
@@ -490,45 +420,29 @@
|
||||
|
||||
Raises a :validation/:missing-chunks error when the number of stored
|
||||
chunks does not match `:total-chunks` recorded in the session row.
|
||||
Raises :not-found when the session does not belong to `profile-id` or
|
||||
was already consumed. Marks the session row as consumed (`deleted_at`);
|
||||
the chunk mappings stay until the objects-gc task purges them (touching
|
||||
the chunk objects so storage GC reclaims them), and the session row is
|
||||
purged afterwards."
|
||||
Raises :not-found when the session does not belong to `profile-id`.
|
||||
Deletes the session row from `upload_session` on success."
|
||||
[{:keys [::db/conn] :as cfg} profile-id session-id]
|
||||
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})]
|
||||
(when (:deleted-at session)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "upload session already consumed"
|
||||
:session-id session-id))
|
||||
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
|
||||
chunks (get-upload-chunks conn session-id)]
|
||||
|
||||
(let [chunks (get-upload-chunks conn session-id)]
|
||||
(when (not= (count chunks) (:total-chunks session))
|
||||
(ex/raise :type :validation
|
||||
:code :missing-chunks
|
||||
:hint "number of stored chunks does not match expected total"
|
||||
:session-id session-id
|
||||
:expected (:total-chunks session)
|
||||
:found (count chunks)))
|
||||
|
||||
(when (not= (count chunks) (:total-chunks session))
|
||||
(ex/raise :type :validation
|
||||
:code :missing-chunks
|
||||
:hint "number of stored chunks does not match expected total"
|
||||
:session-id session-id
|
||||
:expected (:total-chunks session)
|
||||
:found (count chunks)))
|
||||
(let [storage (sto/resolve cfg ::db/reuse-conn true)
|
||||
path (concat-chunks storage chunks)
|
||||
size (reduce #(+ %1 (:size %2)) 0 chunks)]
|
||||
|
||||
(let [storage (sto/resolve cfg ::db/reuse-conn true)
|
||||
path (concat-chunks storage chunks)
|
||||
size (reduce #(+ %1 (:size %2)) 0 chunks)]
|
||||
(db/delete! conn :upload-session {:id session-id})
|
||||
|
||||
;; NOTE: the session row is only marked (deleted_at) here; the
|
||||
;; chunk mappings stay until the objects-gc task removes them
|
||||
;; (before the session row, as the NO ACTION foreign keys
|
||||
;; require) while touching the chunk objects.
|
||||
(db/update! conn :upload-session
|
||||
{:deleted-at (ct/now)}
|
||||
{:id session-id}
|
||||
{::db/return-keys false})
|
||||
|
||||
{:filename "upload"
|
||||
:path path
|
||||
:size size}))))
|
||||
{:filename "upload"
|
||||
:path path
|
||||
:size size})))
|
||||
|
||||
;; --- Chunked Upload: Assemble all chunks into a final media object
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.rpc.commands.plugins
|
||||
(:require
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
[app.rpc.notifications :as notifications]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
[app.util.ssrf :as ssrf]
|
||||
[app.worker :as wrk]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
@@ -961,18 +960,13 @@ RETURNING id, deleted_at;")
|
||||
(sv/defmethod ::check-organization-sso
|
||||
"Validate an organization SSO configuration by generating a login redirect URL.
|
||||
Nitrate calls this while configuring SSO to verify client credentials and OIDC
|
||||
discovery before saving the settings. The issuer URL is nitrate-supplied
|
||||
(customer-configured), so it is checked against the SSRF blocklist before
|
||||
any outbound request is attempted."
|
||||
discovery before saving the settings."
|
||||
{::doc/added "2.18"
|
||||
::sm/params cto/schema:nitrate-sso
|
||||
::sm/result schema:check-organization-sso-result
|
||||
::rpc/auth false}
|
||||
[cfg params]
|
||||
(let [issuer (oidc/organization-sso-discovery-uri params)]
|
||||
{:valid (boolean (and issuer
|
||||
(ssrf/safe-url? issuer)
|
||||
(oidc/is-organization-sso-config-valid? cfg params)))}))
|
||||
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
|
||||
|
||||
;; ---- API: notify-organization-sso-change
|
||||
(sv/defmethod ::notify-organization-sso-change
|
||||
|
||||
@@ -535,8 +535,7 @@
|
||||
(def ^:private sql:get-upload-sessions-per-profile
|
||||
"SELECT count(*) AS total
|
||||
FROM upload_session
|
||||
WHERE profile_id = ?
|
||||
AND deleted_at IS NULL")
|
||||
WHERE profile_id = ?")
|
||||
|
||||
(defmethod check-quote ::upload-sessions-per-profile
|
||||
[{:keys [::profile-id ::target] :as quote}]
|
||||
|
||||
@@ -42,10 +42,6 @@
|
||||
"Bucket name for temporary file uploads (10-minute expiry)."
|
||||
"tempfile")
|
||||
|
||||
(def upload-session-bucket
|
||||
"Bucket name for chunked-upload chunks."
|
||||
"upload-session")
|
||||
|
||||
(def valid-buckets
|
||||
#{"file-media-object"
|
||||
"team-font-variant"
|
||||
@@ -54,7 +50,6 @@
|
||||
"profile"
|
||||
"organization"
|
||||
tempfile-bucket
|
||||
upload-session-bucket
|
||||
"file-data"
|
||||
"file-data-fragment"
|
||||
"file-change"})
|
||||
@@ -216,8 +211,7 @@
|
||||
(if-some [hit (when (and (::deduplicate? params)
|
||||
(:hash mdata)
|
||||
(:bucket mdata)
|
||||
(not= tempfile-bucket (:bucket mdata))
|
||||
(not= upload-session-bucket (:bucket mdata)))
|
||||
(not= tempfile-bucket (:bucket mdata)))
|
||||
(get-database-object-by-hash pool backend
|
||||
(:bucket mdata)
|
||||
(:hash mdata)))]
|
||||
|
||||
@@ -57,18 +57,6 @@
|
||||
(-> (db/exec-one! conn [sql:delete-sobjects ids])
|
||||
(db/get-update-count))))
|
||||
|
||||
(def ^:private sql:delete-upload-session-chunks
|
||||
"DELETE FROM upload_session_chunk
|
||||
WHERE object_id = ANY(?::uuid[])")
|
||||
|
||||
(defn- delete-upload-session-chunks!
|
||||
"Remove the chunk mappings for the given storage object ids. This must run
|
||||
before the storage_object rows are deleted: the upload_session_chunk
|
||||
foreign keys are ON DELETE NO ACTION."
|
||||
[conn ids]
|
||||
(let [ids (db/create-array conn "uuid" ids)]
|
||||
(db/exec-one! conn [sql:delete-upload-session-chunks ids])))
|
||||
|
||||
(def ^:private sql:increment-attempts-and-defer
|
||||
"UPDATE storage_object
|
||||
SET deletion_attempts = deletion_attempts + 1,
|
||||
@@ -117,21 +105,10 @@
|
||||
:backend (name backend-id)))
|
||||
|
||||
(when (seq ok-ids)
|
||||
;; NOTE: the chunk mappings must be removed before the
|
||||
;; storage_object rows (NO ACTION foreign keys). It only affects
|
||||
;; objects of the upload-session bucket; for any other bucket the
|
||||
;; delete matches no rows.
|
||||
(delete-upload-session-chunks! conn ok-ids)
|
||||
(delete-sobjects! conn ok-ids))
|
||||
|
||||
(when (seq fail-ids)
|
||||
(increment-attempts-and-defer! conn fail-ids)
|
||||
;; NOTE: same NO ACTION ordering as above: the give-up DELETE below
|
||||
;; removes storage_object rows, so chunk mappings must go first.
|
||||
;; Deferred objects keep their rows; only the mapping of a
|
||||
;; permanently given-up object disappears early, and that object is
|
||||
;; already deleted-marked.
|
||||
(delete-upload-session-chunks! conn fail-ids)
|
||||
(let [given-up (delete-give-up! conn fail-ids)]
|
||||
(when (pos? (db/get-update-count given-up))
|
||||
(l/wrn :hint "giving up on orphan blob after max attempts"
|
||||
|
||||
@@ -162,7 +162,6 @@
|
||||
(= bucket "profile") (process-objects! conn has-profile-refs? bucket objects)
|
||||
(= bucket "file-data") (process-objects! conn has-file-data-refs? bucket objects)
|
||||
(= bucket sto/tempfile-bucket) (process-objects! conn (constantly false) sto/tempfile-bucket objects)
|
||||
(= bucket sto/upload-session-bucket) (process-objects! conn (constantly false) sto/upload-session-bucket objects)
|
||||
(= bucket "organization") (process-objects! conn (constantly false) bucket objects)
|
||||
:else
|
||||
(ex/raise :type :internal
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.storage.pending-gc
|
||||
"A maintenance task that reclaims storage objects created in 'pending'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.tasks.demo-purge
|
||||
"Task handler for delayed demo profile deletion. Submitted at demo
|
||||
|
||||
@@ -16,51 +16,6 @@
|
||||
[app.tasks.delete-object :as dobj]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(def ^:private sql:get-upload-sessions
|
||||
"SELECT us.id
|
||||
FROM upload_session AS us
|
||||
WHERE (us.deleted_at IS NOT NULL
|
||||
AND us.deleted_at <= ?)
|
||||
OR (us.deleted_at IS NULL
|
||||
AND us.created_at <= ?)
|
||||
OR EXISTS (SELECT 1
|
||||
FROM profile AS p
|
||||
WHERE p.id = us.profile_id
|
||||
AND p.deleted_at IS NOT NULL
|
||||
AND p.deleted_at <= ?)
|
||||
ORDER BY us.created_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE OF us
|
||||
SKIP LOCKED")
|
||||
|
||||
(def ^:private sql:delete-session-chunks
|
||||
"DELETE FROM upload_session_chunk
|
||||
WHERE session_id = ?
|
||||
RETURNING object_id")
|
||||
|
||||
(defn- delete-upload-sessions!
|
||||
"Purges consumed upload sessions (marked by assemble-chunks), stalled
|
||||
sessions (never assembled within max-age) and sessions owned by profiles
|
||||
pending purge. Referenced storage objects are touched so the storage GC
|
||||
reclaims them with its usual delay; chunk mappings are removed before the
|
||||
session row (NO ACTION foreign keys)."
|
||||
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
|
||||
(let [stalled-threshold (ct/minus timestamp {:hours 1})]
|
||||
(->> (db/plan conn [sql:get-upload-sessions timestamp stalled-threshold timestamp chunk-size]
|
||||
{:fetch-size 5})
|
||||
(reduce (fn [total {:keys [id]}]
|
||||
(l/trc :obj "upload-session" :id (str id))
|
||||
|
||||
;; Remove the chunk mappings, marking as touched all
|
||||
;; related storage objects in a single round-trip.
|
||||
(doseq [{:keys [object-id]} (db/exec! conn [sql:delete-session-chunks id])]
|
||||
(some->> object-id (sto/touch-object! storage)))
|
||||
|
||||
(let [affected (-> (db/delete! conn :upload-session {:id id})
|
||||
(db/get-update-count))]
|
||||
(+ total affected)))
|
||||
0))))
|
||||
|
||||
(def ^:private sql:get-profiles
|
||||
"SELECT id, photo_id FROM profile
|
||||
WHERE deleted_at IS NOT NULL
|
||||
@@ -337,11 +292,7 @@
|
||||
0)))
|
||||
|
||||
(def ^:private deletion-proc-vars
|
||||
;; NOTE: upload sessions go first: deleting a profile cascades to its
|
||||
;; sessions, which would hit the upload_session_chunk NO ACTION foreign key
|
||||
;; while mappings still exist.
|
||||
[#'delete-upload-sessions!
|
||||
#'delete-profiles!
|
||||
[#'delete-profiles!
|
||||
#'delete-file-media-objects!
|
||||
#'delete-file-object-thumbnails!
|
||||
#'delete-file-thumbnails!
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.tasks.upload-session-gc
|
||||
"A maintenance task that deletes stalled (incomplete) upload sessions.
|
||||
|
||||
An upload session is considered stalled when it was created more than
|
||||
`max-age` ago without being completed (i.e. the session row still
|
||||
exists because `assemble-chunks` was never called to clean it up).
|
||||
The default max-age is 1 hour."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(def ^:private sql:delete-stalled-sessions
|
||||
"DELETE FROM upload_session
|
||||
WHERE created_at < ?::timestamptz")
|
||||
|
||||
(defmethod ig/assert-key ::handler
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
|
||||
|
||||
(defmethod ig/expand-key ::handler
|
||||
[k v]
|
||||
{k (merge {::max-age (ct/duration {:hours 1})} v)})
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ {:keys [::max-age] :as cfg}]
|
||||
(fn [_]
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [threshold (ct/minus (ct/now) max-age)
|
||||
result (-> (db/exec-one! conn [sql:delete-stalled-sessions threshold])
|
||||
(db/get-update-count))]
|
||||
(l/debug :hint "task finished" :deleted result)
|
||||
{:deleted result})))))
|
||||
@@ -1,76 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.auth-ldap-test
|
||||
(:require
|
||||
[app.auth.ldap :as ldap-auth]
|
||||
[clj-ldap.client :as ldap]
|
||||
[clojure.test :as t]))
|
||||
|
||||
;; --- search-user: filter must be escaped (RED: currently not escaped)
|
||||
|
||||
(t/deftest search-user-escapes-email-in-filter
|
||||
(t/testing "wildcard * is escaped before building LDAP filter"
|
||||
(let [captured-query (atom nil)
|
||||
fake-search (fn [_conn _base-dn params]
|
||||
(reset! captured-query (:filter params))
|
||||
[])]
|
||||
(with-redefs [ldap/search fake-search]
|
||||
(#'ldap-auth/search-user {:query "(mail=:username)" :sizelimit 1
|
||||
:attrs-username "uid" :attrs-email "mail"
|
||||
:attrs-fullname "cn"}
|
||||
"fry*@planetexpress.com"))
|
||||
;; After fix: * should be escaped as \2a
|
||||
(t/is (= "(mail=fry\\2a@planetexpress.com)" @captured-query)
|
||||
"filter must have * escaped per RFC 4515"))))
|
||||
|
||||
;; --- retrieve-user: email must come from directory, not client (RED)
|
||||
|
||||
(t/deftest retrieve-user-uses-directory-email
|
||||
(t/testing "returned email is from LDAP directory, not client input"
|
||||
(let [fake-search (fn [_conn _base-dn _params]
|
||||
[{:dn "cn=fry,ou=people,dc=planetexpress,dc=com"
|
||||
:mail "fry@planetexpress.com"
|
||||
:cn "Philip J. Fry"
|
||||
:uid "fry"}])
|
||||
fake-bind? (fn [_conn _dn _password] true)]
|
||||
(with-redefs [ldap/search fake-search
|
||||
ldap/bind? fake-bind?]
|
||||
(let [cfg {:query "(mail=:username)" :sizelimit 1
|
||||
:attrs-username "uid" :attrs-email "mail"
|
||||
:attrs-fullname "cn"}
|
||||
result (#'ldap-auth/retrieve-user cfg {:email "fry*@planetexpress.com" :password "fry"})]
|
||||
;; After fix: email should be from directory (fry@planetexpress.com)
|
||||
;; BUG: email is client input (fry*@planetexpress.com)
|
||||
(t/is (= "fry@planetexpress.com" (:email result))
|
||||
"email must come from LDAP directory attribute, not client input"))))))
|
||||
|
||||
;; --- authenticate: full flow with directory email (RED)
|
||||
|
||||
(t/deftest authenticate-returns-directory-email
|
||||
(t/testing "authenticate returns directory email for profile"
|
||||
(let [fake-search (fn [_conn _base-dn _params]
|
||||
[{:dn "cn=amy,ou=people,dc=planetexpress,dc=com"
|
||||
:mail "amy@planetexpress.com"
|
||||
:cn "Amy Wong"
|
||||
:uid "amy"}])
|
||||
fake-bind? (fn [_conn _dn _password] true)]
|
||||
(with-redefs [ldap/search fake-search
|
||||
ldap/bind? fake-bind?
|
||||
ldap/connect (fn [_cfg] (reify java.lang.AutoCloseable (close [_] nil)))]
|
||||
(let [cfg {:query "(mail=:username)" :sizelimit 1
|
||||
:attrs-username "uid" :attrs-email "mail"
|
||||
:attrs-fullname "cn"
|
||||
:bind-dn "cn=admin,dc=planetexpress,dc=com"
|
||||
:bind-password "GoodNewsEveryone"
|
||||
:host "localhost" :port 10389
|
||||
:ssl false :tls false
|
||||
:base-dn "ou=people,dc=planetexpress,dc=com"}
|
||||
result (ldap-auth/authenticate cfg {:email "*@planetexpress.com" :password "amy"})]
|
||||
;; After fix: email should be amy@planetexpress.com (directory)
|
||||
;; BUG: email is *@planetexpress.com (client)
|
||||
(t/is (= "amy@planetexpress.com" (:email result))
|
||||
"authenticate must return directory email, not client-supplied wildcard"))))))
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.demo-test
|
||||
(:require
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.graph-binder-gate-test
|
||||
"Binder gate for the incremental-sync statement templates.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.graph-sync-parity-test
|
||||
"Cold projection and incremental sync are two implementations of one mapping,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.http-client-test
|
||||
(:require
|
||||
[app.http.client :as http]
|
||||
[clojure.test :as t]
|
||||
[java-http-clj.core :as jhttp]
|
||||
[mockery.core :refer [with-mocks]]))
|
||||
|
||||
(t/deftest send-injects-default-timeout-when-absent
|
||||
(with-mocks [mock {:target 'java-http-clj.core/send
|
||||
:return {:status 200 :body ""}}]
|
||||
(let [client (jhttp/build-client {})]
|
||||
(http/send! client {:method :get :uri "https://example.com/"})
|
||||
(let [[req _opts] (:call-args @mock)]
|
||||
(t/is (= http/default-request-timeout (:timeout req)))))))
|
||||
|
||||
(t/deftest send-preserves-caller-supplied-timeout
|
||||
(with-mocks [mock {:target 'java-http-clj.core/send
|
||||
:return {:status 200 :body ""}}]
|
||||
(let [client (jhttp/build-client {})]
|
||||
(http/send! client {:method :get
|
||||
:uri "https://example.com/"
|
||||
:timeout 5000})
|
||||
(let [[req _opts] (:call-args @mock)]
|
||||
(t/is (= 5000 (:timeout req)))))))
|
||||
@@ -8,7 +8,6 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.media.remote :as media.remote]
|
||||
[app.setup :as-alias setup]
|
||||
[app.util.json :as json]
|
||||
@@ -501,22 +500,6 @@
|
||||
:headers {}})]
|
||||
(t/is (= 200 (:status resp))))))))
|
||||
|
||||
(t/deftest service-request-puts-configured-timeout-in-request
|
||||
(t/testing "service-request puts media-processing-service-timeout on the http request"
|
||||
(let [captured (atom nil)]
|
||||
(with-redefs [cf/get (th/config-get-mock config-mock)
|
||||
http/req (fn [_client request _opts]
|
||||
(reset! captured request)
|
||||
{:status 200
|
||||
:body (json-stream {:width 100 :height 100})})]
|
||||
(media.remote/service-request
|
||||
(mk-system)
|
||||
{:method :post
|
||||
:uri "http://localhost:6065/api/image/info"
|
||||
:body nil
|
||||
:headers {}})
|
||||
(t/is (= 5000 (:timeout @captured)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared key
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.passwords-test
|
||||
(:require
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-demo-test
|
||||
(:require
|
||||
|
||||
@@ -158,8 +158,7 @@
|
||||
|
||||
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
;; processed = 4: the 2 font variants plus the 2 consumed upload sessions
|
||||
(t/is (= 4 (:processed res)))))
|
||||
(t/is (= 2 (:processed res)))))
|
||||
|
||||
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
@@ -225,8 +224,7 @@
|
||||
|
||||
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
;; processed = 3: the font plus the 2 consumed upload sessions
|
||||
(t/is (= 3 (:processed res)))))
|
||||
(t/is (= 1 (:processed res)))))
|
||||
|
||||
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
@@ -273,8 +271,7 @@
|
||||
;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h
|
||||
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
;; processed = 3: the font variant plus the 2 consumed upload sessions
|
||||
(t/is (= 3 (:processed res)))))
|
||||
(t/is (= 1 (:processed res)))))
|
||||
|
||||
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
[app.msgbus :as mbus]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.util.ssrf :as ssrf]
|
||||
[app.worker :as wrk]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.set :as set]
|
||||
@@ -1807,14 +1806,13 @@
|
||||
|
||||
(t/deftest check-organization-sso-returns-valid-true
|
||||
(let [organization-id (uuid/random)
|
||||
out (with-redefs [ssrf/safe-url? (constantly true)
|
||||
oidc/is-organization-sso-config-valid? (constantly true)]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id organization-id
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com"}))]
|
||||
out (with-redefs [oidc/is-organization-sso-config-valid? (constantly true)]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id organization-id
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com"}))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (true? (-> out :result :valid)))))
|
||||
|
||||
@@ -1829,36 +1827,19 @@
|
||||
|
||||
(t/deftest check-organization-sso-passes-issuer-to-validation
|
||||
(let [organization-id (uuid/random)
|
||||
out (with-redefs [ssrf/safe-url? (constantly true)
|
||||
oidc/is-organization-sso-config-valid?
|
||||
(fn [_cfg sso]
|
||||
(and (= "test-client" (:client-id sso))
|
||||
(= "https://idp.example.com/" (:issuer sso))))]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id organization-id
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com/"}))]
|
||||
out (with-redefs [oidc/is-organization-sso-config-valid?
|
||||
(fn [_cfg sso]
|
||||
(and (= "test-client" (:client-id sso))
|
||||
(= "https://idp.example.com/" (:issuer sso))))]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id organization-id
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com/"}))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (true? (-> out :result :valid)))))
|
||||
|
||||
(t/deftest check-organization-sso-returns-valid-false-on-ssrf-blocked-issuer
|
||||
(t/testing "an SSRF-blocked issuer must not reach the OIDC validation flow"
|
||||
(let [called? (atom false)
|
||||
out (with-redefs [oidc/is-organization-sso-config-valid?
|
||||
(fn [_cfg _sso] (reset! called? true) true)]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id (uuid/random)
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "http://127.0.0.1/idp"}))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (false? (-> out :result :valid)))
|
||||
(t/is (false? @called?)
|
||||
"OIDC validation should not run when the issuer is SSRF-blocked"))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PUSH AUDIT EVENTS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
(ns backend-tests.rpc-media-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http.client :as http]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
@@ -533,7 +532,7 @@
|
||||
:index 0
|
||||
:content mfile})
|
||||
|
||||
;; First assemble succeeds; session row is marked as consumed afterwards
|
||||
;; First assemble succeeds; session row is deleted afterwards
|
||||
(let [out1 (th/command! {::th/type :assemble-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
@@ -546,7 +545,7 @@
|
||||
(t/is (= media-id (:id (:result out1)))))
|
||||
|
||||
;; Second assemble with the same session-id must fail because the
|
||||
;; session row has been marked as consumed after the first assembly
|
||||
;; session row has been deleted after the first assembly
|
||||
(let [out2 (th/command! {::th/type :assemble-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
@@ -682,94 +681,6 @@
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :missing-chunks (-> out :error ex-data :code))))))
|
||||
|
||||
(t/deftest chunked-upload-duplicate-then-assemble
|
||||
;; A rejected duplicate must leave the first chunk intact: upload 0,
|
||||
;; re-upload 0 (rejected), then assemble succeeds with the original size.
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:is-shared false})
|
||||
session-id (create-session! prof 1)
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
chunks (split-file-into-chunks source-path 312043)
|
||||
mtype "image/jpeg"
|
||||
size (alength (first chunks))]
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (first chunks) mtype)})]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (first chunks) mtype)})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :chunk-already-exists (-> out :error ex-data :code))))
|
||||
|
||||
(let [out (th/command! {::th/type :assemble-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "after-dupe"
|
||||
:mtype mtype})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
mobj (sto/get-object storage (:media-id (:result out)))]
|
||||
(t/is (= size (:size mobj)))))))
|
||||
|
||||
(t/deftest chunked-upload-rejected-duplicate-keeps-session-usable
|
||||
;; Rejecting a duplicate must not poison the session: the remaining
|
||||
;; distinct indices still accumulate and assemble normally.
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:is-shared false})
|
||||
session-id (create-session! prof 2)
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
chunks (split-file-into-chunks source-path 110000)
|
||||
mtype "image/jpeg"]
|
||||
|
||||
(t/is (= 3 (count chunks)))
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (nth chunks 0) mtype)})]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (nth chunks 0) mtype)})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :chunk-already-exists (-> out :error ex-data :code))))
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 1
|
||||
:content (make-chunk-mfile (nth chunks 1) mtype)})]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
;; The mapping table holds exactly the two distinct indices: the
|
||||
;; rejected duplicate stored nothing.
|
||||
(let [rows (th/db-exec! ["SELECT chunk_index FROM upload_session_chunk WHERE session_id = ? ORDER BY chunk_index"
|
||||
session-id])]
|
||||
(t/is (= [0 1] (mapv :chunk-index rows))))))
|
||||
|
||||
(t/deftest chunked-upload-session-not-found
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
@@ -807,48 +718,6 @@
|
||||
(t/is (= :max-quote-reached (-> out :error ex-data :code)))
|
||||
(t/is (= "upload-chunks-per-session" (-> out :error ex-data :target))))))
|
||||
|
||||
(t/deftest chunked-upload-consumed-session-frees-quota
|
||||
;; Consumed sessions must not count against the sessions-per-profile
|
||||
;; quota: with the limit set to 1, assembling a session frees the slot
|
||||
;; for a new one.
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/config-get-mock
|
||||
{:quotes-upload-sessions-per-profile 1})}]
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:is-shared false})
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
mfile {:filename "sample.jpg"
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
session-id (create-session! prof 1)
|
||||
upload-out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error upload-out)))
|
||||
|
||||
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "assembled-image"
|
||||
:mtype "image/jpeg"})]
|
||||
(t/is (nil? (:error assemble-out))))
|
||||
|
||||
;; the consumed session frees the quota slot
|
||||
(let [out (th/command! {::th/type :create-upload-session
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (uuid? (:session-id (:result out))))))))
|
||||
|
||||
(t/deftest chunked-upload-invalid-total-chunks
|
||||
;; total-chunks must be at least 1; zero and negative values are rejected
|
||||
;; with a :validation error.
|
||||
@@ -898,41 +767,6 @@
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))))
|
||||
|
||||
(t/deftest chunked-upload-chunk-too-large
|
||||
;; Chunks larger than the configured cap must be rejected with
|
||||
;; :validation / :chunk-too-large before anything is stored, while a
|
||||
;; chunk exactly at the cap still uploads fine.
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/config-get-mock
|
||||
{:upload-max-chunk-size 1024})}]
|
||||
(let [prof (th/create-profile* 1)
|
||||
session-id (create-session! prof 1)
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
chunks (split-file-into-chunks source-path 312043)
|
||||
mtype "image/jpeg"]
|
||||
|
||||
;; 312043 bytes exceeds the mocked 1024-byte cap: rejected
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (first chunks) mtype)})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :chunk-too-large (-> out :error ex-data :code))))
|
||||
|
||||
;; Nothing stored for the rejected chunk
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["SELECT count(*) FROM upload_session_chunk WHERE session_id = ?"
|
||||
session-id]))))
|
||||
|
||||
;; A chunk exactly at the cap still uploads fine
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})]
|
||||
(t/is (nil? (:error out)))))))
|
||||
|
||||
(t/deftest chunked-upload-sessions-per-profile-quota
|
||||
;; With the session limit set to 2, creating a third session for the
|
||||
;; same profile must fail with :restriction / :max-quote-reached.
|
||||
@@ -954,158 +788,6 @@
|
||||
(t/is (= :restriction (-> out :error ex-data :type)))
|
||||
(t/is (= :max-quote-reached (-> out :error ex-data :code)))))))
|
||||
|
||||
;; --- upload_session_chunk mapping tests ---
|
||||
|
||||
(t/deftest chunked-upload-creates-chunk-mapping
|
||||
;; Uploading a chunk creates a row in upload_session_chunk pointing to the
|
||||
;; storage object, and the object itself carries no session metadata.
|
||||
(let [prof (th/create-profile* 1)
|
||||
session-id (create-session! prof 1)
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
mfile {:filename "sample.jpg"
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [row (th/db-exec-one! ["select session_id, object_id, chunk_index from upload_session_chunk where session_id = ?"
|
||||
session-id])]
|
||||
(t/is (= session-id (:session-id row)))
|
||||
(t/is (= 0 (:chunk-index row)))
|
||||
|
||||
(let [storage (:app.storage/storage th/*system*)
|
||||
obj (sto/get-object storage (:object-id row))]
|
||||
(t/is (sto/object? obj))
|
||||
(t/is (= "upload-session" (-> obj meta :bucket)))
|
||||
(t/is (nil? (-> obj meta :upload-id)))
|
||||
(t/is (nil? (-> obj meta :chunk-index)))))))
|
||||
|
||||
(t/deftest chunked-upload-duplicate-index-fails
|
||||
;; Re-uploading an already stored index fails with
|
||||
;; :validation/:chunk-already-exists and creates no new storage object.
|
||||
(let [prof (th/create-profile* 1)
|
||||
session-id (create-session! prof 1)
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
mfile {:filename "sample.jpg"
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
out1 (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out1)))
|
||||
|
||||
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
|
||||
out2 (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (some? (:error out2)))
|
||||
(t/is (= :validation (-> out2 :error ex-data :type)))
|
||||
(t/is (= :chunk-already-exists (-> out2 :error ex-data :code)))
|
||||
(t/is (= before (:count (th/db-exec-one! ["select count(*) from storage_object"])))))))
|
||||
|
||||
(t/deftest chunked-upload-to-consumed-session-fails
|
||||
;; Once assembled, the session is consumed: uploading another chunk fails
|
||||
;; with :not-found and the session row stays, marked with deleted_at.
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:is-shared false})
|
||||
session-id (create-session! prof 1)
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
mfile {:filename "sample.jpg"
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
out1 (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out1)))
|
||||
|
||||
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "assembled-image"
|
||||
:mtype "image/jpeg"})]
|
||||
(t/is (nil? (:error assemble-out))))
|
||||
|
||||
;; chunk mappings stay until objects-gc purges them, session row
|
||||
;; stays marked as consumed
|
||||
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
session-id]))))
|
||||
(t/is (some? (:deleted-at (th/db-exec-one! ["select deleted_at from upload_session where id = ?"
|
||||
session-id]))))
|
||||
|
||||
;; uploading to the consumed session fails without creating an object
|
||||
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))
|
||||
(t/is (= :object-not-found (-> out :error ex-data :code)))
|
||||
(t/is (= before (:count (th/db-exec-one! ["select count(*) from storage_object"])))))))
|
||||
|
||||
(defn- sql-state-of
|
||||
"Runs thunk (a db statement) and returns the SQLState of the raised
|
||||
SQLException, or nil when no error is raised."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
nil
|
||||
(catch java.sql.SQLException cause
|
||||
(.getSQLState cause))))
|
||||
|
||||
(t/deftest upload-session-chunk-restrict-blocks-direct-deletes
|
||||
;; With a live mapping row, deleting the storage object or the session
|
||||
;; directly violates the RESTRICT foreign keys (SQLState 23503).
|
||||
(let [prof (th/create-profile* 1)
|
||||
session-id (create-session! prof 1)
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
mfile {:filename "sample.jpg"
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?"
|
||||
session-id]))]
|
||||
(t/is (= "23503" (sql-state-of #(th/db-exec! ["delete from storage_object where id = ?"
|
||||
object-id]))))
|
||||
(t/is (= "23503" (sql-state-of #(th/db-exec! ["delete from upload_session where id = ?"
|
||||
session-id]))))
|
||||
;; the profile cannot disappear either while its session is live
|
||||
;; (profile_id FK is NO ACTION DEFERRABLE; purge goes through
|
||||
;; objects-gc). The deletion_protection rule is disabled here so the
|
||||
;; statement reaches the FK check.
|
||||
(t/is (= "23503" (sql-state-of #(db/transact! th/*pool*
|
||||
(fn [conn]
|
||||
(db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"])
|
||||
(db/exec! conn ["delete from profile where id = ?"
|
||||
(:id prof)])))))))))
|
||||
|
||||
;; --- Clone File Media Object BOLA tests ---
|
||||
|
||||
(defn- create-storage-object!
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-plugins-test
|
||||
(:require
|
||||
|
||||
@@ -292,9 +292,8 @@
|
||||
{:id (:id result-2)})
|
||||
|
||||
;; run the objects gc task for permanent deletion
|
||||
;; (processed = 2: the consumed upload session plus the font variant)
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
(t/is (= 2 (:processed res))))
|
||||
(t/is (= 1 (:processed res))))
|
||||
|
||||
;; revert touched state to all storage objects
|
||||
|
||||
@@ -818,8 +817,8 @@
|
||||
|
||||
;; mark all the chunks of this session as pending (simulates rows that
|
||||
;; were never promoted)
|
||||
(th/db-exec! ["update storage_object set status = 'pending' where id in (select object_id from upload_session_chunk where session_id = ?)"
|
||||
session-id])
|
||||
(th/db-exec! ["update storage_object set status = 'pending' where (metadata->>'~:upload-id') = ?"
|
||||
(str session-id)])
|
||||
|
||||
;; assembling fails because no chunk is visible anymore
|
||||
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
|
||||
@@ -831,143 +830,6 @@
|
||||
:mtype "image/jpeg"})]
|
||||
(t/is (some? (:error assemble-out))))))
|
||||
|
||||
(t/deftest upload-session-stalled-purge-lifecycle
|
||||
;; Full lifecycle of a stalled session: objects-gc purges the session and
|
||||
;; its mappings while touching the objects, touched-gc marks them deleted
|
||||
;; and deleted-gc removes rows and blobs.
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
_ (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:is-shared false})
|
||||
mfile {:filename "chunk"
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
session-id (-> (th/command! {::th/type :create-upload-session
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
session-id]))))
|
||||
|
||||
;; backdate the session so it counts as stalled
|
||||
(th/db-exec! ["update upload_session set created_at = now() - interval '2 hours' where id = ?"
|
||||
session-id])
|
||||
|
||||
;; objects-gc purges session and mappings, touching the objects
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
(t/is (= 1 (:processed res))))
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session where id = ?"
|
||||
session-id]))))
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
session-id]))))
|
||||
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"]))))
|
||||
|
||||
;; touched-gc marks the orphaned object as deleted
|
||||
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
|
||||
(th/run-task! :storage-gc-touched {}))]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 1 (:delete res))))
|
||||
|
||||
;; deleted-gc removes the row and the blob (clock past the mark time)
|
||||
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 4}))]
|
||||
(th/run-task! :storage-gc-deleted {}))]
|
||||
(t/is (= 1 (:deleted res))))
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from storage_object"]))))))
|
||||
|
||||
(t/deftest upload-session-consumed-purge
|
||||
;; An assembled session is marked as consumed and objects-gc purges it
|
||||
;; right away, without waiting for the stalled threshold.
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:is-shared false})
|
||||
mfile {:filename "chunk"
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
session-id (-> (th/command! {::th/type :create-upload-session
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "assembled-image"
|
||||
:mtype "image/jpeg"})]
|
||||
(t/is (nil? (:error assemble-out))))
|
||||
|
||||
;; mappings stay, session row stays marked as consumed; objects-gc
|
||||
;; purges both
|
||||
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
session-id]))))
|
||||
(t/is (some? (:deleted-at (th/db-exec-one! ["select deleted_at from upload_session where id = ?"
|
||||
session-id]))))
|
||||
|
||||
;; objects-gc purges the consumed session immediately
|
||||
(let [res (th/run-task! :objects-gc {})]
|
||||
(t/is (= 1 (:processed res))))
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session where id = ?"
|
||||
session-id]))))
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
session-id]))))))
|
||||
|
||||
(t/deftest upload-session-profile-purge
|
||||
;; Sessions owned by a profile pending purge are drained first, so the
|
||||
;; profile delete (which cascades to its sessions) never hits the chunk
|
||||
;; RESTRICT foreign keys.
|
||||
(let [prof (th/create-profile* 1)
|
||||
mfile {:filename "chunk"
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
session-id (-> (th/command! {::th/type :create-upload-session
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
;; soft-delete the profile; the live session is neither consumed nor stalled
|
||||
(th/db-update! :profile {:deleted-at (ct/now)} {:id (:id prof)})
|
||||
|
||||
(th/run-task! :objects-gc {})
|
||||
|
||||
;; session and mappings are gone, profile row deletes cleanly
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session where id = ?"
|
||||
session-id]))))
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
session-id]))))
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from profile where id = ?"
|
||||
(:id prof)]))))
|
||||
;; and the chunk object was touched for the storage GC
|
||||
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"]))))))
|
||||
|
||||
(defn- fake-s3-backend
|
||||
[]
|
||||
{::sto/type :s3
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { rpcPost, extractCookie } from "./helpers/client.mjs";
|
||||
|
||||
async function loginWithLdap(email, password) {
|
||||
const res = await rpcPost("login-with-ldap", { email, password });
|
||||
if (res.status !== 200 || res.body.type) {
|
||||
throw new Error(
|
||||
`LDAP login failed: ${JSON.stringify(res.body)}`
|
||||
);
|
||||
}
|
||||
const cookie = extractCookie(res.setCookie);
|
||||
return { profile: res.body, cookie };
|
||||
}
|
||||
|
||||
describe("LDAP injection — T5-N1-03", () => {
|
||||
|
||||
it("normal LDAP login works with valid credentials", async () => {
|
||||
const { profile, cookie } = await loginWithLdap(
|
||||
"fry@planetexpress.com",
|
||||
"fry"
|
||||
);
|
||||
assert.equal(profile.email, "fry@planetexpress.com");
|
||||
assert.ok(profile.id, "profile should have id");
|
||||
assert.ok(cookie, "cookie should be set");
|
||||
});
|
||||
|
||||
it("wildcard injection: *@planetexpress.com must not return client literal as email", async () => {
|
||||
// ATTACK SCENARIO (from Criptored audit):
|
||||
// 1. Attacker (amy) sends email="*@planetexpress.com" with her own password
|
||||
// 2. LDAP filter becomes (mail=*@planetexpress.com) — * is a wildcard
|
||||
// 3. With sizelimit=1, LDAP returns amy's entry (first match)
|
||||
// 4. Bind succeeds: amy's DN + amy's password = valid
|
||||
//
|
||||
// EXPECTED BEHAVIOR AFTER FIX (two valid outcomes):
|
||||
// A) If * is escaped: LDAP finds no match → wrong-credentials (injection blocked)
|
||||
// B) If * matches: profile email must be "amy@planetexpress.com" (directory), not "*@planetexpress.com" (client)
|
||||
//
|
||||
// Either outcome is correct — the vulnerability is fixed.
|
||||
try {
|
||||
const { profile } = await loginWithLdap("*@planetexpress.com", "amy");
|
||||
// Outcome B: login succeeded, verify email is from directory
|
||||
assert.equal(
|
||||
profile.email,
|
||||
"amy@planetexpress.com",
|
||||
"email must come from LDAP directory, not client input"
|
||||
);
|
||||
} catch (e) {
|
||||
// Outcome A: injection blocked — * is escaped, no LDAP match
|
||||
assert.ok(
|
||||
e.message.includes("wrong-credentials"),
|
||||
"wildcard should be rejected or return directory email"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("identity swap: alternate email must return primary directory email", async () => {
|
||||
// Professor has two emails in LDAP: professor@ and hubert@.
|
||||
// Login with hubert@ — the profile email should be the one
|
||||
// the LDAP directory returns as attrs-email, not what the client typed.
|
||||
//
|
||||
// EXPECTED BEHAVIOR AFTER FIX:
|
||||
// Profile email should be "professor@planetexpress.com" (primary directory email),
|
||||
// NOT "hubert@planetexpress.com" (client literal).
|
||||
//
|
||||
// CURRENT BUG: email is "hubert@planetexpress.com" (client literal) — test FAILS
|
||||
const { profile, cookie } = await loginWithLdap(
|
||||
"hubert@planetexpress.com",
|
||||
"professor"
|
||||
);
|
||||
assert.ok(profile.id, "profile should have id");
|
||||
assert.ok(cookie, "cookie should be set");
|
||||
// This assertion FAILS with current code (RED) — proves the vulnerability
|
||||
assert.equal(
|
||||
profile.email,
|
||||
"professor@planetexpress.com",
|
||||
"email must come from LDAP directory, not client input"
|
||||
);
|
||||
});
|
||||
|
||||
it("wrong password fails", async () => {
|
||||
try {
|
||||
await loginWithLdap("fry@planetexpress.com", "wrong-password");
|
||||
assert.fail("should have thrown");
|
||||
} catch (e) {
|
||||
assert.ok(
|
||||
e.message.includes("LDAP login failed") ||
|
||||
e.message.includes("wrong-credentials"),
|
||||
"should fail with wrong credentials"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("non-existent user fails", async () => {
|
||||
try {
|
||||
await loginWithLdap("nobody@planetexpress.com", "password");
|
||||
assert.fail("should have thrown");
|
||||
} catch (e) {
|
||||
assert.ok(
|
||||
e.message.includes("LDAP login failed") ||
|
||||
e.message.includes("wrong-credentials"),
|
||||
"should fail for non-existent user"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.common.files.changes
|
||||
(:require
|
||||
#?(:cljs [app.common.files.validate :as val])
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
@@ -428,7 +429,14 @@
|
||||
[:set-base-font-size
|
||||
[:map {:title "ModBaseFontSize"}
|
||||
[:type [:= :set-base-font-size]]
|
||||
[:base-font-size :string]]]])
|
||||
[:base-font-size :string]]]
|
||||
|
||||
[:validate-shapes
|
||||
[:map {:title "ValidateShapesChange"}
|
||||
[:type [:= :validate-shapes]]
|
||||
[:page-id ::sm/uuid]
|
||||
[:shape-ids [:vector ::sm/uuid]]
|
||||
[:context :string]]]])
|
||||
|
||||
(def schema:changes
|
||||
[:sequential {:gen/max 5 :gen/min 1} schema:change])
|
||||
@@ -464,7 +472,7 @@
|
||||
to the processor backend."
|
||||
nil)
|
||||
|
||||
(defmulti process-change (fn [_ change] (:type change)))
|
||||
(defmulti process-change (fn [_ change _] (:type change)))
|
||||
(defmulti process-operation (fn [_ op] (:type op)))
|
||||
|
||||
;; Changes Processing Impl
|
||||
@@ -496,22 +504,25 @@
|
||||
|
||||
(defn process-changes
|
||||
([data items]
|
||||
(process-changes data items true))
|
||||
(process-changes data items true {}))
|
||||
|
||||
([data items verify?]
|
||||
(process-changes data items verify? {}))
|
||||
|
||||
([data items verify? libraries]
|
||||
;; When verify? false we spec the schema validation. Currently used
|
||||
;; to make just 1 validation even if the changes are applied twice
|
||||
(when verify?
|
||||
(check-changes items))
|
||||
|
||||
(binding [*touched-changes* (volatile! #{})]
|
||||
(let [result (reduce #(or (process-change %1 %2) %1) data items)]
|
||||
(let [result (reduce #(or (process-change %1 %2 libraries) %1) data items)]
|
||||
(reduce process-touched-change result @*touched-changes*)))))
|
||||
|
||||
;; --- Comment Threads
|
||||
|
||||
(defmethod process-change :set-comment-thread-position
|
||||
[data {:keys [page-id comment-thread-id position frame-id]}]
|
||||
[data {:keys [page-id comment-thread-id position frame-id]} _]
|
||||
(d/update-in-when data [:pages-index page-id]
|
||||
(fn [page]
|
||||
(if (and position frame-id)
|
||||
@@ -524,7 +535,7 @@
|
||||
;; --- Guides
|
||||
|
||||
(defmethod process-change :set-guide
|
||||
[data {:keys [page-id id params]}]
|
||||
[data {:keys [page-id id params]} _]
|
||||
(if (nil? params)
|
||||
(d/update-in-when data [:pages-index page-id]
|
||||
(fn [page]
|
||||
@@ -540,7 +551,7 @@
|
||||
;; --- Flows
|
||||
|
||||
(defmethod process-change :set-flow
|
||||
[data {:keys [page-id id params]}]
|
||||
[data {:keys [page-id id params]} _]
|
||||
(if (nil? params)
|
||||
(d/update-in-when data [:pages-index page-id]
|
||||
(fn [page]
|
||||
@@ -556,7 +567,7 @@
|
||||
;; --- Grids
|
||||
|
||||
(defmethod process-change :set-default-grid
|
||||
[data {:keys [page-id grid-type params]}]
|
||||
[data {:keys [page-id grid-type params]} _]
|
||||
(if (nil? params)
|
||||
(d/update-in-when data [:pages-index page-id]
|
||||
(fn [page]
|
||||
@@ -593,7 +604,7 @@
|
||||
(update state :media-refs into xform media-refs)))
|
||||
|
||||
(defmethod process-change :add-obj
|
||||
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}]
|
||||
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]} _]
|
||||
;; NOTE: we only perform hard validation on backend
|
||||
#?(:clj (validate-shape obj page-id))
|
||||
|
||||
@@ -628,7 +639,7 @@
|
||||
objects))
|
||||
|
||||
(defmethod process-change :mod-obj
|
||||
[data {:keys [page-id component-id] :as change}]
|
||||
[data {:keys [page-id component-id] :as change} _]
|
||||
(if page-id
|
||||
(d/update-in-when data [:pages-index page-id :objects] process-operations change)
|
||||
(d/update-in-when data [:components component-id :objects] process-operations change)))
|
||||
@@ -658,19 +669,19 @@
|
||||
objects))
|
||||
|
||||
(defmethod process-change :reorder-children
|
||||
[data {:keys [page-id component-id] :as change}]
|
||||
[data {:keys [page-id component-id] :as change} _]
|
||||
(if page-id
|
||||
(d/update-in-when data [:pages-index page-id :objects] process-children-reordering change)
|
||||
(d/update-in-when data [:components component-id :objects] process-children-reordering change)))
|
||||
|
||||
(defmethod process-change :del-obj
|
||||
[data {:keys [page-id component-id id ignore-touched]}]
|
||||
[data {:keys [page-id component-id id ignore-touched]} _]
|
||||
(if page-id
|
||||
(d/update-in-when data [:pages-index page-id] ctst/delete-shape id ignore-touched)
|
||||
(d/update-in-when data [:components component-id] ctst/delete-shape id ignore-touched)))
|
||||
|
||||
(defmethod process-change :fix-obj
|
||||
[data {:keys [page-id component-id id] :as params}]
|
||||
[data {:keys [page-id component-id id] :as params} _]
|
||||
(letfn [(fix-container [container]
|
||||
(case (:fix params :broken-children)
|
||||
:broken-children (ctst/fix-broken-children container id)
|
||||
@@ -682,7 +693,7 @@
|
||||
(d/update-in-when data [:components component-id] fix-container))))
|
||||
|
||||
(defmethod process-change :reg-objects
|
||||
[data {:keys [page-id component-id shapes]}]
|
||||
[data {:keys [page-id component-id shapes]} _]
|
||||
;; FIXME: Improve performance
|
||||
(letfn [(reg-objects [objects]
|
||||
(let [lookup (d/getf objects)
|
||||
@@ -734,7 +745,7 @@
|
||||
|
||||
(defmethod process-change :mov-objects
|
||||
;; FIXME: ignore-touched is no longer used, so we can consider it deprecated
|
||||
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]}]
|
||||
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]} _]
|
||||
(letfn [(calculate-invalid-targets [objects shape-id]
|
||||
(let [reduce-fn #(into %1 (calculate-invalid-targets objects %2))]
|
||||
(->> (get-in objects [shape-id :shapes])
|
||||
@@ -849,7 +860,7 @@
|
||||
(d/update-in-when data [:components component-id :objects] move-objects))))
|
||||
|
||||
(defmethod process-change :add-page
|
||||
[data {:keys [id name page]}]
|
||||
[data {:keys [id name page]} _]
|
||||
(when (and id name page)
|
||||
(ex/raise :type :conflict
|
||||
:hint "id+name or page should be provided, never both"))
|
||||
@@ -859,7 +870,7 @@
|
||||
(ctpl/add-page data page)))
|
||||
|
||||
(defmethod process-change :mod-page
|
||||
[data {:keys [id] :as params}]
|
||||
[data {:keys [id] :as params} _]
|
||||
(d/update-in-when data [:pages-index id]
|
||||
(fn [page]
|
||||
(let [name (get params :name)
|
||||
@@ -889,7 +900,7 @@
|
||||
(dissoc :pixel-grid-opacity))))))
|
||||
|
||||
(defmethod process-change :set-plugin-data
|
||||
[data {:keys [object-type object-id page-id namespace key value]}]
|
||||
[data {:keys [object-type object-id page-id namespace key value]} _]
|
||||
(letfn [(update-fn [data]
|
||||
(if (some? value)
|
||||
(assoc-in data [:plugin-data namespace key] value)
|
||||
@@ -915,83 +926,83 @@
|
||||
(d/update-in-when data [:components object-id] update-fn))))
|
||||
|
||||
(defmethod process-change :del-page
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(ctpl/delete-page data id))
|
||||
|
||||
(defmethod process-change :mov-page
|
||||
[data {:keys [id index]}]
|
||||
[data {:keys [id index]} _]
|
||||
(update data :pages d/insert-at-index index [id]))
|
||||
|
||||
(defmethod process-change :add-color
|
||||
[data {:keys [color]}]
|
||||
[data {:keys [color]} _]
|
||||
(ctl/add-color data color))
|
||||
|
||||
(defmethod process-change :mod-color
|
||||
[data {:keys [color]}]
|
||||
[data {:keys [color]} _]
|
||||
(ctl/set-color data color))
|
||||
|
||||
(defmethod process-change :del-color
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(ctl/delete-color data id))
|
||||
|
||||
;; -- Media
|
||||
|
||||
(defmethod process-change :add-media
|
||||
[data {:keys [object]}]
|
||||
[data {:keys [object]} _]
|
||||
(update data :media assoc (:id object) object))
|
||||
|
||||
(defmethod process-change :mod-media
|
||||
[data {:keys [object]}]
|
||||
[data {:keys [object]} _]
|
||||
(d/update-in-when data [:media (:id object)] merge object))
|
||||
|
||||
(defmethod process-change :del-media
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(d/update-when data :media dissoc id))
|
||||
|
||||
;; -- Components
|
||||
|
||||
(defmethod process-change :add-component
|
||||
[data params]
|
||||
[data params _]
|
||||
(ctkl/add-component data params))
|
||||
|
||||
(defmethod process-change :mod-component
|
||||
[data params]
|
||||
[data params _]
|
||||
(ctkl/mod-component data params))
|
||||
|
||||
(defmethod process-change :del-component
|
||||
[data {:keys [id skip-undelete? delta]}]
|
||||
[data {:keys [id skip-undelete? delta]} _]
|
||||
(ctf/delete-component data id skip-undelete? delta))
|
||||
|
||||
(defmethod process-change :restore-component
|
||||
[data {:keys [id page-id]}]
|
||||
[data {:keys [id page-id]} _]
|
||||
(ctf/restore-component data id page-id))
|
||||
|
||||
(defmethod process-change :purge-component
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(ctf/purge-component data id))
|
||||
|
||||
;; -- Typography
|
||||
|
||||
(defmethod process-change :add-typography
|
||||
[data {:keys [typography]}]
|
||||
[data {:keys [typography]} _]
|
||||
(ctyl/add-typography data typography))
|
||||
|
||||
(defmethod process-change :mod-typography
|
||||
[data {:keys [typography]}]
|
||||
[data {:keys [typography]} _]
|
||||
(ctyl/update-typography data (:id typography) merge typography))
|
||||
|
||||
(defmethod process-change :del-typography
|
||||
[data {:keys [id]}]
|
||||
[data {:keys [id]} _]
|
||||
(ctyl/delete-typography data id))
|
||||
|
||||
;; -- Design Tokens
|
||||
|
||||
(defmethod process-change :set-tokens-lib
|
||||
[data {:keys [tokens-lib]}]
|
||||
[data {:keys [tokens-lib]} _]
|
||||
(assoc data :tokens-lib tokens-lib))
|
||||
|
||||
(defmethod process-change :set-token
|
||||
[data {:keys [set-id token-id attrs]}]
|
||||
[data {:keys [set-id token-id attrs]} _]
|
||||
(update data :tokens-lib
|
||||
(fn [lib]
|
||||
(let [lib' (ctob/ensure-tokens-lib lib)]
|
||||
@@ -1008,7 +1019,7 @@
|
||||
(ctob/make-token (merge prev-token attrs)))))))))
|
||||
|
||||
(defmethod process-change :set-token-set
|
||||
[data {:keys [id attrs]}]
|
||||
[data {:keys [id attrs]} _]
|
||||
(update data :tokens-lib
|
||||
(fn [lib]
|
||||
(let [lib' (ctob/ensure-tokens-lib lib)]
|
||||
@@ -1023,7 +1034,7 @@
|
||||
(ctob/update-set lib' id (fn [_] (ctob/make-token-set attrs))))))))
|
||||
|
||||
(defmethod process-change :set-token-theme
|
||||
[data {:keys [id attrs]}]
|
||||
[data {:keys [id attrs]} _]
|
||||
(update data :tokens-lib
|
||||
(fn [lib]
|
||||
(let [lib' (ctob/ensure-tokens-lib lib)]
|
||||
@@ -1041,35 +1052,67 @@
|
||||
(ctob/make-token-theme (merge prev-token-theme attrs)))))))))
|
||||
|
||||
(defmethod process-change :set-active-token-themes
|
||||
[data {:keys [theme-paths]}]
|
||||
[data {:keys [theme-paths]} _]
|
||||
(update data :tokens-lib #(-> % (ctob/ensure-tokens-lib)
|
||||
(ctob/set-active-themes theme-paths))))
|
||||
|
||||
(defmethod process-change :rename-token-set-group
|
||||
[data {:keys [set-group-path set-group-fname]}]
|
||||
[data {:keys [set-group-path set-group-fname]} _]
|
||||
(update data :tokens-lib (fn [lib]
|
||||
(-> lib
|
||||
(ctob/ensure-tokens-lib)
|
||||
(ctob/rename-set-group set-group-path set-group-fname)))))
|
||||
|
||||
(defmethod process-change :move-token-set
|
||||
[data {:keys [from-path to-path before-path before-group] :as changes}]
|
||||
[data {:keys [from-path to-path before-path before-group] :as changes} _]
|
||||
(update data :tokens-lib #(-> %
|
||||
(ctob/ensure-tokens-lib)
|
||||
(ctob/move-set from-path to-path before-path before-group))))
|
||||
|
||||
(defmethod process-change :move-token-set-group
|
||||
[data {:keys [from-path to-path before-path before-group]}]
|
||||
[data {:keys [from-path to-path before-path before-group]} _]
|
||||
(update data :tokens-lib #(-> %
|
||||
(ctob/ensure-tokens-lib)
|
||||
(ctob/move-set-group from-path to-path before-path before-group))))
|
||||
|
||||
;; === Design Tokens configuration
|
||||
;; --- Design Tokens configuration
|
||||
|
||||
(defmethod process-change :set-base-font-size
|
||||
[data {:keys [base-font-size]}]
|
||||
[data {:keys [base-font-size]} _]
|
||||
(ctf/set-base-font-size data base-font-size))
|
||||
|
||||
;; --- Validate Shapes
|
||||
|
||||
#?(:clj
|
||||
(defmethod process-change :validate-shapes
|
||||
[data _ _]
|
||||
data))
|
||||
|
||||
#?(:cljs
|
||||
(defmethod process-change :validate-shapes
|
||||
[data {:keys [page-id shape-ids context]} libraries]
|
||||
(if libraries
|
||||
(println "Validating shapes: \n"
|
||||
" page-id:" (str page-id) "\n"
|
||||
" shape-ids:" (str shape-ids) "\n"
|
||||
" context:" context)
|
||||
(let [file {:data data :id uuid/zero}
|
||||
errors (reduce (fn [acc shape-id]
|
||||
(if-let [page (ctpl/get-page data page-id)]
|
||||
(let [page-errors (val/validate-shape shape-id file page libraries)]
|
||||
(if (seq page-errors)
|
||||
(into acc page-errors)
|
||||
acc))
|
||||
acc))
|
||||
[]
|
||||
shape-ids)]
|
||||
(when (seq errors)
|
||||
(ex/raise :type :validation
|
||||
:code :referential-integrity
|
||||
:hint (str "error on validating shapes: " context)
|
||||
:details errors))
|
||||
data))
|
||||
data))
|
||||
|
||||
;; === Operations
|
||||
|
||||
|
||||
@@ -1203,7 +1203,6 @@
|
||||
[changes]
|
||||
(::page-id (meta changes)))
|
||||
|
||||
|
||||
(defn set-text-content
|
||||
[changes id content prev-content]
|
||||
(assert-page-id! changes)
|
||||
@@ -1224,3 +1223,12 @@
|
||||
(-> changes
|
||||
(update :redo-changes conj redo-change)
|
||||
(update :undo-changes conj undo-change))))
|
||||
|
||||
;; Validate Shapes
|
||||
|
||||
(defn validate-shapes
|
||||
[changes page-id shape-ids context]
|
||||
(update changes :redo-changes conj {:type :validate-shapes
|
||||
:page-id page-id
|
||||
:shape-ids (vec shape-ids)
|
||||
:context context}))
|
||||
@@ -10,7 +10,6 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.files.variant :as cfv]
|
||||
[app.common.path-names :as cpn]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.component :as ctk]
|
||||
@@ -569,7 +568,17 @@
|
||||
objects (:objects page)
|
||||
file-data (:data file)
|
||||
first-child (get objects (first shapes))
|
||||
prop-names (cfv/extract-properties-names first-child file-data)]
|
||||
|
||||
extract-properties-names
|
||||
(fn [shape]
|
||||
;; Get the names of the properties of the shape's component
|
||||
(->> shape
|
||||
(#(ctkl/get-component file-data (:component-id %) true))
|
||||
:variant-properties
|
||||
(map :name)))
|
||||
|
||||
prop-names (extract-properties-names first-child)]
|
||||
|
||||
(run! (fn [child-id]
|
||||
(when-let [child (get objects child-id)]
|
||||
(if (not (ctk/is-variant? child))
|
||||
@@ -583,7 +592,7 @@
|
||||
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
|
||||
child file page
|
||||
:variant-id shape-id))
|
||||
(when (not= prop-names (cfv/extract-properties-names child file-data))
|
||||
(when (not= prop-names (extract-properties-names child))
|
||||
(report-error :invalid-variant-properties
|
||||
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
|
||||
child file page
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
(ns app.common.files.variant
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.component :as ctc]
|
||||
[app.common.types.components-list :as ctcl]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.common.types.variant :as ctv]))
|
||||
|
||||
(defn find-variant-components
|
||||
"Find a list of the components that belongs to this variant-id"
|
||||
"Find the components that belong to the variant container identified by `variant-id`,
|
||||
preserving the order defined by the container's shapes.
|
||||
|
||||
Example return:
|
||||
(<component1> <component2> ...)"
|
||||
([data variant-id]
|
||||
(let [page-id (->> data
|
||||
:components
|
||||
@@ -22,22 +25,24 @@
|
||||
objects (dm/get-in data [:pages-index page-id :objects])]
|
||||
(find-variant-components data objects variant-id)))
|
||||
([data objects variant-id]
|
||||
(assert (or (uuid? variant-id) (nil? variant-id)))
|
||||
;; We can't simply filter components, because we need to maintain the order
|
||||
(->> (dm/get-in objects [variant-id :shapes])
|
||||
(map #(dm/get-in objects [% :component-id]))
|
||||
(map #(ctcl/get-component data % true))
|
||||
reverse)))
|
||||
|
||||
(defn extract-properties-names
|
||||
[shape data]
|
||||
(->> shape
|
||||
(#(ctcl/get-component data (:component-id %) true))
|
||||
:variant-properties
|
||||
(map :name)))
|
||||
(let [container (get objects variant-id)]
|
||||
(if (ctv/variant-container? container)
|
||||
(->> (:shapes container)
|
||||
(map #(dm/get-in objects [% :component-id]))
|
||||
(map #(ctkl/get-component data % true))
|
||||
reverse)
|
||||
[]))))
|
||||
|
||||
(defn extract-properties-values
|
||||
"Get a map of properties associated to their possible values"
|
||||
"Get a map of variant property names to their distinct possible values,
|
||||
collected from all components that belong to the variant container.
|
||||
|
||||
Example return:
|
||||
[{:name 'Property 1' :value ('Value1' 'Value2')}]"
|
||||
[data objects variant-id]
|
||||
(assert (or (uuid? variant-id) (nil? variant-id)))
|
||||
(->> (find-variant-components data objects variant-id)
|
||||
(mapcat :variant-properties)
|
||||
(group-by :name)
|
||||
@@ -47,9 +52,13 @@
|
||||
:value (->> v (map :value) distinct)}
|
||||
mdata))))))
|
||||
|
||||
(defn get-variant-mains
|
||||
[component data]
|
||||
(assert (ctv/valid-variant-component? component) "expected valid component variant")
|
||||
(defn- get-variant-mains
|
||||
"Return the ids of the main instance shapes of the variant this component belongs to,
|
||||
in the order they appear in the container.
|
||||
|
||||
Example return:
|
||||
[<main-shape-a-id> <main-shape-b-id>]"
|
||||
[data component]
|
||||
(when-let [variant-id (:variant-id component)]
|
||||
(let [page-id (:main-instance-page component)
|
||||
objects (-> (dm/get-in data [:pages-index page-id])
|
||||
@@ -57,27 +66,20 @@
|
||||
(dm/get-in objects [variant-id :shapes]))))
|
||||
|
||||
(defn is-secondary-variant?
|
||||
[component data]
|
||||
(let [shapes (get-variant-mains component data)]
|
||||
"Return true if the component is a secondary variant in its variant container.
|
||||
The primary variant is the last one in the container's children list.
|
||||
Return false if the component is the primary variant or if it's not part of a variant."
|
||||
[data component]
|
||||
(let [shapes (get-variant-mains data component)]
|
||||
(and (seq shapes)
|
||||
(not= (:main-instance-id component) (last shapes)))))
|
||||
|
||||
(defn get-primary-variant
|
||||
"Return the main instance of the primary variant (the last one) in the variant container."
|
||||
[data component]
|
||||
(let [page-id (:main-instance-page component)
|
||||
objects (-> (dm/get-in data [:pages-index page-id])
|
||||
(get :objects))
|
||||
variant-id (:variant-id component)]
|
||||
(->> (dm/get-in objects [variant-id :shapes])
|
||||
(let [page-id (:main-instance-page component)
|
||||
objects (-> (dm/get-in data [:pages-index page-id])
|
||||
(get :objects))]
|
||||
(->> (get-variant-mains data component)
|
||||
peek
|
||||
(get objects))))
|
||||
|
||||
(defn get-primary-component
|
||||
[data component-id]
|
||||
(when-let [component (ctcl/get-component data component-id)]
|
||||
(if (ctc/is-variant? component)
|
||||
(->> component
|
||||
(get-primary-variant data)
|
||||
:component-id
|
||||
(ctcl/get-component data))
|
||||
component)))
|
||||
@@ -290,8 +290,11 @@
|
||||
duplicated-parent?
|
||||
(->> ids-map vals (some #(= % (:parent-id first-shape))))
|
||||
|
||||
grid-parent?
|
||||
(and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
|
||||
|
||||
changes
|
||||
(if (and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
|
||||
(if grid-parent?
|
||||
(let [target-cell (-> position meta :cell)
|
||||
|
||||
[row column]
|
||||
@@ -313,7 +316,19 @@
|
||||
changes
|
||||
(reduce #(pcb/add-object %1 %2 {:ignore-touched true})
|
||||
changes
|
||||
(rest new-shapes))]
|
||||
(rest new-shapes))
|
||||
|
||||
ids-to-validate (cond-> [(:id first-shape)]
|
||||
grid-parent?
|
||||
(conj (:parent-id first-shape)))
|
||||
|
||||
changes (if (seq ids-to-validate)
|
||||
(pcb/validate-shapes changes
|
||||
(:id page)
|
||||
ids-to-validate
|
||||
(str "generate-instantiate-component: " component-id
|
||||
" under parent-id" (or parent-id " root")))
|
||||
changes)]
|
||||
|
||||
[new-shape changes])))
|
||||
|
||||
@@ -3123,7 +3138,6 @@
|
||||
;; we calculate a new one because the components will have created new shapes.
|
||||
ids-map (into {} (map #(vector % (uuid/next))) all-ids)
|
||||
|
||||
|
||||
;; If there is an alt-duplication we change to root
|
||||
;; For variants so the copy is made as a child of root
|
||||
;; This is because inside a variant-container can't be a copy
|
||||
@@ -3135,7 +3149,6 @@
|
||||
(assoc :parent-id uuid/zero :frame-id uuid/zero)))
|
||||
shapes)
|
||||
|
||||
|
||||
changes (-> changes
|
||||
(pcb/with-page page)
|
||||
(pcb/with-objects all-objects)
|
||||
@@ -3165,7 +3178,20 @@
|
||||
(comp
|
||||
(filter #(= :add-obj (:type %)))
|
||||
(map #(vector (:old-id %) (-> % :obj :id))))
|
||||
(:redo-changes changes))]
|
||||
(:redo-changes changes))
|
||||
|
||||
copied-components
|
||||
(ctn/get-all-instance-roots (:objects page) ids)
|
||||
|
||||
ids-to-validate
|
||||
(map #(get ids-map % %) copied-components)
|
||||
|
||||
changes (if (seq ids-to-validate)
|
||||
(pcb/validate-shapes changes
|
||||
(:id page)
|
||||
ids-to-validate
|
||||
(cond-> (str "generate-duplicate-changes: " ids)))
|
||||
changes)]
|
||||
|
||||
(-> changes
|
||||
(generate-duplicate-flows shapes page ids-map)
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
(reduce check-shape changes mod-obj-changes)))
|
||||
|
||||
(defn generate-update-shapes
|
||||
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation?]}]
|
||||
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation? extra-context]}]
|
||||
(let [changes (reduce
|
||||
(fn [changes id]
|
||||
(let [opts {:attrs attrs
|
||||
@@ -96,7 +96,19 @@
|
||||
(pcb/reorder-grid-children ids))
|
||||
|
||||
(not ignore-touched)
|
||||
(generate-unapply-tokens objects changed-sub-attr))]
|
||||
(generate-unapply-tokens objects changed-sub-attr))
|
||||
|
||||
page-id (pcb/get-page-id changes)
|
||||
modified-components (ctn/get-all-instance-roots objects ids)
|
||||
|
||||
changes (if (and page-id (seq modified-components))
|
||||
(pcb/validate-shapes changes
|
||||
page-id
|
||||
modified-components
|
||||
(cond-> (str "generate-update-shapes: " ids " " attrs)
|
||||
(some? extra-context)
|
||||
(str " \n -> from " extra-context)))
|
||||
changes)]
|
||||
changes))
|
||||
|
||||
(defn- generate-update-shape-flags
|
||||
@@ -248,8 +260,8 @@
|
||||
page-id (pcb/get-page-id changes)
|
||||
page (or (pcb/get-page changes)
|
||||
(ctpl/get-page data page-id))
|
||||
|
||||
ids (cfh/clean-loops objects ids)
|
||||
|
||||
in-component-copy?
|
||||
(fn [shape-id]
|
||||
;; Look for shapes that are inside a component copy, but are
|
||||
@@ -258,7 +270,7 @@
|
||||
;; If we want to specifically allow altering the copies, this is
|
||||
;; a special case, like a component swap, in which case we want
|
||||
;; to delete the old shape
|
||||
(let [shape (get objects shape-id)]
|
||||
(let [shape (get objects shape-id)]
|
||||
(and (ctn/has-any-copy-parent? objects shape)
|
||||
(not allow-altering-copies))))
|
||||
|
||||
@@ -437,7 +449,19 @@
|
||||
(into []
|
||||
(remove #(and (ctsi/has-destination %)
|
||||
(id-to-delete? (:destination %))))
|
||||
interactions))))))]
|
||||
interactions))))))
|
||||
|
||||
modified-components (ctn/get-all-instance-roots objects (disj all-parents uuid/zero))
|
||||
;; There is no need to validate deleted objects. Probably also no need to validate hidden or unmasked objects,
|
||||
;; but we may think of it
|
||||
|
||||
changes (if (seq modified-components)
|
||||
(pcb/validate-shapes changes
|
||||
page-id
|
||||
modified-components
|
||||
(str "generate-delete-shapes: " ids))
|
||||
changes)]
|
||||
|
||||
[all-parents changes])))
|
||||
|
||||
|
||||
|
||||
@@ -172,10 +172,10 @@
|
||||
new-props (- min-props
|
||||
(+ (count props)
|
||||
(if add-name? 1 0)))
|
||||
props (ctv/add-new-props props (repeat new-props ""))]
|
||||
props (ctv/add-new-properties props (repeat new-props ""))]
|
||||
|
||||
(if add-name?
|
||||
(ctv/add-new-prop props (:name component))
|
||||
(ctv/add-new-property props (:name component))
|
||||
props)))
|
||||
|
||||
(defn- create-new-properties-from-non-variant
|
||||
|
||||
@@ -72,13 +72,6 @@
|
||||
[:map {:title "PlainColorAttrs"}
|
||||
[:color schema:hex-color]])
|
||||
|
||||
(def schema:image-transform
|
||||
[:map {:title "ImageTransform" :closed true}
|
||||
[:x {:optional true} ::sm/safe-number]
|
||||
[:y {:optional true} ::sm/safe-number]
|
||||
[:width {:optional true} ::sm/safe-number]
|
||||
[:height {:optional true} ::sm/safe-number]])
|
||||
|
||||
(def schema:image
|
||||
[:map {:title "ImageColor" :closed true}
|
||||
[:width [::sm/int {:min 0 :gen/gen sg/int}]]
|
||||
@@ -86,8 +79,7 @@
|
||||
[:mtype {:gen/gen (sg/elements cm/image-types)} ::sm/text]
|
||||
[:id ::sm/uuid]
|
||||
[:name {:optional true} ::sm/text]
|
||||
[:keep-aspect-ratio {:optional true} :boolean]
|
||||
[:transform {:optional true} schema:image-transform]])
|
||||
[:keep-aspect-ratio {:optional true} :boolean]])
|
||||
|
||||
(def image-attrs
|
||||
"A set of attrs that corresponds to image data type"
|
||||
|
||||
@@ -216,6 +216,41 @@
|
||||
:else
|
||||
(get-instance-root objects (get objects (:parent-id shape)))))
|
||||
|
||||
(defn get-all-instance-roots
|
||||
"Given a list of shape ids and an objects tree, returns a set with the ids of
|
||||
all instance roots that are at, above or below any of the shapes identified by
|
||||
the given list. An instance root is a shape that has :component-root set to
|
||||
true (checked by ctk/instance-root?). There is at most one instance root in
|
||||
any subtree rooted at an instance root, so the downward search stops at the
|
||||
first instance root found in each branch. Uses a visited set to avoid
|
||||
reprocessing the same shapes."
|
||||
[objects shape-ids]
|
||||
(let [visited (atom #{})
|
||||
result (atom #{})]
|
||||
(letfn [(search-up [shape-id]
|
||||
(when-not (contains? @visited shape-id)
|
||||
(swap! visited conj shape-id)
|
||||
(let [shape (get objects shape-id)]
|
||||
(when-not (nil? shape)
|
||||
(if (ctk/instance-root? shape)
|
||||
(swap! result conj (:id shape))
|
||||
(when-not (cfh/root? shape)
|
||||
(when-let [parent-id (:parent-id shape)]
|
||||
(search-up parent-id))))))))
|
||||
(search-down [shape-id]
|
||||
(when-not (contains? @visited shape-id)
|
||||
(swap! visited conj shape-id)
|
||||
(let [shape (get objects shape-id)]
|
||||
(when-not (nil? shape)
|
||||
(if (ctk/instance-root? shape)
|
||||
(swap! result conj (:id shape))
|
||||
(doseq [child-id (:shapes shape)]
|
||||
(search-down child-id)))))))]
|
||||
(doseq [shape-id shape-ids]
|
||||
(search-up shape-id)
|
||||
(search-down shape-id))
|
||||
@result)))
|
||||
|
||||
(defn find-component-main
|
||||
"If the shape is a component main instance or is inside one, return that instance.
|
||||
Uses an iterative loop with cycle detection to prevent stack overflow on circular
|
||||
|
||||
@@ -119,15 +119,12 @@
|
||||
|
||||
(defn write-image-fill
|
||||
[offset buffer opacity image]
|
||||
(let [image-id (get image :id)
|
||||
image-width (get image :width)
|
||||
image-height (get image :height)
|
||||
alpha (mth/floor (* opacity 0xff))
|
||||
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
|
||||
transform (get image :transform)
|
||||
has-transform? (some? transform)
|
||||
transform-flag (if has-transform? 0x02 0x00)
|
||||
flags (bit-or keep-aspect-ratio transform-flag)]
|
||||
(let [image-id (get image :id)
|
||||
image-width (get image :width)
|
||||
image-height (get image :height)
|
||||
alpha (mth/floor (* opacity 0xff))
|
||||
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
|
||||
flags (bit-or keep-aspect-ratio 0x00)]
|
||||
(buf/write-byte buffer (+ offset 0) 0x03)
|
||||
(buf/write-uuid buffer (+ offset 4) image-id)
|
||||
(buf/write-byte buffer (+ offset 20) alpha)
|
||||
@@ -135,17 +132,6 @@
|
||||
(buf/write-short buffer (+ offset 22) 0) ;; 2-byte padding (reserved for future use)
|
||||
(buf/write-int buffer (+ offset 24) image-width)
|
||||
(buf/write-int buffer (+ offset 28) image-height)
|
||||
(if has-transform?
|
||||
(do
|
||||
(buf/write-float buffer (+ offset 32) (double (get transform :x 0.0)))
|
||||
(buf/write-float buffer (+ offset 36) (double (get transform :y 0.0)))
|
||||
(buf/write-float buffer (+ offset 40) (double (get transform :width 1.0)))
|
||||
(buf/write-float buffer (+ offset 44) (double (get transform :height 1.0))))
|
||||
(do
|
||||
(buf/write-float buffer (+ offset 32) 0.0)
|
||||
(buf/write-float buffer (+ offset 36) 0.0)
|
||||
(buf/write-float buffer (+ offset 40) 1.0)
|
||||
(buf/write-float buffer (+ offset 44) 1.0)))
|
||||
(+ offset FILL-U8-SIZE)))
|
||||
|
||||
(defn- write-metadata
|
||||
@@ -222,36 +208,28 @@
|
||||
:type type}})
|
||||
|
||||
3 ;; image fill
|
||||
(let [id (buf/read-uuid dbuffer (+ doffset 4))
|
||||
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
|
||||
opacity (mth/precision (/ alpha 0xff) 2)
|
||||
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
|
||||
ratio (not (zero? (bit-and flags 0x01)))
|
||||
has-tf (not (zero? (bit-and flags 0x02)))
|
||||
width (buf/read-int dbuffer (+ doffset 24))
|
||||
height (buf/read-int dbuffer (+ doffset 28))
|
||||
transform (when has-tf
|
||||
{:x (buf/read-float dbuffer (+ doffset 32))
|
||||
:y (buf/read-float dbuffer (+ doffset 36))
|
||||
:width (buf/read-float dbuffer (+ doffset 40))
|
||||
:height (buf/read-float dbuffer (+ doffset 44))})
|
||||
mtype (buf/read-short mbuffer (+ moffset 2))
|
||||
mtype (case mtype
|
||||
0x01 "image/jpeg"
|
||||
0x02 "image/png"
|
||||
0x03 "image/gif"
|
||||
0x04 "image/webp"
|
||||
0x05 "image/svg+xml")]
|
||||
(let [id (buf/read-uuid dbuffer (+ doffset 4))
|
||||
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
|
||||
opacity (mth/precision (/ alpha 0xff) 2)
|
||||
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
|
||||
ratio (boolean (bit-and flags 0x01))
|
||||
width (buf/read-int dbuffer (+ doffset 24))
|
||||
height (buf/read-int dbuffer (+ doffset 28))
|
||||
mtype (buf/read-short mbuffer (+ moffset 2))
|
||||
mtype (case mtype
|
||||
0x01 "image/jpeg"
|
||||
0x02 "image/png"
|
||||
0x03 "image/gif"
|
||||
0x04 "image/webp"
|
||||
0x05 "image/svg+xml")]
|
||||
{:fill-opacity opacity
|
||||
:fill-image (cond-> {:id id
|
||||
:width width
|
||||
:height height
|
||||
:mtype mtype
|
||||
:keep-aspect-ratio ratio
|
||||
;; FIXME: we are not encodign the name, looks useless
|
||||
:name "sample"}
|
||||
(some? transform)
|
||||
(assoc :transform transform))}))]
|
||||
:fill-image {:id id
|
||||
:width width
|
||||
:height height
|
||||
:mtype mtype
|
||||
:keep-aspect-ratio ratio
|
||||
;; FIXME: we are not encodign the name, looks useless
|
||||
:name "sample"}}))]
|
||||
|
||||
(if refs?
|
||||
(let [ref-file (buf/read-uuid mbuffer (+ moffset 4))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.common.types.path.fit
|
||||
"Curve fitting helpers."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.common.types.path.selection
|
||||
"Transforms selected path nodes and handlers."
|
||||
|
||||
@@ -272,20 +272,6 @@
|
||||
-1))))
|
||||
items))))
|
||||
|
||||
(defn- clipped-by-ancestor?
|
||||
"Checks whether position falls outside the visible (clipped) bounds of
|
||||
some ancestor frame with clip content enabled. Used so that a nested
|
||||
frame that extends beyond a clipping ancestor's own bounds is never
|
||||
considered hit/reachable in the invisible, clipped-away region."
|
||||
[objects shape position]
|
||||
(->> (cfh/get-parent-ids objects (dm/get-prop shape :id))
|
||||
(keep (d/getf objects))
|
||||
(some (fn [ancestor]
|
||||
(and (not= (dm/get-prop ancestor :id) uuid/zero)
|
||||
^boolean (cfh/frame-shape? ancestor)
|
||||
(not (:show-content ancestor))
|
||||
(not ^boolean (gsh/has-point? ancestor position)))))))
|
||||
|
||||
(defn get-frame-by-position
|
||||
([objects position]
|
||||
(get-frame-by-position objects position nil))
|
||||
@@ -301,7 +287,6 @@
|
||||
validator (or (get options :validator) #(-> true))]
|
||||
(or (d/seek #(and ^boolean (some? position)
|
||||
^boolean (gsh/has-point? % position)
|
||||
^boolean (not (clipped-by-ancestor? objects % position))
|
||||
^boolean (validator %))
|
||||
frames)
|
||||
(get objects uuid/zero)))))
|
||||
@@ -317,8 +302,7 @@
|
||||
([objects position options]
|
||||
(->> (get-frames objects options)
|
||||
(filter #(and ^boolean (some? position)
|
||||
^boolean (gsh/has-point? % position)
|
||||
^boolean (not (clipped-by-ancestor? objects % position))))
|
||||
^boolean (gsh/has-point? % position)))
|
||||
(sort-z-index-objects objects))))
|
||||
|
||||
(defn top-nested-frame
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.common.types.tokens-status
|
||||
(:require
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
[:variant-id {:optional true} ::sm/uuid]
|
||||
[:variant-properties {:optional true} [:vector schema:variant-property]]])
|
||||
|
||||
(def valid-variant-component?
|
||||
(sm/check-fn schema:variant-component))
|
||||
|
||||
(def schema:variant-shape
|
||||
"The root shape of the main instance of a variant component"
|
||||
[:map
|
||||
@@ -34,14 +37,17 @@
|
||||
[:variant-name {:optional true} :string]
|
||||
[:variant-error {:optional true} :string]])
|
||||
|
||||
(def valid-variant-shape?
|
||||
(sm/check-fn schema:variant-shape))
|
||||
|
||||
(def schema:variant-container
|
||||
"Is a board that contains all variant components of a variant set,
|
||||
for grouping them visually in the workspace"
|
||||
[:map
|
||||
[:is-variant-container {:optional true} :boolean]])
|
||||
|
||||
(def valid-variant-component?
|
||||
(sm/check-fn schema:variant-component))
|
||||
(def valid-variant-container?
|
||||
(sm/check-fn schema:variant-container))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -50,17 +56,41 @@
|
||||
(def property-max-length 60)
|
||||
(def value-prefix "Value ")
|
||||
|
||||
(defn variant-component?
|
||||
[component]
|
||||
(some? (:variant-id component)))
|
||||
|
||||
(defn variant-shape?
|
||||
[shape]
|
||||
(some? (:variant-id shape)))
|
||||
|
||||
(defn variant-container?
|
||||
[shape]
|
||||
(some? (:is-variant-container shape)))
|
||||
|
||||
(defn properties-to-name
|
||||
"Transform the properties into a name, with the values separated by comma"
|
||||
"Transform the properties into a name, with the values separated by comma, excluding the empty ones.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'Button'}
|
||||
{:name 'Property 2' :value 'Primary'}] -> 'Button, Primary'"
|
||||
[properties]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(->> properties
|
||||
(map :value)
|
||||
(remove str/empty?)
|
||||
(str/join ", ")))
|
||||
|
||||
(defn next-property-number
|
||||
"Returns the next property number, to avoid duplicates on the property names"
|
||||
"Returns the next property number, to avoid duplicates on the property names.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 3' :value 'y'}] -> 4"
|
||||
[properties]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(let [numbers (keep
|
||||
#(some->> (:name %) (re-find property-regex) second d/parse-integer)
|
||||
properties)
|
||||
@@ -69,38 +99,70 @@
|
||||
0)]
|
||||
(inc (max max-num (count properties)))))
|
||||
|
||||
(defn add-new-prop
|
||||
"Adds a new property with generated name and provided value to the existing props list."
|
||||
[props value]
|
||||
(conj props {:name (str property-prefix (next-property-number props))
|
||||
:value value}))
|
||||
(defn add-new-property
|
||||
"Adds a new property with generated name and provided value to the existing properties list.
|
||||
|
||||
(defn add-new-props
|
||||
"Adds new properties with generated names and provided values to the existing props list."
|
||||
[props values]
|
||||
(let [next-prop-num (next-property-number props)
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}] 'y' -> [{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}]"
|
||||
[properties value]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(assert (or (string? value) (nil? value)))
|
||||
(conj properties {:name (str property-prefix (next-property-number properties))
|
||||
:value value}))
|
||||
|
||||
(defn add-new-properties
|
||||
"Adds new properties with generated names and provided values to the existing properties list.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}] ['a' 'b'] -> [{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'a'}
|
||||
{:name 'Property 3' :value 'b'}]"
|
||||
[properties values]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(assert (or (sequential? values) (nil? values)))
|
||||
(let [next-prop-num (next-property-number properties)
|
||||
xf (map-indexed (fn [i v]
|
||||
{:name (str property-prefix (+ next-prop-num i))
|
||||
:value v}))]
|
||||
(into props xf values)))
|
||||
(into properties xf values)))
|
||||
|
||||
(defn path-to-properties
|
||||
"From a list of properties and a name with path, assign each token of the
|
||||
path as value of a different property"
|
||||
path as value of a different property. It can add blank properties if
|
||||
necessary, until the min-properties number is reached.
|
||||
|
||||
Example with min-properties=4:
|
||||
|
||||
'Button / Primary / Hover' -> [{:name 'Property 1' :value 'Button'}
|
||||
{:name 'Property 2' :value 'Primary'}
|
||||
{:name 'Property 3' :value 'Hover'}
|
||||
{:name 'Property 4' :value ''}]"
|
||||
([path properties]
|
||||
(path-to-properties path properties 0))
|
||||
([path properties min-props]
|
||||
([path properties min-properties]
|
||||
(assert (or (string? path) (nil? path)))
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(assert (int? min-properties))
|
||||
(let [cpath (cpn/split-path path)
|
||||
total-props (max (count cpath) min-props)
|
||||
total-properties (max (count cpath) min-properties)
|
||||
assigned (mapv #(assoc % :value (nth cpath %2 "")) properties (range))
|
||||
;; Add empty strings to the end of cpath to reach the minimum number of properties
|
||||
cpath (take total-props (concat cpath (repeat "")))
|
||||
cpath (take total-properties (concat cpath (repeat "")))
|
||||
remaining (drop (count properties) cpath)]
|
||||
(add-new-props assigned remaining))))
|
||||
(add-new-properties assigned remaining))))
|
||||
|
||||
(defn properties-map->formula
|
||||
"Transforms a map of properties to a formula of properties omitting the empty ones"
|
||||
"Transforms a map of properties to a formula of properties omitting the empty ones.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'Button'}
|
||||
{:name 'Property 2' :value 'Primary'}] -> 'Property 1=Button, Property 2=Primary'"
|
||||
[properties]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(->> properties
|
||||
(keep (fn [{:keys [name value]}]
|
||||
(when (not (str/blank? value))
|
||||
@@ -108,9 +170,15 @@
|
||||
(str/join ", ")))
|
||||
|
||||
(defn properties-formula->map
|
||||
"Transforms a formula of properties to a map of properties"
|
||||
[s]
|
||||
(->> (str/split s ",")
|
||||
"Transforms a formula of properties to a map of properties.
|
||||
|
||||
Example:
|
||||
|
||||
'Property 1=Button, Property 2=Primary' -> [{:name 'Property 1' :value 'Button'}
|
||||
{:name 'Property 2' :value 'Primary'}]"
|
||||
[formula]
|
||||
(assert (or (string? formula) (nil? formula)))
|
||||
(->> (str/split formula ",")
|
||||
(mapv #(str/split % "=" 2))
|
||||
(filter (fn [[_ v]] (not (str/blank? v))))
|
||||
(mapv (fn [[k v]]
|
||||
@@ -118,9 +186,15 @@
|
||||
:value (str/trim v)}))))
|
||||
|
||||
(defn valid-properties-formula?
|
||||
"Checks if a formula is valid"
|
||||
[s]
|
||||
(->> (str/split s ",")
|
||||
"Checks if a formula is valid.
|
||||
|
||||
Example:
|
||||
|
||||
'Property 1=Button, Property 2=Primary' -> true
|
||||
'Property 1=Button, Property 2' -> false"
|
||||
[formula]
|
||||
(assert (or (string? formula) (nil? formula)))
|
||||
(->> (str/split formula ",")
|
||||
(mapv #(str/split % "=" 2))
|
||||
(every? #(and (= 2 (count %))
|
||||
(not (str/blank? (first %)))
|
||||
@@ -128,22 +202,47 @@
|
||||
(< (count (second %)) property-max-length)))))
|
||||
|
||||
(defn find-properties-to-remove
|
||||
"Compares two property maps to find which properties should be removed"
|
||||
[prev-props upd-props]
|
||||
(let [upd-names (set (map :name upd-props))]
|
||||
(filterv #(not (contains? upd-names (:name %))) prev-props)))
|
||||
"Compares two property maps to find which properties should be removed.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}]
|
||||
[{:name 'Property 1' :value 'x'}] -> [{:name 'Property 2' :value 'y'}]"
|
||||
[prev-properties upd-properties]
|
||||
(assert (or (sequential? prev-properties) (nil? prev-properties)))
|
||||
(assert (or (sequential? upd-properties) (nil? upd-properties)))
|
||||
(let [upd-names (set (map :name upd-properties))]
|
||||
(filterv #(not (contains? upd-names (:name %))) prev-properties)))
|
||||
|
||||
(defn find-properties-to-update
|
||||
"Compares two property maps to find which properties should be updated"
|
||||
[prev-props upd-props]
|
||||
"Compares two property maps to find which properties should be updated.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}]
|
||||
[{:name 'Property 1' :value 'new-x'}
|
||||
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 1' :value 'new-x'}]"
|
||||
[prev-properties upd-properties]
|
||||
(assert (or (sequential? prev-properties) (nil? prev-properties)))
|
||||
(assert (or (sequential? upd-properties) (nil? upd-properties)))
|
||||
(filterv #(some (fn [prop] (and (= (:name %) (:name prop))
|
||||
(not= (:value %) (:value prop)))) prev-props) upd-props))
|
||||
(not= (:value %) (:value prop)))) prev-properties) upd-properties))
|
||||
|
||||
(defn find-properties-to-add
|
||||
"Compares two property maps to find which properties should be added"
|
||||
[prev-props upd-props]
|
||||
(let [prev-names (set (map :name prev-props))]
|
||||
(filterv #(not (contains? prev-names (:name %))) upd-props)))
|
||||
"Compares two property maps to find which properties should be added.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}]
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 2' :value 'y'}]"
|
||||
[prev-properties upd-properties]
|
||||
(assert (or (sequential? prev-properties) (nil? prev-properties)))
|
||||
(assert (or (sequential? upd-properties) (nil? upd-properties)))
|
||||
(let [prev-names (set (map :name prev-properties))]
|
||||
(filterv #(not (contains? prev-names (:name %))) upd-properties)))
|
||||
|
||||
(defn- split-base-name-and-number
|
||||
"Extract the number in parentheses from an item, if present, and return both the base name and the number"
|
||||
@@ -165,8 +264,15 @@
|
||||
|
||||
(defn update-number-in-repeated-item
|
||||
"Add, keep or update a number in parentheses for a given item, if necessary, depending on the items
|
||||
already present in a list, to avoid repetitions"
|
||||
already present in a list, to avoid repetitions.
|
||||
|
||||
Example:
|
||||
|
||||
['Property'] 'Property' -> 'Property (1)'
|
||||
['Property' 'Property (1)'] 'Property' -> 'Property (2)'"
|
||||
[items item]
|
||||
(assert (or (sequential? items) (nil? items)))
|
||||
(assert (or (string? item) (nil? item)))
|
||||
(let [names (group-numbers-by-base-name items)
|
||||
[base num] (split-base-name-and-number item)
|
||||
nums-taken (get names base #{})]
|
||||
@@ -176,25 +282,46 @@
|
||||
(str base (when (pos? n) (str " (" n ")")))))))
|
||||
|
||||
(defn update-number-in-repeated-prop-names
|
||||
"Add, keep or update a number for each prop name depending on the previous ones"
|
||||
[props]
|
||||
(->> props
|
||||
"Add, keep or update a number for each prop name depending on the previous ones.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property' :value 'x'}
|
||||
{:name 'Property' :value 'y'}] -> [{:name 'Property' :value 'x'}
|
||||
{:name 'Property (1)' :value 'y'}]"
|
||||
[properties]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(->> properties
|
||||
(reduce (fn [acc prop]
|
||||
(conj acc {:name (update-number-in-repeated-item (mapv :name acc) (:name prop))
|
||||
:value (:value prop)}))
|
||||
[])))
|
||||
|
||||
(defn find-index-for-property-name
|
||||
"Finds the index of a name in a property map"
|
||||
[props name]
|
||||
"Finds the index of a name in a property map.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}] 'Property 2' -> 1"
|
||||
[properties name]
|
||||
(assert (or (sequential? properties) (nil? properties)))
|
||||
(assert (or (string? name) (nil? name)))
|
||||
(some (fn [[idx prop]]
|
||||
(when (= (:name prop) name)
|
||||
idx))
|
||||
(map-indexed vector props)))
|
||||
(map-indexed vector properties)))
|
||||
|
||||
(defn remove-prefix
|
||||
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name"
|
||||
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name.
|
||||
|
||||
Example:
|
||||
|
||||
'Button / Primary' 'Button' -> 'Primary'
|
||||
'Button / Primary' 'Other' -> 'Button / Primary'"
|
||||
[name prefix]
|
||||
(assert (or (string? name) (nil? name)))
|
||||
(assert (or (string? prefix) (nil? prefix)))
|
||||
(let [long-name (str prefix " / ")]
|
||||
(cond
|
||||
(str/starts-with? name long-name)
|
||||
@@ -210,22 +337,22 @@
|
||||
(map :name))
|
||||
|
||||
(defn- matching-indices
|
||||
[props1 props2]
|
||||
(let [names-in-p2 (into #{} xf:map-name props2)
|
||||
[properties1 properties2]
|
||||
(let [names-in-p2 (into #{} xf:map-name properties2)
|
||||
xform (comp
|
||||
(map-indexed (fn [index {:keys [name]}]
|
||||
(when (contains? names-in-p2 name)
|
||||
index)))
|
||||
(filter some?))]
|
||||
(into #{} xform props1)))
|
||||
(into #{} xform properties1)))
|
||||
|
||||
(defn- find-index-by-name
|
||||
"Returns the index of the first item in props with the given name, or nil if not found."
|
||||
[name props]
|
||||
"Returns the index of the first item in properties with the given name, or nil if not found."
|
||||
[name properties]
|
||||
(some (fn [[idx item]]
|
||||
(when (= (:name item) name)
|
||||
idx))
|
||||
(map-indexed vector props)))
|
||||
(map-indexed vector properties)))
|
||||
|
||||
(defn- next-valid-position
|
||||
"Returns the first non-negative integer not present in the used-pos set."
|
||||
@@ -236,42 +363,64 @@
|
||||
p)))
|
||||
|
||||
(defn- find-position
|
||||
"Returns the index of the property with the given name in `props`,
|
||||
"Returns the index of the property with the given name in `properties`,
|
||||
or the next available index not in `used-pos` if not found."
|
||||
[name props used-pos]
|
||||
(or (find-index-by-name name props)
|
||||
[name properties used-pos]
|
||||
(or (find-index-by-name name properties)
|
||||
(next-valid-position used-pos)))
|
||||
|
||||
(defn merge-properties
|
||||
"Merges props2 into props1 with the following rules:
|
||||
- For each property p2 in props2:
|
||||
"Merges properties2 into properties1 with the following rules:
|
||||
- For each property p2 in properties2:
|
||||
- Skip it if its value is empty.
|
||||
- If props1 contains a property with the same name, update its value with that of p2.
|
||||
- Otherwise, assign p2's value to the first unused property in props1. A property is considered used if:
|
||||
- Its name exists in both props1 and props2, or
|
||||
- If properties1 contains a property with the same name, update its value with that of p2.
|
||||
- Otherwise, assign p2's value to the first unused property in properties1. A property is considered used if:
|
||||
- Its name exists in both properties1 and properties2, or
|
||||
- Its value has already been updated during the merge.
|
||||
- If no unused properties are available in props1, append a new property with a default name and p2's value."
|
||||
[props1 props2]
|
||||
(let [props2 (remove #(str/empty? (:value %)) props2)]
|
||||
- If no unused properties are available in properties1, append a new property with a default name and p2's value.
|
||||
|
||||
Example:
|
||||
|
||||
[{:name 'Property 1' :value 'a'}
|
||||
{:name 'Property 2' :value 'b'}]
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}
|
||||
{:name 'Property 3' :value 'z'}] -> [{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}
|
||||
{:name 'Property 3' :value 'z'}]"
|
||||
[properties1 properties2]
|
||||
(assert (or (sequential? properties1) (nil? properties1)))
|
||||
(assert (or (sequential? properties2) (nil? properties2)))
|
||||
(let [properties2 (remove #(str/empty? (:value %)) properties2)]
|
||||
(-> (reduce
|
||||
(fn [{:keys [props used-pos]} prop]
|
||||
(let [pos (find-position (:name prop) props used-pos)
|
||||
(fn [{:keys [properties used-pos]} prop]
|
||||
(let [pos (find-position (:name prop) properties used-pos)
|
||||
used-pos (conj used-pos pos)]
|
||||
(if (< pos (count props))
|
||||
{:props (assoc-in (vec props) [pos :value] (:value prop)) :used-pos used-pos}
|
||||
{:props (add-new-prop props (:value prop)) :used-pos used-pos})))
|
||||
{:props (vec props1) :used-pos (matching-indices props1 props2)}
|
||||
props2)
|
||||
:props)))
|
||||
(if (< pos (count properties))
|
||||
{:properties (assoc-in (vec properties) [pos :value] (:value prop)) :used-pos used-pos}
|
||||
{:properties (add-new-property properties (:value prop)) :used-pos used-pos})))
|
||||
{:properties (vec properties1) :used-pos (matching-indices properties1 properties2)}
|
||||
properties2)
|
||||
:properties)))
|
||||
|
||||
(defn compare-properties
|
||||
"Compares vectors of properties keeping the value if it is the same for all
|
||||
or setting a custom value where their values do not coincide"
|
||||
([props-list]
|
||||
(compare-properties props-list nil))
|
||||
or setting a custom value where their values do not coincide.
|
||||
|
||||
([props-list distinct-mark]
|
||||
(let [grouped (group-by :name (apply concat props-list))
|
||||
Example:
|
||||
|
||||
[[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'y'}]
|
||||
[{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value 'z'}]] -> [{:name 'Property 1' :value 'x'}
|
||||
{:name 'Property 2' :value nil}]"
|
||||
([properties-list]
|
||||
(compare-properties properties-list nil))
|
||||
|
||||
([properties-list distinct-mark]
|
||||
(assert (or (sequential? properties-list) (nil? properties-list)))
|
||||
(assert (or (string? distinct-mark) (nil? distinct-mark)))
|
||||
(let [grouped (group-by :name (apply concat properties-list))
|
||||
check-values (fn [values]
|
||||
(let [vals (map :value values)]
|
||||
(if (apply = vals)
|
||||
@@ -281,33 +430,37 @@
|
||||
{:name name :value (check-values values)})
|
||||
grouped))))
|
||||
|
||||
(defn same-variant?
|
||||
"Determines if all elements belong to the same variant"
|
||||
[components]
|
||||
(let [variant-ids (distinct (map :variant-id components))
|
||||
not-blank? (complement str/blank?)]
|
||||
(and
|
||||
(= 1 (count variant-ids))
|
||||
(not-blank? (first variant-ids)))))
|
||||
(defn properties-distance
|
||||
"Computes a weighted distance between two property lists `properties1` and `properties2`.
|
||||
Latter properties weight less that previous ones.
|
||||
|
||||
(defn distance
|
||||
"Computes a weighted distance between two property lists `props1` and `props2`.
|
||||
Latter properties weight less that previous ones"
|
||||
[props1 props2]
|
||||
(let [total-num-props (count props1)
|
||||
Example:
|
||||
|
||||
[{:name 'type' :value 'primary'}
|
||||
{:name 'status' :value 'default'}]
|
||||
[{:name 'type' :value 'primary'}
|
||||
{:name 'status' :value 'hover'}] -> 1.0"
|
||||
[properties1 properties2]
|
||||
(assert (or (sequential? properties1) (nil? properties1)))
|
||||
(assert (or (sequential? properties2) (nil? properties2)))
|
||||
(let [total-num-properties (count properties1)
|
||||
xform (map-indexed
|
||||
(fn [idx [p1 p2]]
|
||||
(if (not= p1 p2)
|
||||
(math/pow 2 (- total-num-props idx))
|
||||
(math/pow 2 (- total-num-properties idx))
|
||||
0)))]
|
||||
(transduce
|
||||
xform
|
||||
+
|
||||
(map vector props1 props2))))
|
||||
(map vector properties1 properties2))))
|
||||
|
||||
(defn variant-name-to-name
|
||||
"Transforms a variant-name (its properties values) into a standard name:
|
||||
the real name of the shape joined by the properties values separated by '/'"
|
||||
the real name of the shape joined by the properties values separated by '/'.
|
||||
|
||||
Example:
|
||||
|
||||
{:name 'Button' :variant-name 'Primary, Hover'} -> 'Button / Primary / Hover'"
|
||||
[variant]
|
||||
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
|
||||
|
||||
@@ -317,8 +470,13 @@
|
||||
["true" "false"]])
|
||||
|
||||
(defn find-boolean-pair
|
||||
"Given a vector, return a map that contains the boolean equivalency if the values match
|
||||
with any of the boolean pairs. Returns nil if none match."
|
||||
"Given a collection, return a map that contains the boolean equivalency if the values match
|
||||
with any of the boolean pairs. Returns nil if none match.
|
||||
|
||||
Example:
|
||||
|
||||
['on' 'off'] -> {'on' true 'off' false}
|
||||
['foo' 'bar'] -> nil"
|
||||
[[a b :as v]]
|
||||
(let [a' (-> a str/trim str/lower)
|
||||
b' (-> b str/trim str/lower)]
|
||||
@@ -330,3 +488,18 @@
|
||||
(= a' f)) {b true a false}
|
||||
:else nil))
|
||||
boolean-pairs))))
|
||||
|
||||
(defn same-variant?
|
||||
"Determines if all elements belong to the same variant.
|
||||
|
||||
Example:
|
||||
|
||||
[{:variant-id 'abc'} {:variant-id 'abc'}] -> true
|
||||
[{:variant-id 'abc'} {:variant-id 'def'}] -> false"
|
||||
[components]
|
||||
(assert (or (sequential? components) (nil? components)))
|
||||
(let [variant-ids (distinct (map :variant-id components))
|
||||
not-blank? (complement str/blank?)]
|
||||
(and
|
||||
(= 1 (count variant-ids))
|
||||
(not-blank? (first variant-ids)))))
|
||||
@@ -0,0 +1,165 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns common-tests.files.variant-test
|
||||
(:require
|
||||
[app.common.files.variant :as fv]
|
||||
[app.common.test-helpers.components :as thc]
|
||||
[app.common.test-helpers.compositions :as tho]
|
||||
[app.common.test-helpers.files :as thf]
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.test-helpers.variants :as thv]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :each thi/test-fixture)
|
||||
|
||||
;; ============================================================
|
||||
;; find-variant-components
|
||||
;; ============================================================
|
||||
|
||||
(t/deftest find-variant-components-empty
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)]
|
||||
(t/is (= (fv/find-variant-components data (uuid/next))
|
||||
[]))
|
||||
(t/is (= (fv/find-variant-components data objects (uuid/next))
|
||||
[]))))
|
||||
|
||||
(t/deftest find-variant-components-non-variant
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(tho/add-simple-component :c01 :m01 :s01))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)]
|
||||
(t/is (= (fv/find-variant-components data (thi/id :m01))
|
||||
[]))
|
||||
(t/is (= (fv/find-variant-components data objects (thi/id :m01))
|
||||
[]))))
|
||||
|
||||
(t/deftest find-variant-components-normal
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)
|
||||
result (fv/find-variant-components data objects (thi/id :v01))]
|
||||
(t/is (= (count result) 2))
|
||||
(t/is (every? #(contains? % :id) result))
|
||||
(t/is (every? #(contains? % :variant-id) result))))
|
||||
|
||||
(t/deftest find-variant-components-single-variant
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)
|
||||
result (fv/find-variant-components data objects (thi/id :v01))]
|
||||
;; Verify the order is maintained (reversed from shapes order)
|
||||
(t/is (= (:variant-id (first result)) (thi/id :v01)))
|
||||
(t/is (= (:variant-id (second result)) (thi/id :v01)))))
|
||||
|
||||
;; ============================================================
|
||||
;; extract-properties-values
|
||||
;; ============================================================
|
||||
|
||||
(t/deftest extract-properties-values-empty
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)]
|
||||
(t/is (= (fv/extract-properties-values data objects (uuid/next))
|
||||
[]))))
|
||||
|
||||
(t/deftest extract-properties-values-non-variant
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(tho/add-simple-component :c01 :m01 :s01))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)]
|
||||
(t/is (= (fv/extract-properties-values data objects (thi/id :m01))
|
||||
[]))))
|
||||
|
||||
(t/deftest extract-properties-values-normal
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)
|
||||
result (fv/extract-properties-values data objects (thi/id :v01))]
|
||||
(t/is (seq result))
|
||||
(t/is (every? #(contains? % :name) result))
|
||||
(t/is (every? #(contains? % :value) result))
|
||||
(t/is (= (:name (first result)) "Property 1"))
|
||||
(t/is (= (set (:value (first result))) #{"Value1" "Value2"}))))
|
||||
|
||||
(t/deftest extract-properties-values-two-properties
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
page (thf/current-page file)
|
||||
objects (:objects page)
|
||||
result (fv/extract-properties-values data objects (thi/id :v01))]
|
||||
(t/is (= (count result) 2))
|
||||
(t/is (= (set (map :name result)) #{"Property 1" "Property 2"}))))
|
||||
|
||||
;; ============================================================
|
||||
;; is-secondary-variant?
|
||||
;; ============================================================
|
||||
|
||||
(t/deftest is-secondary-variant-primary
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
component (thc/get-component file :c01)]
|
||||
(t/is (not (fv/is-secondary-variant? data component)))))
|
||||
|
||||
(t/deftest is-secondary-variant-secondary
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
component (thc/get-component file :c02)]
|
||||
(t/is (fv/is-secondary-variant? data component))))
|
||||
|
||||
(t/deftest is-secondary-variant-not-variant
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(tho/add-simple-component :c01 :m01 :s01))
|
||||
data (:data file)
|
||||
component (thc/get-component file :c01)]
|
||||
(t/is (not (fv/is-secondary-variant? data component)))))
|
||||
|
||||
(t/deftest is-secondary-variant-no-shapes
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)
|
||||
component {:id :comp :variant-id (thi/id :file1) :main-instance-page (thi/id :file1)}]
|
||||
(t/is (not (fv/is-secondary-variant? data component)))))
|
||||
|
||||
;; ============================================================
|
||||
;; get-primary-variant
|
||||
;; ============================================================
|
||||
|
||||
(t/deftest get-primary-variant-nil
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)]
|
||||
(t/is (nil? (fv/get-primary-variant data nil)))))
|
||||
|
||||
(t/deftest get-primary-variant-empty
|
||||
(let [file (thf/sample-file :file1)
|
||||
data (:data file)
|
||||
component {:id :comp :variant-id (thi/id :file1) :main-instance-page (thi/id :file1)}]
|
||||
(t/is (nil? (fv/get-primary-variant data component)))))
|
||||
|
||||
(t/deftest get-primary-variant-normal
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
data (:data file)
|
||||
component (thc/get-component file :c01)
|
||||
result (fv/get-primary-variant data component)]
|
||||
(t/is (some? result))
|
||||
(t/is (contains? result :id))
|
||||
(t/is (contains? result :component-id))))
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns common-tests.files-migrations-0026-test
|
||||
(:require
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns common-tests.geom-image-bounds-resize-test
|
||||
(:require
|
||||
#?(:clj [clojure.test :refer [deftest is testing]]
|
||||
:cljs [cljs.test :refer-macros [deftest is testing]])
|
||||
[app.common.math :as mth]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.fills :as fills]
|
||||
[app.common.types.fills.impl :as fills.impl]
|
||||
[app.common.uuid :as uuid]))
|
||||
|
||||
(deftest test-image-transform-schema
|
||||
(testing "validates image with transform"
|
||||
(let [img {:id (uuid/custom 1)
|
||||
:width 400
|
||||
:height 300
|
||||
:mtype "image/png"
|
||||
:keep-aspect-ratio true
|
||||
:transform {:x 0.1 :y -0.2 :width 1.5 :height 2.0}}]
|
||||
(is (sm/validate clr/schema:image img))))
|
||||
|
||||
(testing "validates image without transform"
|
||||
(let [img {:id (uuid/custom 1)
|
||||
:width 400
|
||||
:height 300
|
||||
:mtype "image/png"
|
||||
:keep-aspect-ratio true}]
|
||||
(is (sm/validate clr/schema:image img))))
|
||||
|
||||
(testing "validates fill with image transform"
|
||||
(let [fill {:fill-opacity 0.8
|
||||
:fill-image {:id (uuid/custom 1)
|
||||
:width 400
|
||||
:height 300
|
||||
:mtype "image/png"
|
||||
:keep-aspect-ratio true
|
||||
:transform {:x -0.5 :y -0.5 :width 2.0 :height 2.0}}}]
|
||||
(is (sm/validate fills/schema:fill fill)))))
|
||||
|
||||
(deftest test-image-fill-buffer-roundtrip
|
||||
(testing "roundtrip image fill without transform"
|
||||
(let [fill-vec [{:fill-opacity 0.9
|
||||
:fill-image {:id (uuid/custom 1)
|
||||
:width 800
|
||||
:height 600
|
||||
:mtype "image/jpeg"
|
||||
:keep-aspect-ratio true
|
||||
:name "sample"}}]
|
||||
coerced (fills/from-plain fill-vec)
|
||||
plain (into [] coerced)]
|
||||
(is (= 1 (count plain)))
|
||||
(is (= 0.9 (:fill-opacity (first plain))))
|
||||
(is (= 800 (-> plain first :fill-image :width)))
|
||||
(is (= 600 (-> plain first :fill-image :height)))
|
||||
(is (true? (-> plain first :fill-image :keep-aspect-ratio)))
|
||||
(is (nil? (-> plain first :fill-image :transform)))))
|
||||
|
||||
(testing "roundtrip image fill with transform"
|
||||
(let [fill-vec [{:fill-opacity 0.75
|
||||
:fill-image {:id (uuid/custom 2)
|
||||
:width 1920
|
||||
:height 1080
|
||||
:mtype "image/webp"
|
||||
:keep-aspect-ratio false
|
||||
:name "sample"
|
||||
:transform {:x 0.25 :y -0.15 :width 1.5 :height 2.0}}}]
|
||||
coerced (fills/from-plain fill-vec)
|
||||
plain (into [] coerced)
|
||||
tf (-> plain first :fill-image :transform)]
|
||||
(is (= 1 (count plain)))
|
||||
(is (= 0.75 (:fill-opacity (first plain))))
|
||||
(is (= 1920 (-> plain first :fill-image :width)))
|
||||
(is (= 1080 (-> plain first :fill-image :height)))
|
||||
(is (false? (-> plain first :fill-image :keep-aspect-ratio)))
|
||||
(is (some? tf))
|
||||
(is (mth/close? 0.25 (double (:x tf))))
|
||||
(is (mth/close? -0.15 (double (:y tf))))
|
||||
(is (mth/close? 1.5 (double (:width tf))))
|
||||
(is (mth/close? 2.0 (double (:height tf)))))))
|
||||
|
||||
(defn compute-bounds-resize-transform
|
||||
"Mathematical model for independent image bounds resizing"
|
||||
[{:keys [width height handler center? sx sy transform]}]
|
||||
(let [w-new (* width sx)
|
||||
h-new (* height sy)
|
||||
[dx dy] (if ^boolean center?
|
||||
[(/ (* width (- 1.0 sx)) 2.0)
|
||||
(/ (* height (- 1.0 sy)) 2.0)]
|
||||
[(case handler
|
||||
(:left :bottom-left :top-left) (* width (- 1.0 sx))
|
||||
0.0)
|
||||
(case handler
|
||||
(:top :top-left :top-right) (* height (- 1.0 sy))
|
||||
0.0)])
|
||||
nx0 (get transform :x 0.0)
|
||||
ny0 (get transform :y 0.0)
|
||||
nw0 (get transform :width 1.0)
|
||||
nh0 (get transform :height 1.0)
|
||||
nx' (/ (- (* nx0 width) dx) w-new)
|
||||
ny' (/ (- (* ny0 height) dy) h-new)
|
||||
nw' (/ nw0 sx)
|
||||
nh' (/ nh0 sy)]
|
||||
{:transform {:x nx' :y ny' :width nw' :height nh'}
|
||||
:rendered-pixel-rect {:x (* nx' w-new)
|
||||
:y (* ny' h-new)
|
||||
:width (* nw' w-new)
|
||||
:height (* nh' h-new)}}))
|
||||
|
||||
(deftest test-handle-anchoring-mathematics
|
||||
(testing "Right handle crop (shrinking width to 50%)"
|
||||
(let [res (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})]
|
||||
(is (mth/close? 0.0 (-> res :transform :x)))
|
||||
(is (mth/close? 0.0 (-> res :transform :y)))
|
||||
(is (mth/close? 2.0 (-> res :transform :width)))
|
||||
(is (mth/close? 1.0 (-> res :transform :height)))
|
||||
;; Rendered pixel content remains 200x100 starting at (0, 0)
|
||||
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
|
||||
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
|
||||
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
|
||||
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
|
||||
|
||||
(testing "Left handle crop (shrinking width to 50% from left)"
|
||||
(let [res (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :left :center? false :sx 0.5 :sy 1.0})]
|
||||
(is (mth/close? -1.0 (-> res :transform :x)))
|
||||
(is (mth/close? 0.0 (-> res :transform :y)))
|
||||
(is (mth/close? 2.0 (-> res :transform :width)))
|
||||
(is (mth/close? 1.0 (-> res :transform :height)))
|
||||
;; Rendered pixel content has left at -100, width 200 -> right edge at +100 (matches right edge of 100px container!)
|
||||
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
|
||||
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))))
|
||||
|
||||
(testing "Top handle crop (shrinking height to 50% from top)"
|
||||
(let [res (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :top :center? false :sx 1.0 :sy 0.5})]
|
||||
(is (mth/close? 0.0 (-> res :transform :x)))
|
||||
(is (mth/close? -1.0 (-> res :transform :y)))
|
||||
(is (mth/close? 1.0 (-> res :transform :width)))
|
||||
(is (mth/close? 2.0 (-> res :transform :height)))
|
||||
;; Rendered pixel content has top at -50, height 100 -> bottom edge at +50 (matches bottom edge of 50px container!)
|
||||
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
|
||||
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
|
||||
|
||||
(testing "Top-Left handle crop (shrinking both dimensions to 50%)"
|
||||
(let [res (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :top-left :center? false :sx 0.5 :sy 0.5})]
|
||||
(is (mth/close? -1.0 (-> res :transform :x)))
|
||||
(is (mth/close? -1.0 (-> res :transform :y)))
|
||||
(is (mth/close? 2.0 (-> res :transform :width)))
|
||||
(is (mth/close? 2.0 (-> res :transform :height)))
|
||||
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
|
||||
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
|
||||
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
|
||||
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
|
||||
|
||||
(testing "Center resize (Alt modifier)"
|
||||
(let [res (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :right :center? true :sx 0.5 :sy 0.5})]
|
||||
(is (mth/close? -0.5 (-> res :transform :x)))
|
||||
(is (mth/close? -0.5 (-> res :transform :y)))
|
||||
(is (mth/close? 2.0 (-> res :transform :width)))
|
||||
(is (mth/close? 2.0 (-> res :transform :height)))
|
||||
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :x)))
|
||||
(is (mth/close? -25.0 (-> res :rendered-pixel-rect :y)))
|
||||
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
|
||||
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
|
||||
|
||||
(testing "Bottom handle crop (shrinking height to 50% from bottom)"
|
||||
(let [res (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :bottom :center? false :sx 1.0 :sy 0.5})]
|
||||
(is (mth/close? 0.0 (-> res :transform :x)))
|
||||
(is (mth/close? 0.0 (-> res :transform :y)))
|
||||
(is (mth/close? 1.0 (-> res :transform :width)))
|
||||
(is (mth/close? 2.0 (-> res :transform :height)))
|
||||
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
|
||||
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
|
||||
|
||||
(testing "Top-Right handle crop (shrinking both dimensions to 50%)"
|
||||
(let [res (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :top-right :center? false :sx 0.5 :sy 0.5})]
|
||||
(is (mth/close? 0.0 (-> res :transform :x)))
|
||||
(is (mth/close? -1.0 (-> res :transform :y)))
|
||||
(is (mth/close? 2.0 (-> res :transform :width)))
|
||||
(is (mth/close? 2.0 (-> res :transform :height)))
|
||||
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
|
||||
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
|
||||
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
|
||||
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
|
||||
|
||||
(testing "Bottom-Left handle crop (shrinking both dimensions to 50%)"
|
||||
(let [res (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :bottom-left :center? false :sx 0.5 :sy 0.5})]
|
||||
(is (mth/close? -1.0 (-> res :transform :x)))
|
||||
(is (mth/close? 0.0 (-> res :transform :y)))
|
||||
(is (mth/close? 2.0 (-> res :transform :width)))
|
||||
(is (mth/close? 2.0 (-> res :transform :height)))
|
||||
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
|
||||
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
|
||||
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
|
||||
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
|
||||
|
||||
(testing "Expanding bounds beyond original size (empty space exposure)"
|
||||
(let [res (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :right :center? false :sx 2.0 :sy 1.0})]
|
||||
(is (mth/close? 0.0 (-> res :transform :x)))
|
||||
(is (mth/close? 0.0 (-> res :transform :y)))
|
||||
(is (mth/close? 0.5 (-> res :transform :width)))
|
||||
(is (mth/close? 1.0 (-> res :transform :height)))
|
||||
;; Rendered pixel content is 200px wide in a 400px container -> exposes 200px empty space
|
||||
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
|
||||
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))))))
|
||||
|
||||
(deftest test-sequential-resize-operations
|
||||
(testing "Sequential crops: crop right then crop left"
|
||||
;; Initial shape: 200x100, transform: {:x 0 :y 0 :width 1 :height 1}
|
||||
;; Step 1: Crop right handle from 200 to 150 (sx = 0.75)
|
||||
(let [step1 (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :right :center? false :sx 0.75 :sy 1.0})
|
||||
tf1 (:transform step1)]
|
||||
(is (mth/close? 0.0 (:x tf1)))
|
||||
(is (mth/close? (/ 1.0 0.75) (:width tf1)))
|
||||
;; Step 2: Now shape is 150x100 with tf1. Crop left handle from 150 to 100 (sx = 100/150 = 2/3)
|
||||
(let [step2 (compute-bounds-resize-transform
|
||||
{:width 150 :height 100 :handler :left :center? false :sx (/ 2.0 3.0) :sy 1.0 :transform tf1})
|
||||
tf2 (:transform step2)]
|
||||
;; The final 100x100 container has bitmap with width 200px
|
||||
(is (mth/close? 200.0 (-> step2 :rendered-pixel-rect :width)))
|
||||
;; The bitmap left edge is at -50px in the 100px container, so right edge is at -50 + 200 = 150px
|
||||
(is (mth/close? -50.0 (-> step2 :rendered-pixel-rect :x))))))
|
||||
|
||||
(testing "Bounds resize followed by standard proportional scaling"
|
||||
;; Step 1: Bounds resize crops width from 200 to 100
|
||||
(let [step1 (compute-bounds-resize-transform
|
||||
{:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})
|
||||
tf1 (:transform step1)]
|
||||
(is (mth/close? 2.0 (:width tf1)))
|
||||
(is (mth/close? 1.0 (:height tf1)))
|
||||
|
||||
;; Step 2: Standard proportional scale of the 100x100 cropped shape to 200x200 (scale 2x)
|
||||
;; During standard scale, normalized transform tf1 is kept constant!
|
||||
(let [scaled-w (* 100.0 2.0)
|
||||
scaled-h (* 100.0 2.0)
|
||||
rendered-w (* (:width tf1) scaled-w)
|
||||
rendered-h (* (:height tf1) scaled-h)]
|
||||
;; The underlying bitmap scaled from 200x100 to 400x200, matching the 2x scale of the cropped frame!
|
||||
(is (mth/close? 400.0 rendered-w))
|
||||
(is (mth/close? 200.0 rendered-h))))))
|
||||
|
||||
(deftest test-proportion-lock-invariance
|
||||
(testing "Shape proportion-lock attribute remains unchanged"
|
||||
(let [shape {:id (uuid/custom 10)
|
||||
:type :rect
|
||||
:width 200
|
||||
:height 100
|
||||
:proportion-lock true
|
||||
:fills [{:fill-image {:id (uuid/custom 1)
|
||||
:width 800
|
||||
:height 600
|
||||
:keep-aspect-ratio true}}]}
|
||||
;; Simulate bounds resize interaction
|
||||
has-img? (boolean (or (some :fill-image (:fills shape)) (:fill-image shape)))
|
||||
mod-pressed? true
|
||||
bounds-resize? (and has-img? mod-pressed?)
|
||||
lock-during-drag (if bounds-resize? false (:proportion-lock shape))]
|
||||
;; During drag, lock is bypassed (unless Shift is pressed)
|
||||
(is (false? lock-during-drag))
|
||||
;; Shape's persistent setting is completely preserved
|
||||
(is (true? (:proportion-lock shape))))))
|
||||
@@ -23,13 +23,13 @@
|
||||
[common-tests.files-migrations-test]
|
||||
[common-tests.files.shapes-builder-test]
|
||||
[common-tests.files.validate-test]
|
||||
[common-tests.files.variant-test]
|
||||
[common-tests.geom-align-test]
|
||||
[common-tests.geom-bounds-layout-nil-test]
|
||||
[common-tests.geom-bounds-map-test]
|
||||
[common-tests.geom-flex-layout-test]
|
||||
[common-tests.geom-grid-layout-test]
|
||||
[common-tests.geom-grid-test]
|
||||
[common-tests.geom-image-bounds-resize-test]
|
||||
[common-tests.geom-line-test]
|
||||
[common-tests.geom-modif-tree-test]
|
||||
[common-tests.geom-modifiers-test]
|
||||
@@ -88,6 +88,7 @@
|
||||
[common-tests.types.token-test]
|
||||
[common-tests.types.tokens-lib-test]
|
||||
[common-tests.types.tokens-status-test]
|
||||
[common-tests.types.variant-test]
|
||||
[common-tests.undo-stack-test]
|
||||
[common-tests.uuid-test]))
|
||||
|
||||
@@ -103,13 +104,13 @@
|
||||
'common-tests.files-migrations-0026-test
|
||||
'common-tests.files-migrations-test
|
||||
'common-tests.files.validate-test
|
||||
'common-tests.files.variant-test
|
||||
'common-tests.geom-align-test
|
||||
'common-tests.geom-bounds-layout-nil-test
|
||||
'common-tests.geom-bounds-map-test
|
||||
'common-tests.geom-flex-layout-test
|
||||
'common-tests.geom-grid-layout-test
|
||||
'common-tests.geom-grid-test
|
||||
'common-tests.geom-image-bounds-resize-test
|
||||
'common-tests.geom-line-test
|
||||
'common-tests.geom-modif-tree-test
|
||||
'common-tests.geom-modifiers-test
|
||||
@@ -144,6 +145,7 @@
|
||||
'common-tests.logic.token-test
|
||||
'common-tests.logic.variants-switch-test
|
||||
'common-tests.math-test
|
||||
'common-tests.types.variant-test
|
||||
'common-tests.media-test
|
||||
'common-tests.path-names-test
|
||||
'common-tests.record-test
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns common-tests.types.tokens-status-test
|
||||
(:require
|
||||
|
||||
@@ -7,10 +7,238 @@
|
||||
(ns common-tests.types.variant-test
|
||||
(:require
|
||||
[app.common.types.variant :as ctv]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest variant-component
|
||||
(t/is (not (ctv/variant-component? nil)))
|
||||
(t/is (not (ctv/variant-component? {})))
|
||||
(t/is (ctv/variant-component? {:variant-id (uuid/next)})))
|
||||
|
||||
(t/deftest variant-distance01
|
||||
(t/deftest variant-shape
|
||||
(t/is (not (ctv/variant-shape? nil)))
|
||||
(t/is (not (ctv/variant-shape? {})))
|
||||
(t/is (ctv/variant-shape? {:variant-id (uuid/next)})))
|
||||
|
||||
(t/deftest variant-container
|
||||
(t/is (not (ctv/variant-container? nil)))
|
||||
(t/is (not (ctv/variant-container? {})))
|
||||
(t/is (ctv/variant-container? {:is-variant-container true})))
|
||||
|
||||
(t/deftest properties-to-name-test
|
||||
(t/is (= "" (ctv/properties-to-name [])))
|
||||
(t/is (= "" (ctv/properties-to-name nil)))
|
||||
(t/is (= "Button, Primary" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}])))
|
||||
(t/is (= "Button" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value ""}]))))
|
||||
|
||||
(t/deftest next-property-number-test
|
||||
(t/is (= 1 (ctv/next-property-number [])))
|
||||
(t/is (= 1 (ctv/next-property-number nil)))
|
||||
(t/is (= 2 (ctv/next-property-number [{:name "Property 1" :value "x"}])))
|
||||
(t/is (= 4 (ctv/next-property-number [{:name "Property 3" :value "x"}])))
|
||||
(t/is (= 3 (ctv/next-property-number [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]))))
|
||||
|
||||
(t/deftest add-new-property-test
|
||||
(t/is (= [{:name "Property 1" :value "x"}]
|
||||
(ctv/add-new-property [] "x")))
|
||||
(t/is (= [{:name "Property 1" :value "x"}]
|
||||
(ctv/add-new-property nil "x")))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
|
||||
(ctv/add-new-property [{:name "Property 1" :value "x"}] "y"))))
|
||||
|
||||
(t/deftest add-new-properties-test
|
||||
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
|
||||
(ctv/add-new-properties [] ["a" "b"])))
|
||||
(t/is (= '({:name "Property 2" :value "b"} {:name "Property 1" :value "a"})
|
||||
(ctv/add-new-properties nil ["a" "b"])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "a"} {:name "Property 3" :value "b"}]
|
||||
(ctv/add-new-properties [{:name "Property 1" :value "x"}] ["a" "b"]))))
|
||||
|
||||
(t/deftest path-to-properties-test
|
||||
(t/is (= [] (ctv/path-to-properties "" [])))
|
||||
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
|
||||
(ctv/path-to-properties "a / b" nil)))
|
||||
(t/is (= [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}
|
||||
{:name "Property 3" :value "Hover"}]
|
||||
(ctv/path-to-properties "Button / Primary / Hover" [])))
|
||||
(t/is (= [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}
|
||||
{:name "Property 3" :value "Hover"}
|
||||
{:name "Property 4" :value ""}]
|
||||
(ctv/path-to-properties "Button / Primary / Hover" [] 4)))
|
||||
(t/is (= [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}]
|
||||
(ctv/path-to-properties "Button / Primary" [{:name "Property 1" :value "old"}
|
||||
{:name "Property 2" :value "old2"}]))))
|
||||
|
||||
(t/deftest properties-map->formula-test
|
||||
(t/is (= "" (ctv/properties-map->formula [])))
|
||||
(t/is (= "" (ctv/properties-map->formula nil)))
|
||||
(t/is (= "Property 1=Button, Property 2=Primary"
|
||||
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value "Primary"}])))
|
||||
(t/is (= "Property 1=Button"
|
||||
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
|
||||
{:name "Property 2" :value ""}]))))
|
||||
|
||||
(t/deftest properties-formula->map-test
|
||||
(t/is (= [] (ctv/properties-formula->map "")))
|
||||
(t/is (= [] (ctv/properties-formula->map nil)))
|
||||
(t/is (= [{:name "Property 1" :value "Button"} {:name "Property 2" :value "Primary"}]
|
||||
(ctv/properties-formula->map "Property 1=Button, Property 2=Primary")))
|
||||
(t/is (= [{:name "Property 1" :value "Button"}]
|
||||
(ctv/properties-formula->map "Property 1=Button, Property 2="))))
|
||||
|
||||
(t/deftest valid-properties-formula?-test
|
||||
(t/is (= true (ctv/valid-properties-formula? "Property 1=Button, Property 2=Primary")))
|
||||
(t/is (= false (ctv/valid-properties-formula? "")))
|
||||
(t/is (= true (ctv/valid-properties-formula? nil)))
|
||||
(t/is (= false (ctv/valid-properties-formula? "Property 1=Button, Property 2"))))
|
||||
|
||||
(t/deftest find-properties-to-remove-test
|
||||
(t/is (= [] (ctv/find-properties-to-remove [] [])))
|
||||
(t/is (= [] (ctv/find-properties-to-remove nil nil)))
|
||||
(t/is (= [{:name "Property 3" :value "z"}]
|
||||
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}
|
||||
{:name "Property 3" :value "z"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
|
||||
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 3" :value "z"}]))))
|
||||
|
||||
(t/deftest find-properties-to-update-test
|
||||
(t/is (= [] (ctv/find-properties-to-update [] [])))
|
||||
(t/is (= [] (ctv/find-properties-to-update nil nil)))
|
||||
(t/is (= [{:name "Property 1" :value "new-x"}]
|
||||
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "new-x"}
|
||||
{:name "Property 2" :value "y"}])))
|
||||
(t/is (= [{:name "Property 1" :value "new-x"} {:name "Property 2" :value "new-y"}]
|
||||
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "new-x"}
|
||||
{:name "Property 2" :value "new-y"}]))))
|
||||
|
||||
(t/deftest find-properties-to-add-test
|
||||
(t/is (= [] (ctv/find-properties-to-add [] [])))
|
||||
(t/is (= [] (ctv/find-properties-to-add nil nil)))
|
||||
(t/is (= [{:name "Property 3" :value "z"}]
|
||||
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}
|
||||
{:name "Property 3" :value "z"}])))
|
||||
(t/is (= [{:name "Property 2" :value "y"}]
|
||||
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]))))
|
||||
|
||||
(t/deftest update-number-in-repeated-item-test
|
||||
(t/is (= "Property" (ctv/update-number-in-repeated-item [] "Property")))
|
||||
(t/is (= "Property" (ctv/update-number-in-repeated-item nil "Property")))
|
||||
(t/is (= "Property (1)" (ctv/update-number-in-repeated-item ["Property"] "Property")))
|
||||
(t/is (= "Property (2)" (ctv/update-number-in-repeated-item ["Property" "Property (1)"] "Property")))
|
||||
(t/is (= "Property" (ctv/update-number-in-repeated-item ["Other"] "Property"))))
|
||||
|
||||
(t/deftest update-number-in-repeated-prop-names-test
|
||||
(t/is (= [] (ctv/update-number-in-repeated-prop-names [])))
|
||||
(t/is (= [] (ctv/update-number-in-repeated-prop-names nil)))
|
||||
(t/is (= [{:name "Property" :value "x"}]
|
||||
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}])))
|
||||
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"}]
|
||||
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
|
||||
{:name "Property" :value "y"}])))
|
||||
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"} {:name "Property (2)" :value "z"}]
|
||||
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
|
||||
{:name "Property" :value "y"}
|
||||
{:name "Property" :value "z"}]))))
|
||||
|
||||
(t/deftest find-index-for-property-name-test
|
||||
(t/is (= nil (ctv/find-index-for-property-name [] "Property 1")))
|
||||
(t/is (= nil (ctv/find-index-for-property-name nil "Property 1")))
|
||||
(t/is (= 0 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 1")))
|
||||
(t/is (= 1 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}] "Property 2")))
|
||||
(t/is (= nil (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 3"))))
|
||||
|
||||
(t/deftest remove-prefix-test
|
||||
(t/is (= "name" (ctv/remove-prefix "name" "")))
|
||||
(t/is (= "name" (ctv/remove-prefix "name" nil)))
|
||||
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button")))
|
||||
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button / ")))
|
||||
(t/is (= "Button / Primary" (ctv/remove-prefix "Button / Primary" "Other"))))
|
||||
|
||||
(t/deftest merge-properties-test
|
||||
(t/is (= [] (ctv/merge-properties [] [])))
|
||||
(t/is (= [] (ctv/merge-properties nil nil)))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
|
||||
(ctv/merge-properties [{:name "Property 1" :value "a"}
|
||||
{:name "Property 2" :value "b"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"} {:name "Property 3" :value "z"}]
|
||||
(ctv/merge-properties [{:name "Property 1" :value "a"}
|
||||
{:name "Property 2" :value "b"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}
|
||||
{:name "Property 3" :value "z"}])))
|
||||
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "y"}]
|
||||
(ctv/merge-properties [{:name "Property 1" :value "a"}
|
||||
{:name "Property 2" :value "b"}]
|
||||
[{:name "Property 2" :value "y"}]))))
|
||||
|
||||
(t/deftest compare-properties-test
|
||||
(t/is (= [] (ctv/compare-properties [])))
|
||||
(t/is (= [] (ctv/compare-properties nil)))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
|
||||
(ctv/compare-properties [[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value nil}]
|
||||
(ctv/compare-properties [[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "z"}]])))
|
||||
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "*"}]
|
||||
(ctv/compare-properties [[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "y"}]
|
||||
[{:name "Property 1" :value "x"}
|
||||
{:name "Property 2" :value "z"}]]
|
||||
"*"))))
|
||||
|
||||
(t/deftest variant-name-to-name-test
|
||||
(t/is (= "Button / Primary / Hover" (ctv/variant-name-to-name {:name "Button" :variant-name "Primary, Hover"})))
|
||||
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name ""})))
|
||||
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name nil})))
|
||||
(t/is (= "" (ctv/variant-name-to-name {:name "" :variant-name ""})))
|
||||
(t/is (= nil (ctv/variant-name-to-name {:name nil :variant-name nil}))))
|
||||
|
||||
(t/deftest find-boolean-pair-test
|
||||
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["on" "off"])))
|
||||
(t/is (= {"yes" true "no" false} (ctv/find-boolean-pair ["yes" "no"])))
|
||||
(t/is (= {"true" true "false" false} (ctv/find-boolean-pair ["true" "false"])))
|
||||
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["off" "on"])))
|
||||
(t/is (= {"ON" true "OFF" false} (ctv/find-boolean-pair ["ON" "OFF"])))
|
||||
(t/is (= nil (ctv/find-boolean-pair ["foo" "bar"])))
|
||||
(t/is (= nil (ctv/find-boolean-pair nil)))
|
||||
(t/is (= nil (ctv/find-boolean-pair ["on"]))))
|
||||
|
||||
(t/deftest same-variant?-test
|
||||
(t/is (= false (ctv/same-variant? [])))
|
||||
(t/is (= false (ctv/same-variant? nil)))
|
||||
(t/is (= true (ctv/same-variant? [{:variant-id "abc"}])))
|
||||
(t/is (= true (ctv/same-variant? [{:variant-id "abc"} {:variant-id "abc"}])))
|
||||
(t/is (= false (ctv/same-variant? [{:variant-id "abc"} {:variant-id "def"}])))
|
||||
(t/is (= false (ctv/same-variant? [{:variant-id ""} {:variant-id ""}]))))
|
||||
|
||||
(t/deftest properties-distance01
|
||||
;;c1: primary, default, rounded, blue, dark
|
||||
;;c2: primary, hover, squared, blue, dark
|
||||
;;c3: primary, default, squared, blue, light
|
||||
@@ -35,12 +263,11 @@
|
||||
{:name "borders" :value "rounded"}
|
||||
{:name "color" :value "blue"}
|
||||
{:name "theme" :value "light"}]
|
||||
dist2 (ctv/distance target props2)
|
||||
dist3 (ctv/distance target props3)]
|
||||
dist2 (ctv/properties-distance target props2)
|
||||
dist3 (ctv/properties-distance target props3)]
|
||||
(t/is (< dist3 dist2))))
|
||||
|
||||
|
||||
(t/deftest variant-distance02
|
||||
(t/deftest properties-distance02
|
||||
;;c1: primary, default, rounded, blue, dark
|
||||
;;c2: primary, hover, squared, red, dark
|
||||
;;c3: secondary, hover, rounded, blue, dark
|
||||
@@ -65,11 +292,11 @@
|
||||
{:name "borders" :value "rounded"}
|
||||
{:name "color" :value "blue"}
|
||||
{:name "theme" :value "dark"}]
|
||||
dist2 (ctv/distance target props2)
|
||||
dist3 (ctv/distance target props3)]
|
||||
dist2 (ctv/properties-distance target props2)
|
||||
dist3 (ctv/properties-distance target props3)]
|
||||
(t/is (< dist2 dist3))))
|
||||
|
||||
(t/deftest variant-distance03
|
||||
(t/deftest properties-distance03
|
||||
;;c1: primary, default, rounded, blue, dark
|
||||
;;c2: secondary, default, rounded, blue, light
|
||||
;;c3: secondary, hover, squared, blue, dark
|
||||
@@ -101,12 +328,18 @@
|
||||
{:name "borders" :value "rounded"}
|
||||
{:name "color" :value "blue"}
|
||||
{:name "theme" :value "dark"}]
|
||||
dist2 (ctv/distance target props2)
|
||||
dist3 (ctv/distance target props3)
|
||||
dist4 (ctv/distance target props4)]
|
||||
dist2 (ctv/properties-distance target props2)
|
||||
dist3 (ctv/properties-distance target props3)
|
||||
dist4 (ctv/properties-distance target props4)]
|
||||
(t/is (< dist2 dist4))
|
||||
(t/is (< dist4 dist3))))
|
||||
|
||||
(t/deftest properties-distance04
|
||||
(t/is (= 0 (ctv/properties-distance [] [])))
|
||||
(t/is (= 0 (ctv/properties-distance nil nil)))
|
||||
(t/is (= 0 (ctv/properties-distance [{:name "a" :value "x"}] [{:name "a" :value "x"}])))
|
||||
(t/is (= 2.0 (ctv/properties-distance [{:name "a" :value "x"} {:name "b" :value "y"}] [{:name "a" :value "x"} {:name "b" :value "z"}]))))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns common-tests.types-shape-tree-test
|
||||
(:require
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.types.shape-tree :as ctt]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(defn- make-frame
|
||||
[id parent-id shapes x y width height show-content]
|
||||
{:id id
|
||||
:type :frame
|
||||
:parent-id parent-id
|
||||
:frame-id parent-id
|
||||
:shapes (vec shapes)
|
||||
:x x
|
||||
:y y
|
||||
:width width
|
||||
:height height
|
||||
:rotation nil
|
||||
:hidden false
|
||||
:blocked false
|
||||
:show-content show-content})
|
||||
|
||||
(t/deftest top-nested-frame-clip-content-test
|
||||
(t/testing "board A (clip) contains a wider board B; point inside both resolves to B"
|
||||
(let [a-id (uuid/next)
|
||||
b-id (uuid/next)
|
||||
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false)
|
||||
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
|
||||
position (gpt/point 150 150)
|
||||
result (ctt/top-nested-frame objects position)]
|
||||
(t/is (= b-id result))))
|
||||
|
||||
(t/testing "point inside B but outside A's clipped bounds is not reachable at all"
|
||||
(let [a-id (uuid/next)
|
||||
b-id (uuid/next)
|
||||
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false)
|
||||
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
|
||||
position (gpt/point 300 300)
|
||||
result (ctt/top-nested-frame objects position)]
|
||||
;; Outside A (the clip ancestor) and B's visible/clipped region there is
|
||||
;; not visible either, so no frame should be resolved at that point.
|
||||
(t/is (= uuid/zero result))))
|
||||
|
||||
(t/testing "with show-content true on A, the same point can resolve into B"
|
||||
(let [a-id (uuid/next)
|
||||
b-id (uuid/next)
|
||||
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 true)
|
||||
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
|
||||
position (gpt/point 300 300)
|
||||
result (ctt/top-nested-frame objects position)]
|
||||
(t/is (= b-id result)))))
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.auth
|
||||
"Resolves the caller's session cookie to a real profile id.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.handlers.export
|
||||
"Handle export jobs"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.handlers.jobs
|
||||
"REST surface for export jobs, under `/api/export/jobs`.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.jobs
|
||||
"Export job model and lifecycle.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.jobs.scheduler
|
||||
"Admission control for export jobs.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.jobs.store
|
||||
"Redis persistence for export jobs.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.jobs.utils
|
||||
"Temp file ownership for export jobs.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.router
|
||||
"Method + path dispatch.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.wasm.pool
|
||||
"Pool of headless render workers.
|
||||
|
||||
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user