mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-14 19:29:34 +01:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b98adc6e | ||
|
|
4f12fd7390 | ||
|
|
13b98cc0b1 | ||
|
|
7f8f5990a7 | ||
|
|
bcbbb1ecb9 | ||
|
|
8727d83097 | ||
|
|
70919d953a | ||
|
|
a722608ae8 | ||
|
|
12a760d27e | ||
|
|
b8d1414403 | ||
|
|
463d7b89f6 | ||
|
|
6e4d252617 | ||
|
|
3ed27f6c0f | ||
|
|
0d58262e19 | ||
|
|
db8881621c | ||
|
|
ceda91488c | ||
|
|
e84bae2807 | ||
|
|
e8d90487d2 | ||
|
|
598cf3ed80 | ||
|
|
30e3bc3153 | ||
|
|
f95a58087b | ||
|
|
83608fffcf | ||
|
|
78b1d41cf8 | ||
|
|
616c3a6742 | ||
|
|
d24a50a696 | ||
|
|
3480dd146e | ||
|
|
dbe49b5f00 | ||
|
|
1c28efb12e | ||
|
|
a6ee68d75a | ||
|
|
30ece64423 | ||
|
|
77ef77961d | ||
|
|
a629949479 | ||
|
|
af6ddffa1d | ||
|
|
f15979ed11 | ||
|
|
df3262a3f7 | ||
|
|
a071701870 | ||
|
|
c549665578 | ||
|
|
2137eecddf | ||
|
|
32a666f6c3 | ||
|
|
1ee998853f | ||
|
|
9ae585d2b7 | ||
|
|
8f92615491 | ||
|
|
e5dcfad3ff | ||
|
|
b7cc14fa14 | ||
|
|
b9d940ae33 | ||
|
|
46617f01a4 | ||
|
|
f097b79103 | ||
|
|
a8f9f9832e | ||
|
|
f3dab36bbe | ||
|
|
bebd603117 | ||
|
|
2660f4ee82 | ||
|
|
eb2bbdd633 | ||
|
|
24966230ea | ||
|
|
477cc1c0bb | ||
|
|
1eee2b30fa | ||
|
|
1650ade338 | ||
|
|
0c83fd4799 | ||
|
|
4004cf9c88 | ||
|
|
419b46e806 | ||
|
|
dcafc8a1a1 | ||
|
|
628f794b37 | ||
|
|
a1fd3199d6 | ||
|
|
4a5cc454ce | ||
|
|
63dd344c02 | ||
|
|
a7a1026f9b | ||
|
|
a67f106bc6 | ||
|
|
430a564592 | ||
|
|
e283d9ced6 | ||
|
|
300382f6e3 | ||
|
|
0b9b2cbf58 | ||
|
|
87919eb445 | ||
|
|
258289482b | ||
|
|
aa9436a19b | ||
|
|
cee6c0ef11 | ||
|
|
c6cbc17c66 | ||
|
|
2ba0f2a95d | ||
|
|
e2b43ba01f | ||
|
|
b6d77af91b | ||
|
|
36e6c9a402 | ||
|
|
f124fa0023 | ||
|
|
c29605ef23 | ||
|
|
8ef9dd432f | ||
|
|
d4d1964aea | ||
|
|
3ffb5e8278 | ||
|
|
70cde4c3a8 | ||
|
|
28e6ca52fe | ||
|
|
5b4c1505b7 | ||
|
|
8ad3c2e612 | ||
|
|
d7ed2225b4 | ||
|
|
7d6b84af3d | ||
|
|
80492a7b68 | ||
|
|
7069af4054 | ||
|
|
05a9e4d035 | ||
|
|
be808e28bc | ||
|
|
7354b37ef6 | ||
|
|
6afca44897 | ||
|
|
c17cf2baa1 | ||
|
|
c00556829a | ||
|
|
f024c4b09e | ||
|
|
8e0fcdb73b | ||
|
|
e19929249f | ||
|
|
f6764170e1 | ||
|
|
1641708508 | ||
|
|
97a74815d3 | ||
|
|
7998cdcd71 | ||
|
|
5e9f7a11a3 | ||
|
|
1c6bf3f472 | ||
|
|
aed2652f1d | ||
|
|
233c5e8550 | ||
|
|
6b83c772cc | ||
|
|
1996db6a53 | ||
|
|
f69b0889eb | ||
|
|
c8b1320bb9 | ||
|
|
e11cb7d5cb | ||
|
|
097041a43a | ||
|
|
b21d294cf8 | ||
|
|
43d72faf48 | ||
|
|
bc9a93d71f | ||
|
|
df0ac76394 | ||
|
|
66040b687f | ||
|
|
7a83581597 | ||
|
|
47c0d78985 | ||
|
|
76f0b05a09 | ||
|
|
35598df354 | ||
|
|
3c87fe0932 | ||
|
|
d8fdaa9529 | ||
|
|
2f9601364e | ||
|
|
e5231e29f2 | ||
|
|
8ac8743792 | ||
|
|
586375d921 | ||
|
|
41252d8bb9 | ||
|
|
4740b6d19e | ||
|
|
5a47b15c97 | ||
|
|
3bff5fa8bd | ||
|
|
f95e39748e | ||
|
|
90c82aab2e | ||
|
|
a4c2b8f885 | ||
|
|
2c56ec746c | ||
|
|
35e844dd7b | ||
|
|
4de6dbba27 | ||
|
|
a962e5e019 | ||
|
|
1130f71075 | ||
|
|
fd76ca12fc | ||
|
|
57c8368b5e | ||
|
|
7fd7697c02 |
@@ -12,7 +12,7 @@ opcache.max_accelerated_files = 20000
|
||||
opcache.memory_consumption = 256
|
||||
opcache.enable_file_override = 1
|
||||
|
||||
memory_limit = 256M
|
||||
memory_limit = 512M
|
||||
|
||||
upload_max_filesize=256M
|
||||
post_max_size=300M
|
||||
post_max_size=300M
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
worker {
|
||||
file ./public/index.php
|
||||
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
|
||||
}
|
||||
|
||||
5
.env
5
.env
@@ -71,6 +71,11 @@ DISABLE_WEB_UPDATES=1
|
||||
# Restoring backups is a destructive operation that could overwrite your database.
|
||||
DISABLE_BACKUP_RESTORE=1
|
||||
|
||||
# Disable backup download from the Update Manager UI (0=enabled, 1=disabled).
|
||||
# Backups contain sensitive data including password hashes and secrets.
|
||||
# When enabled, users must confirm their password before downloading.
|
||||
DISABLE_BACKUP_DOWNLOAD=1
|
||||
|
||||
###################################################################################
|
||||
# SAML Single sign on-settings
|
||||
###################################################################################
|
||||
|
||||
16
.github/workflows/assets_artifact_build.yml
vendored
16
.github/workflows/assets_artifact_build.yml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- '*'
|
||||
- "!l10n_*" # Dont test localization branches
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- 'v*.*.*-**'
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
@@ -17,6 +20,8 @@ jobs:
|
||||
assets_artifact_build:
|
||||
name: Build assets artifact
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
APP_ENV: prod
|
||||
@@ -80,13 +85,20 @@ jobs:
|
||||
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
|
||||
|
||||
- name: Upload assets artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Only dependencies and built assets
|
||||
path: /tmp/partdb_assets.zip
|
||||
|
||||
- name: Upload full artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Full Part-DB including dependencies and built assets
|
||||
path: /tmp/partdb_with_assets.zip
|
||||
|
||||
- name: Upload assets as release attachment
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" /tmp/partdb_assets.zip /tmp/partdb_with_assets.zip --clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
113
.github/workflows/docker_build.yml
vendored
113
.github/workflows/docker_build.yml
vendored
@@ -15,8 +15,20 @@ on:
|
||||
- 'v*.*.*-**'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
platform-slug: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
platform-slug: arm64
|
||||
- platform: linux/arm/v7
|
||||
runner: ubuntu-24.04-arm
|
||||
platform-slug: armv7
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
@@ -32,13 +44,12 @@ jobs:
|
||||
# Mark the image build from master as latest (as we dont have really releases yet)
|
||||
tags: |
|
||||
type=edge,branch=master
|
||||
type=ref,event=branch,
|
||||
type=ref,event=tag,
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=schedule
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
|
||||
@@ -49,12 +60,10 @@ jobs:
|
||||
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-symfony
|
||||
org.opencontainers.image.authors=Jan Böhmer
|
||||
org.opencontainers.licenses=AGPL-3.0-or-later
|
||||
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -67,13 +76,85 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
-
|
||||
name: Build and push
|
||||
name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
outputs: type=image,name=jbtronics/part-db1,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||
|
||||
-
|
||||
name: Export digest
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
-
|
||||
name: Upload digest
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ matrix.platform-slug }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
-
|
||||
name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
jbtronics/part-db1
|
||||
tags: |
|
||||
type=edge,branch=master
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=schedule
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=ref,event=pr
|
||||
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
-
|
||||
name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'jbtronics/part-db1@sha256:%s ' *)
|
||||
|
||||
-
|
||||
name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect jbtronics/part-db1:${{ steps.docker_meta.outputs.version }}
|
||||
|
||||
113
.github/workflows/docker_frankenphp.yml
vendored
113
.github/workflows/docker_frankenphp.yml
vendored
@@ -15,8 +15,20 @@ on:
|
||||
- 'v*.*.*-**'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
platform-slug: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
platform-slug: arm64
|
||||
- platform: linux/arm/v7
|
||||
runner: ubuntu-24.04-arm
|
||||
platform-slug: armv7
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
@@ -32,13 +44,12 @@ jobs:
|
||||
# Mark the image build from master as latest (as we dont have really releases yet)
|
||||
tags: |
|
||||
type=edge,branch=master
|
||||
type=ref,event=branch,
|
||||
type=ref,event=tag,
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=schedule
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
|
||||
@@ -49,12 +60,10 @@ jobs:
|
||||
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-server
|
||||
org.opencontainers.image.authors=Jan Böhmer
|
||||
org.opencontainers.licenses=AGPL-3.0-or-later
|
||||
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -67,14 +76,86 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
-
|
||||
name: Build and push
|
||||
name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile-frankenphp
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
outputs: type=image,name=partdborg/part-db,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||
|
||||
-
|
||||
name: Export digest
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
-
|
||||
name: Upload digest
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ matrix.platform-slug }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
-
|
||||
name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
partdborg/part-db
|
||||
tags: |
|
||||
type=edge,branch=master
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=schedule
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=ref,event=pr
|
||||
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
-
|
||||
name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'partdborg/part-db@sha256:%s ' *)
|
||||
|
||||
-
|
||||
name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect partdborg/part-db:${{ steps.docker_meta.outputs.version }}
|
||||
|
||||
115
Dockerfile
115
Dockerfile
@@ -1,15 +1,75 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
ARG PHP_VERSION=8.4
|
||||
ARG NODE_VERSION=22
|
||||
# Node.js build stage for building frontend assets
|
||||
# Use native platform for build stage as it's platform-independent
|
||||
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
|
||||
ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
|
||||
# Install composer and minimal PHP for running Symfony commands
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Use BuildKit cache mounts for apt in builder stage
|
||||
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
|
||||
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
php-cli \
|
||||
php-xml \
|
||||
php-mbstring \
|
||||
unzip \
|
||||
git \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy composer files and install dependencies (needed for Symfony UX assets)
|
||||
COPY composer.json composer.lock symfony.lock ./
|
||||
|
||||
# Use BuildKit cache for Composer downloads
|
||||
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
|
||||
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
|
||||
|
||||
# Copy all application files needed for cache warmup and webpack build
|
||||
COPY .env* ./
|
||||
COPY bin ./bin
|
||||
COPY config ./config
|
||||
COPY src ./src
|
||||
COPY translations ./translations
|
||||
COPY public ./public
|
||||
COPY assets ./assets
|
||||
COPY webpack.config.js ./
|
||||
|
||||
# Generate autoloader
|
||||
RUN composer dump-autoload
|
||||
|
||||
# Create required directories for cache warmup
|
||||
RUN mkdir -p var/cache var/log uploads public/media
|
||||
|
||||
# Dump translations, which we need for cache warmup
|
||||
RUN php bin/console cache:warmup -n --env=prod 2>&1
|
||||
|
||||
# Copy package files and install node dependencies
|
||||
COPY package.json yarn.lock ./
|
||||
# Use BuildKit cache for yarn/npm
|
||||
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
|
||||
--mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
yarn install --network-timeout 600000
|
||||
|
||||
# Build the assets
|
||||
RUN yarn build
|
||||
|
||||
# Clean up
|
||||
RUN yarn cache clean && rm -rf node_modules/
|
||||
|
||||
# Base stage for PHP
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
ARG PHP_VERSION
|
||||
ARG TARGETARCH
|
||||
|
||||
# Install needed dependencies for PHP build
|
||||
#RUN apt-get update && apt-get install -y pkg-config curl libcurl4-openssl-dev libicu-dev \
|
||||
# libpng-dev libjpeg-dev libfreetype6-dev gnupg zip libzip-dev libjpeg62-turbo-dev libonig-dev libxslt-dev libwebp-dev vim \
|
||||
# && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && apt-get -y install \
|
||||
# Use BuildKit cache mounts for apt in base stage
|
||||
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
|
||||
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
|
||||
apt-get update && apt-get -y install \
|
||||
apt-transport-https \
|
||||
lsb-release \
|
||||
ca-certificates \
|
||||
@@ -39,19 +99,10 @@ RUN apt-get update && apt-get -y install \
|
||||
gpg \
|
||||
sudo \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* \
|
||||
# Create workdir and set permissions if directory does not exists
|
||||
&& mkdir -p /var/www/html \
|
||||
&& chown -R www-data:www-data /var/www/html \
|
||||
# delete the "index.html" that installing Apache drops in here
|
||||
&& rm -rvf /var/www/html/*
|
||||
|
||||
# Install node and yarn
|
||||
RUN curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get update && apt-get install -y \
|
||||
nodejs \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* && \
|
||||
npm install -g yarn
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -65,14 +116,12 @@ ENV APACHE_ENVVARS=$APACHE_CONFDIR/envvars
|
||||
# : ${APACHE_RUN_USER:=www-data}
|
||||
# export APACHE_RUN_USER
|
||||
# so that they can be overridden at runtime ("-e APACHE_RUN_USER=...")
|
||||
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"; \
|
||||
set -eux; . "$APACHE_ENVVARS"; \
|
||||
\
|
||||
# logs should go to stdout / stderr
|
||||
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
|
||||
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log"; \
|
||||
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \
|
||||
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR";
|
||||
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS" && \
|
||||
set -eux; . "$APACHE_ENVVARS" && \
|
||||
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log" && \
|
||||
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log" && \
|
||||
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log" && \
|
||||
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR"
|
||||
|
||||
# ---
|
||||
|
||||
@@ -141,7 +190,6 @@ COPY --chown=www-data:www-data . .
|
||||
# Setup apache2
|
||||
RUN a2dissite 000-default.conf && \
|
||||
a2ensite symfony.conf && \
|
||||
# Enable php-fpm
|
||||
a2enmod proxy_fcgi setenvif && \
|
||||
a2enconf php${PHP_VERSION}-fpm && \
|
||||
a2enconf docker-php && \
|
||||
@@ -149,12 +197,13 @@ RUN a2dissite 000-default.conf && \
|
||||
|
||||
# Install composer and yarn dependencies for Part-DB
|
||||
USER www-data
|
||||
RUN composer install -a --no-dev && \
|
||||
# Use BuildKit cache for Composer when running as www-data by setting COMPOSER_CACHE_DIR
|
||||
RUN --mount=type=cache,id=composer-cache,target=/tmp/.composer-cache \
|
||||
COMPOSER_CACHE_DIR=/tmp/.composer-cache composer install -a --no-dev && \
|
||||
composer clear-cache
|
||||
RUN yarn install --network-timeout 600000 && \
|
||||
yarn build && \
|
||||
yarn cache clean && \
|
||||
rm -rf node_modules/
|
||||
|
||||
# Copy built frontend assets from node-builder stage
|
||||
COPY --from=node-builder --chown=www-data:www-data /app/public/build ./public/build
|
||||
|
||||
# Use docker env to output logs to stdout
|
||||
ENV APP_ENV=docker
|
||||
@@ -166,10 +215,12 @@ USER root
|
||||
RUN sed -i "s/PHP_VERSION/${PHP_VERSION}/g" ./.docker/partdb-entrypoint.sh
|
||||
|
||||
# Copy entrypoint and apache2-foreground to /usr/local/bin and make it executable
|
||||
RUN install ./.docker/partdb-entrypoint.sh /usr/local/bin && \
|
||||
install ./.docker/apache2-foreground /usr/local/bin
|
||||
# Convert CRLF -> LF and install entrypoint scripts with executable mode
|
||||
RUN sed -i 's/\r$//' ./.docker/partdb-entrypoint.sh ./.docker/apache2-foreground && \
|
||||
install -m 0755 ./.docker/partdb-entrypoint.sh /usr/local/bin/ && \
|
||||
install -m 0755 ./.docker/apache2-foreground /usr/local/bin/
|
||||
ENTRYPOINT ["partdb-entrypoint.sh"]
|
||||
CMD ["apache2-foreground"]
|
||||
CMD ["/usr/local/bin/apache2-foreground"]
|
||||
|
||||
# https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop
|
||||
STOPSIGNAL SIGWINCH
|
||||
|
||||
@@ -1,6 +1,72 @@
|
||||
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
RUN apt-get update && apt-get -y install \
|
||||
# Node.js build stage for building frontend assets
|
||||
# Use native platform for build stage as it's platform-independent
|
||||
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
|
||||
ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
|
||||
# Install composer and minimal PHP for running Symfony commands
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Use BuildKit cache mounts for apt in builder stage
|
||||
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
|
||||
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
php-cli \
|
||||
php-xml \
|
||||
php-mbstring \
|
||||
unzip \
|
||||
git \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy composer files and install dependencies (needed for Symfony UX assets)
|
||||
COPY composer.json composer.lock symfony.lock ./
|
||||
|
||||
# Use BuildKit cache for Composer downloads
|
||||
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
|
||||
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
|
||||
|
||||
# Copy all application files needed for cache warmup and webpack build
|
||||
COPY .env* ./
|
||||
COPY bin ./bin
|
||||
COPY config ./config
|
||||
COPY src ./src
|
||||
COPY translations ./translations
|
||||
COPY public ./public
|
||||
COPY assets ./assets
|
||||
COPY webpack.config.js ./
|
||||
|
||||
# Generate autoloader
|
||||
RUN composer dump-autoload
|
||||
|
||||
# Create required directories for cache warmup
|
||||
RUN mkdir -p var/cache var/log uploads public/media
|
||||
|
||||
# Dump translations, which we need for cache warmup
|
||||
RUN php bin/console cache:warmup -n --env=prod 2>&1
|
||||
|
||||
# Copy package files and install node dependencies
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Use BuildKit cache for yarn/npm
|
||||
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
|
||||
--mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
yarn install --network-timeout 600000
|
||||
|
||||
|
||||
# Build the assets
|
||||
RUN yarn build
|
||||
|
||||
# Clean up
|
||||
RUN yarn cache clean && rm -rf node_modules/
|
||||
|
||||
# FrankenPHP base stage
|
||||
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||
ARG TARGETARCH
|
||||
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
|
||||
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
|
||||
apt-get update && apt-get -y install \
|
||||
curl \
|
||||
ca-certificates \
|
||||
mariadb-client \
|
||||
@@ -13,24 +79,6 @@ RUN apt-get update && apt-get -y install \
|
||||
zip \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
RUN set -eux; \
|
||||
# Run NodeSource setup script
|
||||
curl -sL https://deb.nodesource.com/setup_22.x | bash -; \
|
||||
\
|
||||
# Install Node.js
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
nodejs; \
|
||||
\
|
||||
# Cleanup
|
||||
apt-get -y autoremove; \
|
||||
apt-get clean autoclean; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
\
|
||||
# Install Yarn via npm
|
||||
npm install -g yarn
|
||||
|
||||
|
||||
# Install PHP
|
||||
RUN set -eux; \
|
||||
install-php-extensions \
|
||||
@@ -76,14 +124,11 @@ COPY --link . ./
|
||||
RUN set -eux; \
|
||||
mkdir -p var/cache var/log; \
|
||||
composer dump-autoload --classmap-authoritative --no-dev; \
|
||||
composer dump-env prod; \
|
||||
composer run-script --no-dev post-install-cmd; \
|
||||
chmod +x bin/console; sync;
|
||||
|
||||
RUN yarn install --network-timeout 600000 && \
|
||||
yarn build && \
|
||||
yarn cache clean && \
|
||||
rm -rf node_modules/
|
||||
# Copy built frontend assets from node-builder stage
|
||||
COPY --from=node-builder /app/public/build ./public/build
|
||||
|
||||
# Use docker env to output logs to stdout
|
||||
ENV APP_ENV=docker
|
||||
@@ -102,8 +147,8 @@ VOLUME ["/var/www/html/uploads", "/var/www/html/public/media"]
|
||||
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
|
||||
|
||||
# See https://caddyserver.com/docs/conventions#file-locations for details
|
||||
ENV XDG_CONFIG_HOME /config
|
||||
ENV XDG_DATA_HOME /data
|
||||
ENV XDG_CONFIG_HOME=/config
|
||||
ENV XDG_DATA_HOME=/data
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
|
||||
206
assets/commands/kicad_populate_default_mappings.json
Normal file
206
assets/commands/kicad_populate_default_mappings.json
Normal file
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"_comment": "Default KiCad footprint/symbol mappings for partdb:kicad:populate command. Based on KiCad 9.x standard libraries. Use --mapping-file to override or extend these mappings.",
|
||||
"footprints": {
|
||||
"SOT-23": "Package_TO_SOT_SMD:SOT-23",
|
||||
"SOT-23-3": "Package_TO_SOT_SMD:SOT-23",
|
||||
"SOT-23-5": "Package_TO_SOT_SMD:SOT-23-5",
|
||||
"SOT-23-6": "Package_TO_SOT_SMD:SOT-23-6",
|
||||
"SOT-223": "Package_TO_SOT_SMD:SOT-223-3_TabPin2",
|
||||
"SOT-223-3": "Package_TO_SOT_SMD:SOT-223-3_TabPin2",
|
||||
"SOT-89": "Package_TO_SOT_SMD:SOT-89-3",
|
||||
"SOT-89-3": "Package_TO_SOT_SMD:SOT-89-3",
|
||||
"SOT-323": "Package_TO_SOT_SMD:SOT-323_SC-70",
|
||||
"SOT-363": "Package_TO_SOT_SMD:SOT-363_SC-70-6",
|
||||
"TSOT-25": "Package_TO_SOT_SMD:SOT-23-5",
|
||||
"SC-70-5": "Package_TO_SOT_SMD:SOT-353_SC-70-5",
|
||||
"SC-70-6": "Package_TO_SOT_SMD:SOT-363_SC-70-6",
|
||||
"TO-220": "Package_TO_SOT_THT:TO-220-3_Vertical",
|
||||
"TO-220AB": "Package_TO_SOT_THT:TO-220-3_Vertical",
|
||||
"TO-220AB-3": "Package_TO_SOT_THT:TO-220-3_Vertical",
|
||||
"TO-220FP": "Package_TO_SOT_THT:TO-220F-3_Vertical",
|
||||
"TO-247-3": "Package_TO_SOT_THT:TO-247-3_Vertical",
|
||||
"TO-92": "Package_TO_SOT_THT:TO-92_Inline",
|
||||
"TO-92-3": "Package_TO_SOT_THT:TO-92_Inline",
|
||||
"TO-252": "Package_TO_SOT_SMD:TO-252-2",
|
||||
"TO-252-2L": "Package_TO_SOT_SMD:TO-252-2",
|
||||
"TO-252-3L": "Package_TO_SOT_SMD:TO-252-3",
|
||||
"TO-263": "Package_TO_SOT_SMD:TO-263-2",
|
||||
"TO-263-2": "Package_TO_SOT_SMD:TO-263-2",
|
||||
"D2PAK": "Package_TO_SOT_SMD:TO-252-2",
|
||||
"DPAK": "Package_TO_SOT_SMD:TO-252-2",
|
||||
"SOIC-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm",
|
||||
"ESOP-8": "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm",
|
||||
"SOIC-14": "Package_SO:SOIC-14_3.9x8.7mm_P1.27mm",
|
||||
"SOIC-16": "Package_SO:SOIC-16_3.9x9.9mm_P1.27mm",
|
||||
"TSSOP-8": "Package_SO:TSSOP-8_3x3mm_P0.65mm",
|
||||
"TSSOP-14": "Package_SO:TSSOP-14_4.4x5mm_P0.65mm",
|
||||
"TSSOP-16": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm",
|
||||
"TSSOP-16L": "Package_SO:TSSOP-16_4.4x5mm_P0.65mm",
|
||||
"TSSOP-20": "Package_SO:TSSOP-20_4.4x6.5mm_P0.65mm",
|
||||
"MSOP-8": "Package_SO:MSOP-8_3x3mm_P0.65mm",
|
||||
"MSOP-10": "Package_SO:MSOP-10_3x3mm_P0.5mm",
|
||||
"MSOP-16": "Package_SO:MSOP-16_3x4mm_P0.5mm",
|
||||
"SO-5": "Package_TO_SOT_SMD:SOT-23-5",
|
||||
"DIP-4": "Package_DIP:DIP-4_W7.62mm",
|
||||
"DIP-6": "Package_DIP:DIP-6_W7.62mm",
|
||||
"DIP-8": "Package_DIP:DIP-8_W7.62mm",
|
||||
"DIP-14": "Package_DIP:DIP-14_W7.62mm",
|
||||
"DIP-16": "Package_DIP:DIP-16_W7.62mm",
|
||||
"DIP-18": "Package_DIP:DIP-18_W7.62mm",
|
||||
"DIP-20": "Package_DIP:DIP-20_W7.62mm",
|
||||
"DIP-24": "Package_DIP:DIP-24_W7.62mm",
|
||||
"DIP-28": "Package_DIP:DIP-28_W7.62mm",
|
||||
"DIP-40": "Package_DIP:DIP-40_W15.24mm",
|
||||
"QFN-8": "Package_DFN_QFN:QFN-8-1EP_3x3mm_P0.65mm_EP1.55x1.55mm",
|
||||
"QFN-12(3x3)": "Package_DFN_QFN:QFN-12-1EP_3x3mm_P0.5mm_EP1.65x1.65mm",
|
||||
"QFN-16": "Package_DFN_QFN:QFN-16-1EP_3x3mm_P0.5mm_EP1.45x1.45mm",
|
||||
"QFN-20": "Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm",
|
||||
"QFN-24": "Package_DFN_QFN:QFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm",
|
||||
"QFN-32": "Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm",
|
||||
"QFN-48": "Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm",
|
||||
"TQFP-32": "Package_QFP:TQFP-32_7x7mm_P0.8mm",
|
||||
"TQFP-44": "Package_QFP:TQFP-44_10x10mm_P0.8mm",
|
||||
"TQFP-48": "Package_QFP:TQFP-48_7x7mm_P0.5mm",
|
||||
"TQFP-48(7x7)": "Package_QFP:TQFP-48_7x7mm_P0.5mm",
|
||||
"TQFP-64": "Package_QFP:TQFP-64_10x10mm_P0.5mm",
|
||||
"TQFP-100": "Package_QFP:TQFP-100_14x14mm_P0.5mm",
|
||||
"LQFP-32": "Package_QFP:LQFP-32_7x7mm_P0.8mm",
|
||||
"LQFP-48": "Package_QFP:LQFP-48_7x7mm_P0.5mm",
|
||||
"LQFP-64": "Package_QFP:LQFP-64_10x10mm_P0.5mm",
|
||||
"LQFP-100": "Package_QFP:LQFP-100_14x14mm_P0.5mm",
|
||||
|
||||
"SOD-123": "Diode_SMD:D_SOD-123",
|
||||
"SOD-123F": "Diode_SMD:D_SOD-123F",
|
||||
"SOD-123FL": "Diode_SMD:D_SOD-123F",
|
||||
"SOD-323": "Diode_SMD:D_SOD-323",
|
||||
"SOD-523": "Diode_SMD:D_SOD-523",
|
||||
"SOD-882": "Diode_SMD:D_SOD-882",
|
||||
"SOD-882D": "Diode_SMD:D_SOD-882",
|
||||
"SMA(DO-214AC)": "Diode_SMD:D_SMA",
|
||||
"SMA": "Diode_SMD:D_SMA",
|
||||
"SMB": "Diode_SMD:D_SMB",
|
||||
"SMC": "Diode_SMD:D_SMC",
|
||||
|
||||
"DO-35": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal",
|
||||
"DO-35(DO-204AH)": "Diode_THT:D_DO-35_SOD27_P7.62mm_Horizontal",
|
||||
"DO-41": "Diode_THT:D_DO-41_SOD81_P10.16mm_Horizontal",
|
||||
"DO-201": "Diode_THT:D_DO-201_P15.24mm_Horizontal",
|
||||
|
||||
"DFN-2(0.6x1)": "Package_DFN_QFN:DFN-2-1EP_0.6x1.0mm_P0.65mm_EP0.2x0.55mm",
|
||||
"DFN1006-2": "Package_DFN_QFN:DFN-2_1.0x0.6mm",
|
||||
"DFN-6": "Package_DFN_QFN:DFN-6-1EP_2x2mm_P0.65mm_EP1x1.6mm",
|
||||
"DFN-8": "Package_DFN_QFN:DFN-8-1EP_3x2mm_P0.5mm_EP1.3x1.5mm",
|
||||
|
||||
"0201": "Resistor_SMD:R_0201_0603Metric",
|
||||
"0402": "Resistor_SMD:R_0402_1005Metric",
|
||||
"0603": "Resistor_SMD:R_0603_1608Metric",
|
||||
"0805": "Resistor_SMD:R_0805_2012Metric",
|
||||
"1206": "Resistor_SMD:R_1206_3216Metric",
|
||||
"1210": "Resistor_SMD:R_1210_3225Metric",
|
||||
"1812": "Resistor_SMD:R_1812_4532Metric",
|
||||
"2010": "Resistor_SMD:R_2010_5025Metric",
|
||||
"2512": "Resistor_SMD:R_2512_6332Metric",
|
||||
"2917": "Resistor_SMD:R_2917_7343Metric",
|
||||
"2920": "Resistor_SMD:R_2920_7350Metric",
|
||||
|
||||
"CASE-A-3216-18(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3216-18_Kemet-A",
|
||||
"CASE-B-3528-21(mm)": "Capacitor_Tantalum_SMD:CP_EIA-3528-21_Kemet-B",
|
||||
"CASE-C-6032-28(mm)": "Capacitor_Tantalum_SMD:CP_EIA-6032-28_Kemet-C",
|
||||
"CASE-D-7343-31(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-31_Kemet-D",
|
||||
"CASE-E-7343-43(mm)": "Capacitor_Tantalum_SMD:CP_EIA-7343-43_Kemet-E",
|
||||
|
||||
"SMD,D4xL5.4mm": "Capacitor_SMD:CP_Elec_4x5.4",
|
||||
"SMD,D5xL5.4mm": "Capacitor_SMD:CP_Elec_5x5.4",
|
||||
"SMD,D6.3xL5.4mm": "Capacitor_SMD:CP_Elec_6.3x5.4",
|
||||
"SMD,D6.3xL7.7mm": "Capacitor_SMD:CP_Elec_6.3x7.7",
|
||||
"SMD,D8xL6.5mm": "Capacitor_SMD:CP_Elec_8x6.5",
|
||||
"SMD,D8xL10mm": "Capacitor_SMD:CP_Elec_8x10",
|
||||
"SMD,D10xL10mm": "Capacitor_SMD:CP_Elec_10x10",
|
||||
"SMD,D10xL10.5mm": "Capacitor_SMD:CP_Elec_10x10.5",
|
||||
|
||||
"Through Hole,D5xL11mm": "Capacitor_THT:CP_Radial_D5.0mm_P2.00mm",
|
||||
"Through Hole,D6.3xL11mm": "Capacitor_THT:CP_Radial_D6.3mm_P2.50mm",
|
||||
"Through Hole,D8xL11mm": "Capacitor_THT:CP_Radial_D8.0mm_P3.50mm",
|
||||
"Through Hole,D10xL16mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm",
|
||||
"Through Hole,D10xL20mm": "Capacitor_THT:CP_Radial_D10.0mm_P5.00mm",
|
||||
"Through Hole,D12.5xL20mm": "Capacitor_THT:CP_Radial_D12.5mm_P5.00mm",
|
||||
|
||||
"LED 3mm": "LED_THT:LED_D3.0mm",
|
||||
"LED 5mm": "LED_THT:LED_D5.0mm",
|
||||
"LED 0603": "LED_SMD:LED_0603_1608Metric",
|
||||
"LED 0805": "LED_SMD:LED_0805_2012Metric",
|
||||
"SMD5050-4P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm",
|
||||
"SMD5050-6P": "LED_SMD:LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm",
|
||||
|
||||
"HC-49": "Crystal:Crystal_HC49-4H_Vertical",
|
||||
"HC-49/U": "Crystal:Crystal_HC49-4H_Vertical",
|
||||
"HC-49/S": "Crystal:Crystal_HC49-U_Vertical",
|
||||
"HC-49/US": "Crystal:Crystal_HC49-U_Vertical",
|
||||
|
||||
"USB-A": "Connector_USB:USB_A_Stewart_SS-52100-001_Horizontal",
|
||||
"USB-B": "Connector_USB:USB_B_OST_USB-B1HSxx_Horizontal",
|
||||
"USB-Mini-B": "Connector_USB:USB_Mini-B_Lumberg_2486_01_Horizontal",
|
||||
"USB-Micro-B": "Connector_USB:USB_Micro-B_Molex-105017-0001",
|
||||
"USB-C": "Connector_USB:USB_C_Receptacle_GCT_USB4085",
|
||||
|
||||
"1x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical",
|
||||
"1x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_Vertical",
|
||||
"1x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical",
|
||||
"1x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x05_P2.54mm_Vertical",
|
||||
"1x6 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_Vertical",
|
||||
"1x8 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_Vertical",
|
||||
"1x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_1x10_P2.54mm_Vertical",
|
||||
"2x2 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x02_P2.54mm_Vertical",
|
||||
"2x3 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x03_P2.54mm_Vertical",
|
||||
"2x4 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x04_P2.54mm_Vertical",
|
||||
"2x5 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Vertical",
|
||||
"2x10 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x10_P2.54mm_Vertical",
|
||||
"2x20 P2.54mm": "Connector_PinHeader_2.54mm:PinHeader_2x20_P2.54mm_Vertical",
|
||||
"SIP-3-2.54mm": "Package_SIP:SIP-3_P2.54mm",
|
||||
"SIP-4-2.54mm": "Package_SIP:SIP-4_P2.54mm",
|
||||
"SIP-5-2.54mm": "Package_SIP:SIP-5_P2.54mm"
|
||||
},
|
||||
"categories": {
|
||||
"Electrolytic": "Device:C_Polarized",
|
||||
"Polarized": "Device:C_Polarized",
|
||||
"Tantalum": "Device:C_Polarized",
|
||||
"Zener": "Device:D_Zener",
|
||||
"Schottky": "Device:D_Schottky",
|
||||
"TVS": "Device:D_TVS",
|
||||
"LED": "Device:LED",
|
||||
"NPN": "Device:Q_NPN_BCE",
|
||||
"PNP": "Device:Q_PNP_BCE",
|
||||
"N-MOSFET": "Device:Q_NMOS_GDS",
|
||||
"NMOS": "Device:Q_NMOS_GDS",
|
||||
"N-MOS": "Device:Q_NMOS_GDS",
|
||||
"P-MOSFET": "Device:Q_PMOS_GDS",
|
||||
"PMOS": "Device:Q_PMOS_GDS",
|
||||
"P-MOS": "Device:Q_PMOS_GDS",
|
||||
"MOSFET": "Device:Q_NMOS_GDS",
|
||||
"JFET": "Device:Q_NJFET_DSG",
|
||||
"Ferrite": "Device:Ferrite_Bead",
|
||||
"Crystal": "Device:Crystal",
|
||||
"Oscillator": "Oscillator:Oscillator_Crystal",
|
||||
"Fuse": "Device:Fuse",
|
||||
"Transformer": "Device:Transformer_1P_1S",
|
||||
"Resistor": "Device:R",
|
||||
"Capacitor": "Device:C",
|
||||
"Inductor": "Device:L",
|
||||
"Diode": "Device:D",
|
||||
"Transistor": "Device:Q_NPN_BCE",
|
||||
"Voltage Regulator": "Regulator_Linear:LM317_TO-220",
|
||||
"LDO": "Regulator_Linear:AMS1117-3.3",
|
||||
"Op-Amp": "Amplifier_Operational:LM358",
|
||||
"Comparator": "Comparator:LM393",
|
||||
"Optocoupler": "Isolator:PC817",
|
||||
"Relay": "Relay:Relay_DPDT",
|
||||
"Connector": "Connector:Conn_01x02",
|
||||
"Switch": "Switch:SW_Push",
|
||||
"Button": "Switch:SW_Push",
|
||||
"Potentiometer": "Device:R_POT",
|
||||
"Trimpot": "Device:R_POT",
|
||||
"Thermistor": "Device:Thermistor",
|
||||
"Varistor": "Device:Varistor",
|
||||
"Photo": "Device:LED"
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { Toast } from 'bootstrap';
|
||||
|
||||
/**
|
||||
* The purpose of this controller, is to show all containers.
|
||||
* They should already be added via turbo-streams, but have to be called for to show them.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
//Move all toasts from the page into our toast container and show them
|
||||
@@ -33,4 +37,4 @@ export default class extends Controller {
|
||||
const toast = new Toast(this.element);
|
||||
toast.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export default class extends Controller {
|
||||
maxItems: 1,
|
||||
createOnBlur: true,
|
||||
selectOnTab: true,
|
||||
clearAfterSelect: true,
|
||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||
dropdownParent: dropdownParent,
|
||||
|
||||
@@ -74,15 +74,33 @@ export default class extends Controller {
|
||||
const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID()));
|
||||
|
||||
|
||||
let ret = null;
|
||||
|
||||
//Insert new html after the last child element
|
||||
//If the table has a tbody, insert it there
|
||||
//Afterwards return the newly created row
|
||||
if(targetTable.tBodies[0]) {
|
||||
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
|
||||
return targetTable.tBodies[0].lastElementChild;
|
||||
ret = targetTable.tBodies[0].lastElementChild;
|
||||
} else { //Otherwise just insert it
|
||||
targetTable.insertAdjacentHTML('beforeend', newElementStr);
|
||||
return targetTable.lastElementChild;
|
||||
ret = targetTable.lastElementChild;
|
||||
}
|
||||
|
||||
//Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it
|
||||
targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true}));
|
||||
|
||||
this.focusNumberInput(ret);
|
||||
|
||||
return ret;
|
||||
|
||||
}
|
||||
|
||||
focusNumberInput(element) {
|
||||
const fields = element.querySelectorAll("input[type=number]");
|
||||
//Focus the first available number input field to open the numeric keyboard on mobile devices
|
||||
if(fields.length > 0) {
|
||||
fields[0].focus();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,11 +108,19 @@ export default class extends Controller {
|
||||
const raw_order = saved_state.order;
|
||||
|
||||
settings.initial_order = raw_order.map((order) => {
|
||||
//Skip if direction is empty, as this is the default, otherwise datatables server is confused when the order is sent in the request, but the initial order is set to an empty direction
|
||||
if (order[1] === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
column: order[0],
|
||||
dir: order[1]
|
||||
}
|
||||
});
|
||||
|
||||
//Remove null values from the initial_order array
|
||||
settings.initial_order = settings.initial_order.filter(order => order !== null);
|
||||
}
|
||||
|
||||
let options = {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* Purpose of this controller is to allow users to input non-printable characters like EOT, FS, etc. in a form field and submit them correctly with the form.
|
||||
* The visible input field encodes non-printable characters via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉, so that they can be displayed in the input field without breaking the form submission.
|
||||
* The actual value of the field, which is submitted with the form, is stored in a hidden input and contains the non-printable characters in their original form.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
|
||||
_hiddenInput;
|
||||
|
||||
connect() {
|
||||
this.element.addEventListener("input", this._update.bind(this));
|
||||
|
||||
// We use a hidden input to store the actual value of the field, which is submitted with the form.
|
||||
// The visible input is just for user interaction and can contain non-printable characters, which are not allowed in the hidden input.
|
||||
this._hiddenInput = document.createElement("input");
|
||||
this._hiddenInput.type = "hidden";
|
||||
this._hiddenInput.name = this.element.name;
|
||||
this.element.removeAttribute("name");
|
||||
this.element.parentNode.insertBefore(this._hiddenInput, this.element.nextSibling);
|
||||
|
||||
this.element.addEventListener("keypress", this._onKeyPress.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that non-printable characters like EOT, FS, etc. gets added to the input value when the user types them
|
||||
* @param event
|
||||
* @private
|
||||
*/
|
||||
_onKeyPress(event) {
|
||||
const ALLOWED_INPUT_CODES = [4, 28, 29, 30, 31]; //EOT, FS, GS, RS, US
|
||||
|
||||
if (!ALLOWED_INPUT_CODES.includes(event.keyCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const char = String.fromCharCode(event.keyCode);
|
||||
this.element.value += char;
|
||||
|
||||
this._update();
|
||||
|
||||
|
||||
}
|
||||
|
||||
_update() {
|
||||
//Chrome workaround: Remove a leading ∠ character (U+2220) that appears when the user types a non-printable character at the beginning of the input field.
|
||||
if (this.element.value.startsWith("∠")) {
|
||||
this.element.value = this.element.value.substring(1);
|
||||
}
|
||||
|
||||
// Remove non-printable characters from the input value and store them in the hidden input
|
||||
const normalizedValue = this.decodeNonPrintableChars(this.element.value);
|
||||
this._hiddenInput.value = normalizedValue;
|
||||
|
||||
// Encode non-printable characters in the visible input to their Unicode Control picture representation
|
||||
const encodedValue = this.encodeNonPrintableChars(normalizedValue);
|
||||
if (encodedValue !== this.element.value) {
|
||||
this.element.value = encodedValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes non-printable characters in the given string via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉.
|
||||
* This allows us to display non-printable characters in the input field without breaking the form submission.
|
||||
* @param str
|
||||
*/
|
||||
encodeNonPrintableChars(str) {
|
||||
return str.replace(/[\x00-\x1F\x7F]/g, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return String.fromCharCode(0x2400 + code);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the Unicode Control picture representation of non-printable characters back to their original form, e.g. ␊ becomes \n and ␉ becomes \t.
|
||||
* @param str
|
||||
*/
|
||||
decodeNonPrintableChars(str) {
|
||||
return str.replace(/[\u2400-\u241F\u2421]/g, (char) => {
|
||||
const code = char.charCodeAt(0) - 0x2400;
|
||||
return String.fromCharCode(code);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export default class extends Controller {
|
||||
valueField: "id",
|
||||
labelField: "name",
|
||||
dropdownParent: dropdownParent,
|
||||
selectOnTab: true,
|
||||
clearAfterSelect: true,
|
||||
preload: "focus",
|
||||
render: {
|
||||
item: (data, escape) => {
|
||||
|
||||
@@ -49,6 +49,7 @@ export default class extends Controller {
|
||||
selectOnTab: true,
|
||||
maxOptions: null,
|
||||
dropdownParent: dropdownParent,
|
||||
clearAfterSelect: true,
|
||||
|
||||
render: {
|
||||
item: this.renderItem.bind(this),
|
||||
|
||||
@@ -35,6 +35,8 @@ export default class extends Controller {
|
||||
maxItems: 1000,
|
||||
allowEmptyOption: true,
|
||||
dropdownParent: dropdownParent,
|
||||
selectOnTab: true,
|
||||
clearAfterSelect: true,
|
||||
plugins: ['remove_button'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
import {default as TreeController} from "./tree_controller";
|
||||
import {EVENT_INITIALIZED} from "@jbtronics/bs-treeview";
|
||||
|
||||
export default class extends TreeController {
|
||||
static targets = [ "tree", 'sourceText' ];
|
||||
@@ -40,6 +41,8 @@ export default class extends TreeController {
|
||||
//Check if we have a saved mode
|
||||
const stored_mode = localStorage.getItem(this._storage_key);
|
||||
|
||||
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
|
||||
|
||||
//Use stored mode if possible, otherwise use default
|
||||
if(stored_mode) {
|
||||
try {
|
||||
@@ -55,6 +58,39 @@ export default class extends TreeController {
|
||||
|
||||
//Register an event listener which checks if the tree needs to be updated
|
||||
document.addEventListener('turbo:render', this.doUpdateIfNeeded.bind(this));
|
||||
|
||||
//Register an event listener, to check if we end up on a page we can highlight in the tree, if so then higlight it
|
||||
document.addEventListener('turbo:load', this._onTurboLoad.bind(this));
|
||||
//On initial page load the tree is not available yet, so do another check after the tree is initialized
|
||||
this.treeTarget.addEventListener(EVENT_INITIALIZED, (event) => {
|
||||
this.selectNodeWithURL(document.location)
|
||||
});
|
||||
}
|
||||
|
||||
_onTurboLoad(event) {
|
||||
this.selectNodeWithURL(event.detail.url);
|
||||
}
|
||||
|
||||
selectNodeWithURL(url) {
|
||||
//Get path from url
|
||||
const path = new URL(url).pathname;
|
||||
|
||||
if (!this._tree) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Unselect all nodes
|
||||
this._tree.unselectAll({silent: true, ignorePreventUnselect: true});
|
||||
|
||||
//Try to find a node with this path as data-path
|
||||
const nodes = this._tree.findNodes(path, "href");
|
||||
if (nodes.length !== 1) {
|
||||
return; //We can only work with exactly one node, if there are multiple nodes with the same path, we cannot know which one to select, so we do nothing
|
||||
}
|
||||
const node = nodes[0];
|
||||
|
||||
node.setSelected(true, {ignorePreventUnselect: true, silent: true});
|
||||
this._tree.revealNode(node);
|
||||
}
|
||||
|
||||
doUpdateIfNeeded()
|
||||
|
||||
@@ -56,6 +56,7 @@ export default class extends Controller {
|
||||
searchField: 'text',
|
||||
orderField: 'text',
|
||||
dropdownParent: dropdownParent,
|
||||
clearAfterSelect: true,
|
||||
|
||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||
|
||||
@@ -58,6 +58,7 @@ export default class extends Controller {
|
||||
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
|
||||
splitOn: null,
|
||||
dropdownParent: dropdownParent,
|
||||
clearAfterSelect: true,
|
||||
|
||||
searchField: [
|
||||
{field: "text", weight : 2},
|
||||
|
||||
@@ -49,6 +49,7 @@ export default class extends Controller {
|
||||
createOnBlur: true,
|
||||
create: true,
|
||||
dropdownParent: dropdownParent,
|
||||
clearAfterSelect: true,
|
||||
};
|
||||
|
||||
if(this.element.dataset.autocomplete) {
|
||||
|
||||
@@ -39,6 +39,8 @@ export default class extends Controller {
|
||||
*/
|
||||
_tree = null;
|
||||
|
||||
_frame = "frame";
|
||||
|
||||
connect() {
|
||||
const treeElement = this.treeTarget;
|
||||
if (!treeElement) {
|
||||
@@ -48,6 +50,7 @@ export default class extends Controller {
|
||||
|
||||
this._url = this.element.dataset.treeUrl;
|
||||
this._data = this.element.dataset.treeData;
|
||||
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
|
||||
|
||||
if(this.element.dataset.treeShowTags === "true") {
|
||||
this._showTags = true;
|
||||
@@ -99,8 +102,7 @@ export default class extends Controller {
|
||||
onNodeSelected: (event) => {
|
||||
const node = event.detail.node;
|
||||
if (node.href) {
|
||||
window.Turbo.visit(node.href, {action: "advance"});
|
||||
this._registerURLWatcher(node);
|
||||
window.Turbo.visit(node.href, {action: "advance", frame: this._frame});
|
||||
}
|
||||
},
|
||||
}, [BS5Theme, BS53Theme, FAIconTheme]);
|
||||
@@ -110,41 +112,12 @@ export default class extends Controller {
|
||||
const treeView = event.detail.treeView;
|
||||
treeView.revealNode(treeView.getSelected());
|
||||
|
||||
//Add the url watcher to all selected nodes
|
||||
for (const node of treeView.getSelected()) {
|
||||
this._registerURLWatcher(node);
|
||||
}
|
||||
|
||||
//Add contextmenu event listener to the tree, which allows us to open the links in a new tab with a right click
|
||||
treeView.getTreeElement().addEventListener("contextmenu", this._onContextMenu.bind(this));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
_registerURLWatcher(node)
|
||||
{
|
||||
//Register a watcher for a location change, which will unselect the node, if the location changes
|
||||
const desired_url = node.href;
|
||||
|
||||
//Ensure that the node is unselected, if the location changes
|
||||
const unselectNode = () => {
|
||||
//Parse url so we can properly compare them
|
||||
const desired = new URL(node.href, window.location.origin);
|
||||
|
||||
//We only compare the pathname, because the hash and parameters should not matter
|
||||
if(window.location.pathname !== desired.pathname) {
|
||||
//The ignore parameter is important here, otherwise the node will not be unselected
|
||||
node.setSelected(false, {silent: true, ignorePreventUnselect: true});
|
||||
|
||||
//Unregister the watcher
|
||||
document.removeEventListener('turbo:load', unselectNode);
|
||||
}
|
||||
};
|
||||
|
||||
//Register the watcher via hotwire turbo
|
||||
//We must just load to have the new url in window.location
|
||||
document.addEventListener('turbo:load', unselectNode);
|
||||
}
|
||||
|
||||
_onContextMenu(event)
|
||||
{
|
||||
@@ -198,4 +171,4 @@ export default class extends Controller {
|
||||
return myResolve(this._data);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
136
assets/controllers/helpers/scan_special_char_controller.js
Normal file
136
assets/controllers/helpers/scan_special_char_controller.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
/**
|
||||
* This controller listens for a special non-printable character (SOH / ASCII 1) to be entered anywhere on the page,
|
||||
* which is then used as a trigger to submit the following characters as a barcode / scan input.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
// Optional: Log to confirm global attachment
|
||||
console.log("Scanner listener active")
|
||||
|
||||
this.isCapturing = false
|
||||
this.buffer = ""
|
||||
|
||||
window.addEventListener("keypress", this.handleKeydown.bind(this))
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.isCapturing = false
|
||||
this.buffer = ""
|
||||
this.timeoutId = null
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
|
||||
// Ignore if the user is typing in a form field
|
||||
const isInput = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName) ||
|
||||
event.target.isContentEditable;
|
||||
if (isInput) return
|
||||
|
||||
// 1. Detect Start of Header (SOH / Ctrl+A)
|
||||
if (event.key === "\x01" || event.keyCode === 1) {
|
||||
this.startCapturing(event)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Process characters if in capture mode
|
||||
if (this.isCapturing) {
|
||||
this.resetTimeout() // Push the expiration back with every keypress
|
||||
|
||||
if (event.key === "Enter" || event.keyCode === 13) {
|
||||
|
||||
this.finishCapturing(event)
|
||||
} else if (event.key.length === 1) {
|
||||
this.buffer += event.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startCapturing(event) {
|
||||
this.isCapturing = true
|
||||
this.buffer = ""
|
||||
this.resetTimeout()
|
||||
event.preventDefault()
|
||||
console.debug("Scan character detected. Capture started...")
|
||||
}
|
||||
|
||||
finishCapturing(event) {
|
||||
event.preventDefault()
|
||||
const data = this.buffer;
|
||||
this.stopCapturing()
|
||||
this.processCapture(data)
|
||||
}
|
||||
|
||||
stopCapturing() {
|
||||
this.isCapturing = false
|
||||
this.buffer = ""
|
||||
if (this.timeoutId) clearTimeout(this.timeoutId)
|
||||
console.debug("Capture cleared/finished.")
|
||||
}
|
||||
|
||||
resetTimeout() {
|
||||
if (this.timeoutId) clearTimeout(this.timeoutId)
|
||||
|
||||
this.timeoutId = setTimeout(() => {
|
||||
if (this.isCapturing) {
|
||||
console.warn("Capture timed out. Resetting buffer.")
|
||||
this.stopCapturing()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
processCapture(data) {
|
||||
if (!data) return
|
||||
|
||||
console.debug("Captured scan data: " + data)
|
||||
|
||||
const scanInput = document.getElementById("scan_dialog_input");
|
||||
if (scanInput) { //When we are on the scan dialog page, submit the form there
|
||||
this._submitScanForm(data);
|
||||
} else { //Otherwise use our own form (e.g. on the part list page)
|
||||
this.element.querySelector("input[name='input']").value = data;
|
||||
this.element.requestSubmit();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
_submitScanForm(data) {
|
||||
const scanInput = document.getElementById("scan_dialog_input");
|
||||
if (!scanInput) {
|
||||
console.error("Scan input field not found!")
|
||||
return;
|
||||
}
|
||||
|
||||
scanInput.value = data;
|
||||
scanInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
const form = document.getElementById("scan_dialog_form");
|
||||
if (!form) {
|
||||
console.error("Scan form not found!")
|
||||
return;
|
||||
}
|
||||
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,31 @@ import {Controller} from "@hotwired/stimulus";
|
||||
//import * as ZXing from "@zxing/library";
|
||||
|
||||
import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
|
||||
import { generateCsrfToken, generateCsrfHeaders } from "../csrf_protection_controller";
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
//codeReader = null;
|
||||
|
||||
_scanner = null;
|
||||
|
||||
_submitting = false;
|
||||
_lastDecodedText = "";
|
||||
_onInfoChange = null;
|
||||
|
||||
connect() {
|
||||
console.log('Init Scanner');
|
||||
|
||||
// Prevent double init if connect fires twice
|
||||
if (this._scanner) return;
|
||||
|
||||
// clear last decoded barcode when state changes on info box
|
||||
const info = document.getElementById("scan_dialog_info_mode");
|
||||
if (info) {
|
||||
this._onInfoChange = () => {
|
||||
this._lastDecodedText = "";
|
||||
};
|
||||
info.addEventListener("change", this._onInfoChange);
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
|
||||
//This function ensures, that the qrbox is 70% of the total viewport
|
||||
let qrboxFunction = function(viewfinderWidth, viewfinderHeight) {
|
||||
@@ -45,30 +59,66 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
//Try to get the number of cameras. If the number is 0, then the promise will fail, and we show the warning dialog
|
||||
Html5Qrcode.getCameras().catch((devices) => {
|
||||
document.getElementById('scanner-warning').classList.remove('d-none');
|
||||
Html5Qrcode.getCameras().catch(() => {
|
||||
document.getElementById("scanner-warning")?.classList.remove("d-none");
|
||||
});
|
||||
|
||||
this._scanner = new Html5QrcodeScanner(this.element.id, {
|
||||
fps: 10,
|
||||
qrbox: qrboxFunction,
|
||||
// Key change: shrink preview height on mobile
|
||||
...(isMobile ? { aspectRatio: 1.0 } : {}),
|
||||
experimentalFeatures: {
|
||||
//This option improves reading quality on android chrome
|
||||
useBarCodeDetectorIfSupported: true
|
||||
}
|
||||
useBarCodeDetectorIfSupported: true,
|
||||
},
|
||||
}, false);
|
||||
|
||||
this._scanner.render(this.onScanSuccess.bind(this));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._scanner.pause();
|
||||
this._scanner.clear();
|
||||
|
||||
// If we already stopped/cleared before submit, nothing to do.
|
||||
const scanner = this._scanner;
|
||||
this._scanner = null;
|
||||
this._lastDecodedText = "";
|
||||
|
||||
// Unbind info-mode change handler (always do this, even if scanner is null)
|
||||
const info = document.getElementById("scan_dialog_info_mode");
|
||||
if (info && this._onInfoChange) {
|
||||
info.removeEventListener("change", this._onInfoChange);
|
||||
}
|
||||
this._onInfoChange = null;
|
||||
|
||||
if (!scanner) return;
|
||||
|
||||
try {
|
||||
const p = scanner.clear?.();
|
||||
if (p && typeof p.then === "function") p.catch(() => {});
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onScanSuccess(decodedText, decodedResult) {
|
||||
//Put our decoded Text into the input box
|
||||
document.getElementById('scan_dialog_input').value = decodedText;
|
||||
|
||||
onScanSuccess(decodedText) {
|
||||
if (!decodedText) return;
|
||||
|
||||
const normalized = String(decodedText).trim();
|
||||
if (!normalized) return;
|
||||
|
||||
// scan once per barcode
|
||||
if (normalized === this._lastDecodedText) return;
|
||||
|
||||
// Mark as handled immediately (prevents spam even if callback fires repeatedly)
|
||||
this._lastDecodedText = normalized;
|
||||
|
||||
const input = document.getElementById('scan_dialog_input');
|
||||
input.value = decodedText;
|
||||
//Trigger nonprintable char input controller to update the hidden input value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
//Submit form
|
||||
document.getElementById('scan_dialog_form').requestSubmit();
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export default class extends Controller
|
||||
searchField: "name",
|
||||
//labelField: "name",
|
||||
valueField: "name",
|
||||
clearAfterSelect: true,
|
||||
onItemAdd: this.onItemAdd.bind(this),
|
||||
render: {
|
||||
option: (data, escape) => {
|
||||
@@ -136,4 +137,4 @@ export default class extends Controller
|
||||
//Destroy the TomSelect instance
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal file
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
import {Modal} from "bootstrap";
|
||||
|
||||
export default class extends Controller
|
||||
{
|
||||
connect() {
|
||||
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
|
||||
}
|
||||
|
||||
_handleModalOpen(event) {
|
||||
// Button that triggered the modal
|
||||
const button = event.relatedTarget;
|
||||
|
||||
const amountInput = this.element.querySelector('input[name="amount"]');
|
||||
|
||||
// Extract info from button attributes
|
||||
const lotID = button.getAttribute('data-lot-id');
|
||||
const lotAmount = button.getAttribute('data-lot-amount');
|
||||
|
||||
//Find the expected amount field and set the value to the lot amount
|
||||
const expectedAmountInput = this.element.querySelector('#stocktake-modal-expected-amount');
|
||||
expectedAmountInput.textContent = lotAmount;
|
||||
|
||||
//Set the action and lotID inputs in the form
|
||||
this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export default class extends Controller
|
||||
{
|
||||
connect() {
|
||||
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
|
||||
this.element.addEventListener('shown.bs.modal', event => this._handleModalShown(event));
|
||||
}
|
||||
|
||||
_handleModalOpen(event) {
|
||||
@@ -61,4 +62,8 @@ export default class extends Controller
|
||||
amountInput.setAttribute('max', lotAmount);
|
||||
}
|
||||
}
|
||||
|
||||
_handleModalShown(event) {
|
||||
this.element.querySelector('input[name="amount"]').focus();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
const menu = document.getElementById('locale-select-menu');
|
||||
menu.innerHTML = this.element.innerHTML;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,12 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.part-info-image {
|
||||
max-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.object-fit-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ class RegisterEventHelper {
|
||||
constructor() {
|
||||
this.registerTooltips();
|
||||
this.configureDropdowns();
|
||||
this.registerSpecialCharInput();
|
||||
|
||||
// Only register special character input if enabled in configuration
|
||||
const keybindingsEnabled = document.body.dataset.keybindingsSpecialCharacters !== 'false';
|
||||
if (keybindingsEnabled) {
|
||||
this.registerSpecialCharInput();
|
||||
}
|
||||
|
||||
//Initialize ClipboardJS
|
||||
this.registerLoadHandler(() => {
|
||||
|
||||
@@ -56,7 +56,8 @@ class TristateHelper {
|
||||
|
||||
document.addEventListener("turbo:load", listener);
|
||||
document.addEventListener("turbo:render", listener);
|
||||
document.addEventListener("collection:elementAdded", listener);
|
||||
}
|
||||
}
|
||||
|
||||
export default new TristateHelper();
|
||||
export default new TristateHelper();
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"api-platform/json-api": "^4.0.0",
|
||||
"api-platform/symfony": "^4.0.0",
|
||||
"beberlei/doctrineextensions": "^1.2",
|
||||
"brick/math": "^0.13.1",
|
||||
"brick/math": "^0.14.8",
|
||||
"brick/schema": "^0.2.0",
|
||||
"composer/ca-bundle": "^1.5",
|
||||
"composer/package-versions-deprecated": "^1.11.99.5",
|
||||
@@ -28,7 +28,7 @@
|
||||
"doctrine/orm": "^3.2.0",
|
||||
"dompdf/dompdf": "^3.1.2",
|
||||
"gregwar/captcha-bundle": "^2.1.0",
|
||||
"hshn/base64-encoded-file": "^5.0",
|
||||
"hshn/base64-encoded-file": "^6.0",
|
||||
"jbtronics/2fa-webauthn": "^3.0.0",
|
||||
"jbtronics/dompdf-font-loader-bundle": "^1.0.0",
|
||||
"jbtronics/settings-bundle": "^3.0.0",
|
||||
@@ -45,7 +45,6 @@
|
||||
"nelmio/security-bundle": "^3.0",
|
||||
"nyholm/psr7": "^1.1",
|
||||
"omines/datatables-bundle": "^0.10.0",
|
||||
"paragonie/sodium_compat": "^1.21",
|
||||
"part-db/label-fonts": "^1.0",
|
||||
"part-db/swap-bundle": "^6.0.0",
|
||||
"phpoffice/phpspreadsheet": "^5.0.0",
|
||||
@@ -70,7 +69,7 @@
|
||||
"symfony/http-client": "7.4.*",
|
||||
"symfony/http-kernel": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/monolog-bundle": "^3.1",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
@@ -88,8 +87,9 @@
|
||||
"symfony/web-link": "7.4.*",
|
||||
"symfony/webpack-encore-bundle": "^v2.0.1",
|
||||
"symfony/yaml": "7.4.*",
|
||||
"symplify/easy-coding-standard": "^12.5.20",
|
||||
"symplify/easy-coding-standard": "^13.0",
|
||||
"tecnickcom/tc-lib-barcode": "^2.1.4",
|
||||
"tiendanube/gtinvalidation": "^1.0",
|
||||
"twig/cssinliner-extra": "^3.0",
|
||||
"twig/extra-bundle": "^3.8",
|
||||
"twig/html-extra": "^3.8",
|
||||
@@ -128,7 +128,7 @@
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "Used to improve price calculation performance",
|
||||
"ext-gmp": "Used to improve price calculation performanice"
|
||||
"ext-gmp": "Used to improve price calculation performance"
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": {
|
||||
@@ -177,6 +177,11 @@
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*",
|
||||
"docker": true
|
||||
},
|
||||
"phpstan/extension-installer": {
|
||||
"ignore" : [
|
||||
"ekino/phpstan-banned-code"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1347
composer.lock
generated
1347
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -25,5 +25,5 @@ framework:
|
||||
adapter: cache.app
|
||||
|
||||
cache.settings:
|
||||
adapter: cache.app
|
||||
adapter: cache.system
|
||||
tags: true
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Config\DoctrineConfig;
|
||||
|
||||
/**
|
||||
* This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+.
|
||||
* We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version.
|
||||
*/
|
||||
|
||||
return static function(\Symfony\Config\DoctrineConfig $doctrine) {
|
||||
return static function(DoctrineConfig $doctrine) {
|
||||
//On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
|
||||
if (PHP_VERSION_ID >= 80400) {
|
||||
$doctrine->orm()->enableNativeLazyObjects(true);
|
||||
|
||||
@@ -18,6 +18,11 @@ twig:
|
||||
saml_enabled: '%partdb.saml.enabled%'
|
||||
part_preview_generator: '@App\Services\Attachments\PartPreviewGenerator'
|
||||
|
||||
# Bootstrap grid classes used for horizontal form layouts
|
||||
col_label: 'col-sm-3 col-lg-2' # The column classes for form labels
|
||||
col_input: 'col-sm-9 col-lg-10' # The column classes for form input fields
|
||||
offset_label: 'offset-sm-3 offset-lg-2' # Offset classes for elements that should be aligned with the input fields (e.g., submit buttons)
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
strict_variables: true
|
||||
|
||||
@@ -68,6 +68,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
||||
move:
|
||||
label: "perm.parts_stock.move"
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
stocktake:
|
||||
label: "perm.parts_stock.stocktake"
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
|
||||
|
||||
storelocations: &PART_CONTAINING
|
||||
|
||||
@@ -208,29 +208,29 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* initial_marking?: list<scalar|Param|null>,
|
||||
* events_to_dispatch?: list<string|Param>|null,
|
||||
* places?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* metadata?: list<mixed>,
|
||||
* name?: scalar|Param|null,
|
||||
* metadata?: array<string, mixed>,
|
||||
* }>,
|
||||
* transitions: list<array{ // Default: []
|
||||
* name: string|Param,
|
||||
* transitions?: list<array{ // Default: []
|
||||
* name?: string|Param,
|
||||
* guard?: string|Param, // An expression to block the transition.
|
||||
* from?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* to?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* place?: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* metadata?: list<mixed>,
|
||||
* metadata?: array<string, mixed>,
|
||||
* }>,
|
||||
* metadata?: list<mixed>,
|
||||
* metadata?: array<string, mixed>,
|
||||
* }>,
|
||||
* },
|
||||
* router?: bool|array{ // Router configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resource: scalar|Param|null,
|
||||
* resource?: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%"
|
||||
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
||||
@@ -360,10 +360,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* mapping?: array{
|
||||
* paths?: list<scalar|Param|null>,
|
||||
* },
|
||||
* default_context?: list<mixed>,
|
||||
* default_context?: array<string, mixed>,
|
||||
* named_serializers?: array<string, array{ // Default: []
|
||||
* name_converter?: scalar|Param|null,
|
||||
* default_context?: list<mixed>,
|
||||
* default_context?: array<string, mixed>,
|
||||
* include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true
|
||||
* include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true
|
||||
* }>,
|
||||
@@ -427,7 +427,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* routing?: array<string, string|array{ // Default: []
|
||||
* senders?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* serializer?: array{
|
||||
@@ -440,7 +440,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* transports?: array<string, string|array{ // Default: []
|
||||
* dsn?: scalar|Param|null,
|
||||
* serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null
|
||||
* options?: list<mixed>,
|
||||
* options?: array<string, mixed>,
|
||||
* failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||
* retry_strategy?: string|array{
|
||||
* service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null
|
||||
@@ -462,7 +462,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* allow_no_senders?: bool|Param, // Default: true
|
||||
* },
|
||||
* middleware?: list<string|array{ // Default: []
|
||||
* id: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* arguments?: list<mixed>,
|
||||
* }>,
|
||||
* }>,
|
||||
@@ -634,7 +634,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
||||
* policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* limiters?: list<scalar|Param|null>,
|
||||
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
||||
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
@@ -679,7 +679,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* service: scalar|Param|null,
|
||||
* service?: scalar|Param|null,
|
||||
* secret?: scalar|Param|null, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
@@ -694,7 +694,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* dbal?: array{
|
||||
* default_connection?: scalar|Param|null,
|
||||
* types?: array<string, string|array{ // Default: []
|
||||
* class: scalar|Param|null,
|
||||
* class?: scalar|Param|null,
|
||||
* commented?: bool|Param, // Deprecated: The doctrine-bundle type commenting features were removed; the corresponding config parameter was deprecated in 2.0 and will be dropped in 3.0.
|
||||
* }>,
|
||||
* driver_schemes?: array<string, scalar|Param|null>,
|
||||
@@ -910,7 +910,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* datetime_functions?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* filters?: array<string, string|array{ // Default: []
|
||||
* class: scalar|Param|null,
|
||||
* class?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* parameters?: array<string, mixed>,
|
||||
* }>,
|
||||
@@ -975,7 +975,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* providers?: list<scalar|Param|null>,
|
||||
* },
|
||||
* entity?: array{
|
||||
* class: scalar|Param|null, // The full entity class name of your user class.
|
||||
* class?: scalar|Param|null, // The full entity class name of your user class.
|
||||
* property?: scalar|Param|null, // Default: null
|
||||
* manager_name?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
@@ -986,8 +986,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* },
|
||||
* ldap?: array{
|
||||
* service: scalar|Param|null,
|
||||
* base_dn: scalar|Param|null,
|
||||
* service?: scalar|Param|null,
|
||||
* base_dn?: scalar|Param|null,
|
||||
* search_dn?: scalar|Param|null, // Default: null
|
||||
* search_password?: scalar|Param|null, // Default: null
|
||||
* extra_fields?: list<scalar|Param|null>,
|
||||
@@ -998,11 +998,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* password_attribute?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* saml?: array{
|
||||
* user_class: scalar|Param|null,
|
||||
* user_class?: scalar|Param|null,
|
||||
* default_roles?: list<scalar|Param|null>,
|
||||
* },
|
||||
* }>,
|
||||
* firewalls: array<string, array{ // Default: []
|
||||
* firewalls?: array<string, array{ // Default: []
|
||||
* pattern?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* methods?: list<scalar|Param|null>,
|
||||
@@ -1136,9 +1136,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* failure_path_parameter?: scalar|Param|null, // Default: "_failure_path"
|
||||
* },
|
||||
* login_link?: array{
|
||||
* check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
|
||||
* signature_properties: list<scalar|Param|null>,
|
||||
* signature_properties?: list<scalar|Param|null>,
|
||||
* lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600
|
||||
* max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null
|
||||
* used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set.
|
||||
@@ -1240,13 +1240,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* failure_handler?: scalar|Param|null,
|
||||
* realm?: scalar|Param|null, // Default: null
|
||||
* token_extractors?: list<scalar|Param|null>,
|
||||
* token_handler: string|array{
|
||||
* token_handler?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* oidc_user_info?: string|array{
|
||||
* base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
||||
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
||||
* discovery?: array{ // Enable the OIDC discovery.
|
||||
* cache?: array{
|
||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* },
|
||||
* },
|
||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub"
|
||||
@@ -1254,27 +1254,27 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* oidc?: array{
|
||||
* discovery?: array{ // Enable the OIDC discovery.
|
||||
* base_uri: list<scalar|Param|null>,
|
||||
* base_uri?: list<scalar|Param|null>,
|
||||
* cache?: array{
|
||||
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* },
|
||||
* },
|
||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
|
||||
* audience: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||
* issuers: list<scalar|Param|null>,
|
||||
* audience?: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||
* issuers?: list<scalar|Param|null>,
|
||||
* algorithm?: array<mixed>,
|
||||
* algorithms: list<scalar|Param|null>,
|
||||
* algorithms?: list<scalar|Param|null>,
|
||||
* key?: scalar|Param|null, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key).
|
||||
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).
|
||||
* encryption?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
|
||||
* algorithms: list<scalar|Param|null>,
|
||||
* keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
||||
* algorithms?: list<scalar|Param|null>,
|
||||
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
|
||||
* },
|
||||
* },
|
||||
* cas?: array{
|
||||
* validation_url: scalar|Param|null, // CAS server validation URL
|
||||
* validation_url?: scalar|Param|null, // CAS server validation URL
|
||||
* prefix?: scalar|Param|null, // CAS prefix // Default: "cas"
|
||||
* http_client?: scalar|Param|null, // HTTP Client service // Default: null
|
||||
* },
|
||||
@@ -1379,7 +1379,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* use_microseconds?: scalar|Param|null, // Default: true
|
||||
* channels?: list<scalar|Param|null>,
|
||||
* handlers?: array<string, array{ // Default: []
|
||||
* type: scalar|Param|null,
|
||||
* type?: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* priority?: scalar|Param|null, // Default: 0
|
||||
@@ -1387,7 +1387,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* bubble?: bool|Param, // Default: true
|
||||
* interactive_only?: bool|Param, // Default: false
|
||||
* app_name?: scalar|Param|null, // Default: null
|
||||
* fill_extra_context?: bool|Param, // Default: false
|
||||
* include_stacktraces?: bool|Param, // Default: false
|
||||
* process_psr_3_messages?: array{
|
||||
* enabled?: bool|Param|null, // Default: null
|
||||
@@ -1407,7 +1406,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* activation_strategy?: scalar|Param|null, // Default: null
|
||||
* stop_buffering?: bool|Param, // Default: true
|
||||
* passthru_level?: scalar|Param|null, // Default: null
|
||||
* excluded_404s?: list<scalar|Param|null>,
|
||||
* excluded_http_codes?: list<array{ // Default: []
|
||||
* code?: scalar|Param|null,
|
||||
* urls?: list<scalar|Param|null>,
|
||||
@@ -1421,9 +1419,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* url?: scalar|Param|null,
|
||||
* exchange?: scalar|Param|null,
|
||||
* exchange_name?: scalar|Param|null, // Default: "log"
|
||||
* room?: scalar|Param|null,
|
||||
* message_format?: scalar|Param|null, // Default: "text"
|
||||
* api_version?: scalar|Param|null, // Default: null
|
||||
* channel?: scalar|Param|null, // Default: null
|
||||
* bot_name?: scalar|Param|null, // Default: "Monolog"
|
||||
* use_attachment?: scalar|Param|null, // Default: true
|
||||
@@ -1432,9 +1427,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* icon_emoji?: scalar|Param|null, // Default: null
|
||||
* webhook_url?: scalar|Param|null,
|
||||
* exclude_fields?: list<scalar|Param|null>,
|
||||
* team?: scalar|Param|null,
|
||||
* notify?: scalar|Param|null, // Default: false
|
||||
* nickname?: scalar|Param|null, // Default: "Monolog"
|
||||
* token?: scalar|Param|null,
|
||||
* region?: scalar|Param|null,
|
||||
* source?: scalar|Param|null,
|
||||
@@ -1452,12 +1444,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* store?: scalar|Param|null, // Default: null
|
||||
* connection_timeout?: scalar|Param|null,
|
||||
* persistent?: bool|Param,
|
||||
* dsn?: scalar|Param|null,
|
||||
* hub_id?: scalar|Param|null, // Default: null
|
||||
* client_id?: scalar|Param|null, // Default: null
|
||||
* auto_log_stacks?: scalar|Param|null, // Default: false
|
||||
* release?: scalar|Param|null, // Default: null
|
||||
* environment?: scalar|Param|null, // Default: null
|
||||
* message_type?: scalar|Param|null, // Default: 0
|
||||
* parse_mode?: scalar|Param|null, // Default: null
|
||||
* disable_webpage_preview?: bool|Param|null, // Default: null
|
||||
@@ -1467,7 +1453,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* topic?: int|Param, // Default: null
|
||||
* factor?: int|Param, // Default: 1
|
||||
* tags?: list<scalar|Param|null>,
|
||||
* console_formater_options?: mixed, // Deprecated: "monolog.handlers..console_formater_options.console_formater_options" is deprecated, use "monolog.handlers..console_formater_options.console_formatter_options" instead.
|
||||
* console_formatter_options?: mixed, // Default: []
|
||||
* formatter?: scalar|Param|null,
|
||||
* nested?: bool|Param, // Default: false
|
||||
@@ -1478,15 +1463,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* chunk_size?: scalar|Param|null, // Default: 1420
|
||||
* encoder?: "json"|"compressed_json"|Param,
|
||||
* },
|
||||
* mongo?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* port?: scalar|Param|null, // Default: 27017
|
||||
* user?: scalar|Param|null,
|
||||
* pass?: scalar|Param|null,
|
||||
* database?: scalar|Param|null, // Default: "monolog"
|
||||
* collection?: scalar|Param|null, // Default: "logs"
|
||||
* },
|
||||
* mongodb?: string|array{
|
||||
* id?: scalar|Param|null, // ID of a MongoDB\Client service
|
||||
* uri?: scalar|Param|null,
|
||||
@@ -1526,10 +1502,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* headers?: list<scalar|Param|null>,
|
||||
* mailer?: scalar|Param|null, // Default: null
|
||||
* email_prototype?: string|array{
|
||||
* id: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* method?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* lazy?: bool|Param, // Default: true
|
||||
* verbosity_levels?: array{
|
||||
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
|
||||
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
|
||||
@@ -1556,7 +1531,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* generate_final_entities?: bool|Param, // Default: false
|
||||
* }
|
||||
* @psalm-type WebpackEncoreConfig = array{
|
||||
* output_path: scalar|Param|null, // The path where Encore is building the assets - i.e. Encore.setOutputPath()
|
||||
* output_path?: scalar|Param|null, // The path where Encore is building the assets - i.e. Encore.setOutputPath()
|
||||
* crossorigin?: false|"anonymous"|"use-credentials"|Param, // crossorigin value when Encore.enableIntegrityHashes() is used, can be false (default), anonymous or use-credentials // Default: false
|
||||
* preload?: bool|Param, // preload all rendered script and link tags automatically via the http2 Link header. // Default: false
|
||||
* cache?: bool|Param, // Enable caching of the entry point file(s) // Default: false
|
||||
@@ -1586,27 +1561,27 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* cache_prefix?: scalar|Param|null, // Default: "media/cache"
|
||||
* },
|
||||
* aws_s3?: array{
|
||||
* bucket: scalar|Param|null,
|
||||
* bucket?: scalar|Param|null,
|
||||
* cache?: scalar|Param|null, // Default: false
|
||||
* use_psr_cache?: bool|Param, // Default: false
|
||||
* acl?: scalar|Param|null, // Default: "public-read"
|
||||
* cache_prefix?: scalar|Param|null, // Default: ""
|
||||
* client_id?: scalar|Param|null, // Default: null
|
||||
* client_config: list<mixed>,
|
||||
* client_config?: list<mixed>,
|
||||
* get_options?: array<string, scalar|Param|null>,
|
||||
* put_options?: array<string, scalar|Param|null>,
|
||||
* proxies?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* flysystem?: array{
|
||||
* filesystem_service: scalar|Param|null,
|
||||
* filesystem_service?: scalar|Param|null,
|
||||
* cache_prefix?: scalar|Param|null, // Default: ""
|
||||
* root_url: scalar|Param|null,
|
||||
* root_url?: scalar|Param|null,
|
||||
* visibility?: "public"|"private"|"noPredefinedVisibility"|Param, // Default: "public"
|
||||
* },
|
||||
* }>,
|
||||
* loaders?: array<string, array{ // Default: []
|
||||
* stream?: array{
|
||||
* wrapper: scalar|Param|null,
|
||||
* wrapper?: scalar|Param|null,
|
||||
* context?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* filesystem?: array{
|
||||
@@ -1620,11 +1595,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* },
|
||||
* flysystem?: array{
|
||||
* filesystem_service: scalar|Param|null,
|
||||
* filesystem_service?: scalar|Param|null,
|
||||
* },
|
||||
* asset_mapper?: array<mixed>,
|
||||
* chain?: array{
|
||||
* loaders: list<scalar|Param|null>,
|
||||
* loaders?: list<scalar|Param|null>,
|
||||
* },
|
||||
* }>,
|
||||
* driver?: scalar|Param|null, // Default: "gd"
|
||||
@@ -1771,23 +1746,23 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* providers?: array{
|
||||
* apilayer_fixer?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* apilayer_currency_data?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* apilayer_exchange_rates_data?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* abstract_api?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* fixer?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* access_key: scalar|Param|null,
|
||||
* access_key?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* cryptonator?: array{
|
||||
@@ -1795,7 +1770,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* exchange_rates_api?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* access_key: scalar|Param|null,
|
||||
* access_key?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* webservicex?: array{
|
||||
@@ -1830,38 +1805,38 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* currency_data_feed?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* currency_layer?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* access_key: scalar|Param|null,
|
||||
* access_key?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* forge?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* open_exchange_rates?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* app_id: scalar|Param|null,
|
||||
* app_id?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* xignite?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* token: scalar|Param|null,
|
||||
* token?: scalar|Param|null,
|
||||
* },
|
||||
* xchangeapi?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* api_key: scalar|Param|null,
|
||||
* api_key?: scalar|Param|null,
|
||||
* },
|
||||
* currency_converter?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* access_key: scalar|Param|null,
|
||||
* access_key?: scalar|Param|null,
|
||||
* enterprise?: bool|Param, // Default: false
|
||||
* },
|
||||
* array?: array{
|
||||
* priority?: int|Param, // Default: 0
|
||||
* latestRates: mixed,
|
||||
* latestRates?: mixed,
|
||||
* historicalRates?: mixed,
|
||||
* },
|
||||
* },
|
||||
@@ -2123,9 +2098,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* counter_checker?: scalar|Param|null, // This service will check if the counter is valid. By default it throws an exception (recommended). // Default: "Webauthn\\Counter\\ThrowExceptionIfInvalid"
|
||||
* top_origin_validator?: scalar|Param|null, // For cross origin (e.g. iframe), this service will be in charge of verifying the top origin. // Default: null
|
||||
* creation_profiles?: array<string, array{ // Default: []
|
||||
* rp: array{
|
||||
* rp?: array{
|
||||
* id?: scalar|Param|null, // Default: null
|
||||
* name: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* icon?: scalar|Param|null, // Deprecated: The child node "icon" at path "webauthn.creation_profiles..rp.icon" is deprecated and has no effect. // Default: null
|
||||
* },
|
||||
* challenge_length?: int|Param, // Default: 32
|
||||
@@ -2149,21 +2124,21 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* metadata?: bool|array{ // Enable the support of the Metadata Statements. Please read the documentation for this feature.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* mds_repository: scalar|Param|null, // The Metadata Statement repository.
|
||||
* status_report_repository: scalar|Param|null, // The Status Report repository.
|
||||
* mds_repository?: scalar|Param|null, // The Metadata Statement repository.
|
||||
* status_report_repository?: scalar|Param|null, // The Status Report repository.
|
||||
* certificate_chain_checker?: scalar|Param|null, // A Certificate Chain checker. // Default: "Webauthn\\MetadataService\\CertificateChain\\PhpCertificateChainValidator"
|
||||
* },
|
||||
* controllers?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* creation?: array<string, array{ // Default: []
|
||||
* options_method?: scalar|Param|null, // Default: "POST"
|
||||
* options_path: scalar|Param|null,
|
||||
* options_path?: scalar|Param|null,
|
||||
* result_method?: scalar|Param|null, // Default: "POST"
|
||||
* result_path?: scalar|Param|null, // Default: null
|
||||
* host?: scalar|Param|null, // Default: null
|
||||
* profile?: scalar|Param|null, // Default: "default"
|
||||
* options_builder?: scalar|Param|null, // When set, corresponds to the ID of the Public Key Credential Creation Builder. The profile-based ebuilder is ignored. // Default: null
|
||||
* user_entity_guesser: scalar|Param|null,
|
||||
* user_entity_guesser?: scalar|Param|null,
|
||||
* hide_existing_credentials?: scalar|Param|null, // In order to prevent username enumeration, the existing credentials can be hidden. This is highly recommended when the attestation ceremony is performed by anonymous users. // Default: false
|
||||
* options_storage?: scalar|Param|null, // Deprecated: The child node "options_storage" at path "webauthn.controllers.creation..options_storage" is deprecated. Please use the root option "options_storage" instead. // Service responsible of the options/user entity storage during the ceremony // Default: null
|
||||
* success_handler?: scalar|Param|null, // Default: "Webauthn\\Bundle\\Service\\DefaultSuccessHandler"
|
||||
@@ -2175,7 +2150,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* request?: array<string, array{ // Default: []
|
||||
* options_method?: scalar|Param|null, // Default: "POST"
|
||||
* options_path: scalar|Param|null,
|
||||
* options_path?: scalar|Param|null,
|
||||
* result_method?: scalar|Param|null, // Default: "POST"
|
||||
* result_path?: scalar|Param|null, // Default: null
|
||||
* host?: scalar|Param|null, // Default: null
|
||||
@@ -2196,10 +2171,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* baseurl?: scalar|Param|null, // Default: "<request_scheme_and_host>/saml/"
|
||||
* strict?: bool|Param,
|
||||
* debug?: bool|Param,
|
||||
* idp: array{
|
||||
* entityId: scalar|Param|null,
|
||||
* singleSignOnService: array{
|
||||
* url: scalar|Param|null,
|
||||
* idp?: array{
|
||||
* entityId?: scalar|Param|null,
|
||||
* singleSignOnService?: array{
|
||||
* url?: scalar|Param|null,
|
||||
* binding?: scalar|Param|null,
|
||||
* },
|
||||
* singleLogoutService?: array{
|
||||
@@ -2270,30 +2245,30 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* contactPerson?: array{
|
||||
* technical?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* support?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* administrative?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* billing?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* other?: array{
|
||||
* givenName: scalar|Param|null,
|
||||
* emailAddress: scalar|Param|null,
|
||||
* givenName?: scalar|Param|null,
|
||||
* emailAddress?: scalar|Param|null,
|
||||
* },
|
||||
* },
|
||||
* organization?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* displayname: scalar|Param|null,
|
||||
* url: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* displayname?: scalar|Param|null,
|
||||
* url?: scalar|Param|null,
|
||||
* }>,
|
||||
* }>,
|
||||
* use_proxy_vars?: bool|Param, // Default: false
|
||||
@@ -2329,7 +2304,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* auto_install?: bool|Param, // Default: false
|
||||
* fonts?: list<array{ // Default: []
|
||||
* normal: scalar|Param|null,
|
||||
* normal?: scalar|Param|null,
|
||||
* bold?: scalar|Param|null,
|
||||
* italic?: scalar|Param|null,
|
||||
* bold_italic?: scalar|Param|null,
|
||||
@@ -2480,7 +2455,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* },
|
||||
* max_query_depth?: int|Param, // Default: 20
|
||||
* graphql_playground?: array<mixed>,
|
||||
* graphql_playground?: bool|array{ // Deprecated: The "graphql_playground" configuration is deprecated and will be ignored.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* max_query_complexity?: int|Param, // Default: 500
|
||||
* nesting_separator?: scalar|Param|null, // The separator to use to filter nested fields. // Default: "_"
|
||||
* collection?: array{
|
||||
@@ -2537,7 +2514,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* termsOfService?: scalar|Param|null, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null
|
||||
* tags?: list<array{ // Default: []
|
||||
* name: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* description?: scalar|Param|null, // Default: null
|
||||
* }>,
|
||||
* license?: array{
|
||||
@@ -2829,7 +2806,10 @@ final class App
|
||||
*/
|
||||
public static function config(array $config): array
|
||||
{
|
||||
return AppReference::config($config);
|
||||
/** @var ConfigType $config */
|
||||
$config = AppReference::config($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,9 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
|
||||
* `ATTACHMENT_DOWNLOAD_BY_DEFAULT`: When this is set to 1, the "download external file" checkbox is checked by default
|
||||
when adding a new attachment. Otherwise, it is unchecked by default. Use this if you wanna download all attachments
|
||||
locally by default. Attachment download is only possible, when `ALLOW_ATTACHMENT_DOWNLOADS` is set to 1.
|
||||
* `ATTACHMENT_SHOW_HTML_FILES`: When enabled, user uploaded HTML attachments can be viewed directly in the browser.
|
||||
Many potential malicious functions are restricted, still this is a potential security risk and should only be enabled,
|
||||
if you trust the users who can upload files. When set to 0, HTML files are rendered as plain text.
|
||||
* `USE_GRAVATAR`: Set to `1` to use [gravatar.com](https://gravatar.com/) images for user avatars (as long as they have
|
||||
not set their own picture). The users browsers have to download the pictures from a third-party (gravatar) server, so
|
||||
this might be a privacy risk.
|
||||
@@ -126,6 +129,8 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
|
||||
unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation.
|
||||
* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the part’s description is used to find existing parts with the same
|
||||
description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list.
|
||||
* `KEYBINDINGS_SPECIAL_CHARS_ENABLED`: Set this to 0 to disable the special character keybindings (Alt + key) for inserting special characters. This can be useful if
|
||||
they conflict with your keyboard layout or system shortcuts.
|
||||
|
||||
### E-Mail settings (all env only)
|
||||
|
||||
@@ -139,6 +144,18 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
|
||||
* `ALLOW_EMAIL_PW_RESET`: Set this value to true, if you want to allow users to reset their password via an email
|
||||
notification. You have to configure the mail provider first before via the MAILER_DSN setting.
|
||||
|
||||
### Update manager settings
|
||||
* `DISABLE_WEB_UPDATES` (default `1`): Set this to 0 to enable web-based updates. When enabled, you can perform updates
|
||||
via the web interface in the update manager. This is disabled by default for security reasons, as it can be a risk if
|
||||
not used carefully. You can still use the CLI commands to perform updates, even when web updates are disabled.
|
||||
* `DISABLE_BACKUP_RESTORE` (default `1`): Set this to 0 to enable backup restore via the web interface. When enabled, you can
|
||||
restore backups via the web interface in the update manager. This is disabled by default for security reasons, as it can
|
||||
be a risk if not used carefully. You can still use the CLI commands to perform backup restores, even when web-based
|
||||
backup restore is disabled.
|
||||
* `DISABLE_BACKUP_DOWNLOAD` (default `1`): Set this to 0 to enable backup download via the web interface. When enabled, you can download backups via the web interface
|
||||
in the update manager. This is disabled by default for security reasons, as it can be a risk if not used carefully, as
|
||||
the downloads contain sensitive data like password hashes or secrets.
|
||||
|
||||
### Table related settings
|
||||
|
||||
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set
|
||||
|
||||
@@ -88,3 +88,6 @@ The value of the environment variable is copied to the settings database, so the
|
||||
* `php bin/console partdb:attachments:download`: Download all attachments that are not already downloaded to the
|
||||
local filesystem. This is useful to create local backups of the attachments, no matter what happens on the remote, and
|
||||
also makes picture thumbnails available for the frontend for them.
|
||||
|
||||
## EDA integration commands
|
||||
* `php bin/console partdb:kicad:populate`: Populate KiCad footprint paths and symbol paths for footprints and categories based on their names. Use `--dry-run` to preview changes without applying them, and `--list` to list current values. See the [EDA integration documentation](eda_integration.md) for more details.
|
||||
|
||||
@@ -87,3 +87,31 @@ To show more levels of categories, you can set this value to a higher number.
|
||||
If you set this value to -1, all parts are shown inside a single category in KiCad, without any subcategories.
|
||||
|
||||
You can view the "real" category path of a part in the part details dialog in KiCad.
|
||||
|
||||
### Kicad:populate command
|
||||
|
||||
Part-DB also provides a command that attempts to automatically populate the KiCad symbol and footprint fields based on the part's category and footprint names.
|
||||
This is especially useful if you have a large database and want to quickly assign symbols and footprints to parts without doing it manually.
|
||||
|
||||
For this run `bin/console partdb:kicad:populate --dry-run` in the terminal, it will show you a list of suggestions for mappings for your existing categories and footprints.
|
||||
It uses names and alternative names, when the primary name doesn't match, to find the right mapping.
|
||||
If you are happy with the suggestions, you can run the command without the `--dry-run` option to apply the changes to your database. By default, only empty values are updated, but you can use the `--force` option to overwrite existing values as well.
|
||||
|
||||
It uses the mapping under `assets/commands/kicad_populate_default_mappings.json` by default, but you can extend/override it by providing your own mapping file
|
||||
with the `--mapping-file` option.
|
||||
The mapping file is a JSON file with the following structure, where the key is the name of the footprint or category, and the value is the corresponding KiCad library path:
|
||||
```json
|
||||
{
|
||||
"footprints": {
|
||||
"MyCustomPackage": "MyLibrary:MyFootprint",
|
||||
"0805": "Capacitor_SMD:C_0805_2012Metric"
|
||||
},
|
||||
"categories": {
|
||||
"Sensor": "Sensor:Sensor_Temperature",
|
||||
"MCU": "MCU_Microchip:PIC16F877A"
|
||||
}
|
||||
}
|
||||
```
|
||||
Its okay if the file contains just one of the `footprints` or `categories` keys, so you can choose to only provide mappings for one of them if you want.
|
||||
|
||||
It is recommended to take a backup of your database before running this command.
|
||||
|
||||
@@ -303,7 +303,17 @@ That method is not officially supported nor encouraged by Part-DB, and might bre
|
||||
The following env configuration options are available:
|
||||
* `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory)
|
||||
|
||||
### Custom provider
|
||||
### Canopy / Amazon
|
||||
The Canopy provider uses the [Canopy API](https://www.canopyapi.co/) to search for parts and get shopping information from Amazon.
|
||||
Canopy is a third-party service that provides access to Amazon product data through their API. Their trial plan offers 100 requests per month for free,
|
||||
and they also offer paid plans with higher limits. To use the Canopy provider, you need to create an account on the Canopy website and obtain an API key.
|
||||
Once you have the API key, you can configure the Canopy provider in Part-DB using the web UI or environment variables:
|
||||
|
||||
* `PROVIDER_CANOPY_API_KEY`: The API key you got from Canopy (mandatory)
|
||||
|
||||
|
||||
|
||||
### Custom providers
|
||||
|
||||
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
||||
as it is a valid Symfony service, it will be automatically loaded and can be used.
|
||||
|
||||
@@ -8,6 +8,21 @@ parent: Usage
|
||||
|
||||
This page lists all the keybindings of Part-DB. Currently, there are only the special character keybindings.
|
||||
|
||||
## Disabling keybindings
|
||||
|
||||
If you want to disable the special character keybindings (for example, because they conflict with your keyboard layout or system shortcuts), you can do so in two ways:
|
||||
|
||||
### Via the System Settings UI (recommended)
|
||||
|
||||
1. Navigate to **System Settings** (Tools → System Settings)
|
||||
2. Go to **Behavior** → **Keybindings**
|
||||
3. Uncheck **Enable special character keybindings**
|
||||
4. Save the settings
|
||||
|
||||
### Via Environment Variable
|
||||
|
||||
Alternatively, you can set the environment variable `KEYBINDINGS_SPECIAL_CHARS_ENABLED=0` in your `.env.local` file or your server environment configuration.
|
||||
|
||||
## Special characters
|
||||
|
||||
Using the keybindings below (Alt + key) you can insert special characters into the text fields of Part-DB. This works on
|
||||
|
||||
@@ -91,18 +91,20 @@ in [official documentation](https://twig.symfony.com/doc/3.x/).
|
||||
|
||||
Twig allows you for much more complex and dynamic label generation. You can use loops, conditions, and functions to create
|
||||
the label content and you can access almost all data Part-DB offers. The label templates are evaluated in a special sandboxed environment,
|
||||
where only certain operations are allowed. Only read access to entities is allowed. However as it circumvents Part-DB normal permission system,
|
||||
where only certain operations are allowed. Only read access to entities is allowed. However, as it circumvents Part-DB normal permission system,
|
||||
the twig mode is only available to users with the "Twig mode" permission.
|
||||
|
||||
It is useful to use the HTML embed feature of the editor, to have a block where you can write the twig code without worrying about the WYSIWYG editor messing with your code.
|
||||
|
||||
The following variables are in injected into Twig and can be accessed using `{% raw %}{{ variable }}{% endraw %}` (
|
||||
or `{% raw %}{{ variable.property }}{% endraw %}`):
|
||||
|
||||
| Variable name | Description |
|
||||
|--------------------------------------------|--------------------------------------------------------------------------------------|
|
||||
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
|
||||
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
|
||||
| `{% raw %}{{ user }}{% endraw %}` | The current logged in user. Null if you are not logged in |
|
||||
| `{% raw %}{{ install_title }}{% endraw %}` | The name of the current Part-DB instance (similar to [[INSTALL_NAME]] placeholder). |
|
||||
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated |
|
||||
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated ) |
|
||||
| `{% raw %}{{ last_page }}{% endraw %}` | The page number of the last element. Equals the number of all pages / element labels |
|
||||
| `{% raw %}{{ paper_width }}{% endraw %}` | The width of the label paper in mm |
|
||||
| `{% raw %}{{ paper_height }}{% endraw %}` | The height of the label paper in mm |
|
||||
@@ -236,12 +238,18 @@ certain data:
|
||||
|
||||
#### Functions
|
||||
|
||||
| Function name | Description |
|
||||
|----------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
|
||||
| `entity_type(element)` | Get the type of an entity as string |
|
||||
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
|
||||
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
|
||||
| Function name | Description |
|
||||
|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
|
||||
| `entity_type(element)` | Get the type of an entity as string |
|
||||
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
|
||||
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
|
||||
| `associated_parts(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. Only the directly associated parts are returned |
|
||||
| `associated_parts_r(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. including all sub-entities recursively (e.g. sub-locations) |
|
||||
| `associated_parts_count(element)` | Get the count of associated parts of an element like a storagelocation, footprint, excluding sub-entities |
|
||||
| `associated_parts_count_r(element)` | Get the count of associated parts of an element like a storagelocation, footprint, including all sub-entities recursively (e.g. sub-locations) |
|
||||
| `type_label(element)` | Get the name of the type of an element (e.g. "Part", "Storage location", etc.) |
|
||||
| `type_label_p(element)` | Get the name of the type of an element in plural form (e.g. "Parts", "Storage locations", etc.) |
|
||||
|
||||
### Filters
|
||||
|
||||
@@ -285,5 +293,5 @@ If you want to use a different (more beautiful) font, you can use the [custom fo
|
||||
feature.
|
||||
There is the [Noto](https://www.google.com/get/noto/) font family from Google, which supports a lot of languages and is
|
||||
available in different styles (regular, bold, italic, bold-italic).
|
||||
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
|
||||
and Korean characters.
|
||||
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
|
||||
and Korean characters.
|
||||
|
||||
51
docs/usage/scanner.md
Normal file
51
docs/usage/scanner.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: Barcode Scanner
|
||||
layout: default
|
||||
parent: Usage
|
||||
---
|
||||
|
||||
# Barcode scanner
|
||||
|
||||
When the user has the correct permission there will be a barcode scanner button in the navbar.
|
||||
On this page you can either input a barcode code by hand, use an external barcode scanner, or use your devices camera to
|
||||
scan a barcode.
|
||||
|
||||
In info mode (when the "Info" toggle is enabled) you can scan a barcode and Part-DB will parse it and show information
|
||||
about it.
|
||||
|
||||
Without info mode, the barcode will directly redirect you to the corresponding page.
|
||||
|
||||
### Barcode matching
|
||||
|
||||
When you scan a barcode, Part-DB will try to match it to an existing part, part lot or storage location first.
|
||||
For Part-DB generated barcodes, it will use the internal ID of a part. Alternatively you can also scan a barcode that contains the part's IPN.
|
||||
|
||||
You can set a GTIN/EAN code in the part properties and Part-DB will open the part page when you scan the corresponding GTIN/EAN barcode.
|
||||
|
||||
On a part lot you can under "Advanced" set a user barcode, that will redirect you to the part lot page when scanned. This allows to reuse
|
||||
arbitrary existing barcodes that already exist on the part lots (for example, from the manufacturer) and link them to the part lot in Part-DB.
|
||||
|
||||
Part-DB can also parse various distributor barcodes (for example from Digikey and Mouser) and will try to redirect you to the corresponding
|
||||
part page based on the distributor part number in the barcode.
|
||||
|
||||
### Part creation from barcodes
|
||||
For certain barcodes Part-DB can automatically create a new part, when it cannot find a matching part.
|
||||
Part-DB will try to retrieve the part information from an information provider and redirects you to the part creation page
|
||||
with the retrieved information pre-filled.
|
||||
|
||||
## Using an external barcode scanner
|
||||
|
||||
Part-DB supports the use of external barcode scanners that emulate keyboard input. To use a barcode scanner with Part-DB,
|
||||
simply connect the scanner to your computer and scan a barcode while the cursor is in a text field in Part-DB.
|
||||
The scanned barcode will be entered into the text field as if you had typed it on the keyboard.
|
||||
|
||||
In scanner fields, it will also try to insert special non-printable characters the scanner send via Alt + key combinations.
|
||||
This is required for EIGP114 datamatrix codes.
|
||||
|
||||
### Automatically redirect on barcode scanning
|
||||
|
||||
If you configure your barcode scanner to send a <SOH> (Start of heading, 0x01) non-printable character at the beginning
|
||||
of the scanned barcode, Part-DB will automatically scan the barcode that comes afterward (and is ended with an enter key)
|
||||
and redirects you to the corresponding page.
|
||||
This allows you to quickly scan a barcode from anywhere in Part-DB without the need to first open the scanner page.
|
||||
If an input field is focused, the barcode will be entered into the field as usual and no redirection will happen.
|
||||
129
migrations/Version20260208131116.php
Normal file
129
migrations/Version20260208131116.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
|
||||
final class Version20260208131116 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add GTIN fields, allowed targets for attachment types and last stocktake date for part lots and add include_vat field for price details.';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets LONGTEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at DATETIME DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat TINYINT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE `attachment_types` DROP allowed_targets');
|
||||
$this->addSql('DROP INDEX parts_idx_gtin ON `parts`');
|
||||
$this->addSql('ALTER TABLE `parts` DROP gtin');
|
||||
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
|
||||
$this->addSql('ALTER TABLE `orderdetails` DROP prices_includes_vat');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE attachment_types ADD COLUMN allowed_targets CLOB DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE part_lots ADD COLUMN last_stocktake_at DATETIME DEFAULT NULL');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM parts');
|
||||
$this->addSql('DROP TABLE parts');
|
||||
$this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, gtin VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES part_custom_states (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO parts (id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint) SELECT id, id_preview_attachment, id_category, id_footprint, id_part_unit, id_manufacturer, id_part_custom_state, order_orderdetails_id, built_project_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint FROM __temp__parts');
|
||||
$this->addSql('DROP TABLE __temp__parts');
|
||||
$this->addSql('CREATE INDEX parts_idx_name ON parts (name)');
|
||||
$this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)');
|
||||
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state)');
|
||||
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD COLUMN prices_includes_vat BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__attachment_types AS SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM "attachment_types"');
|
||||
$this->addSql('DROP TABLE "attachment_types"');
|
||||
$this->addSql('CREATE TABLE "attachment_types" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, alternative_names CLOB DEFAULT NULL, filetype_filter CLOB NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, CONSTRAINT FK_EFAED719727ACA70 FOREIGN KEY (parent_id) REFERENCES "attachment_types" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EFAED719EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO "attachment_types" (id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment) SELECT id, name, last_modified, datetime_added, comment, not_selectable, alternative_names, filetype_filter, parent_id, id_preview_attachment FROM __temp__attachment_types');
|
||||
$this->addSql('DROP TABLE __temp__attachment_types');
|
||||
$this->addSql('CREATE INDEX IDX_EFAED719727ACA70 ON "attachment_types" (parent_id)');
|
||||
$this->addSql('CREATE INDEX IDX_EFAED719EA7100A1 ON "attachment_types" (id_preview_attachment)');
|
||||
$this->addSql('CREATE INDEX attachment_types_idx_name ON "attachment_types" (name)');
|
||||
$this->addSql('CREATE INDEX attachment_types_idx_parent_name ON "attachment_types" (parent_id, name)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots');
|
||||
$this->addSql('DROP TABLE part_lots');
|
||||
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots');
|
||||
$this->addSql('DROP TABLE __temp__part_lots');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM "parts"');
|
||||
$this->addSql('DROP TABLE "parts"');
|
||||
$this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, ipn VARCHAR(100) DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url CLOB NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, provider_reference_provider_key VARCHAR(255) DEFAULT NULL, provider_reference_provider_id VARCHAR(255) DEFAULT NULL, provider_reference_provider_url VARCHAR(2048) DEFAULT NULL, provider_reference_last_updated DATETIME DEFAULT NULL, eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, eda_info_value VARCHAR(255) DEFAULT NULL, eda_info_invisible BOOLEAN DEFAULT NULL, eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, eda_info_exclude_from_board BOOLEAN DEFAULT NULL, eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, id_preview_attachment INTEGER DEFAULT NULL, id_part_custom_state INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO "parts" (id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id) SELECT id, name, last_modified, datetime_added, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, provider_reference_provider_key, provider_reference_provider_id, provider_reference_provider_url, provider_reference_last_updated, eda_info_reference_prefix, eda_info_value, eda_info_invisible, eda_info_exclude_from_bom, eda_info_exclude_from_board, eda_info_exclude_from_sim, eda_info_kicad_symbol, eda_info_kicad_footprint, id_preview_attachment, id_part_custom_state, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, built_project_id FROM __temp__parts');
|
||||
$this->addSql('DROP TABLE __temp__parts');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FEA3ED1215 ON "parts" (id_part_custom_state)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit)');
|
||||
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id)');
|
||||
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)');
|
||||
$this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)');
|
||||
$this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__orderdetails AS SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM "orderdetails"');
|
||||
$this->addSql('DROP TABLE "orderdetails"');
|
||||
$this->addSql('CREATE TABLE "orderdetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, supplierpartnr VARCHAR(255) NOT NULL, obsolete BOOLEAN NOT NULL, supplier_product_url CLOB NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, part_id INTEGER NOT NULL, id_supplier INTEGER DEFAULT NULL, CONSTRAINT FK_489AFCDC4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_489AFCDCCBF180EB FOREIGN KEY (id_supplier) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO "orderdetails" (id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier) SELECT id, supplierpartnr, obsolete, supplier_product_url, last_modified, datetime_added, part_id, id_supplier FROM __temp__orderdetails');
|
||||
$this->addSql('DROP TABLE __temp__orderdetails');
|
||||
$this->addSql('CREATE INDEX IDX_489AFCDC4CE34BEC ON "orderdetails" (part_id)');
|
||||
$this->addSql('CREATE INDEX IDX_489AFCDCCBF180EB ON "orderdetails" (id_supplier)');
|
||||
$this->addSql('CREATE INDEX orderdetails_supplier_part_nr ON "orderdetails" (supplierpartnr)');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE attachment_types ADD allowed_targets TEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE part_lots ADD last_stocktake_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE parts ADD gtin VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX parts_idx_gtin ON parts (gtin)');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD prices_includes_vat BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "attachment_types" DROP allowed_targets');
|
||||
$this->addSql('ALTER TABLE part_lots DROP last_stocktake_at');
|
||||
$this->addSql('DROP INDEX parts_idx_gtin');
|
||||
$this->addSql('ALTER TABLE "parts" DROP gtin');
|
||||
$this->addSql('ALTER TABLE "orderdetails" DROP prices_includes_vat');
|
||||
}
|
||||
}
|
||||
52
migrations/Version20260211000000.php
Normal file
52
migrations/Version20260211000000.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
final class Version20260211000000 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add eda_visibility nullable boolean column to parameters and orderdetails tables';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters ADD eda_visibility TINYINT(1) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE `orderdetails` ADD eda_visibility TINYINT(1) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters DROP COLUMN eda_visibility');
|
||||
$this->addSql('ALTER TABLE `orderdetails` DROP COLUMN eda_visibility');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters ADD COLUMN eda_visibility BOOLEAN DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD COLUMN eda_visibility BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters DROP COLUMN eda_visibility');
|
||||
$this->addSql('ALTER TABLE orderdetails DROP COLUMN eda_visibility');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters ADD eda_visibility BOOLEAN DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE orderdetails ADD eda_visibility BOOLEAN DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE parameters DROP COLUMN eda_visibility');
|
||||
$this->addSql('ALTER TABLE orderdetails DROP COLUMN eda_visibility');
|
||||
}
|
||||
}
|
||||
73
migrations/Version20260307204859.php
Normal file
73
migrations/Version20260307204859.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260307204859 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Increase the length of the vendor_barcode field in part_lots to 1000 characters and update the index accordingly';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode(100))');
|
||||
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode LONGTEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode)');
|
||||
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM part_lots');
|
||||
$this->addSql('DROP TABLE part_lots');
|
||||
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, vendor_barcode CLOB DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES storelocations (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO part_lots (id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM __temp__part_lots');
|
||||
$this->addSql('DROP TABLE __temp__part_lots');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots');
|
||||
$this->addSql('DROP TABLE part_lots');
|
||||
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots');
|
||||
$this->addSql('DROP TABLE __temp__part_lots');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX part_lots_idx_barcode');
|
||||
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE TEXT');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX part_lots_idx_barcode');
|
||||
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE VARCHAR(255)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
}
|
||||
86
phpstan.banned_code.neon
Normal file
86
phpstan.banned_code.neon
Normal file
@@ -0,0 +1,86 @@
|
||||
# Manually configure ekino/phpstan-banned-code to detect usage of echo, eval, die/exit, print, shell execution and a set of functions that should not be used in production code.
|
||||
|
||||
parametersSchema:
|
||||
banned_code: structure([
|
||||
nodes: listOf(structure([
|
||||
type: string()
|
||||
functions: schema(listOf(string()), nullable())
|
||||
]))
|
||||
use_from_tests: bool()
|
||||
non_ignorable: bool()
|
||||
])
|
||||
|
||||
parameters:
|
||||
banned_code:
|
||||
nodes:
|
||||
# enable detection of echo
|
||||
-
|
||||
type: Stmt_Echo
|
||||
functions: null
|
||||
|
||||
# enable detection of eval
|
||||
-
|
||||
type: Expr_Eval
|
||||
functions: null
|
||||
|
||||
# enable detection of die/exit
|
||||
-
|
||||
type: Expr_Exit
|
||||
functions: null
|
||||
|
||||
# enable detection of a set of functions
|
||||
-
|
||||
type: Expr_FuncCall
|
||||
functions:
|
||||
- dd
|
||||
- debug_backtrace
|
||||
- dump
|
||||
- exec
|
||||
- passthru
|
||||
- phpinfo
|
||||
- print_r
|
||||
- proc_open
|
||||
- shell_exec
|
||||
- system
|
||||
- var_dump
|
||||
|
||||
# enable detection of print statements
|
||||
-
|
||||
type: Expr_Print
|
||||
functions: null
|
||||
|
||||
# enable detection of shell execution by backticks
|
||||
-
|
||||
type: Expr_ShellExec
|
||||
functions: null
|
||||
|
||||
# enable detection of empty()
|
||||
#-
|
||||
# type: Expr_Empty
|
||||
# functions: null
|
||||
|
||||
# enable detection of `use Tests\Foo\Bar` in a non-test file
|
||||
use_from_tests: true
|
||||
|
||||
# when true, errors cannot be excluded
|
||||
non_ignorable: false
|
||||
|
||||
services:
|
||||
-
|
||||
class: Ekino\PHPStanBannedCode\Rules\BannedNodesRule
|
||||
tags:
|
||||
- phpstan.rules.rule
|
||||
arguments:
|
||||
- '%banned_code.nodes%'
|
||||
|
||||
-
|
||||
class: Ekino\PHPStanBannedCode\Rules\BannedUseTestRule
|
||||
tags:
|
||||
- phpstan.rules.rule
|
||||
arguments:
|
||||
- '%banned_code.use_from_tests%'
|
||||
|
||||
-
|
||||
class: Ekino\PHPStanBannedCode\Rules\BannedNodesErrorBuilder
|
||||
arguments:
|
||||
- '%banned_code.non_ignorable%'
|
||||
@@ -1,3 +1,6 @@
|
||||
includes:
|
||||
- phpstan.banned_code.neon
|
||||
|
||||
parameters:
|
||||
|
||||
level: 5
|
||||
@@ -6,9 +9,6 @@ parameters:
|
||||
- src
|
||||
# - tests
|
||||
|
||||
banned_code:
|
||||
non_ignorable: false # Allow to ignore some banned code
|
||||
|
||||
excludePaths:
|
||||
- src/DataTables/Adapter/*
|
||||
- src/Configuration/*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ use Rector\Symfony\Set\SymfonySetList;
|
||||
use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withComposerBased(phpunit: true)
|
||||
->withComposerBased(phpunit: true, symfony: true)
|
||||
|
||||
->withSymfonyContainerPhp(__DIR__ . '/tests/symfony-container.php')
|
||||
->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml')
|
||||
@@ -36,8 +36,6 @@ return RectorConfig::configure()
|
||||
PHPUnitSetList::PHPUNIT_90,
|
||||
PHPUnitSetList::PHPUNIT_110,
|
||||
PHPUnitSetList::PHPUNIT_CODE_QUALITY,
|
||||
|
||||
|
||||
])
|
||||
|
||||
->withRules([
|
||||
@@ -59,6 +57,9 @@ return RectorConfig::configure()
|
||||
PreferPHPUnitThisCallRector::class,
|
||||
//Do not replace 'GET' with class constant,
|
||||
LiteralGetToRequestClassConstantRector::class,
|
||||
|
||||
//Do not move help text of commands to the command class, as we want to keep the help text in the command definition for better readability
|
||||
\Rector\Symfony\Symfony73\Rector\Class_\CommandHelpToAttributeRector::class
|
||||
])
|
||||
|
||||
//Do not apply rules to Symfony own files
|
||||
@@ -67,6 +68,7 @@ return RectorConfig::configure()
|
||||
__DIR__ . '/src/Kernel.php',
|
||||
__DIR__ . '/config/preload.php',
|
||||
__DIR__ . '/config/bundles.php',
|
||||
__DIR__ . '/config/reference.php'
|
||||
])
|
||||
|
||||
;
|
||||
|
||||
84
src/ApiResource/LabelGenerationRequest.php
Normal file
84
src/ApiResource/LabelGenerationRequest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\OpenApi\Model\RequestBody;
|
||||
use ApiPlatform\OpenApi\Model\Response;
|
||||
use App\Entity\LabelSystem\LabelSupportedElement;
|
||||
use App\State\LabelGenerationProcessor;
|
||||
use App\Validator\Constraints\Misc\ValidRange;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource for generating PDF labels for parts, part lots, or storage locations.
|
||||
* This endpoint allows generating labels using saved label profiles.
|
||||
*/
|
||||
#[ApiResource(
|
||||
uriTemplate: '/labels/generate',
|
||||
description: 'Generate PDF labels for parts, part lots, or storage locations using label profiles.',
|
||||
operations: [
|
||||
new Post(
|
||||
inputFormats: ['json' => ['application/json']],
|
||||
outputFormats: [],
|
||||
openapi: new Operation(
|
||||
responses: [
|
||||
"200" => new Response(description: "PDF file containing the generated labels"),
|
||||
],
|
||||
summary: 'Generate PDF labels',
|
||||
description: 'Generate PDF labels for one or more elements using a label profile. Returns a PDF file.',
|
||||
requestBody: new RequestBody(
|
||||
description: 'Label generation request',
|
||||
required: true,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
processor: LabelGenerationProcessor::class,
|
||||
)]
|
||||
class LabelGenerationRequest
|
||||
{
|
||||
/**
|
||||
* @var int The ID of the label profile to use for generation
|
||||
*/
|
||||
#[Assert\NotBlank(message: 'Profile ID is required')]
|
||||
#[Assert\Positive(message: 'Profile ID must be a positive integer')]
|
||||
public int $profileId = 0;
|
||||
|
||||
/**
|
||||
* @var string Comma-separated list of element IDs or ranges (e.g., "1,2,5-10,15")
|
||||
*/
|
||||
#[Assert\NotBlank(message: 'Element IDs are required')]
|
||||
#[ValidRange()]
|
||||
#[ApiProperty(example: "1,2,5-10,15")]
|
||||
public string $elementIds = '';
|
||||
|
||||
/**
|
||||
* @var LabelSupportedElement|null Optional: Override the element type. If not provided, uses profile's default.
|
||||
*/
|
||||
public ?LabelSupportedElement $elementType = null;
|
||||
}
|
||||
364
src/Command/PopulateKicadCommand.php
Normal file
364
src/Command/PopulateKicadCommand.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
#[AsCommand('partdb:kicad:populate', 'Populate KiCad footprint paths and symbol paths for footprints and categories')]
|
||||
final class PopulateKicadCommand extends Command
|
||||
{
|
||||
private const DEFAULT_MAPPING_FILE = 'assets/commands/kicad_populate_default_mappings.json';
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager, #[Autowire("%kernel.project_dir%")] private readonly string $projectDir)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setHelp('This command populates KiCad footprint paths on Footprint entities and KiCad symbol paths on Category entities based on their names.');
|
||||
|
||||
$this
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview changes without applying them')
|
||||
->addOption('footprints', null, InputOption::VALUE_NONE, 'Only update footprint entities')
|
||||
->addOption('categories', null, InputOption::VALUE_NONE, 'Only update category entities')
|
||||
->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing values (by default, only empty values are updated)')
|
||||
->addOption('list', null, InputOption::VALUE_NONE, 'List all footprints and categories with their current KiCad values')
|
||||
->addOption('mapping-file', null, InputOption::VALUE_REQUIRED, 'Path to a JSON file with custom mappings (merges with built-in defaults)')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
$footprintsOnly = $input->getOption('footprints');
|
||||
$categoriesOnly = $input->getOption('categories');
|
||||
$force = $input->getOption('force');
|
||||
$list = $input->getOption('list');
|
||||
$mappingFile = $input->getOption('mapping-file');
|
||||
|
||||
// If neither specified, do both
|
||||
$doFootprints = !$categoriesOnly || $footprintsOnly;
|
||||
$doCategories = !$footprintsOnly || $categoriesOnly;
|
||||
|
||||
if ($list) {
|
||||
$this->listCurrentValues($io);
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Load mappings: start with built-in defaults, then merge user-supplied file
|
||||
['footprints' => $footprintMappings, 'categories' => $categoryMappings] = $this->getDefaultMappings();
|
||||
|
||||
if ($mappingFile !== null) {
|
||||
$customMappings = $this->loadMappingFile($mappingFile, $io);
|
||||
if ($customMappings === null) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (isset($customMappings['footprints']) && is_array($customMappings['footprints'])) {
|
||||
// User mappings take priority (overwrite defaults)
|
||||
$footprintMappings = array_merge($footprintMappings, $customMappings['footprints']);
|
||||
$io->text(sprintf('Loaded %d custom footprint mappings from %s', count($customMappings['footprints']), $mappingFile));
|
||||
}
|
||||
if (isset($customMappings['categories']) && is_array($customMappings['categories'])) {
|
||||
$categoryMappings = array_merge($categoryMappings, $customMappings['categories']);
|
||||
$io->text(sprintf('Loaded %d custom category mappings from %s', count($customMappings['categories']), $mappingFile));
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->note('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
$totalUpdated = 0;
|
||||
|
||||
if ($doFootprints) {
|
||||
$updated = $this->updateFootprints($io, $dryRun, $force, $footprintMappings);
|
||||
$totalUpdated += $updated;
|
||||
}
|
||||
|
||||
if ($doCategories) {
|
||||
$updated = $this->updateCategories($io, $dryRun, $force, $categoryMappings);
|
||||
$totalUpdated += $updated;
|
||||
}
|
||||
|
||||
if (!$dryRun && $totalUpdated > 0) {
|
||||
$this->entityManager->flush();
|
||||
$io->success(sprintf('Updated %d entities. Run "php bin/console cache:clear" to clear the cache.', $totalUpdated));
|
||||
} elseif ($dryRun && $totalUpdated > 0) {
|
||||
$io->info(sprintf('DRY RUN: Would update %d entities. Run without --dry-run to apply changes.', $totalUpdated));
|
||||
} else {
|
||||
$io->info('No entities needed updating.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function listCurrentValues(SymfonyStyle $io): void
|
||||
{
|
||||
$io->section('Current Footprint KiCad Values');
|
||||
|
||||
$footprintRepo = $this->entityManager->getRepository(Footprint::class);
|
||||
/** @var Footprint[] $footprints */
|
||||
$footprints = $footprintRepo->findAll();
|
||||
|
||||
$rows = [];
|
||||
foreach ($footprints as $footprint) {
|
||||
$kicadValue = $footprint->getEdaInfo()->getKicadFootprint();
|
||||
$rows[] = [
|
||||
$footprint->getId(),
|
||||
$footprint->getName(),
|
||||
$kicadValue ?? '(empty)',
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(['ID', 'Name', 'KiCad Footprint'], $rows);
|
||||
|
||||
$io->section('Current Category KiCad Values');
|
||||
|
||||
$categoryRepo = $this->entityManager->getRepository(Category::class);
|
||||
/** @var Category[] $categories */
|
||||
$categories = $categoryRepo->findAll();
|
||||
|
||||
$rows = [];
|
||||
foreach ($categories as $category) {
|
||||
$kicadValue = $category->getEdaInfo()->getKicadSymbol();
|
||||
$rows[] = [
|
||||
$category->getId(),
|
||||
$category->getName(),
|
||||
$kicadValue ?? '(empty)',
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(['ID', 'Name', 'KiCad Symbol'], $rows);
|
||||
}
|
||||
|
||||
private function updateFootprints(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int
|
||||
{
|
||||
$io->section('Updating Footprint Entities');
|
||||
|
||||
$footprintRepo = $this->entityManager->getRepository(Footprint::class);
|
||||
/** @var Footprint[] $footprints */
|
||||
$footprints = $footprintRepo->findAll();
|
||||
|
||||
$updated = 0;
|
||||
$skipped = [];
|
||||
|
||||
foreach ($footprints as $footprint) {
|
||||
$name = $footprint->getName();
|
||||
$currentValue = $footprint->getEdaInfo()->getKicadFootprint();
|
||||
|
||||
// Skip if already has value and not forcing
|
||||
if (!$force && $currentValue !== null && $currentValue !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for exact match on name first, then try alternative names
|
||||
$matchedValue = $this->findFootprintMapping($mappings, $name, $footprint->getAlternativeNames());
|
||||
|
||||
if ($matchedValue !== null) {
|
||||
$io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $matchedValue));
|
||||
|
||||
if (!$dryRun) {
|
||||
$footprint->getEdaInfo()->setKicadFootprint($matchedValue);
|
||||
}
|
||||
$updated++;
|
||||
} else {
|
||||
// No mapping found
|
||||
$skipped[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->text(sprintf('Updated: %d footprints', $updated));
|
||||
|
||||
if (count($skipped) > 0) {
|
||||
$io->warning(sprintf('No mapping found for %d footprints:', count($skipped)));
|
||||
foreach ($skipped as $name) {
|
||||
$io->text(' - ' . $name);
|
||||
}
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
private function updateCategories(SymfonyStyle $io, bool $dryRun, bool $force, array $mappings): int
|
||||
{
|
||||
$io->section('Updating Category Entities');
|
||||
|
||||
$categoryRepo = $this->entityManager->getRepository(Category::class);
|
||||
/** @var Category[] $categories */
|
||||
$categories = $categoryRepo->findAll();
|
||||
|
||||
$updated = 0;
|
||||
$skipped = [];
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$name = $category->getName();
|
||||
$currentValue = $category->getEdaInfo()->getKicadSymbol();
|
||||
|
||||
// Skip if already has value and not forcing
|
||||
if (!$force && $currentValue !== null && $currentValue !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for matches using the pattern-based mappings (also check alternative names)
|
||||
$matchedValue = $this->findCategoryMapping($mappings, $name, $category->getAlternativeNames());
|
||||
|
||||
if ($matchedValue !== null) {
|
||||
$io->text(sprintf(' %s: %s -> %s', $name, $currentValue ?? '(empty)', $matchedValue));
|
||||
|
||||
if (!$dryRun) {
|
||||
$category->getEdaInfo()->setKicadSymbol($matchedValue);
|
||||
}
|
||||
$updated++;
|
||||
} else {
|
||||
$skipped[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->text(sprintf('Updated: %d categories', $updated));
|
||||
|
||||
if (count($skipped) > 0) {
|
||||
$io->note(sprintf('No mapping found for %d categories (this is often expected):', count($skipped)));
|
||||
foreach ($skipped as $name) {
|
||||
$io->text(' - ' . $name);
|
||||
}
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a JSON mapping file and returns the parsed data.
|
||||
* Expected format: {"footprints": {"Name": "KiCad:Path"}, "categories": {"Pattern": "KiCad:Path"}}
|
||||
*
|
||||
* @return array|null The parsed mappings, or null on error
|
||||
*/
|
||||
private function loadMappingFile(string $path, SymfonyStyle $io): ?array
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
$io->error(sprintf('Mapping file not found: %s', $path));
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
$io->error(sprintf('Could not read mapping file: %s', $path));
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data)) {
|
||||
$io->error(sprintf('Invalid JSON in mapping file: %s', $path));
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function matchesPattern(string $name, string $pattern): bool
|
||||
{
|
||||
// Check for exact match
|
||||
if ($pattern === $name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for case-insensitive contains
|
||||
if (stripos($name, $pattern) !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a footprint mapping by checking the entity name and its alternative names.
|
||||
* Footprints use exact matching.
|
||||
*
|
||||
* @param array<string, string> $mappings
|
||||
* @param string $name The primary name of the footprint
|
||||
* @param string|null $alternativeNames Comma-separated alternative names
|
||||
* @return string|null The matched KiCad path, or null if no match found
|
||||
*/
|
||||
private function findFootprintMapping(array $mappings, string $name, ?string $alternativeNames): ?string
|
||||
{
|
||||
// Check primary name
|
||||
if (isset($mappings[$name])) {
|
||||
return $mappings[$name];
|
||||
}
|
||||
|
||||
// Check alternative names
|
||||
if ($alternativeNames !== null && $alternativeNames !== '') {
|
||||
foreach (explode(',', $alternativeNames) as $altName) {
|
||||
$altName = trim($altName);
|
||||
if ($altName !== '' && isset($mappings[$altName])) {
|
||||
return $mappings[$altName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a category mapping by checking the entity name and its alternative names.
|
||||
* Categories use pattern-based matching (case-insensitive contains).
|
||||
*
|
||||
* @param array<string, string> $mappings
|
||||
* @param string $name The primary name of the category
|
||||
* @param string|null $alternativeNames Comma-separated alternative names
|
||||
* @return string|null The matched KiCad symbol path, or null if no match found
|
||||
*/
|
||||
private function findCategoryMapping(array $mappings, string $name, ?string $alternativeNames): ?string
|
||||
{
|
||||
// Check primary name against all patterns
|
||||
foreach ($mappings as $pattern => $kicadSymbol) {
|
||||
if ($this->matchesPattern($name, $pattern)) {
|
||||
return $kicadSymbol;
|
||||
}
|
||||
}
|
||||
|
||||
// Check alternative names against all patterns
|
||||
if ($alternativeNames !== null && $alternativeNames !== '') {
|
||||
foreach (explode(',', $alternativeNames) as $altName) {
|
||||
$altName = trim($altName);
|
||||
if ($altName === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($mappings as $pattern => $kicadSymbol) {
|
||||
if ($this->matchesPattern($altName, $pattern)) {
|
||||
return $kicadSymbol;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default mappings for footprints and categories.
|
||||
* @return array{footprints: array<string, string>, categories: array<string, string>}
|
||||
* @throws \JsonException
|
||||
*/
|
||||
private function getDefaultMappings(): array
|
||||
{
|
||||
$path = $this->projectDir . '/' . self::DEFAULT_MAPPING_FILE;
|
||||
$content = file_get_contents($path);
|
||||
|
||||
return json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,8 @@ abstract class BaseAdminController extends AbstractController
|
||||
|
||||
$this->commentHelper->setMessage($form['log_comment']->getData());
|
||||
|
||||
//In principle, the form should be disabled, if the edit permission is not granted, but for good measure, we also check it here, before saving changes.
|
||||
$this->denyAccessUnlessGranted('edit', $entity);
|
||||
$em->persist($entity);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'entity.edit_flash');
|
||||
|
||||
@@ -30,6 +30,7 @@ use App\Form\Filters\AttachmentFilterType;
|
||||
use App\Services\Attachments\AttachmentManager;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -41,31 +42,56 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class AttachmentFileController extends AbstractController
|
||||
{
|
||||
|
||||
public function __construct(private readonly AttachmentManager $helper)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
#[Route(path: '/attachment/{id}/sandbox', name: 'attachment_html_sandbox')]
|
||||
public function htmlSandbox(Attachment $attachment, AttachmentsSettings $attachmentsSettings): Response
|
||||
{
|
||||
//Check if the sandbox is enabled in the settings, as it can be a security risk if used without proper precautions, so it should be opt-in
|
||||
if (!$attachmentsSettings->showHTMLAttachments) {
|
||||
throw $this->createAccessDeniedException('The HTML sandbox for attachments is disabled in the settings, as it can be a security risk if used without proper precautions. Please enable it in the settings if you want to use it.');
|
||||
}
|
||||
|
||||
$this->checkPermissions($attachment);
|
||||
|
||||
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
|
||||
|
||||
$attachmentContent = file_get_contents($file_path);
|
||||
|
||||
$response = $this->render('attachments/html_sandbox.html.twig', [
|
||||
'attachment' => $attachment,
|
||||
'content' => $attachmentContent,
|
||||
]);
|
||||
|
||||
//Set an CSP that allows to run inline scripts, styles and images from external ressources, but does not allow any connections or others.
|
||||
//Also set the sandbox CSP directive with only "allow-script" to run basic scripts
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline' *; img-src data: *; sandbox allow-scripts allow-downloads allow-modals;");
|
||||
|
||||
//Forbid to embed the attachment render page in an iframe to prevent clickjacking, as it is not used anywhere else for now
|
||||
$response->headers->set('X-Frame-Options', 'DENY');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the selected attachment.
|
||||
*/
|
||||
#[Route(path: '/attachment/{id}/download', name: 'attachment_download')]
|
||||
public function download(Attachment $attachment, AttachmentManager $helper): BinaryFileResponse
|
||||
public function download(Attachment $attachment): BinaryFileResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('read', $attachment);
|
||||
$this->checkPermissions($attachment);
|
||||
|
||||
if ($attachment->isSecure()) {
|
||||
$this->denyAccessUnlessGranted('show_private', $attachment);
|
||||
}
|
||||
|
||||
if (!$attachment->hasInternal()) {
|
||||
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
|
||||
}
|
||||
|
||||
if (!$helper->isInternalFileExisting($attachment)) {
|
||||
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
|
||||
}
|
||||
|
||||
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
|
||||
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
|
||||
$response = new BinaryFileResponse($file_path);
|
||||
|
||||
$response = $this->forbidHTMLContentType($response);
|
||||
|
||||
//Set header content disposition, so that the file will be downloaded
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $attachment->getFilename());
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -74,7 +100,35 @@ class AttachmentFileController extends AbstractController
|
||||
* View the attachment.
|
||||
*/
|
||||
#[Route(path: '/attachment/{id}/view', name: 'attachment_view')]
|
||||
public function view(Attachment $attachment, AttachmentManager $helper): BinaryFileResponse
|
||||
public function view(Attachment $attachment): BinaryFileResponse
|
||||
{
|
||||
$this->checkPermissions($attachment);
|
||||
|
||||
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
|
||||
$response = new BinaryFileResponse($file_path);
|
||||
|
||||
$response = $this->forbidHTMLContentType($response);
|
||||
|
||||
//Set header content disposition, so that the file will be downloaded
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $attachment->getFilename());
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function forbidHTMLContentType(BinaryFileResponse $response): BinaryFileResponse
|
||||
{
|
||||
$mimeType = $response->getFile()->getMimeType();
|
||||
|
||||
if ($mimeType === 'text/html') {
|
||||
$mimeType = 'text/plain';
|
||||
}
|
||||
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function checkPermissions(Attachment $attachment): void
|
||||
{
|
||||
$this->denyAccessUnlessGranted('read', $attachment);
|
||||
|
||||
@@ -86,17 +140,9 @@ class AttachmentFileController extends AbstractController
|
||||
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
|
||||
}
|
||||
|
||||
if (!$helper->isInternalFileExisting($attachment)) {
|
||||
if (!$this->helper->isInternalFileExisting($attachment)) {
|
||||
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
|
||||
}
|
||||
|
||||
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
|
||||
$response = new BinaryFileResponse($file_path);
|
||||
|
||||
//Set header content disposition, so that the file will be downloaded
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/attachment/list', name: 'attachment_list')]
|
||||
|
||||
117
src/Controller/BatchEdaController.php
Normal file
117
src/Controller/BatchEdaController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Form\Part\EDA\BatchEdaType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class BatchEdaController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute shared EDA values across all parts. If all parts have the same value for a field, return it.
|
||||
* @param Part[] $parts
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getSharedEdaValues(array $parts): array
|
||||
{
|
||||
$fields = [
|
||||
'reference_prefix' => static fn (Part $p) => $p->getEdaInfo()->getReferencePrefix(),
|
||||
'value' => static fn (Part $p) => $p->getEdaInfo()->getValue(),
|
||||
'kicad_symbol' => static fn (Part $p) => $p->getEdaInfo()->getKicadSymbol(),
|
||||
'kicad_footprint' => static fn (Part $p) => $p->getEdaInfo()->getKicadFootprint(),
|
||||
'visibility' => static fn (Part $p) => $p->getEdaInfo()->getVisibility(),
|
||||
'exclude_from_bom' => static fn (Part $p) => $p->getEdaInfo()->getExcludeFromBom(),
|
||||
'exclude_from_board' => static fn (Part $p) => $p->getEdaInfo()->getExcludeFromBoard(),
|
||||
'exclude_from_sim' => static fn (Part $p) => $p->getEdaInfo()->getExcludeFromSim(),
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($fields as $key => $getter) {
|
||||
$values = array_map($getter, $parts);
|
||||
$unique = array_unique($values, SORT_REGULAR);
|
||||
if (count($unique) === 1) {
|
||||
$data[$key] = $unique[array_key_first($unique)];
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
#[Route('/tools/batch_eda_edit', name: 'batch_eda_edit')]
|
||||
public function batchEdaEdit(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@parts.edit');
|
||||
|
||||
$ids = $request->query->getString('ids', '');
|
||||
$redirectUrl = $request->query->getString('_redirect', '');
|
||||
|
||||
//Parse part IDs and load parts
|
||||
$idArray = array_filter(array_map(intval(...), explode(',', $ids)), static fn (int $id): bool => $id > 0);
|
||||
$parts = $this->entityManager->getRepository(Part::class)->findBy(['id' => $idArray]);
|
||||
|
||||
if ($parts === []) {
|
||||
$this->addFlash('error', 'batch_eda.no_parts_selected');
|
||||
|
||||
return $redirectUrl !== '' ? $this->redirect($redirectUrl) : $this->redirectToRoute('parts_show_all');
|
||||
}
|
||||
|
||||
//Pre-populate form with shared values (when all parts have the same value)
|
||||
$initialData = $this->getSharedEdaValues($parts);
|
||||
$form = $this->createForm(BatchEdaType::class, $initialData);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
foreach ($parts as $part) {
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
$edaInfo = $part->getEdaInfo();
|
||||
|
||||
if ($form->get('apply_reference_prefix')->getData()) {
|
||||
$edaInfo->setReferencePrefix($form->get('reference_prefix')->getData() ?: null);
|
||||
}
|
||||
if ($form->get('apply_value')->getData()) {
|
||||
$edaInfo->setValue($form->get('value')->getData() ?: null);
|
||||
}
|
||||
if ($form->get('apply_kicad_symbol')->getData()) {
|
||||
$edaInfo->setKicadSymbol($form->get('kicad_symbol')->getData() ?: null);
|
||||
}
|
||||
if ($form->get('apply_kicad_footprint')->getData()) {
|
||||
$edaInfo->setKicadFootprint($form->get('kicad_footprint')->getData() ?: null);
|
||||
}
|
||||
if ($form->get('apply_visibility')->getData()) {
|
||||
$edaInfo->setVisibility($form->get('visibility')->getData());
|
||||
}
|
||||
if ($form->get('apply_exclude_from_bom')->getData()) {
|
||||
$edaInfo->setExcludeFromBom($form->get('exclude_from_bom')->getData());
|
||||
}
|
||||
if ($form->get('apply_exclude_from_board')->getData()) {
|
||||
$edaInfo->setExcludeFromBoard($form->get('exclude_from_board')->getData());
|
||||
}
|
||||
if ($form->get('apply_exclude_from_sim')->getData()) {
|
||||
$edaInfo->setExcludeFromSim($form->get('exclude_from_sim')->getData());
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'batch_eda.success');
|
||||
|
||||
return $redirectUrl !== '' ? $this->redirect($redirectUrl) : $this->redirectToRoute('parts_show_all');
|
||||
}
|
||||
|
||||
return $this->render('parts/batch_eda_edit.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'parts' => $parts,
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\EDA\KiCadHelper;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -55,15 +57,16 @@ class KiCadApiController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/categories.json', name: 'kicad_api_categories')]
|
||||
public function categories(): Response
|
||||
public function categories(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@categories.read');
|
||||
|
||||
return $this->json($this->kiCADHelper->getCategories());
|
||||
$data = $this->kiCADHelper->getCategories();
|
||||
return $this->createCacheableJsonResponse($request, $data, 300);
|
||||
}
|
||||
|
||||
#[Route('/parts/category/{category}.json', name: 'kicad_api_category')]
|
||||
public function categoryParts(?Category $category): Response
|
||||
public function categoryParts(Request $request, ?Category $category): Response
|
||||
{
|
||||
if ($category !== null) {
|
||||
$this->denyAccessUnlessGranted('read', $category);
|
||||
@@ -72,14 +75,31 @@ class KiCadApiController extends AbstractController
|
||||
}
|
||||
$this->denyAccessUnlessGranted('@parts.read');
|
||||
|
||||
return $this->json($this->kiCADHelper->getCategoryParts($category));
|
||||
$minimal = $request->query->getBoolean('minimal', false);
|
||||
$data = $this->kiCADHelper->getCategoryParts($category, $minimal);
|
||||
return $this->createCacheableJsonResponse($request, $data, 300);
|
||||
}
|
||||
|
||||
#[Route('/parts/{part}.json', name: 'kicad_api_part')]
|
||||
public function partDetails(Part $part): Response
|
||||
public function partDetails(Request $request, Part $part): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('read', $part);
|
||||
|
||||
return $this->json($this->kiCADHelper->getKiCADPart($part));
|
||||
$data = $this->kiCADHelper->getKiCADPart($part);
|
||||
return $this->createCacheableJsonResponse($request, $data, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON response with HTTP cache headers (ETag and Cache-Control).
|
||||
* Returns 304 Not Modified if the client's ETag matches.
|
||||
*/
|
||||
private function createCacheableJsonResponse(Request $request, array $data, int $maxAge): Response
|
||||
{
|
||||
$response = new JsonResponse($data);
|
||||
$response->setEtag(md5(json_encode($data)));
|
||||
$response->headers->set('Cache-Control', 'private, max-age=' . $maxAge);
|
||||
$response->isNotModified($request);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||
use App\DataTables\LogDataTable;
|
||||
use App\Entity\Attachments\AttachmentUpload;
|
||||
use App\Entity\Parts\Category;
|
||||
@@ -54,12 +55,14 @@ use Exception;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
@@ -149,7 +152,7 @@ final class PartController extends AbstractController
|
||||
$jobId = $request->query->get('jobId');
|
||||
$bulkJob = null;
|
||||
if ($jobId) {
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
// Verify user owns this job
|
||||
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
$bulkJob = null;
|
||||
@@ -170,7 +173,7 @@ final class PartController extends AbstractController
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token');
|
||||
}
|
||||
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
throw $this->createNotFoundException('Bulk import job not found');
|
||||
}
|
||||
@@ -287,6 +290,23 @@ final class PartController extends AbstractController
|
||||
$this->addFlash('warning', t("part.create_from_info_provider.no_category_yet"));
|
||||
}
|
||||
|
||||
$lotAmount = $request->query->get('lotAmount');
|
||||
$lotName = $request->query->get('lotName');
|
||||
$lotUserBarcode = $request->query->get('lotUserBarcode');
|
||||
|
||||
if ($lotAmount !== null || $lotName !== null || $lotUserBarcode !== null) {
|
||||
$partLot = new PartLot();
|
||||
$partLot->setAmount($lotAmount !== null ? (float)$lotAmount : 0);
|
||||
$partLot->setDescription($lotName !== null ? (string)$lotName : '');
|
||||
$partLot->setUserBarcode($lotUserBarcode !== null ? (string)$lotUserBarcode : '');
|
||||
|
||||
$new_part->addPartLot($partLot);
|
||||
|
||||
$this->addFlash('notice', t('part.create_from_info_provider.lot_filled_from_barcode'));
|
||||
|
||||
}
|
||||
|
||||
|
||||
return $this->renderPartForm('new', $request, $new_part, [
|
||||
'info_provider_dto' => $dto,
|
||||
]);
|
||||
@@ -336,7 +356,7 @@ final class PartController extends AbstractController
|
||||
$jobId = $request->query->get('jobId');
|
||||
$bulkJob = null;
|
||||
if ($jobId) {
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
// Verify user owns this job
|
||||
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
$bulkJob = null;
|
||||
@@ -463,6 +483,54 @@ final class PartController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/stocktake', name: 'part_stocktake', methods: ['POST'])]
|
||||
#[IsCsrfTokenValid(new Expression("'part_stocktake-' ~ args['part'].getid()"), '_token')]
|
||||
public function stocktakeHandler(Part $part, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper,
|
||||
Request $request,
|
||||
): Response
|
||||
{
|
||||
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
|
||||
|
||||
//Check that the user is allowed to stocktake the partlot
|
||||
$this->denyAccessUnlessGranted('stocktake', $partLot);
|
||||
|
||||
if (!$partLot instanceof PartLot) {
|
||||
throw new \RuntimeException('Part lot not found!');
|
||||
}
|
||||
//Ensure that the partlot belongs to the part
|
||||
if ($partLot->getPart() !== $part) {
|
||||
throw new \RuntimeException("The origin partlot does not belong to the part!");
|
||||
}
|
||||
|
||||
$actualAmount = (float) $request->request->get('actual_amount');
|
||||
$comment = $request->request->get('comment');
|
||||
|
||||
$timestamp = null;
|
||||
$timestamp_str = $request->request->getString('timestamp', '');
|
||||
//Try to parse the timestamp
|
||||
if ($timestamp_str !== '') {
|
||||
$timestamp = new DateTime($timestamp_str);
|
||||
}
|
||||
|
||||
$withdrawAddHelper->stocktake($partLot, $actualAmount, $comment, $timestamp);
|
||||
|
||||
//Ensure that the timestamp is not in the future
|
||||
if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
throw new \LogicException("The timestamp must not be in the future!");
|
||||
}
|
||||
|
||||
//Save the changes to the DB
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'part.withdraw.success');
|
||||
|
||||
//If a redirect was passed, then redirect there
|
||||
if ($request->request->get('_redirect')) {
|
||||
return $this->redirect($request->request->get('_redirect'));
|
||||
}
|
||||
//Otherwise just redirect to the part page
|
||||
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
|
||||
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
|
||||
{
|
||||
|
||||
@@ -41,11 +41,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Exceptions\InfoProviderNotActiveException;
|
||||
use App\Form\LabelSystem\ScanDialogType;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector;
|
||||
use App\Services\InfoProviderSystem\Providers\LCSCProvider;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultHandler;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeScanResultInterface;
|
||||
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
|
||||
use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
|
||||
use App\Services\LabelSystem\BarcodeScanner\LCSCBarcodeScanResult;
|
||||
use App\Services\LabelSystem\BarcodeScanner\EIGP114BarcodeScanResult;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -53,6 +58,13 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use App\Entity\Parts\Part;
|
||||
use \App\Entity\Parts\StorageLocation;
|
||||
use Symfony\UX\Turbo\TurboBundle;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Controller\ScanControllerTest
|
||||
@@ -60,9 +72,10 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
#[Route(path: '/scan')]
|
||||
class ScanController extends AbstractController
|
||||
{
|
||||
public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer)
|
||||
{
|
||||
}
|
||||
public function __construct(
|
||||
protected BarcodeScanResultHandler $resultHandler,
|
||||
protected BarcodeScanHelper $barcodeNormalizer,
|
||||
) {}
|
||||
|
||||
#[Route(path: '', name: 'scan_dialog')]
|
||||
public function dialog(Request $request, #[MapQueryParameter] ?string $input = null): Response
|
||||
@@ -72,35 +85,86 @@ class ScanController extends AbstractController
|
||||
$form = $this->createForm(ScanDialogType::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
// If JS is working, scanning uses /scan/lookup and this action just renders the page.
|
||||
// This fallback only runs if user submits the form manually or uses ?input=...
|
||||
if ($input === null && $form->isSubmitted() && $form->isValid()) {
|
||||
$input = $form['input']->getData();
|
||||
$mode = $form['mode']->getData();
|
||||
}
|
||||
|
||||
$infoModeData = null;
|
||||
|
||||
if ($input !== null) {
|
||||
if ($input !== null && $input !== '') {
|
||||
$mode = $form->isSubmitted() ? $form['mode']->getData() : null;
|
||||
$infoMode = $form->isSubmitted() && $form['info_mode']->getData();
|
||||
|
||||
try {
|
||||
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
|
||||
//Perform a redirect if the info mode is not enabled
|
||||
if (!$form['info_mode']->getData()) {
|
||||
try {
|
||||
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
|
||||
} catch (EntityNotFoundException) {
|
||||
$this->addFlash('success', 'scan.qr_not_found');
|
||||
$scan = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
|
||||
|
||||
// If not in info mode, mimic “normal scan” behavior: redirect if possible.
|
||||
if (!$infoMode) {
|
||||
|
||||
// Try to get an Info URL if possible
|
||||
$url = $this->resultHandler->getInfoURL($scan);
|
||||
if ($url !== null) {
|
||||
return $this->redirect($url);
|
||||
}
|
||||
|
||||
//Try to get an creation URL if possible (only for vendor codes)
|
||||
$createUrl = $this->buildCreateUrlForScanResult($scan);
|
||||
if ($createUrl !== null) {
|
||||
return $this->redirect($createUrl);
|
||||
}
|
||||
|
||||
//// Otherwise: show “not found” (not “format unknown”)
|
||||
$this->addFlash('warning', 'scan.qr_not_found');
|
||||
} else { // Info mode
|
||||
// Info mode fallback: render page with prefilled result
|
||||
$decoded = $scan->getDecodedForInfoMode();
|
||||
|
||||
//Try to resolve to an entity, to enhance info mode with entity-specific data
|
||||
$dbEntity = $this->resultHandler->resolveEntity($scan);
|
||||
$resolvedPart = $this->resultHandler->resolvePart($scan);
|
||||
$openUrl = $this->resultHandler->getInfoURL($scan);
|
||||
|
||||
//If no entity is found, try to create an URL for creating a new part (only for vendor codes)
|
||||
$createUrl = null;
|
||||
if ($dbEntity === null) {
|
||||
$createUrl = $this->buildCreateUrlForScanResult($scan);
|
||||
}
|
||||
|
||||
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
|
||||
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
|
||||
return $this->renderBlock('label_system/scanner/scanner.html.twig', 'scan_results', [
|
||||
'decoded' => $decoded,
|
||||
'entity' => $dbEntity,
|
||||
'part' => $resolvedPart,
|
||||
'openUrl' => $openUrl,
|
||||
'createUrl' => $createUrl,
|
||||
]);
|
||||
}
|
||||
} else { //Otherwise retrieve infoModeData
|
||||
$infoModeData = $scan_result->getDecodedForInfoMode();
|
||||
|
||||
}
|
||||
} catch (InvalidArgumentException) {
|
||||
$this->addFlash('error', 'scan.format_unknown');
|
||||
} catch (\Throwable $e) {
|
||||
// Keep fallback user-friendly; avoid 500
|
||||
$this->addFlash('warning', 'scan.format_unknown');
|
||||
}
|
||||
}
|
||||
|
||||
//When we reach here, only the flash messages are relevant, so if it's a Turbo request, only send the flash message fragment, so the client can show it without a full page reload
|
||||
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
|
||||
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
|
||||
//Only send our flash message, so the client can show it without a full page reload
|
||||
return $this->renderBlock('_turbo_control.html.twig', 'flashes');
|
||||
}
|
||||
|
||||
return $this->render('label_system/scanner/scanner.html.twig', [
|
||||
'form' => $form,
|
||||
'infoModeData' => $infoModeData,
|
||||
|
||||
//Info mode
|
||||
'decoded' => $decoded ?? null,
|
||||
'entity' => $dbEntity ?? null,
|
||||
'part' => $resolvedPart ?? null,
|
||||
'openUrl' => $openUrl ?? null,
|
||||
'createUrl' => $createUrl ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -125,11 +189,30 @@ class ScanController extends AbstractController
|
||||
source_type: BarcodeSourceType::INTERNAL
|
||||
);
|
||||
|
||||
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
|
||||
return $this->redirect($this->resultHandler->getInfoURL($scan_result) ?? throw new EntityNotFoundException("Not found"));
|
||||
} catch (EntityNotFoundException) {
|
||||
$this->addFlash('success', 'scan.qr_not_found');
|
||||
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL for creating a new part based on the barcode data, handles exceptions and shows user-friendly error messages if the provider is not active or if there is an error during URL generation.
|
||||
* @param BarcodeScanResultInterface $scanResult
|
||||
* @return string|null
|
||||
*/
|
||||
private function buildCreateUrlForScanResult(BarcodeScanResultInterface $scanResult): ?string
|
||||
{
|
||||
try {
|
||||
return $this->resultHandler->getCreationURL($scanResult);
|
||||
} catch (InfoProviderNotActiveException $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
} catch (\Throwable) {
|
||||
// Don’t break scanning UX if provider lookup fails
|
||||
$this->addFlash('error', 'An error occurred while looking up the provider for this barcode. Please try again later.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace App\Controller;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Events\SecurityEvent;
|
||||
use App\Events\SecurityEvents;
|
||||
use App\Form\Security\LoginFormType;
|
||||
use App\Services\UserSystem\PasswordResetManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Gregwar\CaptchaBundle\Type\CaptchaType;
|
||||
@@ -61,7 +62,12 @@ class SecurityController extends AbstractController
|
||||
// last username entered by the user
|
||||
$lastUsername = $authenticationUtils->getLastUsername();
|
||||
|
||||
$form = $this->createForm(LoginFormType::class, [
|
||||
'_username' => $lastUsername,
|
||||
]);
|
||||
|
||||
return $this->render('security/login.html.twig', [
|
||||
'form' => $form,
|
||||
'last_username' => $lastUsername,
|
||||
'error' => $error,
|
||||
]);
|
||||
@@ -147,10 +153,7 @@ class SecurityController extends AbstractController
|
||||
'label' => 'user.settings.pw_confirm.label',
|
||||
],
|
||||
'invalid_message' => 'password_must_match',
|
||||
'constraints' => [new Length([
|
||||
'min' => 6,
|
||||
'max' => 128,
|
||||
])],
|
||||
'constraints' => [new Length(min: 6, max: 128)],
|
||||
]);
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
|
||||
@@ -71,7 +71,10 @@ class TypeaheadController extends AbstractController
|
||||
#[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')]
|
||||
public function builtInResources(Request $request, BuiltinAttachmentsFinder $finder): JsonResponse
|
||||
{
|
||||
$query = $request->get('query');
|
||||
//Ensure that the user can access Part-DB at all
|
||||
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
|
||||
|
||||
$query = $request->query->getString('query');
|
||||
$array = $finder->find($query);
|
||||
|
||||
$result = [];
|
||||
@@ -205,9 +208,16 @@ class TypeaheadController extends AbstractController
|
||||
/** @var Category|null $category */
|
||||
$category = $entityManager->getRepository(Category::class)->find($categoryId);
|
||||
|
||||
//Ensure the user has access to both the part and the category
|
||||
$this->denyAccessUnlessGranted('read', $part);
|
||||
if ($category !== null) {
|
||||
$this->denyAccessUnlessGranted('read', $category);
|
||||
}
|
||||
|
||||
$clonedPart = clone $part;
|
||||
$clonedPart->setCategory($category);
|
||||
|
||||
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits);
|
||||
|
||||
|
||||
@@ -23,16 +23,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\System\BackupManager;
|
||||
use App\Services\System\InstallationTypeDetector;
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
@@ -49,10 +54,14 @@ class UpdateManagerController extends AbstractController
|
||||
private readonly UpdateExecutor $updateExecutor,
|
||||
private readonly VersionManagerInterface $versionManager,
|
||||
private readonly BackupManager $backupManager,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
||||
private readonly bool $webUpdatesDisabled = false,
|
||||
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
||||
private readonly bool $backupRestoreDisabled = false,
|
||||
#[Autowire(env: 'bool:DISABLE_BACKUP_DOWNLOAD')]
|
||||
private readonly bool $backupDownloadDisabled = false,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -76,6 +85,16 @@ class UpdateManagerController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if backup download is disabled and throw exception if so.
|
||||
*/
|
||||
private function denyIfBackupDownloadDisabled(): void
|
||||
{
|
||||
if ($this->backupDownloadDisabled) {
|
||||
throw new AccessDeniedHttpException('Backup download is disabled by server configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main update manager page.
|
||||
*/
|
||||
@@ -101,6 +120,8 @@ class UpdateManagerController extends AbstractController
|
||||
'backups' => $this->backupManager->getBackups(),
|
||||
'web_updates_disabled' => $this->webUpdatesDisabled,
|
||||
'backup_restore_disabled' => $this->backupRestoreDisabled,
|
||||
'backup_download_disabled' => $this->backupDownloadDisabled,
|
||||
'is_docker' => $this->installationTypeDetector->isDocker(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -206,6 +227,7 @@ class UpdateManagerController extends AbstractController
|
||||
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
|
||||
public function startUpdate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfWebUpdatesDisabled();
|
||||
|
||||
@@ -314,12 +336,126 @@ class UpdateManagerController extends AbstractController
|
||||
return $this->json($details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a manual backup.
|
||||
*/
|
||||
#[Route('/backup', name: 'admin_update_manager_backup', methods: ['POST'])]
|
||||
public function createBackup(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
if (!$this->isCsrfTokenValid('update_manager_backup', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
if ($this->updateExecutor->isLocked()) {
|
||||
$this->addFlash('error', 'Cannot create backup while an update is in progress.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->backupManager->createBackup(null, 'manual');
|
||||
$this->addFlash('success', 'update_manager.backup.created');
|
||||
} catch (\Exception $e) {
|
||||
$this->addFlash('error', 'Backup failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup file.
|
||||
*/
|
||||
#[Route('/backup/delete', name: 'admin_update_manager_backup_delete', methods: ['POST'])]
|
||||
public function deleteBackup(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$filename = $request->request->get('filename');
|
||||
if ($filename && $this->backupManager->deleteBackup($filename)) {
|
||||
$this->addFlash('success', 'update_manager.backup.deleted');
|
||||
} else {
|
||||
$this->addFlash('error', 'update_manager.backup.delete_error');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an update log file.
|
||||
*/
|
||||
#[Route('/log/delete', name: 'admin_update_manager_log_delete', methods: ['POST'])]
|
||||
public function deleteLog(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$filename = $request->request->get('filename');
|
||||
if ($filename && $this->updateExecutor->deleteLog($filename)) {
|
||||
$this->addFlash('success', 'update_manager.log.deleted');
|
||||
} else {
|
||||
$this->addFlash('error', 'update_manager.log.delete_error');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a backup file.
|
||||
* Requires password confirmation as backups contain sensitive data (password hashes, secrets, etc.).
|
||||
*/
|
||||
#[Route('/backup/download', name: 'admin_update_manager_backup_download', methods: ['POST'])]
|
||||
public function downloadBackup(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfBackupDownloadDisabled();
|
||||
|
||||
if (!$this->isCsrfTokenValid('update_manager_download', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
$password = $request->request->get('password', '');
|
||||
$user = $this->getUser();
|
||||
if (!$user instanceof User || !$this->passwordHasher->isPasswordValid($user, $password)) {
|
||||
$this->addFlash('error', 'update_manager.backup.download.invalid_password');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$filename = $request->request->get('filename', '');
|
||||
$details = $this->backupManager->getBackupDetails($filename);
|
||||
if (!$details) {
|
||||
throw $this->createNotFoundException('Backup not found');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($details['path']);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $details['file']);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a backup.
|
||||
*/
|
||||
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
|
||||
public function restore(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfBackupRestoreDisabled();
|
||||
|
||||
|
||||
@@ -295,10 +295,7 @@ class UserSettingsController extends AbstractController
|
||||
'autocomplete' => 'new-password',
|
||||
],
|
||||
],
|
||||
'constraints' => [new Length([
|
||||
'min' => 6,
|
||||
'max' => 128,
|
||||
])],
|
||||
'constraints' => [new Length(min: 6, max: 128)],
|
||||
])
|
||||
->add('submit', SubmitType::class, [
|
||||
'label' => 'save',
|
||||
|
||||
@@ -66,6 +66,7 @@ class PartFilter implements FilterInterface
|
||||
public readonly BooleanConstraint $favorite;
|
||||
public readonly BooleanConstraint $needsReview;
|
||||
public readonly NumberConstraint $mass;
|
||||
public readonly TextConstraint $gtin;
|
||||
public readonly DateTimeConstraint $lastModified;
|
||||
public readonly DateTimeConstraint $addedDate;
|
||||
public readonly EntityConstraint $category;
|
||||
@@ -132,6 +133,7 @@ class PartFilter implements FilterInterface
|
||||
$this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit');
|
||||
$this->partCustomState = new EntityConstraint($nodesListBuilder, PartCustomState::class, 'part.partCustomState');
|
||||
$this->mass = new NumberConstraint('part.mass');
|
||||
$this->gtin = new TextConstraint('part.gtin');
|
||||
$this->dbId = new IntConstraint('part.id');
|
||||
$this->ipn = new TextConstraint('part.ipn');
|
||||
$this->addedDate = new DateTimeConstraint('part.addedDate');
|
||||
|
||||
@@ -160,7 +160,7 @@ class PartSearchFilter implements FilterInterface
|
||||
if ($search_dbId) {
|
||||
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
|
||||
$queryBuilder->setParameter('id_exact', (int) $this->keyword,
|
||||
\Doctrine\DBAL\ParameterType::INTEGER);
|
||||
ParameterType::INTEGER);
|
||||
}
|
||||
|
||||
//Guard condition
|
||||
|
||||
@@ -115,6 +115,61 @@ class PartDataTableHelper
|
||||
return implode('<br>', $tmp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an EDA/KiCad completeness indicator for the given part.
|
||||
* Shows icons for symbol, footprint, and value status.
|
||||
*/
|
||||
public function renderEdaStatus(Part $context): string
|
||||
{
|
||||
$edaInfo = $context->getEdaInfo();
|
||||
$category = $context->getCategory();
|
||||
$footprint = $context->getFootprint();
|
||||
|
||||
// Determine effective values (direct or inherited)
|
||||
$hasSymbol = $edaInfo->getKicadSymbol() !== null || $category?->getEdaInfo()->getKicadSymbol() !== null;
|
||||
$hasFootprint = $edaInfo->getKicadFootprint() !== null || $footprint?->getEdaInfo()->getKicadFootprint() !== null;
|
||||
$hasReference = $edaInfo->getReferencePrefix() !== null || $category?->getEdaInfo()->getReferencePrefix() !== null;
|
||||
|
||||
$symbolInherited = $edaInfo->getKicadSymbol() === null && $category?->getEdaInfo()->getKicadSymbol() !== null;
|
||||
$footprintInherited = $edaInfo->getKicadFootprint() === null && $footprint?->getEdaInfo()->getKicadFootprint() !== null;
|
||||
|
||||
$icons = [];
|
||||
|
||||
// Symbol status
|
||||
if ($hasSymbol) {
|
||||
$title = $this->translator->trans('eda.status.symbol_set');
|
||||
$class = $symbolInherited ? 'text-info' : 'text-success';
|
||||
$icons[] = sprintf('<i class="fa-solid fa-microchip fa-fw %s" title="%s"></i>', $class, $title);
|
||||
}
|
||||
|
||||
// Footprint status
|
||||
if ($hasFootprint) {
|
||||
$title = $this->translator->trans('eda.status.footprint_set');
|
||||
$class = $footprintInherited ? 'text-info' : 'text-success';
|
||||
$icons[] = sprintf('<i class="fa-solid fa-stamp fa-fw %s" title="%s"></i>', $class, $title);
|
||||
}
|
||||
|
||||
// Reference prefix status
|
||||
if ($hasReference) {
|
||||
$icons[] = sprintf('<i class="fa-solid fa-font fa-fw text-success" title="%s"></i>',
|
||||
$this->translator->trans('eda.status.reference_set'));
|
||||
}
|
||||
|
||||
if (empty($icons)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Overall status: all 3 = green check, partial = yellow
|
||||
$allSet = $hasSymbol && $hasFootprint && $hasReference;
|
||||
$statusIcon = $allSet
|
||||
? sprintf('<i class="fa-solid fa-bolt fa-fw text-success" title="%s"></i>', $this->translator->trans('eda.status.complete'))
|
||||
: sprintf('<i class="fa-solid fa-bolt fa-fw text-warning" title="%s"></i>', $this->translator->trans('eda.status.partial'));
|
||||
|
||||
// Wrap in link to EDA settings tab (data-turbo=false to ensure hash is read on page load)
|
||||
$editUrl = $this->entityURLGenerator->editURL($context) . '#eda';
|
||||
return sprintf('<a href="%s" data-turbo="false">%s</a>', $editUrl, $statusIcon);
|
||||
}
|
||||
|
||||
public function renderAmount(Part $context): string
|
||||
{
|
||||
$amount = $context->getAmountSum();
|
||||
|
||||
@@ -47,6 +47,7 @@ use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use Doctrine\ORM\AbstractQuery;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
|
||||
use Omines\DataTablesBundle\Column\TextColumn;
|
||||
@@ -88,6 +89,10 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
$this->configureOptions($resolver);
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
/*************************************************************************************************************
|
||||
* When adding columns here, add them also to PartTableColumns enum, to make them configurable in the settings!
|
||||
*************************************************************************************************************/
|
||||
|
||||
$this->csh
|
||||
//Color the table rows depending on the review and favorite status
|
||||
->add('row_color', RowClassColumn::class, [
|
||||
@@ -218,11 +223,30 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
'label' => $this->translator->trans('part.table.mass'),
|
||||
'unit' => 'g'
|
||||
])
|
||||
->add('gtin', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.gtin'),
|
||||
'orderField' => 'NATSORT(part.gtin)'
|
||||
])
|
||||
->add('tags', TagsColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.tags'),
|
||||
])
|
||||
->add('attachments', PartAttachmentsColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.attachments'),
|
||||
])
|
||||
->add('eda_reference', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.eda_reference'),
|
||||
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getReferencePrefix() ?? ''),
|
||||
'orderField' => 'NATSORT(part.eda_info.reference_prefix)'
|
||||
])
|
||||
->add('eda_value', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.eda_value'),
|
||||
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getValue() ?? ''),
|
||||
'orderField' => 'NATSORT(part.eda_info.value)'
|
||||
])
|
||||
->add('eda_status', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.eda_status'),
|
||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderEdaStatus($context),
|
||||
'className' => 'text-center',
|
||||
]);
|
||||
|
||||
//Add a column to list the projects where the part is used, when the user has the permission to see the projects
|
||||
@@ -329,6 +353,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
->addSelect('orderdetails')
|
||||
->addSelect('attachments')
|
||||
->addSelect('storelocations')
|
||||
->addSelect('projectBomEntries')
|
||||
->from(Part::class, 'part')
|
||||
->leftJoin('part.category', 'category')
|
||||
->leftJoin('part.master_picture_attachment', 'master_picture_attachment')
|
||||
@@ -343,6 +368,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
->leftJoin('part.partUnit', 'partUnit')
|
||||
->leftJoin('part.partCustomState', 'partCustomState')
|
||||
->leftJoin('part.parameters', 'parameters')
|
||||
->leftJoin('part.project_bom_entries', 'projectBomEntries')
|
||||
->where('part.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
|
||||
@@ -360,7 +386,12 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
->addGroupBy('attachments')
|
||||
->addGroupBy('partUnit')
|
||||
->addGroupBy('partCustomState')
|
||||
->addGroupBy('parameters');
|
||||
->addGroupBy('parameters')
|
||||
->addGroupBy('projectBomEntries')
|
||||
|
||||
->setHint(Query::HINT_READ_ONLY, true)
|
||||
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
|
||||
;
|
||||
|
||||
//Get the results in the same order as the IDs were passed
|
||||
FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');
|
||||
|
||||
@@ -23,11 +23,13 @@ declare(strict_types=1);
|
||||
namespace App\DataTables;
|
||||
|
||||
use App\DataTables\Column\EntityColumn;
|
||||
use App\DataTables\Column\EnumColumn;
|
||||
use App\DataTables\Column\LocaleDateTimeColumn;
|
||||
use App\DataTables\Column\MarkdownColumn;
|
||||
use App\DataTables\Helpers\PartDataTableHelper;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
@@ -145,6 +147,19 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
'orderField' => 'NATSORT(manufacturer.name)',
|
||||
])
|
||||
|
||||
->add('manufacturing_status', EnumColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
||||
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
|
||||
'class' => ManufacturingStatus::class,
|
||||
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
|
||||
if ($status === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->translator->trans($status->toTranslationKey());
|
||||
},
|
||||
])
|
||||
|
||||
->add('mountnames', TextColumn::class, [
|
||||
'label' => 'project.bom.mountnames',
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
|
||||
@@ -97,7 +97,7 @@ use function in_array;
|
||||
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
|
||||
abstract class Attachment extends AbstractNamedDBElement
|
||||
{
|
||||
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
|
||||
final public const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
|
||||
'AttachmentType' => AttachmentTypeAttachment::class,
|
||||
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
|
||||
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
|
||||
@@ -136,7 +136,7 @@ abstract class Attachment extends AbstractNamedDBElement
|
||||
* @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses.
|
||||
* @phpstan-var class-string<T>
|
||||
*/
|
||||
protected const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
|
||||
public const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
|
||||
|
||||
/**
|
||||
* @var AttachmentUpload|null The options used for uploading a file to this attachment or modify it.
|
||||
@@ -296,6 +296,22 @@ abstract class Attachment extends AbstractNamedDBElement
|
||||
return in_array(strtolower($extension), static::MODEL_EXTS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a locally stored HTML file, which can be shown by the sandbox viewer.
|
||||
* This is the case if we have an internal path with a html extension.
|
||||
* @return bool
|
||||
*/
|
||||
public function isLocalHTMLFile(): bool
|
||||
{
|
||||
if($this->hasInternal()){
|
||||
|
||||
$extension = pathinfo($this->getFilename(), PATHINFO_EXTENSION);
|
||||
|
||||
return in_array(strtolower($extension), ['html', 'htm'], true);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this attachment has a path to an external file
|
||||
*
|
||||
|
||||
@@ -134,6 +134,17 @@ class AttachmentType extends AbstractStructuralDBElement
|
||||
#[ORM\OneToMany(mappedBy: 'attachment_type', targetEntity: Attachment::class)]
|
||||
protected Collection $attachments_with_type;
|
||||
|
||||
/**
|
||||
* @var string[]|null A list of allowed targets where this attachment type can be assigned to, as a list of portable names
|
||||
*/
|
||||
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
|
||||
protected ?array $allowed_targets = null;
|
||||
|
||||
/**
|
||||
* @var class-string<Attachment>[]|null
|
||||
*/
|
||||
protected ?array $allowed_targets_parsed_cache = null;
|
||||
|
||||
#[Groups(['attachment_type:read'])]
|
||||
protected ?\DateTimeImmutable $addedDate = null;
|
||||
#[Groups(['attachment_type:read'])]
|
||||
@@ -184,4 +195,81 @@ class AttachmentType extends AbstractStructuralDBElement
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of allowed targets as class names (e.g. PartAttachment::class), where this attachment type can be assigned to. If null, there are no restrictions.
|
||||
* @return class-string<Attachment>[]|null
|
||||
*/
|
||||
public function getAllowedTargets(): ?array
|
||||
{
|
||||
//Use cached value if available
|
||||
if ($this->allowed_targets_parsed_cache !== null) {
|
||||
return $this->allowed_targets_parsed_cache;
|
||||
}
|
||||
|
||||
if (empty($this->allowed_targets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
foreach ($this->allowed_targets as $target) {
|
||||
if (isset(Attachment::ORM_DISCRIMINATOR_MAP[$target])) {
|
||||
$tmp[] = Attachment::ORM_DISCRIMINATOR_MAP[$target];
|
||||
}
|
||||
//Otherwise ignore the entry, as it is invalid
|
||||
}
|
||||
|
||||
//Cache the parsed value
|
||||
$this->allowed_targets_parsed_cache = $tmp;
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the allowed targets for this attachment type. Allowed targets are specified as a list of class names (e.g. PartAttachment::class). If null is passed, there are no restrictions.
|
||||
* @param class-string<Attachment>[]|null $allowed_targets
|
||||
* @return $this
|
||||
*/
|
||||
public function setAllowedTargets(?array $allowed_targets): self
|
||||
{
|
||||
if ($allowed_targets === null) {
|
||||
$this->allowed_targets = null;
|
||||
} else {
|
||||
$tmp = [];
|
||||
foreach ($allowed_targets as $target) {
|
||||
$discriminator = array_search($target, Attachment::ORM_DISCRIMINATOR_MAP, true);
|
||||
if ($discriminator !== false) {
|
||||
$tmp[] = $discriminator;
|
||||
} else {
|
||||
throw new \InvalidArgumentException("Invalid allowed target: $target. Allowed targets must be a class name of an Attachment subclass.");
|
||||
}
|
||||
}
|
||||
$this->allowed_targets = $tmp;
|
||||
}
|
||||
|
||||
//Reset the cache
|
||||
$this->allowed_targets_parsed_cache = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this attachment type is allowed for the given attachment target.
|
||||
* @param Attachment|string $attachment
|
||||
* @return bool
|
||||
*/
|
||||
public function isAllowedForTarget(Attachment|string $attachment): bool
|
||||
{
|
||||
//If no restrictions are set, allow all targets
|
||||
if ($this->getAllowedTargets() === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//Iterate over all allowed targets and check if the attachment is an instance of any of them
|
||||
foreach ($this->getAllowedTargets() as $allowed_target) {
|
||||
if (is_a($attachment, $allowed_target, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\LabelSystem;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Repository\LabelProfileRepository;
|
||||
@@ -58,6 +64,22 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
/**
|
||||
* @extends AttachmentContainingDBElement<LabelAttachment>
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
|
||||
security: "is_granted('read', object)",
|
||||
openapi: new Operation(summary: 'Get a label profile by ID')
|
||||
),
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
|
||||
security: "is_granted('@labels.create_labels')",
|
||||
openapi: new Operation(summary: 'List all available label profiles')
|
||||
),
|
||||
],
|
||||
paginationEnabled: false,
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['options.supported_element' => 'exact', 'show_in_dropdown' => 'exact'])]
|
||||
#[UniqueEntity(['name', 'options.supported_element'])]
|
||||
#[ORM\Entity(repositoryClass: LabelProfileRepository::class)]
|
||||
#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
|
||||
@@ -80,20 +102,21 @@ class LabelProfile extends AttachmentContainingDBElement
|
||||
*/
|
||||
#[Assert\Valid]
|
||||
#[ORM\Embedded(class: 'LabelOptions')]
|
||||
#[Groups(["extended", "full", "import"])]
|
||||
#[Groups(["extended", "full", "import", "label_profile:read"])]
|
||||
protected LabelOptions $options;
|
||||
|
||||
/**
|
||||
* @var string The comment info for this element
|
||||
*/
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
#[Groups(["extended", "full", "import", "label_profile:read"])]
|
||||
protected string $comment = '';
|
||||
|
||||
/**
|
||||
* @var bool determines, if this label profile should be shown in the dropdown quick menu
|
||||
*/
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
#[Groups(["extended", "full", "import"])]
|
||||
#[Groups(["extended", "full", "import", "label_profile:read"])]
|
||||
protected bool $show_in_dropdown = true;
|
||||
|
||||
public function __construct()
|
||||
|
||||
@@ -28,6 +28,8 @@ enum PartStockChangeType: string
|
||||
case WITHDRAW = "withdraw";
|
||||
case MOVE = "move";
|
||||
|
||||
case STOCKTAKE = "stock_take";
|
||||
|
||||
/**
|
||||
* Converts the type to a short representation usable in the extra field of the log entry.
|
||||
* @return string
|
||||
@@ -38,6 +40,7 @@ enum PartStockChangeType: string
|
||||
self::ADD => 'a',
|
||||
self::WITHDRAW => 'w',
|
||||
self::MOVE => 'm',
|
||||
self::STOCKTAKE => 's',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +55,7 @@ enum PartStockChangeType: string
|
||||
'a' => self::ADD,
|
||||
'w' => self::WITHDRAW,
|
||||
'm' => self::MOVE,
|
||||
's' => self::STOCKTAKE,
|
||||
default => throw new \InvalidArgumentException("Invalid short type: $value"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +122,11 @@ class PartStockChangedLogEntry extends AbstractLogEntry
|
||||
return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp);
|
||||
}
|
||||
|
||||
public static function stocktake(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
|
||||
{
|
||||
return new self(PartStockChangeType::STOCKTAKE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the instock change type of this entry
|
||||
* @return PartStockChangeType
|
||||
|
||||
@@ -172,6 +172,13 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
||||
#[Assert\Length(max: 255)]
|
||||
protected string $group = '';
|
||||
|
||||
/**
|
||||
* @var bool|null Whether this parameter should be exported as a field in the EDA HTTP library API. Null means use system default.
|
||||
*/
|
||||
#[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
|
||||
#[ORM\Column(type: Types::BOOLEAN, nullable: true, options: ['default' => null])]
|
||||
protected ?bool $eda_visibility = null;
|
||||
|
||||
/**
|
||||
* Mapping is done in subclasses.
|
||||
*
|
||||
@@ -471,6 +478,21 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
||||
return static::ALLOWED_ELEMENT_CLASS;
|
||||
}
|
||||
|
||||
public function isEdaVisibility(): ?bool
|
||||
{
|
||||
return $this->eda_visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setEdaVisibility(?bool $eda_visibility): self
|
||||
{
|
||||
$this->eda_visibility = $eda_visibility;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComparableFields(): array
|
||||
{
|
||||
return ['name' => $this->name, 'group' => $this->group, 'element' => $this->element?->getId()];
|
||||
|
||||
@@ -80,6 +80,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')]
|
||||
#[ORM\Index(columns: ['name'], name: 'parts_idx_name')]
|
||||
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
||||
#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(normalizationContext: [
|
||||
|
||||
@@ -66,7 +66,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Table(name: 'part_lots')]
|
||||
#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')]
|
||||
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
|
||||
#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
|
||||
#[ORM\Index(name: 'part_lots_idx_barcode', columns: ['vendor_barcode'], options: ['lengths' => [100]])]
|
||||
#[ValidPartLot]
|
||||
#[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
|
||||
#[ApiResource(
|
||||
@@ -81,7 +81,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
denormalizationContext: ['groups' => ['part_lot:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["description", "comment"])]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["description", "comment", "user_barcode"])]
|
||||
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['instock_unknown', 'needs_refill'])]
|
||||
#[ApiFilter(RangeFilter::class, properties: ['amount'])]
|
||||
@@ -166,11 +166,18 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
||||
/**
|
||||
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
|
||||
*/
|
||||
#[ORM\Column(name: "vendor_barcode", type: Types::STRING, nullable: true)]
|
||||
#[ORM\Column(name: "vendor_barcode", type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['part_lot:read', 'part_lot:write'])]
|
||||
#[Length(max: 255)]
|
||||
protected ?string $user_barcode = null;
|
||||
|
||||
/**
|
||||
* @var \DateTimeImmutable|null The date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
|
||||
*/
|
||||
#[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
|
||||
#[ORM\Column( type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
#[Year2038BugWorkaround]
|
||||
protected ?\DateTimeImmutable $last_stocktake_at = null;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
@@ -391,6 +398,26 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the date when the last stocktake was performed for this part lot. Returns null, if no stocktake was performed yet.
|
||||
* @return \DateTimeImmutable|null
|
||||
*/
|
||||
public function getLastStocktakeAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->last_stocktake_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the date when the last stocktake was performed for this part lot. Set to null, if no stocktake was performed yet.
|
||||
* @param \DateTimeImmutable|null $last_stocktake_at
|
||||
* @return $this
|
||||
*/
|
||||
public function setLastStocktakeAt(?\DateTimeImmutable $last_stocktake_at): self
|
||||
{
|
||||
$this->last_stocktake_at = $last_stocktake_at;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[Assert\Callback]
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace App\Entity\Parts\PartTraits;
|
||||
|
||||
use App\Entity\Parts\InfoProviderReference;
|
||||
use App\Entity\Parts\PartCustomState;
|
||||
use App\Validator\Constraints\ValidGTIN;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -84,6 +85,14 @@ trait AdvancedPropertyTrait
|
||||
#[ORM\JoinColumn(name: 'id_part_custom_state')]
|
||||
protected ?PartCustomState $partCustomState = null;
|
||||
|
||||
/**
|
||||
* @var string|null The GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code
|
||||
*/
|
||||
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
|
||||
#[ORM\Column(type: Types::STRING, nullable: true)]
|
||||
#[ValidGTIN]
|
||||
protected ?string $gtin = null;
|
||||
|
||||
/**
|
||||
* Checks if this part is marked, for that it needs further review.
|
||||
*/
|
||||
@@ -211,4 +220,26 @@ trait AdvancedPropertyTrait
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
|
||||
* Returns null if no GTIN is set.
|
||||
*/
|
||||
public function getGtin(): ?string
|
||||
{
|
||||
return $this->gtin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the GTIN (Global Trade Item Number) of the part, for example a UPC or EAN code.
|
||||
*
|
||||
* @param string|null $gtin The new GTIN of the part
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setGtin(?string $gtin): self
|
||||
{
|
||||
$this->gtin = $gtin;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Serializer\Annotation\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
@@ -121,6 +122,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
protected bool $obsolete = false;
|
||||
|
||||
/**
|
||||
* @var bool|null Whether this orderdetail's supplier part number should be exported as an EDA field. Null means use system default.
|
||||
*/
|
||||
#[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
|
||||
#[ORM\Column(type: Types::BOOLEAN, nullable: true, options: ['default' => null])]
|
||||
protected ?bool $eda_visibility = null;
|
||||
|
||||
/**
|
||||
* @var string The URL to the product on the supplier's website
|
||||
*/
|
||||
@@ -147,6 +155,13 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
||||
#[ORM\JoinColumn(name: 'id_supplier')]
|
||||
protected ?Supplier $supplier = null;
|
||||
|
||||
/**
|
||||
* @var bool|null Whether the prices includes VAT or not. Null means, that it is not specified, if the prices includes VAT or not.
|
||||
*/
|
||||
#[ORM\Column(type: Types::BOOLEAN, nullable: true)]
|
||||
#[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
|
||||
protected ?bool $prices_includes_vat = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pricedetails = new ArrayCollection();
|
||||
@@ -388,6 +403,43 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the prices of this orderdetail include VAT. Null means, that it is not specified, if the prices includes
|
||||
* VAT or not.
|
||||
* @return bool|null
|
||||
*/
|
||||
public function getPricesIncludesVAT(): ?bool
|
||||
{
|
||||
return $this->prices_includes_vat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the prices of this orderdetail include VAT.
|
||||
* @param bool|null $includesVat
|
||||
* @return $this
|
||||
*/
|
||||
public function setPricesIncludesVAT(?bool $includesVat): self
|
||||
{
|
||||
$this->prices_includes_vat = $includesVat;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEdaVisibility(): ?bool
|
||||
{
|
||||
return $this->eda_visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setEdaVisibility(?bool $eda_visibility): self
|
||||
{
|
||||
$this->eda_visibility = $eda_visibility;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->getSupplierPartNr();
|
||||
|
||||
@@ -121,6 +121,8 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
|
||||
#[Groups(['pricedetail:read:standalone', 'pricedetail:write'])]
|
||||
protected ?Orderdetail $orderdetail = null;
|
||||
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->price = BigDecimal::zero()->toScale(self::PRICE_PRECISION);
|
||||
@@ -264,6 +266,15 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
|
||||
return $this->currency?->getIsoCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not.
|
||||
* @return bool|null
|
||||
*/
|
||||
public function getIncludesVat(): ?bool
|
||||
{
|
||||
return $this->orderdetail?->getPricesIncludesVAT();
|
||||
}
|
||||
|
||||
/********************************************************************************
|
||||
*
|
||||
* Setters
|
||||
|
||||
@@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable
|
||||
/**
|
||||
* The current schema version of the permission data
|
||||
*/
|
||||
public const CURRENT_SCHEMA_VERSION = 3;
|
||||
public const CURRENT_SCHEMA_VERSION = 4;
|
||||
|
||||
/**
|
||||
* Creates a new Permission Data Instance using the given data.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber\UserSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
|
||||
48
src/Exceptions/InfoProviderNotActiveException.php
Normal file
48
src/Exceptions/InfoProviderNotActiveException.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
|
||||
/**
|
||||
* An exception denoting that a required info provider is not active. This can be used to display a user-friendly error message,
|
||||
* when a user tries to use an info provider that is not active.
|
||||
*/
|
||||
class InfoProviderNotActiveException extends \RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $providerKey, public readonly string $friendlyName)
|
||||
{
|
||||
parent::__construct(sprintf('The info provider "%s" (%s) is not active.', $this->friendlyName, $this->providerKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of this exception from an info provider instance
|
||||
* @param InfoProviderInterface $provider
|
||||
* @return self
|
||||
*/
|
||||
public static function fromProvider(InfoProviderInterface $provider): self
|
||||
{
|
||||
return new self($provider->getProviderKey(), $provider->getProviderInfo()['name'] ?? '???');
|
||||
}
|
||||
}
|
||||
@@ -42,15 +42,14 @@ declare(strict_types=1);
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
use Twig\Error\Error;
|
||||
|
||||
class TwigModeException extends RuntimeException
|
||||
{
|
||||
private const PROJECT_PATH = __DIR__ . '/../../';
|
||||
|
||||
public function __construct(?Error $previous = null)
|
||||
public function __construct(?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($previous->getMessage(), 0, $previous);
|
||||
parent::__construct($previous?->getMessage() ?? "Unknown message", 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,17 +22,23 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\AdminPages;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Attachments\ProjectAttachment;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Services\Attachments\FileTypeFilterTools;
|
||||
use App\Services\LogSystem\EventCommentNeededHelper;
|
||||
use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Translation\StaticMessage;
|
||||
|
||||
class AttachmentTypeAdminForm extends BaseEntityAdminForm
|
||||
{
|
||||
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper)
|
||||
public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper, private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
|
||||
{
|
||||
parent::__construct($security, $eventCommentNeededHelper);
|
||||
}
|
||||
@@ -41,6 +47,25 @@ class AttachmentTypeAdminForm extends BaseEntityAdminForm
|
||||
{
|
||||
$is_new = null === $entity->getID();
|
||||
|
||||
|
||||
$choiceLabel = function (string $class) {
|
||||
if (!is_a($class, Attachment::class, true)) {
|
||||
return $class;
|
||||
}
|
||||
return new StaticMessage($this->elementTypeNameGenerator->typeLabelPlural($class::ALLOWED_ELEMENT_CLASS));
|
||||
};
|
||||
|
||||
|
||||
$builder->add('allowed_targets', ChoiceType::class, [
|
||||
'required' => false,
|
||||
'choices' => array_values(Attachment::ORM_DISCRIMINATOR_MAP),
|
||||
'choice_label' => $choiceLabel,
|
||||
'preferred_choices' => [PartAttachment::class, ProjectAttachment::class],
|
||||
'label' => 'attachment_type.edit.allowed_targets',
|
||||
'help' => 'attachment_type.edit.allowed_targets.help',
|
||||
'multiple' => true,
|
||||
]);
|
||||
|
||||
$builder->add('filetype_filter', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'attachment_type.edit.filetype_filter',
|
||||
|
||||
@@ -71,6 +71,7 @@ class BaseEntityAdminForm extends AbstractType
|
||||
'label' => 'name.label',
|
||||
'attr' => [
|
||||
'placeholder' => 'part.name.placeholder',
|
||||
'autofocus' => $is_new,
|
||||
],
|
||||
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
|
||||
]);
|
||||
@@ -120,6 +121,7 @@ class BaseEntityAdminForm extends AbstractType
|
||||
'label' => 'entity.edit.alternative_names.label',
|
||||
'help' => 'entity.edit.alternative_names.help',
|
||||
'empty_data' => null,
|
||||
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
|
||||
'attr' => [
|
||||
'class' => 'tagsinput',
|
||||
'data-controller' => 'elements--tagsinput',
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Type\AttachmentTypeType;
|
||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -67,10 +68,10 @@ class AttachmentFormType extends AbstractType
|
||||
'required' => false,
|
||||
'empty_data' => '',
|
||||
])
|
||||
->add('attachment_type', StructuralEntityType::class, [
|
||||
->add('attachment_type', AttachmentTypeType::class, [
|
||||
'label' => 'attachment.edit.attachment_type',
|
||||
'class' => AttachmentType::class,
|
||||
'disable_not_selectable' => true,
|
||||
'attachment_filter_class' => $options['data_class'] ?? null,
|
||||
'allow_add' => $this->security->isGranted('@attachment_types.create'),
|
||||
]);
|
||||
|
||||
@@ -121,9 +122,7 @@ class AttachmentFormType extends AbstractType
|
||||
],
|
||||
'constraints' => [
|
||||
//new AllowedFileExtension(),
|
||||
new File([
|
||||
'maxSize' => $options['max_file_size'],
|
||||
]),
|
||||
new File(maxSize: $options['max_file_size']),
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ final class TogglePasswordTypeExtension extends AbstractTypeExtension
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'toggle' => false,
|
||||
'toggle' => true,
|
||||
'hidden_label' => new TranslatableMessage('password_toggle.hide'),
|
||||
'visible_label' => new TranslatableMessage('password_toggle.show'),
|
||||
'hidden_icon' => 'Default',
|
||||
|
||||
@@ -135,6 +135,10 @@ class PartFilterType extends AbstractType
|
||||
'min' => 0,
|
||||
]);
|
||||
|
||||
$builder->add('gtin', TextConstraintType::class, [
|
||||
'label' => 'part.gtin',
|
||||
]);
|
||||
|
||||
$builder->add('measurementUnit', StructuralEntityConstraintType::class, [
|
||||
'label' => 'part.edit.partUnit',
|
||||
'entity_class' => MeasurementUnit::class
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\InfoProviderSystem;
|
||||
|
||||
use Symfony\Component\Validator\Constraints\Range;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
@@ -61,7 +62,7 @@ class FieldToProviderMappingType extends AbstractType
|
||||
'style' => 'width: 80px;'
|
||||
],
|
||||
'constraints' => [
|
||||
new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]),
|
||||
new Range(min: 1, max: 10),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ class ScanDialogType extends AbstractType
|
||||
'attr' => [
|
||||
'autofocus' => true,
|
||||
'id' => 'scan_dialog_input',
|
||||
'style' => 'font-family: var(--bs-font-monospace)',
|
||||
'data-controller' => 'elements--nonprintable-char-input',
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -72,10 +74,7 @@ class ScanDialogType extends AbstractType
|
||||
'placeholder' => 'scan_dialog.mode.auto',
|
||||
'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) {
|
||||
null => 'scan_dialog.mode.auto',
|
||||
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
|
||||
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
|
||||
BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
|
||||
BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp'
|
||||
default => 'scan_dialog.mode.' . $enum->value,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -54,7 +54,9 @@ use App\Entity\Parameters\StorageLocationParameter;
|
||||
use App\Entity\Parameters\SupplierParameter;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Form\Type\ExponentialNumberType;
|
||||
use App\Form\Type\TriStateCheckboxType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -147,6 +149,14 @@ class ParameterType extends AbstractType
|
||||
'class' => 'form-control-sm',
|
||||
],
|
||||
]);
|
||||
|
||||
// Only show the EDA visibility field for part parameters, as it has no function for other entities
|
||||
if ($options['data_class'] === PartParameter::class) {
|
||||
$builder->add('eda_visibility', TriStateCheckboxType::class, [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function finishView(FormView $view, FormInterface $form, array $options): void
|
||||
|
||||
116
src/Form/Part/EDA/BatchEdaType.php
Normal file
116
src/Form/Part/EDA/BatchEdaType.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Part\EDA;
|
||||
|
||||
use App\Form\Type\TriStateCheckboxType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
/**
|
||||
* Form type for batch editing EDA/KiCad fields on multiple parts at once.
|
||||
* Each field has an "apply" checkbox — only checked fields are applied.
|
||||
*/
|
||||
class BatchEdaType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('reference_prefix', TextType::class, [
|
||||
'label' => 'eda_info.reference_prefix',
|
||||
'required' => false,
|
||||
'attr' => ['placeholder' => t('eda_info.reference_prefix.placeholder')],
|
||||
])
|
||||
->add('apply_reference_prefix', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('value', TextType::class, [
|
||||
'label' => 'eda_info.value',
|
||||
'required' => false,
|
||||
'attr' => ['placeholder' => t('eda_info.value.placeholder')],
|
||||
])
|
||||
->add('apply_value', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('kicad_symbol', KicadFieldAutocompleteType::class, [
|
||||
'label' => 'eda_info.kicad_symbol',
|
||||
'type' => KicadFieldAutocompleteType::TYPE_SYMBOL,
|
||||
'required' => false,
|
||||
'attr' => ['placeholder' => t('eda_info.kicad_symbol.placeholder')],
|
||||
])
|
||||
->add('apply_kicad_symbol', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('kicad_footprint', KicadFieldAutocompleteType::class, [
|
||||
'label' => 'eda_info.kicad_footprint',
|
||||
'type' => KicadFieldAutocompleteType::TYPE_FOOTPRINT,
|
||||
'required' => false,
|
||||
'attr' => ['placeholder' => t('eda_info.kicad_footprint.placeholder')],
|
||||
])
|
||||
->add('apply_kicad_footprint', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('visibility', TriStateCheckboxType::class, [
|
||||
'label' => 'eda_info.visibility',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply_visibility', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('exclude_from_bom', TriStateCheckboxType::class, [
|
||||
'label' => 'eda_info.exclude_from_bom',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply_exclude_from_bom', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('exclude_from_board', TriStateCheckboxType::class, [
|
||||
'label' => 'eda_info.exclude_from_board',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply_exclude_from_board', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('exclude_from_sim', TriStateCheckboxType::class, [
|
||||
'label' => 'eda_info.exclude_from_sim',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply_exclude_from_sim', CheckboxType::class, [
|
||||
'label' => 'batch_eda.apply',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('submit', SubmitType::class, [
|
||||
'label' => 'batch_eda.submit',
|
||||
'attr' => ['class' => 'btn btn-primary'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Part;
|
||||
|
||||
use App\Form\Type\TriStateCheckboxType;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\Supplier;
|
||||
@@ -73,6 +74,16 @@ class OrderdetailType extends AbstractType
|
||||
'label' => 'orderdetails.edit.obsolete',
|
||||
]);
|
||||
|
||||
$builder->add('pricesIncludesVAT', TriStateCheckboxType::class, [
|
||||
'required' => false,
|
||||
'label' => 'orderdetails.edit.prices_includes_vat',
|
||||
]);
|
||||
|
||||
$builder->add('eda_visibility', TriStateCheckboxType::class, [
|
||||
'required' => false,
|
||||
'label' => 'orderdetails.edit.eda_visibility',
|
||||
]);
|
||||
|
||||
//Add pricedetails after we know the data, so we can set the default currency
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
|
||||
/** @var Orderdetail $orderdetail */
|
||||
|
||||
@@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\LogSystem\EventCommentNeededHelper;
|
||||
use App\Services\LogSystem\EventCommentType;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
@@ -63,6 +64,7 @@ class PartBaseType extends AbstractType
|
||||
protected UrlGeneratorInterface $urlGenerator,
|
||||
protected EventCommentNeededHelper $event_comment_needed_helper,
|
||||
protected IpnSuggestSettings $ipnSuggestSettings,
|
||||
private readonly LocalizationSettings $localizationSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -115,6 +117,7 @@ class PartBaseType extends AbstractType
|
||||
'label' => 'part.edit.name',
|
||||
'attr' => [
|
||||
'placeholder' => 'part.edit.name.placeholder',
|
||||
'autofocus' => $new_part,
|
||||
],
|
||||
])
|
||||
->add('description', RichTextEditorType::class, [
|
||||
@@ -216,7 +219,13 @@ class PartBaseType extends AbstractType
|
||||
'disable_not_selectable' => true,
|
||||
'label' => 'part.edit.partCustomState',
|
||||
])
|
||||
->add('ipn', TextType::class, $ipnOptions);
|
||||
->add('ipn', TextType::class, $ipnOptions)
|
||||
->add('gtin', TextType::class, [
|
||||
'required' => false,
|
||||
'empty_data' => null,
|
||||
'label' => 'part.gtin',
|
||||
])
|
||||
;
|
||||
|
||||
//Comment section
|
||||
$builder->add('comment', RichTextEditorType::class, [
|
||||
@@ -261,6 +270,9 @@ class PartBaseType extends AbstractType
|
||||
'entity' => $part,
|
||||
]);
|
||||
|
||||
$orderdetailPrototype = new Orderdetail();
|
||||
$orderdetailPrototype->setPricesIncludesVAT($this->localizationSettings->pricesIncludeTaxByDefault);
|
||||
|
||||
//Orderdetails section
|
||||
$builder->add('orderdetails', CollectionType::class, [
|
||||
'entry_type' => OrderdetailType::class,
|
||||
@@ -269,7 +281,7 @@ class PartBaseType extends AbstractType
|
||||
'allow_delete' => true,
|
||||
'label' => false,
|
||||
'by_reference' => false,
|
||||
'prototype_data' => new Orderdetail(),
|
||||
'prototype_data' => $orderdetailPrototype,
|
||||
'entry_options' => [
|
||||
'measurement_unit' => $part->getPartUnit(),
|
||||
],
|
||||
|
||||
@@ -31,6 +31,7 @@ use App\Form\Type\StructuralEntityType;
|
||||
use App\Form\Type\UserSelectType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -110,6 +111,13 @@ class PartLotType extends AbstractType
|
||||
//Do not remove whitespace chars on the beginning and end of the string
|
||||
'trim' => false,
|
||||
]);
|
||||
|
||||
$builder->add('last_stocktake_at', DateTimeType::class, [
|
||||
'label' => 'part_lot.edit.last_stocktake_at',
|
||||
'widget' => 'single_text',
|
||||
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
83
src/Form/Security/LoginFormType.php
Normal file
83
src/Form/Security/LoginFormType.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\Security;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
class LoginFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('_username', TextType::class, [
|
||||
'label' => t('login.username.label'),
|
||||
'attr' => [
|
||||
'autofocus' => 'autofocus',
|
||||
'autocomplete' => 'username',
|
||||
'placeholder' => t('login.username.placeholder'),
|
||||
]
|
||||
])
|
||||
->add('_password', PasswordType::class, [
|
||||
'label' => t('login.password.label'),
|
||||
'attr' => [
|
||||
'autocomplete' => 'current-password',
|
||||
'placeholder' => t('login.password.placeholder'),
|
||||
]
|
||||
])
|
||||
->add('_remember_me', CheckboxType::class, [
|
||||
'label' => t('login.rememberme'),
|
||||
'required' => false,
|
||||
])
|
||||
->add('submit', \Symfony\Component\Form\Extension\Core\Type\SubmitType::class, [
|
||||
'label' => t('login.btn'),
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
// This ensures CSRF protection is active for the login
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_csrf_token',
|
||||
'csrf_token_id' => 'authenticate',
|
||||
'attr' => [
|
||||
'data-turbo' => 'false', // Disable Turbo for the login form to ensure proper redirection after login
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function getBlockPrefix(): string
|
||||
{
|
||||
// This removes the "login_form_" prefix from field names
|
||||
// so that Security can find "_username" directly.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
56
src/Form/Type/AttachmentTypeType.php
Normal file
56
src/Form/Type/AttachmentTypeType.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Form type to select the AttachmentType to use in an attachment form. This is used to filter the available attachment types based on the target class.
|
||||
*/
|
||||
class AttachmentTypeType extends AbstractType
|
||||
{
|
||||
public function getParent(): ?string
|
||||
{
|
||||
return StructuralEntityType::class;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->define('attachment_filter_class')->allowedTypes('null', 'string')->default(null);
|
||||
|
||||
$resolver->setDefault('class', AttachmentType::class);
|
||||
|
||||
$resolver->setDefault('choice_filter', function (Options $options) {
|
||||
if (is_a($options['class'], AttachmentType::class, true) && $options['attachment_filter_class'] !== null) {
|
||||
return static function (?AttachmentType $choice) use ($options) {
|
||||
return $choice?->isAllowedForTarget($options['attachment_filter_class']);
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user