mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-02 13:35:25 +01:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
41252d8bb9 |
@@ -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
|
||||
}
|
||||
|
||||
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@v6
|
||||
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@v7
|
||||
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@v6
|
||||
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@v7
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default class extends Controller {
|
||||
//Afterwards return the newly created row
|
||||
if(targetTable.tBodies[0]) {
|
||||
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
|
||||
ret = targetTable.tBodies[0].lastElementChild;
|
||||
ret = targetTable.tBodies[0].lastElementChild;
|
||||
} else { //Otherwise just insert it
|
||||
targetTable.insertAdjacentHTML('beforeend', newElementStr);
|
||||
ret = targetTable.lastElementChild;
|
||||
@@ -90,10 +90,20 @@ export default class extends Controller {
|
||||
//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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This action opens a file dialog to select multiple files and then creates a new element for each file, where
|
||||
* the file is assigned to the input field.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
//If we encounter an element with global reload controller, then reload the whole page
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,7 +87,7 @@
|
||||
"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",
|
||||
@@ -129,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": {
|
||||
|
||||
1203
composer.lock
generated
1203
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
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');
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -151,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;
|
||||
@@ -172,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');
|
||||
}
|
||||
@@ -338,7 +339,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,10 +147,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, [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, [
|
||||
@@ -227,6 +232,21 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
])
|
||||
->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
|
||||
@@ -333,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')
|
||||
@@ -347,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)
|
||||
|
||||
@@ -364,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');
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -122,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
|
||||
*/
|
||||
@@ -418,6 +425,21 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -122,9 +122,7 @@ class AttachmentFormType extends AbstractType
|
||||
],
|
||||
'constraints' => [
|
||||
//new AllowedFileExtension(),
|
||||
new File([
|
||||
'maxSize' => $options['max_file_size'],
|
||||
]),
|
||||
new File(maxSize: $options['max_file_size']),
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,11 +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',
|
||||
BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin',
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ class OrderdetailType extends AbstractType
|
||||
'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 */
|
||||
|
||||
@@ -117,6 +117,7 @@ class PartBaseType extends AbstractType
|
||||
'label' => 'part.edit.name',
|
||||
'attr' => [
|
||||
'placeholder' => 'part.edit.name.placeholder',
|
||||
'autofocus' => $new_part,
|
||||
],
|
||||
])
|
||||
->add('description', RichTextEditorType::class, [
|
||||
|
||||
@@ -117,7 +117,6 @@ class PartLotType extends AbstractType
|
||||
'widget' => 'single_text',
|
||||
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
|
||||
'required' => false,
|
||||
'empty_data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -177,10 +177,7 @@ class UserAdminForm extends AbstractType
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
|
||||
'constraints' => [new Length([
|
||||
'min' => 6,
|
||||
'max' => 128,
|
||||
])],
|
||||
'constraints' => [new Length(min: 6, max: 128)],
|
||||
])
|
||||
|
||||
->add('need_pw_change', CheckboxType::class, [
|
||||
|
||||
@@ -92,9 +92,7 @@ class UserSettingsType extends AbstractType
|
||||
'accept' => 'image/*',
|
||||
],
|
||||
'constraints' => [
|
||||
new File([
|
||||
'maxSize' => '5M',
|
||||
]),
|
||||
new File(maxSize: '5M'),
|
||||
],
|
||||
])
|
||||
->add('aboutMe', RichTextEditorType::class, [
|
||||
|
||||
100
src/Helpers/RandomizeUseragentHttpClient.php
Normal file
100
src/Helpers/RandomizeUseragentHttpClient.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?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\Helpers;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
|
||||
|
||||
/**
|
||||
* HttpClient wrapper that randomizes the user agent for each request, to make it harder for servers to detect and block us.
|
||||
* When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent, until we run out of retries.
|
||||
*/
|
||||
final class RandomizeUseragentHttpClient implements HttpClientInterface
|
||||
{
|
||||
public const USER_AGENTS = [
|
||||
"Mozilla/5.0 (Windows; U; Windows NT 10.0; Win64; x64) AppleWebKit/534.16 (KHTML, like Gecko) Chrome/52.0.1359.302 Safari/600.6 Edge/15.25690",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299",
|
||||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_8_3) Gecko/20100101 Firefox/51.6",
|
||||
"Mozilla/5.0 (Android; Android 4.4.4; E:number:20-23:00 Build/24.0.B.1.34) AppleWebKit/603.18 (KHTML, like Gecko) Chrome/47.0.1559.384 Mobile Safari/600.5",
|
||||
"Mozilla/5.0 (compatible; MSIE 9.0; Windows; Windows NT 6.3; WOW64 Trident/5.0)",
|
||||
"Mozilla/5.0 (Windows; Windows NT 6.0; Win64; x64) AppleWebKit/602.21 (KHTML, like Gecko) Chrome/51.0.3187.154 Safari/536",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_4_2; like Mac OS X) AppleWebKit/537.24 (KHTML, like Gecko) Chrome/51.0.2432.275 Mobile Safari/535.6",
|
||||
"Mozilla/5.0 (U; Linux i680 ) Gecko/20100101 Firefox/57.5",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 8_8_6; en-US) Gecko/20100101 Firefox/53.9",
|
||||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_6_7) AppleWebKit/534.46 (KHTML, like Gecko) Chrome/55.0.3276.345 Safari/535",
|
||||
"Mozilla/5.0 (Windows; Windows NT 10.5;) AppleWebKit/535.42 (KHTML, like Gecko) Chrome/53.0.1176.353 Safari/534.0 Edge/11.95743",
|
||||
"Mozilla/5.0 (Linux; Android 5.1.1; MOTO G Build/LPH223) AppleWebKit/600.27 (KHTML, like Gecko) Chrome/47.0.1604.204 Mobile Safari/535.1",
|
||||
"Mozilla/5.0 (iPod; CPU iPod OS 7_4_8; like Mac OS X) AppleWebKit/534.17 (KHTML, like Gecko) Chrome/50.0.1632.146 Mobile Safari/600.4",
|
||||
"Mozilla/5.0 (Linux; U; Linux i570 ; en-US) Gecko/20100101 Firefox/49.9",
|
||||
"Mozilla/5.0 (Windows NT 10.2; WOW64; en-US) AppleWebKit/603.2 (KHTML, like Gecko) Chrome/55.0.1299.311 Safari/535",
|
||||
"Mozilla/5.0 (Windows; Windows NT 10.5; x64; en-US) AppleWebKit/603.39 (KHTML, like Gecko) Chrome/52.0.1443.139 Safari/536.6 Edge/13.79436",
|
||||
"Mozilla/5.0 (Linux; U; Android 5.1; SM-G9350T Build/MMB29M) AppleWebKit/537.15 (KHTML, like Gecko) Chrome/55.0.2552.307 Mobile Safari/600.8",
|
||||
"Mozilla/5.0 (Android; Android 6.0; SAMSUNG SM-D9350V Build/MDB08L) AppleWebKit/535.30 (KHTML, like Gecko) Chrome/53.0.1345.278 Mobile Safari/537.4",
|
||||
"Mozilla/5.0 (Windows; Windows NT 10.0;) AppleWebKit/534.44 (KHTML, like Gecko) Chrome/47.0.3503.387 Safari/601",
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly array $userAgents = self::USER_AGENTS,
|
||||
private readonly int $repeatOnFailure = 1,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRandomUserAgent(): string
|
||||
{
|
||||
return $this->userAgents[array_rand($this->userAgents)];
|
||||
}
|
||||
|
||||
public function request(string $method, string $url, array $options = []): ResponseInterface
|
||||
{
|
||||
$repeatsLeft = $this->repeatOnFailure;
|
||||
do {
|
||||
$modifiedOptions = $options;
|
||||
if (!isset($modifiedOptions['headers']['User-Agent'])) {
|
||||
$modifiedOptions['headers']['User-Agent'] = $this->getRandomUserAgent();
|
||||
}
|
||||
$response = $this->client->request($method, $url, $modifiedOptions);
|
||||
|
||||
//When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent
|
||||
if (!in_array($response->getStatusCode(), [403, 429, 503], true)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
//Otherwise we try again with a different user agent, until we run out of retries
|
||||
} while ($repeatsLeft-- > 0);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): ResponseStreamInterface
|
||||
{
|
||||
return $this->client->stream($responses, $timeout);
|
||||
}
|
||||
|
||||
public function withOptions(array $options): static
|
||||
{
|
||||
return new self($this->client->withOptions($options), $this->userAgents, $this->repeatOnFailure);
|
||||
}
|
||||
}
|
||||
@@ -389,4 +389,93 @@ class PartRepository extends NamedDBElementRepository
|
||||
return $baseIpn . '_' . ($maxSuffix + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a part based on the provided info provider key and ID, with an option for case sensitivity.
|
||||
* If no part is found with the given provider key and ID, null is returned.
|
||||
* @param string $providerID
|
||||
* @param string|null $providerKey If null, the provider key will not be included in the search criteria, and only the provider ID will be used for matching.
|
||||
* @param bool $caseInsensitive If true, the provider ID comparison will be case-insensitive. Default is true.
|
||||
* @return Part|null
|
||||
*/
|
||||
public function getPartByProviderInfo(string $providerID, ?string $providerKey = null, bool $caseInsensitive = true): ?Part
|
||||
{
|
||||
$qb = $this->createQueryBuilder('part');
|
||||
$qb->select('part');
|
||||
|
||||
if ($providerKey) {
|
||||
$qb->where("part.providerReference.provider_key = :providerKey");
|
||||
$qb->setParameter('providerKey', $providerKey);
|
||||
}
|
||||
|
||||
|
||||
if ($caseInsensitive) {
|
||||
$qb->andWhere("LOWER(part.providerReference.provider_id) = LOWER(:providerID)");
|
||||
} else {
|
||||
$qb->andWhere("part.providerReference.provider_id = :providerID");
|
||||
}
|
||||
|
||||
$qb->setParameter('providerID', $providerID);
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a part based on the provided MPN (Manufacturer Part Number), with an option for case sensitivity.
|
||||
* If no part is found with the given MPN, null is returned.
|
||||
* @param string $mpn
|
||||
* @param string|null $manufacturerName If provided, the search will also include a match for the manufacturer's name. If null, the manufacturer name will not be included in the search criteria.
|
||||
* @param bool $caseInsensitive If true, the MPN comparison will be case-insensitive. Default is true (case-insensitive).
|
||||
* @return Part|null
|
||||
*/
|
||||
public function getPartByMPN(string $mpn, ?string $manufacturerName = null, bool $caseInsensitive = true): ?Part
|
||||
{
|
||||
$qb = $this->createQueryBuilder('part');
|
||||
$qb->select('part');
|
||||
|
||||
if ($caseInsensitive) {
|
||||
$qb->where("LOWER(part.manufacturer_product_number) = LOWER(:mpn)");
|
||||
} else {
|
||||
$qb->where("part.manufacturer_product_number = :mpn");
|
||||
}
|
||||
|
||||
if ($manufacturerName !== null) {
|
||||
$qb->leftJoin('part.manufacturer', 'manufacturer');
|
||||
|
||||
if ($caseInsensitive) {
|
||||
$qb->andWhere("LOWER(manufacturer.name) = LOWER(:manufacturerName)");
|
||||
} else {
|
||||
$qb->andWhere("manufacturer.name = :manufacturerName");
|
||||
}
|
||||
$qb->setParameter('manufacturerName', $manufacturerName);
|
||||
}
|
||||
|
||||
$qb->setParameter('mpn', $mpn);
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a part based on the provided SPN (Supplier Part Number), with an option for case sensitivity.
|
||||
* If no part is found with the given SPN, null is returned.
|
||||
* @param string $spn
|
||||
* @param bool $caseInsensitive
|
||||
* @return Part|null
|
||||
*/
|
||||
public function getPartBySPN(string $spn, bool $caseInsensitive = true): ?Part
|
||||
{
|
||||
$qb = $this->createQueryBuilder('part');
|
||||
$qb->select('part');
|
||||
|
||||
$qb->leftJoin('part.orderdetails', 'o');
|
||||
|
||||
if ($caseInsensitive) {
|
||||
$qb->where("LOWER(o.supplierpartnr) = LOWER(:spn)");
|
||||
} else {
|
||||
$qb->where("o.supplierpartnr = :spn");
|
||||
}
|
||||
|
||||
$qb->setParameter('spn', $spn);
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
|
||||
'spn' => 'supplier_part_number',
|
||||
'supplier_product_number' => 'supplier_part_number',
|
||||
'storage_location' => 'storelocation',
|
||||
//EDA/KiCad field aliases
|
||||
'kicad_symbol' => 'eda_kicad_symbol',
|
||||
'kicad_footprint' => 'eda_kicad_footprint',
|
||||
'kicad_reference' => 'eda_reference_prefix',
|
||||
'kicad_value' => 'eda_value',
|
||||
'eda_exclude_bom' => 'eda_exclude_from_bom',
|
||||
'eda_exclude_board' => 'eda_exclude_from_board',
|
||||
'eda_exclude_sim' => 'eda_exclude_from_sim',
|
||||
'eda_invisible' => 'eda_visibility',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
@@ -190,9 +199,45 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
|
||||
}
|
||||
}
|
||||
|
||||
//Handle EDA/KiCad fields
|
||||
$this->applyEdaFields($object, $data);
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply EDA/KiCad fields from CSV data to the Part's EDAPartInfo.
|
||||
*/
|
||||
private function applyEdaFields(Part $part, array $data): void
|
||||
{
|
||||
$edaInfo = $part->getEdaInfo();
|
||||
|
||||
if (!empty($data['eda_kicad_symbol'])) {
|
||||
$edaInfo->setKicadSymbol(trim((string) $data['eda_kicad_symbol']));
|
||||
}
|
||||
if (!empty($data['eda_kicad_footprint'])) {
|
||||
$edaInfo->setKicadFootprint(trim((string) $data['eda_kicad_footprint']));
|
||||
}
|
||||
if (!empty($data['eda_reference_prefix'])) {
|
||||
$edaInfo->setReferencePrefix(trim((string) $data['eda_reference_prefix']));
|
||||
}
|
||||
if (!empty($data['eda_value'])) {
|
||||
$edaInfo->setValue(trim((string) $data['eda_value']));
|
||||
}
|
||||
if (isset($data['eda_exclude_from_bom']) && $data['eda_exclude_from_bom'] !== '') {
|
||||
$edaInfo->setExcludeFromBom(filter_var($data['eda_exclude_from_bom'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
if (isset($data['eda_exclude_from_board']) && $data['eda_exclude_from_board'] !== '') {
|
||||
$edaInfo->setExcludeFromBoard(filter_var($data['eda_exclude_from_board'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
if (isset($data['eda_exclude_from_sim']) && $data['eda_exclude_from_sim'] !== '') {
|
||||
$edaInfo->setExcludeFromSim(filter_var($data['eda_exclude_from_sim'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
if (isset($data['eda_visibility']) && $data['eda_visibility'] !== '') {
|
||||
$edaInfo->setVisibility(filter_var($data['eda_visibility'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool[]
|
||||
*/
|
||||
|
||||
@@ -137,7 +137,10 @@ class AttachmentSubmitHandler
|
||||
$attachment->getName()
|
||||
);
|
||||
|
||||
return $safeName.'-'.uniqid('', false).'.'.$extension;
|
||||
// Generate a 12-character URL-safe random string, which should avoid collisions and prevent from guessing file paths.
|
||||
$random = str_replace(['+', '/', '='], ['0', '1', '2'], base64_encode(random_bytes(9)));
|
||||
|
||||
return $safeName.'-'.$random.'.'.$extension;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Attachments;
|
||||
|
||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Imagine\Exception\RuntimeException;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use InvalidArgumentException;
|
||||
@@ -40,7 +41,7 @@ class AttachmentURLGenerator
|
||||
|
||||
public function __construct(protected Packages $assets, protected AttachmentPathResolver $pathResolver,
|
||||
protected UrlGeneratorInterface $urlGenerator, protected AttachmentManager $attachmentHelper,
|
||||
protected CacheManager $thumbnailManager, protected LoggerInterface $logger)
|
||||
protected CacheManager $thumbnailManager, protected LoggerInterface $logger, private readonly AttachmentsSettings $attachmentsSettings)
|
||||
{
|
||||
//Determine a normalized path to the public folder (assets are relative to this folder)
|
||||
$this->public_path = $this->pathResolver->parameterToAbsolutePath('public');
|
||||
@@ -99,6 +100,10 @@ class AttachmentURLGenerator
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->attachmentsSettings->showHTMLAttachments && $attachment->isLocalHTMLFile()) {
|
||||
return $this->urlGenerator->generate('attachment_html_sandbox', ['id' => $attachment->getID()]);
|
||||
}
|
||||
|
||||
$asset_path = $this->absolutePathToAssetPath($absolute_path);
|
||||
//If path is not relative to public path or marked as secure, serve it via controller
|
||||
if (null === $asset_path || $attachment->isSecure()) {
|
||||
|
||||
@@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EDA;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Part;
|
||||
@@ -43,6 +44,9 @@ class KiCadHelper
|
||||
/** @var int The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
|
||||
private readonly int $category_depth;
|
||||
|
||||
/** @var bool Whether to resolve actual datasheet PDF URLs (true) or use Part-DB page links (false) */
|
||||
private readonly bool $datasheetAsPdf;
|
||||
|
||||
public function __construct(
|
||||
private readonly NodesListBuilder $nodesListBuilder,
|
||||
private readonly TagAwareCacheInterface $kicadCache,
|
||||
@@ -51,9 +55,10 @@ class KiCadHelper
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly EntityURLGenerator $entityURLGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
KiCadEDASettings $kiCadEDASettings,
|
||||
private readonly KiCadEDASettings $kiCadEDASettings,
|
||||
) {
|
||||
$this->category_depth = $kiCadEDASettings->categoryDepth;
|
||||
$this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,11 +120,16 @@ class KiCadHelper
|
||||
}
|
||||
|
||||
//Format the category for KiCAD
|
||||
// Use the category comment as description if available, otherwise use the Part-DB URL
|
||||
$description = $category->getComment();
|
||||
if ($description === null || $description === '') {
|
||||
$description = $this->entityURLGenerator->listPartsURL($category);
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'id' => (string)$category->getId(),
|
||||
'name' => $category->getFullPath('/'),
|
||||
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
|
||||
'description' => $this->entityURLGenerator->listPartsURL($category),
|
||||
'description' => $description,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -131,11 +141,13 @@ class KiCadHelper
|
||||
* Returns an array of objects containing all parts for the given category in the format required by KiCAD.
|
||||
* The result is cached for performance and invalidated on category or part changes.
|
||||
* @param Category|null $category
|
||||
* @param bool $minimal If true, only return id and name (faster for symbol chooser listing)
|
||||
* @return array
|
||||
*/
|
||||
public function getCategoryParts(?Category $category): array
|
||||
public function getCategoryParts(?Category $category, bool $minimal = false): array
|
||||
{
|
||||
return $this->kicadCache->get('kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth,
|
||||
$cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth . ($minimal ? '_min' : '');
|
||||
return $this->kicadCache->get($cacheKey,
|
||||
function (ItemInterface $item) use ($category) {
|
||||
$item->tag([
|
||||
$this->tagGenerator->getElementTypeCacheTag(Category::class),
|
||||
@@ -198,14 +210,22 @@ class KiCadHelper
|
||||
$result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true);
|
||||
$result["fields"]["keywords"] = $this->createField($part->getTags());
|
||||
|
||||
//Use the part info page as datasheet link. It must be an absolute URL.
|
||||
$result["fields"]["datasheet"] = $this->createField(
|
||||
$this->urlGenerator->generate(
|
||||
'part_info',
|
||||
['id' => $part->getId()],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL)
|
||||
//Use the part info page as Part-DB link. It must be an absolute URL.
|
||||
$partUrl = $this->urlGenerator->generate(
|
||||
'part_info',
|
||||
['id' => $part->getId()],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL
|
||||
);
|
||||
|
||||
//Try to find an actual datasheet attachment (configurable: PDF URL vs Part-DB page link)
|
||||
if ($this->datasheetAsPdf) {
|
||||
$datasheetUrl = $this->findDatasheetUrl($part);
|
||||
$result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl);
|
||||
} else {
|
||||
$result["fields"]["datasheet"] = $this->createField($partUrl);
|
||||
}
|
||||
$result["fields"]["Part-DB URL"] = $this->createField($partUrl);
|
||||
|
||||
//Add basic fields
|
||||
$result["fields"]["description"] = $this->createField($part->getDescription());
|
||||
if ($part->getCategory() !== null) {
|
||||
@@ -245,32 +265,7 @@ class KiCadHelper
|
||||
$result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
|
||||
}
|
||||
|
||||
// Add supplier information from orderdetails (include obsolete orderdetails)
|
||||
if ($part->getOrderdetails(false)->count() > 0) {
|
||||
$supplierCounts = [];
|
||||
|
||||
foreach ($part->getOrderdetails(false) as $orderdetail) {
|
||||
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
|
||||
$supplierName = $orderdetail->getSupplier()->getName();
|
||||
|
||||
$supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number
|
||||
|
||||
if (!isset($supplierCounts[$supplierName])) {
|
||||
$supplierCounts[$supplierName] = 0;
|
||||
}
|
||||
$supplierCounts[$supplierName]++;
|
||||
|
||||
// Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.)
|
||||
$fieldName = $supplierCounts[$supplierName] > 1
|
||||
? $supplierName . ' ' . $supplierCounts[$supplierName]
|
||||
: $supplierName;
|
||||
|
||||
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Add fields for KiCost:
|
||||
//Add KiCost manufacturer fields (always present, independent of orderdetails)
|
||||
if ($part->getManufacturer() !== null) {
|
||||
$result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
|
||||
}
|
||||
@@ -278,13 +273,74 @@ class KiCadHelper
|
||||
$result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
|
||||
}
|
||||
|
||||
//For each supplier, add a field with the supplier name and the supplier part number for KiCost
|
||||
if ($part->getOrderdetails(false)->count() > 0) {
|
||||
foreach ($part->getOrderdetails(false) as $orderdetail) {
|
||||
// Add supplier information from orderdetails (include obsolete orderdetails)
|
||||
// If any orderdetail has eda_visibility explicitly set to true, only export those;
|
||||
// otherwise export all (backward compat when no flags are set)
|
||||
$allOrderdetails = $part->getOrderdetails(false);
|
||||
if ($allOrderdetails->count() > 0) {
|
||||
$hasExplicitEdaVisibility = false;
|
||||
foreach ($allOrderdetails as $od) {
|
||||
if ($od->isEdaVisibility() !== null) {
|
||||
$hasExplicitEdaVisibility = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$supplierCounts = [];
|
||||
foreach ($allOrderdetails as $orderdetail) {
|
||||
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
|
||||
$fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
|
||||
// When explicit flags exist, filter by resolved visibility
|
||||
$resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->kiCadEDASettings->defaultOrderdetailsVisibility;
|
||||
if ($hasExplicitEdaVisibility && !$resolvedVisibility) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$supplierName = $orderdetail->getSupplier()->getName() . ' SPN';
|
||||
|
||||
if (!isset($supplierCounts[$supplierName])) {
|
||||
$supplierCounts[$supplierName] = 0;
|
||||
}
|
||||
$supplierCounts[$supplierName]++;
|
||||
|
||||
// Create field name with sequential number if more than one from same supplier
|
||||
$fieldName = $supplierCounts[$supplierName] > 1
|
||||
? $supplierName . ' ' . $supplierCounts[$supplierName]
|
||||
: $supplierName;
|
||||
|
||||
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
|
||||
|
||||
//Also add a KiCost-compatible field (supplier_name# = SPN)
|
||||
$kicostFieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
|
||||
$result["fields"][$kicostFieldName] = $this->createField($orderdetail->getSupplierPartNr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Add stock quantity and storage locations (only count non-expired lots with known quantity)
|
||||
$totalStock = 0;
|
||||
$locations = [];
|
||||
foreach ($part->getPartLots() as $lot) {
|
||||
$isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true;
|
||||
if ($isAvailable) {
|
||||
$totalStock += $lot->getAmount();
|
||||
if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) {
|
||||
$locations[] = $lot->getStorageLocation()->getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
$result['fields']['Stock'] = $this->createField($totalStock);
|
||||
if ($locations !== []) {
|
||||
$result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
|
||||
}
|
||||
|
||||
//Add parameters marked for EDA export (explicit true, or system default when null)
|
||||
foreach ($part->getParameters() as $parameter) {
|
||||
$paramVisibility = $parameter->isEdaVisibility() ?? $this->kiCadEDASettings->defaultParameterVisibility;
|
||||
if ($paramVisibility && $parameter->getName() !== '') {
|
||||
$fieldName = $parameter->getName();
|
||||
//Don't overwrite hardcoded fields
|
||||
if (!isset($result['fields'][$fieldName])) {
|
||||
$result['fields'][$fieldName] = $this->createField($parameter->getFormattedValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,7 +400,7 @@ class KiCadHelper
|
||||
|
||||
//If the user set a visibility, then use it
|
||||
if ($eda_info->getVisibility() !== null) {
|
||||
return $part->getEdaInfo()->getVisibility();
|
||||
return $eda_info->getVisibility();
|
||||
}
|
||||
|
||||
//If the part has a category, then use the category visibility if possible
|
||||
@@ -395,4 +451,64 @@ class KiCadHelper
|
||||
'visible' => $this->boolToKicadBool($visible),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the URL to the actual datasheet file for the given part.
|
||||
* Searches attachments by type name, attachment name, and file extension.
|
||||
* @return string|null The datasheet URL, or null if no datasheet was found.
|
||||
*/
|
||||
private function findDatasheetUrl(Part $part): ?string
|
||||
{
|
||||
$firstPdf = null;
|
||||
|
||||
foreach ($part->getAttachments() as $attachment) {
|
||||
//Check if the attachment type name contains "datasheet"
|
||||
$typeName = $attachment->getAttachmentType()?->getName() ?? '';
|
||||
if (str_contains(mb_strtolower($typeName), 'datasheet')) {
|
||||
return $this->getAttachmentUrl($attachment);
|
||||
}
|
||||
|
||||
//Check if the attachment name contains "datasheet"
|
||||
$name = mb_strtolower($attachment->getName());
|
||||
if (str_contains($name, 'datasheet') || str_contains($name, 'data sheet')) {
|
||||
return $this->getAttachmentUrl($attachment);
|
||||
}
|
||||
|
||||
//Track first PDF as fallback (check internal extension or external URL path)
|
||||
if ($firstPdf === null) {
|
||||
$extension = $attachment->getExtension();
|
||||
if ($extension === null && $attachment->hasExternal()) {
|
||||
$urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH);
|
||||
$extension = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null;
|
||||
}
|
||||
if ($extension === 'pdf') {
|
||||
$firstPdf = $attachment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Use first PDF attachment as fallback
|
||||
if ($firstPdf !== null) {
|
||||
return $this->getAttachmentUrl($firstPdf);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an absolute URL for viewing the given attachment.
|
||||
* Prefers the external URL (direct link) over the internal view route.
|
||||
*/
|
||||
private function getAttachmentUrl(Attachment $attachment): string
|
||||
{
|
||||
if ($attachment->hasExternal()) {
|
||||
return $attachment->getExternalPath();
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate(
|
||||
'attachment_view',
|
||||
['id' => $attachment->getId()],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ declare(strict_types=1);
|
||||
*/
|
||||
namespace App\Services\ImportExportSystem;
|
||||
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
@@ -275,7 +277,7 @@ class BOMImporter
|
||||
$mapped_entries = []; // Collect all mapped entries for validation
|
||||
|
||||
// Fetch suppliers once for efficiency
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
|
||||
$supplierSPNKeys = [];
|
||||
$suppliersByName = []; // Map supplier names to supplier objects
|
||||
foreach ($suppliers as $supplier) {
|
||||
@@ -371,7 +373,7 @@ class BOMImporter
|
||||
|
||||
if ($supplier_spn !== null) {
|
||||
// Query for orderdetails with matching supplier and SPN
|
||||
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
|
||||
$orderdetail = $this->entityManager->getRepository(Orderdetail::class)
|
||||
->findOneBy([
|
||||
'supplier' => $supplier,
|
||||
'supplierpartnr' => $supplier_spn,
|
||||
@@ -394,10 +396,14 @@ class BOMImporter
|
||||
}
|
||||
}
|
||||
|
||||
// Create unique key for this entry (name + part ID)
|
||||
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
|
||||
// Create unique key for this entry.
|
||||
// When linked to a Part-DB part, use the part ID as key (merges footprint variants).
|
||||
// Otherwise, use name (which includes package) to avoid merging unrelated components.
|
||||
$entry_key = $part !== null
|
||||
? 'part:' . $part->getID()
|
||||
: 'name:' . $name;
|
||||
|
||||
// Check if we already have an entry with the same name and part
|
||||
// Check if we already have an entry with the same key
|
||||
if (isset($entries_by_key[$entry_key])) {
|
||||
// Merge with existing entry
|
||||
$existing_entry = $entries_by_key[$entry_key];
|
||||
@@ -411,14 +417,22 @@ class BOMImporter
|
||||
$existing_quantity = $existing_entry->getQuantity();
|
||||
$existing_entry->setQuantity($existing_quantity + $quantity);
|
||||
|
||||
// Track footprint variants in comment when merging entries with different packages
|
||||
$currentPackage = trim($mapped_entry['Package'] ?? '');
|
||||
if ($currentPackage !== '' && !str_contains($existing_entry->getComment(), $currentPackage)) {
|
||||
$comment = $existing_entry->getComment();
|
||||
$existing_entry->setComment($comment . ', Footprint variant: ' . $currentPackage);
|
||||
}
|
||||
|
||||
$this->logger->info('Merged duplicate BOM entry', [
|
||||
'name' => $name,
|
||||
'part_id' => $part ? $part->getID() : null,
|
||||
'part_id' => $part?->getID(),
|
||||
'original_quantity' => $existing_quantity,
|
||||
'added_quantity' => $quantity,
|
||||
'new_quantity' => $existing_quantity + $quantity,
|
||||
'original_mountnames' => $existing_mountnames,
|
||||
'added_mountnames' => $designator,
|
||||
'package' => $currentPackage,
|
||||
]);
|
||||
|
||||
continue; // Skip creating new entry
|
||||
@@ -535,7 +549,7 @@ class BOMImporter
|
||||
];
|
||||
|
||||
// Add dynamic supplier fields based on available suppliers in the database
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierName = $supplier->getName();
|
||||
$targets[$supplierName . ' SPN'] = [
|
||||
@@ -570,7 +584,7 @@ class BOMImporter
|
||||
];
|
||||
|
||||
// Add supplier-specific patterns
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierName = $supplier->getName();
|
||||
$supplierLower = strtolower($supplierName);
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ImportExportSystem;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use App\Helpers\FilenameSanatizer;
|
||||
@@ -177,7 +178,7 @@ class EntityExporter
|
||||
$colIndex = 1;
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
|
||||
$cellCoordinate = Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
|
||||
$worksheet->setCellValue($cellCoordinate, $column);
|
||||
$colIndex++;
|
||||
}
|
||||
@@ -265,11 +266,14 @@ class EntityExporter
|
||||
//Sanitize the filename
|
||||
$filename = FilenameSanatizer::sanitizeFilename($filename);
|
||||
|
||||
//Remove percent for fallback
|
||||
$fallback = str_replace("%", "_", $filename);
|
||||
|
||||
// Create the disposition of the file
|
||||
$disposition = $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$filename,
|
||||
u($filename)->ascii()->toString(),
|
||||
$fallback,
|
||||
);
|
||||
// Set the content disposition
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ImportExportSystem;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use App\Entity\Parts\Category;
|
||||
@@ -419,14 +420,14 @@ class EntityImporter
|
||||
'worksheet_title' => $worksheet->getTitle()
|
||||
]);
|
||||
|
||||
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
|
||||
$highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
|
||||
|
||||
for ($row = 1; $row <= $highestRow; $row++) {
|
||||
$rowData = [];
|
||||
|
||||
// Read all columns using numeric index
|
||||
for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
|
||||
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
|
||||
$col = Coordinate::stringFromColumnIndex($colIndex);
|
||||
try {
|
||||
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
|
||||
$rowData[] = $cellValue ?? '';
|
||||
|
||||
@@ -22,6 +22,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\InfoProviderSystem\DTOs;
|
||||
|
||||
use Doctrine\ORM\Exception\ORMException;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Traversable;
|
||||
@@ -176,7 +177,7 @@ readonly class BulkSearchResponseDTO implements \ArrayAccess, \IteratorAggregate
|
||||
* @param array $data
|
||||
* @param EntityManagerInterface $entityManager
|
||||
* @return BulkSearchResponseDTO
|
||||
* @throws \Doctrine\ORM\Exception\ORMException
|
||||
* @throws ORMException
|
||||
*/
|
||||
public static function fromSerializableRepresentation(array $data, EntityManagerInterface $entityManager): BulkSearchResponseDTO
|
||||
{
|
||||
|
||||
@@ -24,10 +24,15 @@ declare(strict_types=1);
|
||||
namespace App\Services\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Exceptions\InfoProviderNotActiveException;
|
||||
use App\Exceptions\OAuthReconnectRequiredException;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpClient\Exception\ClientException;
|
||||
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
@@ -49,6 +54,11 @@ final class PartInfoRetriever
|
||||
* @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances
|
||||
* @param string $keyword The keyword to search for
|
||||
* @return SearchResultDTO[] The search results
|
||||
* @throws InfoProviderNotActiveException if any of the given providers is not active
|
||||
* @throws ClientException if any of the providers throws an exception during the search
|
||||
* @throws \InvalidArgumentException if any of the given providers is not a valid provider key or instance
|
||||
* @throws TransportException if any of the providers throws an exception during the search
|
||||
* @throws OAuthReconnectRequiredException if any of the providers throws an exception during the search that indicates that the OAuth token needs to be refreshed
|
||||
*/
|
||||
public function searchByKeyword(string $keyword, array $providers): array
|
||||
{
|
||||
@@ -61,7 +71,7 @@ final class PartInfoRetriever
|
||||
|
||||
//Ensure that the provider is active
|
||||
if (!$provider->isActive()) {
|
||||
throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!");
|
||||
throw InfoProviderNotActiveException::fromProvider($provider);
|
||||
}
|
||||
|
||||
if (!$provider instanceof InfoProviderInterface) {
|
||||
@@ -82,10 +92,10 @@ final class PartInfoRetriever
|
||||
protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array
|
||||
{
|
||||
//Generate key and escape reserved characters from the provider id
|
||||
$escaped_keyword = urlencode($keyword);
|
||||
$escaped_keyword = hash('xxh3', $keyword);
|
||||
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
|
||||
//Set the expiration time
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10);
|
||||
|
||||
return $provider->searchByKeyword($keyword);
|
||||
});
|
||||
@@ -97,6 +107,7 @@ final class PartInfoRetriever
|
||||
* @param string $provider_key
|
||||
* @param string $part_id
|
||||
* @return PartDetailDTO
|
||||
* @throws InfoProviderNotActiveException if the the given providers is not active
|
||||
*/
|
||||
public function getDetails(string $provider_key, string $part_id): PartDetailDTO
|
||||
{
|
||||
@@ -104,14 +115,14 @@ final class PartInfoRetriever
|
||||
|
||||
//Ensure that the provider is active
|
||||
if (!$provider->isActive()) {
|
||||
throw new \RuntimeException("The provider with key $provider_key is not active!");
|
||||
throw InfoProviderNotActiveException::fromProvider($provider);
|
||||
}
|
||||
|
||||
//Generate key and escape reserved characters from the provider id
|
||||
$escaped_part_id = urlencode($part_id);
|
||||
$escaped_part_id = hash('xxh3', $part_id);
|
||||
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
|
||||
//Set the expiration time
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10);
|
||||
|
||||
return $provider->getDetails($part_id);
|
||||
});
|
||||
@@ -145,4 +156,4 @@ final class PartInfoRetriever
|
||||
|
||||
return $this->dto_to_entity_converter->convertPart($details);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ use App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class BuerklinProvider implements BatchInfoProviderInterface
|
||||
class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
|
||||
@@ -365,7 +365,7 @@ class BuerklinProvider implements BatchInfoProviderInterface
|
||||
* - prefers 'zoom' format, then 'product' format, then all others
|
||||
*
|
||||
* @param array|null $images
|
||||
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
|
||||
* @return FileDTO[]
|
||||
*/
|
||||
private function getProductImages(?array $images): array
|
||||
{
|
||||
@@ -636,4 +636,35 @@ class BuerklinProvider implements BatchInfoProviderInterface
|
||||
);
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['buerklin.com'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Inputs:
|
||||
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/
|
||||
//https://www.buerklin.com/de/p/40F1332/
|
||||
//https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/
|
||||
//https://www.buerklin.com/en/p/40F1332/
|
||||
//The ID is the last part after the manufacturer/category/mpn segment and before the final slash
|
||||
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work
|
||||
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
|
||||
if (!$path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure it's actually a product URL
|
||||
if (strpos($path, '/p/') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = basename(rtrim($path, '/'));
|
||||
|
||||
return $id !== '' && $id !== 'p' ? $id : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
231
src/Services/InfoProviderSystem/Providers/CanopyProvider.php
Normal file
231
src/Services/InfoProviderSystem/Providers/CanopyProvider.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||
use App\Settings\InfoProviderSystem\CanopySettings;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Use canopy API to retrieve infos from amazon
|
||||
*/
|
||||
class CanopyProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
public const BASE_URL = "https://rest.canopyapi.co/api";
|
||||
public const SEARCH_API_URL = self::BASE_URL . "/amazon/search";
|
||||
public const DETAIL_API_URL = self::BASE_URL . "/amazon/product";
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Amazon';
|
||||
|
||||
public function __construct(private readonly CanopySettings $settings,
|
||||
private readonly HttpClientInterface $httpClient, private readonly CacheItemPoolInterface $partInfoCache)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Amazon (Canopy)',
|
||||
'description' => 'Retrieves part infos from Amazon using the Canopy API',
|
||||
'url' => 'https://canopyapi.co',
|
||||
'disabled_help' => 'Set Canopy API key in the provider configuration to enable this provider',
|
||||
'settings_class' => CanopySettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'canopy';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->settings->apiKey !== null;
|
||||
}
|
||||
|
||||
private function productPageFromASIN(string $asin): string
|
||||
{
|
||||
return "https://www.{$this->settings->getRealDomain()}/dp/{$asin}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the given part to the cache.
|
||||
* Everytime this function is called, the cache is overwritten.
|
||||
* @param PartDetailDTO $part
|
||||
* @return void
|
||||
*/
|
||||
private function saveToCache(PartDetailDTO $part): void
|
||||
{
|
||||
$key = 'canopy_part_'.$part->provider_id;
|
||||
|
||||
$item = $this->partInfoCache->getItem($key);
|
||||
$item->set($part);
|
||||
$item->expiresAfter(3600 * 24); //Cache for 1 day
|
||||
$this->partInfoCache->save($item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a from the cache, or null if it was not cached yet.
|
||||
* @param string $id
|
||||
* @return PartDetailDTO|null
|
||||
*/
|
||||
private function getFromCache(string $id): ?PartDetailDTO
|
||||
{
|
||||
$key = 'canopy_part_'.$id;
|
||||
|
||||
$item = $this->partInfoCache->getItem($key);
|
||||
if ($item->isHit()) {
|
||||
return $item->get();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$response = $this->httpClient->request('GET', self::SEARCH_API_URL, [
|
||||
'query' => [
|
||||
'domain' => $this->settings->domain,
|
||||
'searchTerm' => $keyword,
|
||||
],
|
||||
'headers' => [
|
||||
'API-KEY' => $this->settings->apiKey,
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
$results = $data['data']['amazonProductSearchResults']['productResults']['results'] ?? [];
|
||||
|
||||
$out = [];
|
||||
foreach ($results as $result) {
|
||||
|
||||
|
||||
$dto = new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $result['asin'],
|
||||
name: $result["title"],
|
||||
description: "",
|
||||
preview_image_url: $result["mainImageUrl"] ?? null,
|
||||
provider_url: $this->productPageFromASIN($result['asin']),
|
||||
vendor_infos: [$this->priceToPurchaseInfo($result['price'], $result['asin'])]
|
||||
);
|
||||
|
||||
$out[] = $dto;
|
||||
$this->saveToCache($dto);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function categoriesToCategory(array $categories): ?string
|
||||
{
|
||||
if (count($categories) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(" -> ", array_map(static fn($cat) => $cat['name'], $categories));
|
||||
}
|
||||
|
||||
private function feauturesBulletsToNotes(array $featureBullets): string
|
||||
{
|
||||
$notes = "<ul>";
|
||||
foreach ($featureBullets as $bullet) {
|
||||
$notes .= "<li>" . $bullet . "</li>";
|
||||
}
|
||||
$notes .= "</ul>";
|
||||
return $notes;
|
||||
}
|
||||
|
||||
private function priceToPurchaseInfo(?array $price, string $asin): PurchaseInfoDTO
|
||||
{
|
||||
$priceDtos = [];
|
||||
if ($price !== null) {
|
||||
$priceDtos[] = new PriceDTO(minimum_discount_amount: 1, price: (string) $price['value'], currency_iso_code: $price['currency'], includes_tax: true);
|
||||
}
|
||||
|
||||
|
||||
return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin));
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
//Check that the id is a valid ASIN (10 characters, letters and numbers)
|
||||
if (!preg_match('/^[A-Z0-9]{10}$/', $id)) {
|
||||
throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)");
|
||||
}
|
||||
|
||||
//Use cached details if available and the settings allow it, to avoid unnecessary API requests, since the search results already contain most of the details
|
||||
if(!$this->settings->alwaysGetDetails && ($cached = $this->getFromCache($id)) !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request('GET', self::DETAIL_API_URL, [
|
||||
'query' => [
|
||||
'asin' => $id,
|
||||
'domain' => $this->settings->domain,
|
||||
],
|
||||
'headers' => [
|
||||
'API-KEY' => $this->settings->apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$product = $response->toArray()['data']['amazonProduct'];
|
||||
|
||||
|
||||
if ($product === null) {
|
||||
throw new \RuntimeException("Product with ASIN $id not found");
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $product['asin'],
|
||||
name: $product['title'],
|
||||
description: '',
|
||||
category: $this->categoriesToCategory($product['categories']),
|
||||
manufacturer: $product['brand'] ?? null,
|
||||
preview_image_url: $product['mainImageUrl'] ?? $product['imageUrls'][0] ?? null,
|
||||
provider_url: $this->productPageFromASIN($product['asin']),
|
||||
notes: $this->feauturesBulletsToNotes($product['featureBullets'] ?? []),
|
||||
vendor_infos: [$this->priceToPurchaseInfo($product['price'], $product['asin'])]
|
||||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
||||
public function productMediaToDatasheets(array $productMedia): array
|
||||
{
|
||||
$files = [];
|
||||
foreach ($productMedia['manuals'] as $manual) {
|
||||
foreach ($productMedia['manuals'] ?? [] as $manual) {
|
||||
//Filter out unwanted languages
|
||||
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
|
||||
continue;
|
||||
|
||||
@@ -24,6 +24,7 @@ declare(strict_types=1);
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Exceptions\ProviderIDNotSupportedException;
|
||||
use App\Helpers\RandomizeUseragentHttpClient;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
@@ -54,11 +55,8 @@ class GenericWebProvider implements InfoProviderInterface
|
||||
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
|
||||
)
|
||||
{
|
||||
$this->httpClient = $httpClient->withOptions(
|
||||
$this->httpClient = (new RandomizeUseragentHttpClient($httpClient))->withOptions(
|
||||
[
|
||||
'headers' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
|
||||
],
|
||||
'timeout' => 15,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Helpers\RandomizeUseragentHttpClient;
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
@@ -30,7 +31,6 @@ use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\ReicheltSettings;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
@@ -39,10 +39,13 @@ class ReicheltProvider implements InfoProviderInterface
|
||||
|
||||
public const DISTRIBUTOR_NAME = "Reichelt";
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
private readonly HttpClientInterface $client;
|
||||
|
||||
public function __construct(HttpClientInterface $client,
|
||||
private readonly ReicheltSettings $settings,
|
||||
)
|
||||
{
|
||||
$this->client = new RandomizeUseragentHttpClient($client);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
/*
|
||||
* 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)
|
||||
* 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
|
||||
@@ -17,15 +18,29 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
declare(strict_types=1);
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
//If we encounter an element with this, then change the title of our document according to data-title
|
||||
this.changeTitle(this.element.dataset.title);
|
||||
|
||||
namespace App\Services\LabelSystem\BarcodeScanner;
|
||||
|
||||
final readonly class AmazonBarcodeScanResult implements BarcodeScanResultInterface
|
||||
{
|
||||
public function __construct(public string $asin) {
|
||||
if (!self::isAmazonBarcode($asin)) {
|
||||
throw new \InvalidArgumentException("The provided input '$asin' is not a valid Amazon barcode (ASIN)");
|
||||
}
|
||||
}
|
||||
|
||||
changeTitle(title) {
|
||||
document.title = title;
|
||||
public static function isAmazonBarcode(string $input): bool
|
||||
{
|
||||
//Amazon barcodes are 10 alphanumeric characters
|
||||
return preg_match('/^[A-Z0-9]{10}$/i', $input) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
public function getDecodedForInfoMode(): array
|
||||
{
|
||||
return [
|
||||
'ASIN' => $this->asin,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<?php
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\LabelSystem\BarcodeScanner;
|
||||
|
||||
use App\Entity\LabelSystem\LabelSupportedElement;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest
|
||||
*/
|
||||
final class BarcodeRedirector
|
||||
{
|
||||
public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the URL to which the user should be redirected, when scanning a QR code.
|
||||
*
|
||||
* @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan
|
||||
* @return string the URL to which should be redirected
|
||||
*
|
||||
* @throws EntityNotFoundException
|
||||
*/
|
||||
public function getRedirectURL(BarcodeScanResultInterface $barcodeScan): string
|
||||
{
|
||||
if($barcodeScan instanceof LocalBarcodeScanResult) {
|
||||
return $this->getURLLocalBarcode($barcodeScan);
|
||||
}
|
||||
|
||||
if ($barcodeScan instanceof EIGP114BarcodeScanResult) {
|
||||
return $this->getURLVendorBarcode($barcodeScan);
|
||||
}
|
||||
|
||||
if ($barcodeScan instanceof GTINBarcodeScanResult) {
|
||||
return $this->getURLGTINBarcode($barcodeScan);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan));
|
||||
}
|
||||
|
||||
private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string
|
||||
{
|
||||
switch ($barcodeScan->target_type) {
|
||||
case LabelSupportedElement::PART:
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]);
|
||||
case LabelSupportedElement::PART_LOT:
|
||||
//Try to determine the part to the given lot
|
||||
$lot = $this->em->find(PartLot::class, $barcodeScan->target_id);
|
||||
if (!$lot instanceof PartLot) {
|
||||
throw new EntityNotFoundException();
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID(), 'highlightLot' => $lot->getID()]);
|
||||
|
||||
case LabelSupportedElement::STORELOCATION:
|
||||
return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
|
||||
|
||||
default:
|
||||
throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the URL to a part from a scan of a Vendor Barcode
|
||||
*/
|
||||
private function getURLVendorBarcode(EIGP114BarcodeScanResult $barcodeScan): string
|
||||
{
|
||||
$part = $this->getPartFromVendor($barcodeScan);
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
|
||||
}
|
||||
|
||||
private function getURLGTINBarcode(GTINBarcodeScanResult $barcodeScan): string
|
||||
{
|
||||
$part = $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]);
|
||||
if (!$part instanceof Part) {
|
||||
throw new EntityNotFoundException();
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a part from a scan of a Vendor Barcode by filtering for parts
|
||||
* with the same Info Provider Id or, if that fails, by looking for parts with a
|
||||
* matching manufacturer product number. Only returns the first matching part.
|
||||
*/
|
||||
private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part
|
||||
{
|
||||
// first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via
|
||||
// the info provider system or if the part was bought from a different vendor than the data was retrieved
|
||||
// from.
|
||||
if($barcodeScan->digikeyPartNumber) {
|
||||
$qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
|
||||
//Lower() to be case insensitive
|
||||
$qb->where($qb->expr()->like('LOWER(part.providerReference.provider_id)', 'LOWER(:vendor_id)'));
|
||||
$qb->setParameter('vendor_id', $barcodeScan->digikeyPartNumber);
|
||||
$results = $qb->getQuery()->getResult();
|
||||
if ($results) {
|
||||
return $results[0];
|
||||
}
|
||||
}
|
||||
|
||||
if(!$barcodeScan->supplierPartNumber){
|
||||
throw new EntityNotFoundException();
|
||||
}
|
||||
|
||||
//Fallback to the manufacturer part number. This may return false positives, since it is common for
|
||||
//multiple manufacturers to use the same part number for their version of a common product
|
||||
//We assume the user is able to realize when this returns the wrong part
|
||||
//If the barcode specifies the manufacturer we try to use that as well
|
||||
$mpnQb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
|
||||
$mpnQb->where($mpnQb->expr()->like('LOWER(part.manufacturer_product_number)', 'LOWER(:mpn)'));
|
||||
$mpnQb->setParameter('mpn', $barcodeScan->supplierPartNumber);
|
||||
|
||||
if($barcodeScan->mouserManufacturer){
|
||||
$manufacturerQb = $this->em->getRepository(Manufacturer::class)->createQueryBuilder("manufacturer");
|
||||
$manufacturerQb->where($manufacturerQb->expr()->like("LOWER(manufacturer.name)", "LOWER(:manufacturer_name)"));
|
||||
$manufacturerQb->setParameter("manufacturer_name", $barcodeScan->mouserManufacturer);
|
||||
$manufacturers = $manufacturerQb->getQuery()->getResult();
|
||||
|
||||
if($manufacturers) {
|
||||
$mpnQb->andWhere($mpnQb->expr()->eq("part.manufacturer", ":manufacturer"));
|
||||
$mpnQb->setParameter("manufacturer", $manufacturers);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$results = $mpnQb->getQuery()->getResult();
|
||||
if($results){
|
||||
return $results[0];
|
||||
}
|
||||
throw new EntityNotFoundException();
|
||||
}
|
||||
}
|
||||
@@ -92,10 +92,19 @@ final class BarcodeScanHelper
|
||||
if ($type === BarcodeSourceType::EIGP114) {
|
||||
return $this->parseEIGP114Barcode($input);
|
||||
}
|
||||
|
||||
if ($type === BarcodeSourceType::GTIN) {
|
||||
return $this->parseGTINBarcode($input);
|
||||
}
|
||||
|
||||
if ($type === BarcodeSourceType::LCSC) {
|
||||
return $this->parseLCSCBarcode($input);
|
||||
}
|
||||
|
||||
if ($type === BarcodeSourceType::AMAZON) {
|
||||
return new AmazonBarcodeScanResult($input);
|
||||
}
|
||||
|
||||
//Null means auto and we try the different formats
|
||||
$result = $this->parseInternalBarcode($input);
|
||||
|
||||
@@ -125,6 +134,16 @@ final class BarcodeScanHelper
|
||||
return $this->parseGTINBarcode($input);
|
||||
}
|
||||
|
||||
// Try LCSC barcode
|
||||
if (LCSCBarcodeScanResult::isLCSCBarcode($input)) {
|
||||
return $this->parseLCSCBarcode($input);
|
||||
}
|
||||
|
||||
//Try amazon barcode
|
||||
if (AmazonBarcodeScanResult::isAmazonBarcode($input)) {
|
||||
return new AmazonBarcodeScanResult($input);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unknown barcode');
|
||||
}
|
||||
|
||||
@@ -138,6 +157,11 @@ final class BarcodeScanHelper
|
||||
return EIGP114BarcodeScanResult::parseFormat06Code($input);
|
||||
}
|
||||
|
||||
private function parseLCSCBarcode(string $input): LCSCBarcodeScanResult
|
||||
{
|
||||
return LCSCBarcodeScanResult::parse($input);
|
||||
}
|
||||
|
||||
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
|
||||
{
|
||||
$lot_repo = $this->entityManager->getRepository(PartLot::class);
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\LabelSystem\BarcodeScanner;
|
||||
|
||||
use App\Entity\LabelSystem\LabelSupportedElement;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Exceptions\InfoProviderNotActiveException;
|
||||
use App\Repository\Parts\PartRepository;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* This class handles the result of a barcode scan and determines further actions, like which URL the user should be redirected to.
|
||||
*
|
||||
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest
|
||||
*/
|
||||
final readonly class BarcodeScanResultHandler
|
||||
{
|
||||
public function __construct(private UrlGeneratorInterface $urlGenerator, private EntityManagerInterface $em, private PartInfoRetriever $infoRetriever,
|
||||
private ProviderRegistry $providerRegistry)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the URL to which the user should be redirected, when scanning a QR code.
|
||||
*
|
||||
* @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan
|
||||
* @return string|null the URL to which should be redirected, or null if no suitable URL could be determined for the given barcode scan result
|
||||
*/
|
||||
public function getInfoURL(BarcodeScanResultInterface $barcodeScan): ?string
|
||||
{
|
||||
//For other barcodes try to resolve the part first and then redirect to the part page
|
||||
$entity = $this->resolveEntity($barcodeScan);
|
||||
|
||||
if ($entity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($entity instanceof Part) {
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getID()]);
|
||||
}
|
||||
|
||||
if ($entity instanceof PartLot) {
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $entity->getPart()->getID(), 'highlightLot' => $entity->getID()]);
|
||||
}
|
||||
|
||||
if ($entity instanceof StorageLocation) {
|
||||
return $this->urlGenerator->generate('part_list_store_location', ['id' => $entity->getID()]);
|
||||
}
|
||||
|
||||
//@phpstan-ignore-next-line This should never happen, since resolveEntity should only return Part, PartLot or StorageLocation
|
||||
throw new \LogicException("Resolved entity is of unknown type: ".get_class($entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a URL to create a new part based on this barcode scan result, if possible.
|
||||
* @param BarcodeScanResultInterface $scanResult
|
||||
* @return string|null
|
||||
* @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system
|
||||
*/
|
||||
public function getCreationURL(BarcodeScanResultInterface $scanResult): ?string
|
||||
{
|
||||
$infos = $this->getCreateInfos($scanResult);
|
||||
if ($infos === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
//Ensure that the provider is active, otherwise we should not generate a creation URL for it
|
||||
$provider = $this->providerRegistry->getProviderByKey($infos['providerKey']);
|
||||
if (!$provider->isActive()) {
|
||||
throw InfoProviderNotActiveException::fromProvider($provider);
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate('info_providers_create_part', ['providerKey' => $infos['providerKey'], 'providerId' => $infos['providerId']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to resolve the given barcode scan result to a local entity. This can be a Part, a PartLot or a StorageLocation, depending on the type of the barcode and the information contained in it.
|
||||
* Returns null if no matching entity could be found.
|
||||
* @param BarcodeScanResultInterface $barcodeScan
|
||||
* @return Part|PartLot|StorageLocation|null
|
||||
*/
|
||||
public function resolveEntity(BarcodeScanResultInterface $barcodeScan): Part|PartLot|StorageLocation|null
|
||||
{
|
||||
if ($barcodeScan instanceof LocalBarcodeScanResult) {
|
||||
return $this->resolvePartFromLocal($barcodeScan);
|
||||
}
|
||||
|
||||
if ($barcodeScan instanceof EIGP114BarcodeScanResult) {
|
||||
return $this->resolvePartFromVendor($barcodeScan);
|
||||
}
|
||||
|
||||
if ($barcodeScan instanceof GTINBarcodeScanResult) {
|
||||
return $this->em->getRepository(Part::class)->findOneBy(['gtin' => $barcodeScan->gtin]);
|
||||
}
|
||||
|
||||
if ($barcodeScan instanceof LCSCBarcodeScanResult) {
|
||||
return $this->resolvePartFromLCSC($barcodeScan);
|
||||
}
|
||||
|
||||
if ($barcodeScan instanceof AmazonBarcodeScanResult) {
|
||||
return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin)
|
||||
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to resolve a Part from the given barcode scan result. Returns null if no part could be found for the given barcode,
|
||||
* or the barcode doesn't contain information allowing to resolve to a local part.
|
||||
* @param BarcodeScanResultInterface $barcodeScan
|
||||
* @return Part|null
|
||||
* @throws \InvalidArgumentException if the barcode scan result type is unknown and cannot be handled this function
|
||||
*/
|
||||
public function resolvePart(BarcodeScanResultInterface $barcodeScan): ?Part
|
||||
{
|
||||
$entity = $this->resolveEntity($barcodeScan);
|
||||
if ($entity instanceof Part) {
|
||||
return $entity;
|
||||
}
|
||||
if ($entity instanceof PartLot) {
|
||||
return $entity->getPart();
|
||||
}
|
||||
//Storage locations are not associated with a specific part, so we cannot resolve a part for
|
||||
//a storage location barcode
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolvePartFromLocal(LocalBarcodeScanResult $barcodeScan): Part|PartLot|StorageLocation|null
|
||||
{
|
||||
return match ($barcodeScan->target_type) {
|
||||
LabelSupportedElement::PART => $this->em->find(Part::class, $barcodeScan->target_id),
|
||||
LabelSupportedElement::PART_LOT => $this->em->find(PartLot::class, $barcodeScan->target_id),
|
||||
LabelSupportedElement::STORELOCATION => $this->em->find(StorageLocation::class, $barcodeScan->target_id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a part from a scan of a Vendor Barcode by filtering for parts
|
||||
* with the same Info Provider Id or, if that fails, by looking for parts with a
|
||||
* matching manufacturer product number. Only returns the first matching part.
|
||||
*/
|
||||
private function resolvePartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : ?Part
|
||||
{
|
||||
// first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via
|
||||
// the info provider system or if the part was bought from a different vendor than the data was retrieved
|
||||
// from.
|
||||
if($barcodeScan->digikeyPartNumber) {
|
||||
|
||||
$part = $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->digikeyPartNumber);
|
||||
if ($part !== null) {
|
||||
return $part;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$barcodeScan->supplierPartNumber){
|
||||
return null;
|
||||
}
|
||||
|
||||
//Fallback to the manufacturer part number. This may return false positives, since it is common for
|
||||
//multiple manufacturers to use the same part number for their version of a common product
|
||||
//We assume the user is able to realize when this returns the wrong part
|
||||
//If the barcode specifies the manufacturer we try to use that as well
|
||||
|
||||
return $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->supplierPartNumber, $barcodeScan->mouserManufacturer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve LCSC barcode -> Part.
|
||||
* Strategy:
|
||||
* 1) Try providerReference.provider_id == pc (LCSC "Cxxxxxx") if you store it there
|
||||
* 2) Fallback to manufacturer_product_number == pm (MPN)
|
||||
* Returns first match (consistent with EIGP114 logic)
|
||||
*/
|
||||
private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part
|
||||
{
|
||||
// Try LCSC code (pc) as provider id if available
|
||||
$pc = $barcodeScan->lcscCode; // e.g. C138033
|
||||
if ($pc) {
|
||||
$part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pc);
|
||||
if ($part !== null) {
|
||||
return $part;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to MPN (pm)
|
||||
$pm = $barcodeScan->mpn; // e.g. RC0402FR-071ML
|
||||
if (!$pm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->em->getRepository(Part::class)->getPartByMPN($pm);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tries to extract creation information for a part from the given barcode scan result. This can be used to
|
||||
* automatically fill in the info provider reference of a part, when creating a new part based on the scan result.
|
||||
* Returns null if no provider information could be extracted from the scan result, or if the scan result type is unknown and cannot be handled by this function.
|
||||
* It is not necessarily checked that the provider is active, or that the result actually exists on the provider side.
|
||||
* @param BarcodeScanResultInterface $scanResult
|
||||
* @return array{providerKey: string, providerId: string}|null
|
||||
* @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system
|
||||
*/
|
||||
public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array
|
||||
{
|
||||
// LCSC
|
||||
if ($scanResult instanceof LCSCBarcodeScanResult) {
|
||||
return [
|
||||
'providerKey' => 'lcsc',
|
||||
'providerId' => $scanResult->lcscCode,
|
||||
];
|
||||
}
|
||||
|
||||
if ($scanResult instanceof EIGP114BarcodeScanResult) {
|
||||
return $this->getCreationInfoForEIGP114($scanResult);
|
||||
}
|
||||
|
||||
if ($scanResult instanceof AmazonBarcodeScanResult) {
|
||||
return [
|
||||
'providerKey' => 'canopy',
|
||||
'providerId' => $scanResult->asin,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EIGP114BarcodeScanResult $scanResult
|
||||
* @return array{providerKey: string, providerId: string}|null
|
||||
*/
|
||||
private function getCreationInfoForEIGP114(EIGP114BarcodeScanResult $scanResult): ?array
|
||||
{
|
||||
$vendor = $scanResult->guessBarcodeVendor();
|
||||
|
||||
// Mouser: use supplierPartNumber -> search provider -> provider_id
|
||||
if ($vendor === 'mouser' && $scanResult->supplierPartNumber !== null
|
||||
) {
|
||||
// Search Mouser using the MPN
|
||||
$dtos = $this->infoRetriever->searchByKeyword(
|
||||
keyword: $scanResult->supplierPartNumber,
|
||||
providers: ["mouser"]
|
||||
);
|
||||
|
||||
// If there are results, provider_id is MouserPartNumber (per MouserProvider.php)
|
||||
$best = $dtos[0] ?? null;
|
||||
|
||||
if ($best !== null) {
|
||||
return [
|
||||
'providerKey' => 'mouser',
|
||||
'providerId' => $best->provider_id,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Digi-Key: can use customerPartNumber or supplierPartNumber directly
|
||||
if ($vendor === 'digikey') {
|
||||
return [
|
||||
'providerKey' => 'digikey',
|
||||
'providerId' => $scanResult->customerPartNumber ?? $scanResult->supplierPartNumber,
|
||||
];
|
||||
}
|
||||
|
||||
// Element14: can use supplierPartNumber directly
|
||||
if ($vendor === 'element14') {
|
||||
return [
|
||||
'providerKey' => 'element14',
|
||||
'providerId' => $scanResult->supplierPartNumber,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -33,4 +33,4 @@ interface BarcodeScanResultInterface
|
||||
* @return array<string, string|int|float|null>
|
||||
*/
|
||||
public function getDecodedForInfoMode(): array;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,25 +26,30 @@ namespace App\Services\LabelSystem\BarcodeScanner;
|
||||
/**
|
||||
* This enum represents the different types, where a barcode/QR-code can be generated from
|
||||
*/
|
||||
enum BarcodeSourceType
|
||||
enum BarcodeSourceType: string
|
||||
{
|
||||
/** This Barcode was generated using Part-DB internal recommended barcode generator */
|
||||
case INTERNAL;
|
||||
case INTERNAL = 'internal';
|
||||
/** This barcode is containing an internal part number (IPN) */
|
||||
case IPN;
|
||||
case IPN = 'ipn';
|
||||
|
||||
/**
|
||||
* This barcode is a user defined barcode defined on a part lot
|
||||
*/
|
||||
case USER_DEFINED;
|
||||
case USER_DEFINED = 'user';
|
||||
|
||||
/**
|
||||
* EIGP114 formatted barcodes like used by digikey, mouser, etc.
|
||||
*/
|
||||
case EIGP114;
|
||||
case EIGP114 = 'eigp';
|
||||
|
||||
/**
|
||||
* GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part.
|
||||
*/
|
||||
case GTIN;
|
||||
case GTIN = 'gtin';
|
||||
|
||||
/** For LCSC.com formatted QR codes */
|
||||
case LCSC = 'lcsc';
|
||||
|
||||
case AMAZON = 'amazon';
|
||||
}
|
||||
|
||||
@@ -28,40 +28,40 @@ namespace App\Services\LabelSystem\BarcodeScanner;
|
||||
* Based on PR 811, EIGP 114.2018 (https://www.ecianow.org/assets/docs/GIPC/EIGP-114.2018%20ECIA%20Labeling%20Specification%20for%20Product%20and%20Shipment%20Identification%20in%20the%20Electronics%20Industry%20-%202D%20Barcode.pdf),
|
||||
* , https://forum.digikey.com/t/digikey-product-labels-decoding-digikey-barcodes/41097
|
||||
*/
|
||||
class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
{
|
||||
|
||||
/**
|
||||
* @var string|null Ship date in format YYYYMMDD
|
||||
*/
|
||||
public readonly ?string $shipDate;
|
||||
public ?string $shipDate;
|
||||
|
||||
/**
|
||||
* @var string|null Customer assigned part number – Optional based on
|
||||
* agreements between Distributor and Supplier
|
||||
*/
|
||||
public readonly ?string $customerPartNumber;
|
||||
public ?string $customerPartNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Supplier assigned part number
|
||||
*/
|
||||
public readonly ?string $supplierPartNumber;
|
||||
public ?string $supplierPartNumber;
|
||||
|
||||
/**
|
||||
* @var int|null Quantity of product
|
||||
*/
|
||||
public readonly ?int $quantity;
|
||||
public ?int $quantity;
|
||||
|
||||
/**
|
||||
* @var string|null Customer assigned purchase order number
|
||||
*/
|
||||
public readonly ?string $customerPO;
|
||||
public ?string $customerPO;
|
||||
|
||||
/**
|
||||
* @var string|null Line item number from PO. Required on Logistic Label when
|
||||
* used on back of Packing Slip. See Section 4.9
|
||||
*/
|
||||
public readonly ?string $customerPOLine;
|
||||
public ?string $customerPOLine;
|
||||
|
||||
/**
|
||||
* 9D - YYWW (Year and Week of Manufacture). ) If no date code is used
|
||||
@@ -69,7 +69,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
* to indicate the product is Not Traceable by this data field.
|
||||
* @var string|null
|
||||
*/
|
||||
public readonly ?string $dateCode;
|
||||
public ?string $dateCode;
|
||||
|
||||
/**
|
||||
* 10D - YYWW (Year and Week of Manufacture). ) If no date code is used
|
||||
@@ -77,7 +77,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
* to indicate the product is Not Traceable by this data field.
|
||||
* @var string|null
|
||||
*/
|
||||
public readonly ?string $alternativeDateCode;
|
||||
public ?string $alternativeDateCode;
|
||||
|
||||
/**
|
||||
* Traceability number assigned to a batch or group of items. If
|
||||
@@ -86,14 +86,14 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
* by this data field.
|
||||
* @var string|null
|
||||
*/
|
||||
public readonly ?string $lotCode;
|
||||
public ?string $lotCode;
|
||||
|
||||
/**
|
||||
* Country where part was manufactured. Two-letter code from
|
||||
* ISO 3166 country code list
|
||||
* @var string|null
|
||||
*/
|
||||
public readonly ?string $countryOfOrigin;
|
||||
public ?string $countryOfOrigin;
|
||||
|
||||
/**
|
||||
* @var string|null Unique alphanumeric number assigned by supplier
|
||||
@@ -101,85 +101,85 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
* Carton. Always used in conjunction with a mixed logistic label
|
||||
* with a 5S data identifier for Package ID.
|
||||
*/
|
||||
public readonly ?string $packageId1;
|
||||
public ?string $packageId1;
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
* 4S - Package ID for Logistic Carton with like items
|
||||
*/
|
||||
public readonly ?string $packageId2;
|
||||
public ?string $packageId2;
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
* 5S - Package ID for Logistic Carton with mixed items
|
||||
*/
|
||||
public readonly ?string $packageId3;
|
||||
public ?string $packageId3;
|
||||
|
||||
/**
|
||||
* @var string|null Unique alphanumeric number assigned by supplier.
|
||||
*/
|
||||
public readonly ?string $packingListNumber;
|
||||
public ?string $packingListNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Ship date in format YYYYMMDD
|
||||
*/
|
||||
public readonly ?string $serialNumber;
|
||||
public ?string $serialNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Code for sorting and classifying LEDs. Use when applicable
|
||||
*/
|
||||
public readonly ?string $binCode;
|
||||
public ?string $binCode;
|
||||
|
||||
/**
|
||||
* @var int|null Sequential carton count in format “#/#” or “# of #”
|
||||
*/
|
||||
public readonly ?int $packageCount;
|
||||
public ?int $packageCount;
|
||||
|
||||
/**
|
||||
* @var string|null Alphanumeric string assigned by the supplier to distinguish
|
||||
* from one closely-related design variation to another. Use as
|
||||
* required or when applicable
|
||||
*/
|
||||
public readonly ?string $revisionNumber;
|
||||
public ?string $revisionNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey Extension: This is not represented in the ECIA spec, but the field being used is found in the ANSI MH10.8.2-2016 spec on which the ECIA spec is based. In the ANSI spec it is called First Level (Supplier Assigned) Part Number.
|
||||
*/
|
||||
public readonly ?string $digikeyPartNumber;
|
||||
public ?string $digikeyPartNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey Extension: This can be shared across multiple invoices and time periods and is generated as an order enters our system from any vector (web, API, phone order, etc.)
|
||||
*/
|
||||
public readonly ?string $digikeySalesOrderNumber;
|
||||
public ?string $digikeySalesOrderNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey extension: This is typically assigned per shipment as items are being released to be picked in the warehouse. A SO can have many Invoice numbers
|
||||
*/
|
||||
public readonly ?string $digikeyInvoiceNumber;
|
||||
public ?string $digikeyInvoiceNumber;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey extension: This is for internal DigiKey purposes and defines the label type.
|
||||
*/
|
||||
public readonly ?string $digikeyLabelType;
|
||||
public ?string $digikeyLabelType;
|
||||
|
||||
/**
|
||||
* @var string|null You will also see this as the last part of a URL for a product detail page. Ex https://www.digikey.com/en/products/detail/w%C3%BCrth-elektronik/860010672008/5726907
|
||||
*/
|
||||
public readonly ?string $digikeyPartID;
|
||||
public ?string $digikeyPartID;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey Extension: For internal use of Digikey. Probably not needed
|
||||
*/
|
||||
public readonly ?string $digikeyNA;
|
||||
public ?string $digikeyNA;
|
||||
|
||||
/**
|
||||
* @var string|null Digikey Extension: This is a field of varying length used to keep the barcode approximately the same size between labels. It is safe to ignore.
|
||||
*/
|
||||
public readonly ?string $digikeyPadding;
|
||||
public ?string $digikeyPadding;
|
||||
|
||||
public readonly ?string $mouserPositionInOrder;
|
||||
public ?string $mouserPositionInOrder;
|
||||
|
||||
public readonly ?string $mouserManufacturer;
|
||||
public ?string $mouserManufacturer;
|
||||
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
*
|
||||
* @param array<string, string> $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content
|
||||
*/
|
||||
public function __construct(public readonly array $data)
|
||||
public function __construct(public array $data)
|
||||
{
|
||||
//IDs per EIGP 114.2018
|
||||
$this->shipDate = $data['6D'] ?? null;
|
||||
@@ -329,4 +329,4 @@ class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\LabelSystem\BarcodeScanner;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* This class represents the content of a lcsc.com barcode
|
||||
* Its data structure is represented by {pbn:...,on:...,pc:...,pm:...,qty:...}
|
||||
*/
|
||||
readonly class LCSCBarcodeScanResult implements BarcodeScanResultInterface
|
||||
{
|
||||
|
||||
/** @var string|null (pbn) */
|
||||
public ?string $pickBatchNumber;
|
||||
|
||||
/** @var string|null (on) */
|
||||
public ?string $orderNumber;
|
||||
|
||||
/** @var string|null LCSC Supplier part number (pc) */
|
||||
public ?string $lcscCode;
|
||||
|
||||
/** @var string|null (pm) */
|
||||
public ?string $mpn;
|
||||
|
||||
/** @var int|null (qty) */
|
||||
public ?int $quantity;
|
||||
|
||||
/** @var string|null Country Channel as raw value (CC) */
|
||||
public ?string $countryChannel;
|
||||
|
||||
/**
|
||||
* @var string|null Warehouse code as raw value (WC)
|
||||
*/
|
||||
public ?string $warehouseCode;
|
||||
|
||||
/**
|
||||
* @var string|null Unknown numeric code (pdi)
|
||||
*/
|
||||
public ?string $pdi;
|
||||
|
||||
/**
|
||||
* @var string|null Unknown value (hp)
|
||||
*/
|
||||
public ?string $hp;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $fields
|
||||
*/
|
||||
public function __construct(
|
||||
public array $fields,
|
||||
public string $rawInput,
|
||||
) {
|
||||
|
||||
$this->pickBatchNumber = $this->fields['pbn'] ?? null;
|
||||
$this->orderNumber = $this->fields['on'] ?? null;
|
||||
$this->lcscCode = $this->fields['pc'] ?? null;
|
||||
$this->mpn = $this->fields['pm'] ?? null;
|
||||
$this->quantity = isset($this->fields['qty']) ? (int)$this->fields['qty'] : null;
|
||||
$this->countryChannel = $this->fields['cc'] ?? null;
|
||||
$this->warehouseCode = $this->fields['wc'] ?? null;
|
||||
$this->pdi = $this->fields['pdi'] ?? null;
|
||||
$this->hp = $this->fields['hp'] ?? null;
|
||||
|
||||
}
|
||||
|
||||
public function getSourceType(): BarcodeSourceType
|
||||
{
|
||||
return BarcodeSourceType::LCSC;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|float[]|int[]|null[]|string[] An array of fields decoded from the barcode
|
||||
*/
|
||||
public function getDecodedForInfoMode(): array
|
||||
{
|
||||
// Keep it human-friendly
|
||||
return [
|
||||
'Barcode type' => 'LCSC',
|
||||
'MPN (pm)' => $this->mpn ?? '',
|
||||
'LCSC code (pc)' => $this->lcscCode ?? '',
|
||||
'Qty' => $this->quantity !== null ? (string) $this->quantity : '',
|
||||
'Order No (on)' => $this->orderNumber ?? '',
|
||||
'Pick Batch (pbn)' => $this->pickBatchNumber ?? '',
|
||||
'Warehouse (wc)' => $this->warehouseCode ?? '',
|
||||
'Country/Channel (cc)' => $this->countryChannel ?? '',
|
||||
'PDI (unknown meaning)' => $this->pdi ?? '',
|
||||
'HP (unknown meaning)' => $this->hp ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the barcode data to see if the input matches the expected format used by lcsc.com
|
||||
* @param string $input
|
||||
* @return bool
|
||||
*/
|
||||
public static function isLCSCBarcode(string $input): bool
|
||||
{
|
||||
$s = trim($input);
|
||||
|
||||
// Your example: {pbn:...,on:...,pc:...,pm:...,qty:...}
|
||||
if (!str_starts_with($s, '{') || !str_ends_with($s, '}')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must contain at least pm: and pc: (common for LCSC labels)
|
||||
return (stripos($s, 'pm:') !== false) && (stripos($s, 'pc:') !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the barcode input string into the fields used by lcsc.com
|
||||
* @param string $input
|
||||
* @return self
|
||||
*/
|
||||
public static function parse(string $input): self
|
||||
{
|
||||
$raw = trim($input);
|
||||
|
||||
if (!self::isLCSCBarcode($raw)) {
|
||||
throw new InvalidArgumentException('Not an LCSC barcode');
|
||||
}
|
||||
|
||||
$inner = substr($raw, 1, -1); // remove { }
|
||||
|
||||
$fields = [];
|
||||
|
||||
// This format is comma-separated pairs, values do not contain commas in your sample.
|
||||
$pairs = array_filter(
|
||||
array_map(trim(...), explode(',', $inner)),
|
||||
static fn(string $s): bool => $s !== ''
|
||||
);
|
||||
|
||||
foreach ($pairs as $pair) {
|
||||
$pos = strpos($pair, ':');
|
||||
if ($pos === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$k = trim(substr($pair, 0, $pos));
|
||||
$v = trim(substr($pair, $pos + 1));
|
||||
|
||||
if ($k === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields[$k] = $v;
|
||||
}
|
||||
|
||||
if (!isset($fields['pm']) || trim($fields['pm']) === '') {
|
||||
throw new InvalidArgumentException('LCSC barcode missing pm field');
|
||||
}
|
||||
|
||||
return new self($fields, $raw);
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,12 @@ use App\Entity\LabelSystem\LabelSupportedElement;
|
||||
* This class represents the result of a barcode scan of a barcode that uniquely identifies a local entity,
|
||||
* like an internally generated barcode or a barcode that was added manually to the system by a user
|
||||
*/
|
||||
class LocalBarcodeScanResult implements BarcodeScanResultInterface
|
||||
readonly class LocalBarcodeScanResult implements BarcodeScanResultInterface
|
||||
{
|
||||
public function __construct(
|
||||
public readonly LabelSupportedElement $target_type,
|
||||
public readonly int $target_id,
|
||||
public readonly BarcodeSourceType $source_type,
|
||||
public LabelSupportedElement $target_type,
|
||||
public int $target_id,
|
||||
public BarcodeSourceType $source_type,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -46,4 +46,4 @@ class LocalBarcodeScanResult implements BarcodeScanResultInterface
|
||||
'Target ID' => $this->target_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ final class LabelHTMLGenerator
|
||||
'paper_height' => $options->getHeight(),
|
||||
]
|
||||
);
|
||||
} catch (Error $exception) {
|
||||
} catch (\Throwable $exception) {
|
||||
throw new TwigModeException($exception);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -70,12 +70,14 @@ use App\Twig\Sandbox\SandboxedLabelExtension;
|
||||
use App\Twig\TwigCoreExtension;
|
||||
use InvalidArgumentException;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\AttributeExtension;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
use Twig\Extra\Html\HtmlExtension;
|
||||
use Twig\Extra\Intl\IntlExtension;
|
||||
use Twig\Extra\Markdown\MarkdownExtension;
|
||||
use Twig\Extra\String\StringExtension;
|
||||
use Twig\Loader\ArrayLoader;
|
||||
use Twig\RuntimeLoader\FactoryRuntimeLoader;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
@@ -84,11 +86,11 @@ use Twig\Sandbox\SecurityPolicyInterface;
|
||||
*/
|
||||
final class SandboxedTwigFactory
|
||||
{
|
||||
private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'verbatim', 'with'];
|
||||
private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'types', 'verbatim', 'with'];
|
||||
private const ALLOWED_FILTERS = ['abs', 'batch', 'capitalize', 'column', 'country_name',
|
||||
'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'first', 'format',
|
||||
'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'find', 'first', 'format',
|
||||
'format_currency', 'format_date', 'format_datetime', 'format_number', 'format_time', 'html_to_markdown', 'join', 'keys',
|
||||
'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'raw', 'number_format',
|
||||
'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'number_format', 'raw',
|
||||
'reduce', 'replace', 'reverse', 'round', 'slice', 'slug', 'sort', 'spaceless', 'split', 'striptags', 'timezone_name', 'title',
|
||||
'trim', 'u', 'upper', 'url_encode',
|
||||
|
||||
@@ -102,16 +104,17 @@ final class SandboxedTwigFactory
|
||||
];
|
||||
|
||||
private const ALLOWED_FUNCTIONS = ['country_names', 'country_timezones', 'currency_names', 'cycle',
|
||||
'date', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names',
|
||||
'template_from_string', 'timezone_names',
|
||||
'date', 'enum', 'enum_cases', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names',
|
||||
'timezone_names',
|
||||
|
||||
//Part-DB specific extensions:
|
||||
//EntityExtension:
|
||||
'entity_type', 'entity_url',
|
||||
'entity_type', 'entity_url', 'type_label', 'type_label_plural',
|
||||
//BarcodeExtension:
|
||||
'barcode_svg',
|
||||
//SandboxedLabelExtension
|
||||
'placeholder',
|
||||
'associated_parts', 'associated_parts_count', 'associated_parts_r', 'associated_parts_count_r',
|
||||
];
|
||||
|
||||
private const ALLOWED_METHODS = [
|
||||
@@ -128,7 +131,7 @@ final class SandboxedTwigFactory
|
||||
'getValueTypical', 'getUnit', 'getValueText', ],
|
||||
MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'],
|
||||
PartLot::class => ['isExpired', 'getDescription', 'getComment', 'getExpirationDate', 'getStorageLocation',
|
||||
'getPart', 'isInstockUnknown', 'getAmount', 'getNeedsRefill', 'getVendorBarcode'],
|
||||
'getPart', 'isInstockUnknown', 'getAmount', 'getOwner', 'getLastStocktakeAt', 'getNeedsRefill', 'getVendorBarcode'],
|
||||
StorageLocation::class => ['isFull', 'isOnlySinglePart', 'isLimitToExistingParts', 'getStorageType'],
|
||||
Supplier::class => ['getShippingCosts', 'getDefaultCurrency'],
|
||||
Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getIpn', 'getProviderReference',
|
||||
@@ -139,13 +142,13 @@ final class SandboxedTwigFactory
|
||||
'getParameters', 'getGroupedParameters',
|
||||
'isProjectBuildPart', 'getBuiltProject',
|
||||
'getAssociatedPartsAsOwner', 'getAssociatedPartsAsOther', 'getAssociatedPartsAll',
|
||||
'getEdaInfo'
|
||||
'getEdaInfo', 'getGtin'
|
||||
],
|
||||
Currency::class => ['getIsoCode', 'getInverseExchangeRate', 'getExchangeRate'],
|
||||
Orderdetail::class => ['getPart', 'getSupplier', 'getSupplierPartNr', 'getObsolete',
|
||||
'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl'],
|
||||
'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl', 'getPricesIncludesVAT'],
|
||||
Pricedetail::class => ['getOrderdetail', 'getPrice', 'getPricePerUnit', 'getPriceRelatedQuantity',
|
||||
'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode'],
|
||||
'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode', 'getIncludesVat'],
|
||||
InfoProviderReference:: class => ['getProviderKey', 'getProviderId', 'getProviderUrl', 'getLastUpdated', 'isProviderCreated'],
|
||||
PartAssociation::class => ['getType', 'getComment', 'getOwner', 'getOther', 'getOtherType'],
|
||||
|
||||
@@ -186,13 +189,18 @@ final class SandboxedTwigFactory
|
||||
$twig->addExtension(new StringExtension());
|
||||
$twig->addExtension(new HtmlExtension());
|
||||
|
||||
//Add Part-DB specific extension
|
||||
$twig->addExtension($this->formatExtension);
|
||||
$twig->addExtension($this->barcodeExtension);
|
||||
$twig->addExtension($this->entityExtension);
|
||||
$twig->addExtension($this->twigCoreExtension);
|
||||
$twig->addExtension($this->sandboxedLabelExtension);
|
||||
|
||||
//Our other extensions are using attributes, we need a bit more work to load them
|
||||
$dynamicExtensions = [$this->formatExtension, $this->barcodeExtension, $this->entityExtension, $this->twigCoreExtension];
|
||||
$dynamicExtensionsMap = [];
|
||||
|
||||
foreach ($dynamicExtensions as $extension) {
|
||||
$twig->addExtension(new AttributeExtension($extension::class));
|
||||
$dynamicExtensionsMap[$extension::class] = static fn () => $extension;
|
||||
}
|
||||
$twig->addRuntimeLoader(new FactoryRuntimeLoader($dynamicExtensionsMap));
|
||||
|
||||
return $twig;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class LogDiffFormatter
|
||||
* @param $old_data
|
||||
* @param $new_data
|
||||
*/
|
||||
public function formatDiff($old_data, $new_data): string
|
||||
public function formatDiff(mixed $old_data, mixed $new_data): string
|
||||
{
|
||||
if (is_string($old_data) && is_string($new_data)) {
|
||||
return $this->diffString($old_data, $new_data);
|
||||
|
||||
@@ -127,6 +127,15 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
||||
);
|
||||
}
|
||||
|
||||
if ($action === 'batch_edit_eda') {
|
||||
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->generate('batch_eda_edit', [
|
||||
'ids' => $ids,
|
||||
'_redirect' => $redirect_url
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
//Iterate over the parts and apply the action to it:
|
||||
foreach ($selected_parts as $part) {
|
||||
|
||||
@@ -22,6 +22,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
@@ -334,7 +336,7 @@ readonly class BackupManager
|
||||
$params = $connection->getParams();
|
||||
$platform = $connection->getDatabasePlatform();
|
||||
|
||||
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
|
||||
if ($platform instanceof AbstractMySQLPlatform) {
|
||||
// Use mysql command to import - need to use shell to handle input redirection
|
||||
$mysqlCmd = 'mysql';
|
||||
if (isset($params['host'])) {
|
||||
@@ -361,7 +363,7 @@ readonly class BackupManager
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput());
|
||||
}
|
||||
} elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
} elseif ($platform instanceof PostgreSQLPlatform) {
|
||||
// Use psql command to import
|
||||
$psqlCmd = 'psql';
|
||||
if (isset($params['host'])) {
|
||||
|
||||
@@ -41,4 +41,7 @@ class BehaviorSettings
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?PartInfoSettings $partInfo = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?KeybindingsSettings $keybindings = null;
|
||||
}
|
||||
|
||||
47
src/Settings/BehaviorSettings/KeybindingsSettings.php
Normal file
47
src/Settings/BehaviorSettings/KeybindingsSettings.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Settings\BehaviorSettings;
|
||||
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
|
||||
#[Settings(name: "keybindings", label: new TM("settings.behavior.keybindings"))]
|
||||
#[SettingsIcon('fa-keyboard')]
|
||||
class KeybindingsSettings
|
||||
{
|
||||
/**
|
||||
* Whether to enable special character keybindings (Alt+key) in text input fields
|
||||
* @var bool
|
||||
*/
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.behavior.keybindings.enable_special_characters"),
|
||||
description: new TM("settings.behavior.keybindings.enable_special_characters.help"),
|
||||
envVar: "bool:KEYBINDINGS_SPECIAL_CHARS_ENABLED",
|
||||
envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public bool $enableSpecialCharacters = true;
|
||||
}
|
||||
@@ -51,6 +51,13 @@ enum PartTableColumns : string implements TranslatableInterface
|
||||
case GTIN = "gtin";
|
||||
case TAGS = "tags";
|
||||
case ATTACHMENTS = "attachments";
|
||||
|
||||
case EDA_REFERENCE = "eda_reference";
|
||||
|
||||
case EDA_VALUE = "eda_value";
|
||||
|
||||
case EDA_STATUS = "eda_status";
|
||||
|
||||
case EDIT = "edit";
|
||||
|
||||
public function trans(TranslatorInterface $translator, ?string $locale = null): string
|
||||
|
||||
96
src/Settings/InfoProviderSystem/CanopySettings.php
Normal file
96
src/Settings/InfoProviderSystem/CanopySettings.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?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\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Form\Type\APIKeyType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(label: new TM("settings.ips.canopy"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class CanopySettings
|
||||
{
|
||||
public const ALLOWED_DOMAINS = [
|
||||
"amazon.de" => "DE",
|
||||
"amazon.com" => "US",
|
||||
"amazon.co.uk" => "UK",
|
||||
"amazon.fr" => "FR",
|
||||
"amazon.it" => "IT",
|
||||
"amazon.es" => "ES",
|
||||
"amazon.ca" => "CA",
|
||||
"amazon.com.au" => "AU",
|
||||
"amazon.com.br" => "BR",
|
||||
"amazon.com.mx" => "MX",
|
||||
"amazon.in" => "IN",
|
||||
"amazon.co.jp" => "JP",
|
||||
"amazon.nl" => "NL",
|
||||
"amazon.pl" => "PL",
|
||||
"amazon.sa" => "SA",
|
||||
"amazon.sg" => "SG",
|
||||
"amazon.se" => "SE",
|
||||
"amazon.com.tr" => "TR",
|
||||
"amazon.ae" => "AE",
|
||||
"amazon.com.be" => "BE",
|
||||
"amazon.com.cn" => "CN",
|
||||
];
|
||||
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"),
|
||||
formType: APIKeyType::class,
|
||||
formOptions: ["help_html" => true], envVar: "PROVIDER_CANOPY_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
public ?string $apiKey = null;
|
||||
|
||||
/**
|
||||
* @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant
|
||||
*/
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])]
|
||||
public string $domain = "DE";
|
||||
|
||||
/**
|
||||
* @var bool If true, the provider will always retrieve details for a part, resulting in an additional API request
|
||||
*/
|
||||
#[SettingsParameter(label: new TM("settings.ips.canopy.alwaysGetDetails"), description: new TM("settings.ips.canopy.alwaysGetDetails.help"))]
|
||||
public bool $alwaysGetDetails = false;
|
||||
|
||||
/**
|
||||
* Returns the real domain (e.g. amazon.de) based on the selected domain (e.g. DE)
|
||||
* @return string
|
||||
*/
|
||||
public function getRealDomain(): string
|
||||
{
|
||||
$domain = array_search($this->domain, self::ALLOWED_DOMAINS, true);
|
||||
if ($domain === false) {
|
||||
throw new \InvalidArgumentException("Invalid domain selected");
|
||||
}
|
||||
return $domain;
|
||||
}
|
||||
}
|
||||
@@ -72,4 +72,7 @@ class InfoProviderSettings
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?ConradSettings $conrad = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?CanopySettings $canopy = null;
|
||||
}
|
||||
|
||||
@@ -43,4 +43,23 @@ class KiCadEDASettings
|
||||
envVar: "int:EDA_KICAD_CATEGORY_DEPTH", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Range(min: -1)]
|
||||
public int $categoryDepth = 0;
|
||||
}
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.misc.kicad_eda.datasheet_link"),
|
||||
description: new TM("settings.misc.kicad_eda.datasheet_link.help")
|
||||
)]
|
||||
public ?bool $datasheetAsPdf = true;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.kicad_eda.default_parameter_visibility"),
|
||||
description: new TM("settings.misc.kicad_eda.default_parameter_visibility.help"),
|
||||
|
||||
)]
|
||||
public bool $defaultParameterVisibility = false;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.kicad_eda.default_orderdetails_visibility"),
|
||||
description: new TM("settings.misc.kicad_eda.default_orderdetails_visibility.help"),
|
||||
|
||||
)]
|
||||
public bool $defaultOrderdetailsVisibility = false;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user