Merge tag 'v2.12.0' into order-details

# Conflicts:
#	src/Entity/Parts/Part.php
#	src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php
#	src/Services/LabelSystem/SandboxedTwigFactory.php
This commit is contained in:
Fabian Wunsch 2026-06-03 07:34:43 +02:00
commit a1c26de9ae
727 changed files with 78440 additions and 57852 deletions

View file

@ -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

View file

@ -26,6 +26,28 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
composer install --prefer-dist --no-progress --no-interaction
fi
# Install additional composer packages if COMPOSER_EXTRA_PACKAGES is set
if [ -n "$COMPOSER_EXTRA_PACKAGES" ]; then
echo "Installing additional composer packages: $COMPOSER_EXTRA_PACKAGES"
# Note: COMPOSER_EXTRA_PACKAGES is intentionally not quoted to allow word splitting
# This enables passing multiple package names separated by spaces
# shellcheck disable=SC2086
composer require $COMPOSER_EXTRA_PACKAGES --no-install --no-interaction --no-progress
if [ $? -eq 0 ]; then
echo "Running composer install to install packages without dev dependencies..."
composer install --no-dev --no-interaction --no-progress --optimize-autoloader
if [ $? -eq 0 ]; then
echo "Successfully installed additional composer packages"
else
echo "Failed to install composer dependencies"
exit 1
fi
else
echo "Failed to add additional composer packages to composer.json"
exit 1
fi
fi
if grep -q ^DATABASE_URL= .env; then
echo "Waiting for database to be ready..."
ATTEMPTS_LEFT_TO_REACH_DATABASE=60

View file

@ -1,4 +1,3 @@
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}

View file

@ -39,6 +39,28 @@ if [ -d /var/www/html/var/db ]; then
fi
fi
# Install additional composer packages if COMPOSER_EXTRA_PACKAGES is set
if [ -n "$COMPOSER_EXTRA_PACKAGES" ]; then
echo "Installing additional composer packages: $COMPOSER_EXTRA_PACKAGES"
# Note: COMPOSER_EXTRA_PACKAGES is intentionally not quoted to allow word splitting
# This enables passing multiple package names separated by spaces
# shellcheck disable=SC2086
sudo -E -u www-data composer require $COMPOSER_EXTRA_PACKAGES --no-install --no-interaction --no-progress
if [ $? -eq 0 ]; then
echo "Running composer install to install packages without dev dependencies..."
sudo -E -u www-data composer install --no-dev --no-interaction --no-progress --optimize-autoloader
if [ $? -eq 0 ]; then
echo "Successfully installed additional composer packages"
else
echo "Failed to install composer dependencies"
exit 1
fi
else
echo "Failed to add additional composer packages to composer.json"
exit 1
fi
fi
# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
php-fpmPHP_VERSION -F &

38
.env
View file

@ -31,8 +31,7 @@ DATABASE_EMULATE_NATURAL_SORT=0
# General settings
###################################################################################
# The public reachable URL of this Part-DB installation. This is used for generating links in SAML and email templates
# This must end with a slash!
# The public reachable URL of this Part-DB installation. This is used for generating links in SAML and email templates or when no request context is available.
DEFAULT_URI="https://partdb.changeme.invalid/"
###################################################################################
@ -60,6 +59,28 @@ ERROR_PAGE_ADMIN_EMAIL=''
# If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them...
ERROR_PAGE_SHOW_HELP=1
###################################################################################
# Update Manager settings
###################################################################################
# Disable web-based updates from the Update Manager UI (0=enabled, 1=disabled).
# When disabled, use the CLI command "php bin/console partdb:update" instead.
DISABLE_WEB_UPDATES=1
# Disable backup restore from the Update Manager UI (0=enabled, 1=disabled).
# Restoring backups is a destructive operation that could overwrite your database.
DISABLE_BACKUP_RESTORE=1
# Disable backup download from the Update Manager UI (0=enabled, 1=disabled).
# Backups contain sensitive data including password hashes and secrets.
# When enabled, users must confirm their password before downloading.
DISABLE_BACKUP_DOWNLOAD=1
# Watchtower integration for Docker-based updates.
# Set these to enable one-click updates via the Update Manager UI.
# See https://containrrr.dev/watchtower/ for Watchtower setup.
WATCHTOWER_API_URL=
WATCHTOWER_API_TOKEN=
###################################################################################
# SAML Single sign on-settings
@ -106,6 +127,10 @@ SAML_SP_PRIVATE_KEY="MIIE..."
# In demo mode things it is not possible for a user to change his password and his settings.
DEMO_MODE=0
# When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file.
# This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK=0
# Change this to true, if no url rewriting (like mod_rewrite for Apache) is available
# In that case all URL contains the index.php front controller in URL
NO_URL_REWRITE_AVAILABLE=0
@ -134,4 +159,13 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###> symfony/framework-bundle ###
APP_ENV=prod
APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/ai-generic-platform ###
# GENERIC_BASE_URL=https://api.example.com/v1
###< symfony/ai-generic-platform ###
###> symfony/ai-open-router-platform ###
OPENROUTER_API_KEY=
###< symfony/ai-open-router-platform ###

186
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,186 @@
# Copilot Instructions for Part-DB
Part-DB is an Open-Source inventory management system for electronic components built with Symfony 7.4 and modern web technologies.
## Technology Stack
- **Backend**: PHP 8.2+, Symfony 7.4, Doctrine ORM
- **Frontend**: Bootstrap 5, Hotwire Stimulus/Turbo, TypeScript, Webpack Encore
- **Database**: MySQL 5.7+/MariaDB 10.4+/PostgreSQL 10+/SQLite
- **Testing**: PHPUnit with DAMA Doctrine Test Bundle
- **Code Quality**: Easy Coding Standard (ECS), PHPStan (level 5)
## Project Structure
- `src/`: PHP application code organized by purpose (Controller, Entity, Service, Form, etc.)
- `assets/`: Frontend TypeScript/JavaScript and CSS files
- `templates/`: Twig templates for views
- `tests/`: PHPUnit tests mirroring the `src/` structure
- `config/`: Symfony configuration files
- `public/`: Web-accessible files
- `translations/`: Translation files for multi-language support
## Coding Standards
### PHP Code
- Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) and [Symfony coding standards](https://symfony.com/doc/current/contributing/code/standards.html)
- Use type hints for all parameters and return types
- Always declare strict types: `declare(strict_types=1);` at the top of PHP files
- Use PHPDoc blocks for complex logic or when type information is needed
### TypeScript/JavaScript
- Use TypeScript for new frontend code
- Follow existing Stimulus controller patterns in `assets/controllers/`
- Use Bootstrap 5 components and utilities
- Leverage Hotwire Turbo for dynamic page updates
### Naming Conventions
- Entities: Use descriptive names that reflect database models (e.g., `Part`, `StorageLocation`)
- Controllers: Suffix with `Controller` (e.g., `PartController`)
- Services: Descriptive names reflecting their purpose (e.g., `PartService`, `LabelGenerator`)
- Tests: Match the class being tested with `Test` suffix (e.g., `PartTest`, `PartControllerTest`)
## Development Workflow
### Dependencies
- Install PHP dependencies: `composer install`
- Install JS dependencies: `yarn install`
- Build frontend assets: `yarn build` (production) or `yarn watch` (development)
### Database
- Create database: `php bin/console doctrine:database:create --env=dev`
- Run migrations: `php bin/console doctrine:migrations:migrate --env=dev`
- Load fixtures: `php bin/console partdb:fixtures:load -n --env=dev`
Or use Makefile shortcuts:
- `make dev-setup`: Complete development environment setup
- `make dev-reset`: Reset development environment (cache clear + migrate)
### Testing
- Set up test environment: `make test-setup`
- Run all tests: `php bin/phpunit`
- Run specific test: `php bin/phpunit tests/Path/To/SpecificTest.php`
- Run tests with coverage: `php bin/phpunit --coverage-html var/coverage`
- Test environment uses SQLite by default for speed
### Static Analysis
- Run PHPStan: `composer phpstan` or `COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G vendor/bin/phpstan analyse src --level 5`
- PHPStan configuration is in `phpstan.dist.neon`
### Running the Application
- Development server: `symfony serve` (requires Symfony CLI)
- Or configure Apache/nginx to serve from `public/` directory
- Set `APP_ENV=dev` in `.env.local` for development mode
## Best Practices
### Security
- Always sanitize user input
- Use Symfony's security component for authentication/authorization
- Check permissions using the permission system before allowing actions
- Never expose sensitive data in logs or error messages
- Use parameterized queries (Doctrine handles this automatically)
### Performance
- Use Doctrine query builder for complex queries instead of DQL when possible
- Lazy load relationships to avoid N+1 queries
- Cache results when appropriate using Symfony's cache component
- Use pagination for large result sets (DataTables integration available)
### Database
- Always create migrations for schema changes: `php bin/console make:migration`
- Review migration files before running them
- Use Doctrine annotations or attributes for entity mapping
- Follow existing entity patterns for relationships and lifecycle callbacks
### Frontend
- Use Stimulus controllers for interactive components
- Leverage Turbo for dynamic page updates without full page reloads
- Use Bootstrap 5 classes for styling
- Keep JavaScript modular and organized in controllers
- Use the translation system for user-facing strings
### Translations
- Use translation keys, not hardcoded strings: `{{ 'part.info.title'|trans }}`
- Add new translation keys to `translations/` files
- Primary language is English (en)
- Translations are managed via Crowdin, but can be edited locally if needed
### Testing
- Write unit tests for services and helpers
- Write functional tests for controllers
- Use fixtures for test data
- Tests should be isolated and not depend on execution order
- Mock external dependencies when appropriate
- Follow existing test patterns in the repository
## Common Patterns
### Creating an Entity
1. Create entity class in `src/Entity/` with Doctrine attributes
2. Generate migration: `php bin/console make:migration`
3. Review and run migration: `php bin/console doctrine:migrations:migrate`
4. Create repository if needed in `src/Repository/`
5. Add fixtures in `src/DataFixtures/` for testing
### Adding a Form
1. Create form type in `src/Form/`
2. Extend `AbstractType` and implement `buildForm()` and `configureOptions()`
3. Use in controller and render in Twig template
4. Follow existing form patterns for consistency
### Creating a Controller Action
1. Add method to appropriate controller in `src/Controller/`
2. Use route attributes for routing
3. Check permissions using security voters
4. Return Response or render Twig template
5. Add corresponding template in `templates/`
### Adding a Service
1. Create service class in `src/Services/`
2. Use dependency injection via constructor
3. Tag service in `config/services.yaml` if needed
4. Services are autowired by default
## Important Notes
- Part-DB uses fine-grained permissions - always check user permissions before actions
- Multi-language support is critical - use translation keys everywhere
- The application supports multiple database backends - write portable code
- Responsive design is important - test on mobile/tablet viewports
- Event system is used for logging changes - emit events when appropriate
- API Platform is integrated for REST API endpoints
## Multi-tenancy Considerations
- Part-DB is designed as a single-tenant application with multiple users
- User groups have different permission levels
- Always scope queries to respect user permissions
- Use the security context to get current user information
## Resources
- [Documentation](https://docs.part-db.de/)
- [Contributing Guide](CONTRIBUTING.md)
- [Symfony Documentation](https://symfony.com/doc/current/index.html)
- [Doctrine Documentation](https://www.doctrine-project.org/projects/doctrine-orm/en/current/)
- [Bootstrap 5 Documentation](https://getbootstrap.com/docs/5.1/)
- [Hotwire Documentation](https://hotwired.dev/)

View file

@ -8,6 +8,9 @@ on:
branches:
- '*'
- "!l10n_*" # Dont test localization branches
tags:
- 'v*.*.*'
- 'v*.*.*-**'
pull_request:
branches:
- '*'
@ -17,12 +20,14 @@ jobs:
assets_artifact_build:
name: Build assets artifact
runs-on: ubuntu-22.04
permissions:
contents: write
env:
APP_ENV: prod
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -37,7 +42,7 @@ jobs:
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@ -51,7 +56,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@ -60,9 +65,9 @@ jobs:
${{ runner.os }}-yarn-
- name: Setup node
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
- name: Install yarn dependencies
run: yarn install
@ -80,13 +85,20 @@ jobs:
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
- name: Upload assets artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: Only dependencies and built assets
path: /tmp/partdb_assets.zip
- name: Upload full artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: Full Part-DB including dependencies and built assets
path: /tmp/partdb_with_assets.zip
- name: Upload assets as release attachment
if: startsWith(github.ref, 'refs/tags/')
run: |
gh release upload "${{ github.ref_name }}" /tmp/partdb_assets.zip /tmp/partdb_with_assets.zip --clobber
env:
GH_TOKEN: ${{ github.token }}

View file

@ -15,16 +15,28 @@ 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
uses: actions/checkout@v5
uses: actions/checkout@v6
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
# list of Docker images to use as base name for tags
images: |
@ -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,31 +60,101 @@ 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
uses: docker/setup-buildx-action@v4
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v6
name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=jbtronics/part-db1,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v7
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v6
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@v4
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 }}

View file

@ -15,16 +15,28 @@ 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
uses: actions/checkout@v5
uses: actions/checkout@v6
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
# list of Docker images to use as base name for tags
images: |
@ -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,32 +60,102 @@ 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
uses: docker/setup-buildx-action@v4
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v6
name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile-frankenphp
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=partdborg/part-db,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v7
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v6
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@v4
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 }}

View file

@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -34,7 +34,7 @@ jobs:
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}

View file

@ -46,7 +46,7 @@ jobs:
if: matrix.db-type == 'postgres'
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
@ -81,7 +81,7 @@ jobs:
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@ -92,7 +92,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v4
- uses: actions/cache@v5
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@ -104,9 +104,9 @@ jobs:
run: composer install --prefer-dist --no-progress
- name: Setup node
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
- name: Install yarn dependencies
run: yarn install
@ -129,7 +129,7 @@ jobs:
run: ./bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
env_vars: PHP_VERSION,DB_TYPE
token: ${{ secrets.CODECOV_TOKEN }}

13
.gitignore vendored
View file

@ -25,6 +25,10 @@
uploads/*
!uploads/.keep
# Some people use Certbot or similar tools to make SSL certificates.
# Also see https://www.rfc-editor.org/rfc/rfc5785
public/.well-known/
# Do not keep cache files
.php_cs.cache
.phpcs-cache
@ -50,4 +54,11 @@ phpstan.neon
###< phpstan/phpstan ###
.claude/
CLAUDE.md
CLAUDE.md
.codex
migrations/.codex
docker-data/
scripts/
db/
docker-compose.yaml

View file

@ -1,7 +1,7 @@
# How to contribute
Thank you for consider to contribute to Part-DB!
Please read the text below, so your contributed content can be contributed easily to Part-DB.
Thank you for considering contributing to Part-DB!
Please read the text below, so your contributed content can be incorporated into Part-DB easily.
You can contribute to Part-DB in various ways:
* Report bugs and request new features via [issues](https://github.com/Part-DB/Part-DB-server/issues)
@ -18,38 +18,38 @@ Part-DB uses translation keys (e.g. part.info.title) that are sorted by their us
was translated in other languages (this is possible via the "Other languages" dropdown in the translation editor).
## Project structure
Part-DB uses symfony's recommended [project structure](https://symfony.com/doc/current/best_practices.html).
Part-DB uses Symfony's recommended [project structure](https://symfony.com/doc/current/best_practices.html).
Interesting folders are:
* `public`: Everything in this directory will be publicy accessible via web. Use this folder to serve static images.
* `assets`: The frontend assets are saved here. You can find the javascript and CSS code here.
* `src`: Part-DB's PHP code is saved here. Note that the sub directories are structured by the classes purposes (so use `Controller` Controllers, `Entities` for Database models, etc.)
* `translations`: The translations used in Part-DB are saved here
* `public`: Everything in this directory will be publicly accessible via web. Use this folder to serve static images.
* `assets`: The frontend assets are saved here. You can find the JavaScript and CSS code here.
* `src`: Part-DB's PHP code is saved here. Note that the subdirectories are structured by the classes' purposes (so use `Controller` for Controllers, `Entity` for Database models, etc.)
* `translations`: The translations used in Part-DB are saved here.
* `templates`: The templates (HTML) that are used by Twig to render the different pages. Email templates are also saved here.
* `tests/`: Tests that can be run by PHPunit.
* `tests/`: Tests that can be run by PHPUnit.
## Development environment
For setting up an development you will need to install PHP, composer, a database server (MySQL or MariaDB) and yarn (which needs an nodejs environment).
* Copy `.env` to `.env.local` and change `APP_ENV` to `APP_ENV=dev`. That way you will get development tools (symfony profiler) and other features that
For setting up a development environment, you will need to install PHP, Composer, a database server (MySQL or MariaDB) and yarn (which needs a Node.js environment).
* Copy `.env` to `.env.local` and change `APP_ENV` to `APP_ENV=dev`. That way you will get development tools (Symfony profiler) and other features that
will simplify development.
* Run `composer install` (without -o) to install PHP dependencies and `yarn install` to install frontend dependencies
* Run `yarn watch`. The program will run in the background and compile the frontend files whenever you change something in the CSS or TypeScript files
* For running Part-DB it is recommended to use [Symfony CLI](https://symfony.com/download).
That way you can run a correct configured webserver with `symfony serve`
* Run `composer install` (without -o) to install PHP dependencies and `yarn install` to install frontend dependencies.
* Run `yarn watch`. The program will run in the background and compile the frontend files whenever you change something in the CSS or TypeScript files.
* For running Part-DB, it is recommended to use [Symfony CLI](https://symfony.com/download).
That way you can run a correctly configured webserver with `symfony serve`.
## Coding style
Code should follow the [PSR12-Standard](https://www.php-fig.org/psr/psr-12/) and symfony's [coding standards](https://symfony.com/doc/current/contributing/code/standards.html).
Code should follow the [PSR-12 Standard](https://www.php-fig.org/psr/psr-12/) and Symfony's [coding standards](https://symfony.com/doc/current/contributing/code/standards.html).
Part-DB uses [Easy Coding Standard](https://github.com/symplify/easy-coding-standard) to check and fix coding style violations:
* To check your code for valid code style run `vendor/bin/ecs check src/`
* To fix violations run `vendor/bin/ecs check src/` (please checks afterwards if the code is valid afterwards)
* To check your code for valid code style, run `vendor/bin/ecs check src/`
* To fix violations, run `vendor/bin/ecs check src/ --fix` (please check afterwards if the code is still valid)
## GitHub actions
Part-DB uses GitHub actions to run various tests and checks on the code:
Part-DB uses GitHub Actions to run various tests and checks on the code:
* Yarn dependencies can compile
* PHPunit tests run successful
* Config files, translations and templates has valid syntax
* Doctrine schema valid
* No known vulnerable dependecies are used
* Static analysis successful (phpstan with `--level=2`)
* PHPUnit tests run successfully
* Config files, translations, and templates have valid syntax
* Doctrine schema is valid
* No known vulnerable dependencies are used
* Static analysis is successful (phpstan with `--level=2`)
Further the code coverage of the PHPunit tests is determined and uploaded to [CodeCov](https://codecov.io/gh/Part-DB/Part-DB-server).
Further, the code coverage of the PHPUnit tests is determined and uploaded to [CodeCov](https://codecov.io/gh/Part-DB/Part-DB-server).

View file

@ -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,21 +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 -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get update && apt-get install -y \
nodejs \
yarn \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
# Install composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
@ -67,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"
# ---
@ -143,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 && \
@ -151,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
@ -168,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

View file

@ -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-bookworm 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,34 +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; \
# Prepare keyrings directory
mkdir -p /etc/apt/keyrings; \
\
# Import Yarn GPG key
curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg \
| tee /etc/apt/keyrings/yarn.gpg >/dev/null; \
chmod 644 /etc/apt/keyrings/yarn.gpg; \
\
# Add Yarn repo with signed-by
echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian stable main" \
| tee /etc/apt/sources.list.d/yarn.list; \
\
# Run NodeSource setup script (unchanged)
curl -sL https://deb.nodesource.com/setup_22.x | bash -; \
\
# Install Node.js + Yarn
apt-get update; \
apt-get install -y --no-install-recommends \
nodejs \
yarn; \
\
# Cleanup
apt-get -y autoremove; \
apt-get clean autoclean; \
rm -rf /var/lib/apt/lists/*
# Install PHP
RUN set -eux; \
install-php-extensions \
@ -86,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
@ -112,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

View file

@ -1,4 +1,3 @@
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Part-DB/Part-DB-symfony/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/Part-DB/Part-DB-symfony/?branch=master)
![PHPUnit Tests](https://github.com/Part-DB/Part-DB-symfony/workflows/PHPUnit%20Tests/badge.svg)
![Static analysis](https://github.com/Part-DB/Part-DB-symfony/workflows/Static%20analysis/badge.svg)
[![codecov](https://codecov.io/gh/Part-DB/Part-DB-server/branch/master/graph/badge.svg)](https://codecov.io/gh/Part-DB/Part-DB-server)
@ -29,8 +28,8 @@ If you want to test Part-DB without installing it, you can use [this](https://de
You can log in with username: *user* and password: *user*.
Every change to the master branch gets automatically deployed, so it represents the current development progress and is
may not completely stable. Please mind, that the free Heroku instance is used, so it can take some time when loading
Every change to the master branch gets automatically deployed, so it represents the current development progress and
may not be completely stable. Please mind, that the free Heroku instance is used, so it can take some time when loading
the page
for the first time.
@ -62,6 +61,8 @@ for the first time.
* Automatic thumbnail generation for pictures
* Use cloud providers (like Octopart, Digikey, Farnell, LCSC or TME) to automatically get part information, datasheets, and
prices for parts
* Retrieve part information from arbitrary shop websites, using either conventional data extraction from structured metadata, or AI based data extraction.
A browser plugin allows to quickly submit parts from any website to your Part-DB instance, and even allows to circumvent anti-bot measures on shop websites.
* API to access Part-DB from other applications/scripts
* [Integration with KiCad](https://docs.part-db.de/usage/eda_integration.html): Use Part-DB as the central datasource for your
KiCad and see available parts from Part-DB directly inside KiCad.
@ -74,11 +75,11 @@ Part-DB is also used by small companies and universities for managing their inve
## Requirements
* A **web server** (like Apache2 or nginx) that is capable of
running [Symfony 6](https://symfony.com/doc/current/reference/requirements.html),
running [Symfony 7](https://symfony.com/doc/current/reference/requirements.html),
this includes a minimum PHP version of **PHP 8.2**
* A **MySQL** (at least 5.7) /**MariaDB** (at least 10.4) database server, or **PostgreSQL** 10+ if you do not want to use SQLite.
* Shell access to your server is highly recommended!
* For building the client-side assets **yarn** and **nodejs** (>= 20.0) is needed.
* For building the client-side assets **yarn** and **nodejs** (>= 22.0) is needed.
## Installation
@ -142,7 +143,7 @@ There you will find various methods to support development on a monthly or a one
## Built with
* [Symfony 5](https://symfony.com/): The main framework used for the serverside PHP
* [Symfony 6](https://symfony.com/): The main framework used for the serverside PHP
* [Bootstrap 5](https://getbootstrap.com/) and [Bootswatch](https://bootswatch.com/): Used as website theme
* [Fontawesome](https://fontawesome.com/): Used as icon set
* [Hotwire Stimulus](https://stimulus.hotwired.dev/) and [Hotwire Turbo](https://turbo.hotwired.dev/): Frontend

View file

@ -1 +1 @@
2.2.1
2.12.0

View 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"
}
}

View file

@ -0,0 +1,55 @@
/*
* 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/>.
*/
import { Controller } from '@hotwired/stimulus';
/**
* Stimulus controller for backup restore confirmation dialogs.
* Shows a confirmation dialog with backup details before allowing restore.
*/
export default class extends Controller {
static values = {
filename: { type: String, default: '' },
date: { type: String, default: '' },
confirmTitle: { type: String, default: 'Restore Backup' },
confirmMessage: { type: String, default: 'Are you sure you want to restore from this backup?' },
confirmWarning: { type: String, default: 'This will overwrite your current database. This action cannot be undone!' },
};
connect() {
this.element.addEventListener('submit', this.handleSubmit.bind(this));
}
handleSubmit(event) {
// Always prevent default first
event.preventDefault();
// Build confirmation message
const message = this.confirmTitleValue + '\n\n' +
'Backup: ' + this.filenameValue + '\n' +
'Date: ' + this.dateValue + '\n\n' +
this.confirmMessageValue + '\n\n' +
'⚠️ ' + this.confirmWarningValue;
// Only submit if user confirms
if (confirm(message)) {
this.element.submit();
}
}
}

View file

@ -3,14 +3,16 @@ import { generateCsrfHeaders } from "./csrf_protection_controller"
export default class extends Controller {
static targets = ["progressBar", "progressText"]
static values = {
static values = {
jobId: Number,
partId: Number,
researchUrl: String,
researchAllUrl: String,
markCompletedUrl: String,
markSkippedUrl: String,
markPendingUrl: String
markPendingUrl: String,
quickApplyUrl: String,
quickApplyAllUrl: String
}
connect() {
@ -119,13 +121,11 @@ export default class extends Controller {
async markSkipped(event) {
const partId = event.currentTarget.dataset.partId
const reason = prompt('Reason for skipping (optional):') || ''
try {
const url = this.markSkippedUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, {
method: 'POST',
body: JSON.stringify({ reason })
method: 'POST'
})
if (data.success) {
@ -321,6 +321,94 @@ export default class extends Controller {
}
}
async quickApply(event) {
event.preventDefault()
event.stopPropagation()
const partId = event.currentTarget.dataset.partId
const providerKey = event.currentTarget.dataset.providerKey
const providerId = event.currentTarget.dataset.providerId
const button = event.currentTarget
const originalHtml = button.innerHTML
button.disabled = true
button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Applying...'
try {
const url = this.quickApplyUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, {
method: 'POST',
body: JSON.stringify({ providerKey, providerId })
}, 60000)
if (data.success) {
this.updateProgressDisplay(data)
this.showSuccessMessage(data.message || 'Part updated successfully')
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
} else {
this.showErrorMessage(data.error || 'Quick apply failed')
button.innerHTML = originalHtml
button.disabled = false
}
} catch (error) {
console.error('Error in quick apply:', error)
this.showErrorMessage(error.message || 'Quick apply failed')
button.innerHTML = originalHtml
button.disabled = false
}
}
async quickApplyAll(event) {
event.preventDefault()
event.stopPropagation()
if (!confirm('This will apply the top search result to all pending parts without individual review. Continue?')) {
return
}
const button = event.currentTarget
const spinner = document.getElementById('quick-apply-all-spinner')
const originalHtml = button.innerHTML
button.disabled = true
if (spinner) {
spinner.style.display = 'inline-block'
}
try {
const data = await this.fetchWithErrorHandling(this.quickApplyAllUrlValue, {
method: 'POST'
}, 300000)
if (data.success) {
this.updateProgressDisplay(data)
let message = data.message || 'Bulk apply completed'
if (data.errors && data.errors.length > 0) {
message += '\nErrors:\n' + data.errors.join('\n')
}
this.showSuccessMessage(message)
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
window.location.reload()
} else {
this.showErrorMessage(data.error || 'Bulk apply failed')
button.innerHTML = originalHtml
button.disabled = false
}
} catch (error) {
console.error('Error in quick apply all:', error)
this.showErrorMessage(error.message || 'Bulk apply failed')
button.innerHTML = originalHtml
button.disabled = false
} finally {
if (spinner) {
spinner.style.display = 'none'
}
}
}
showSuccessMessage(message) {
this.showToast('success', message)
}

View file

@ -0,0 +1,274 @@
/*
* 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/>.
*/
import {Controller} from "@hotwired/stimulus";
import {visit} from "@hotwired/turbo";
import * as bootbox from "bootbox";
import "../../css/components/bootbox_extensions.css";
import "../../css/components/dirty_form.css";
/**
* Attach to a <form> element (or a wrapper containing a <form>) to prevent accidental navigation
* away when the form has unsaved changes.
*
* Dirty detection is event-driven: `change` and `input` events bubble up to the form and trigger
* a check of whether any element's current value differs from the DOM default recorded in the HTML
* (`defaultValue` / `defaultChecked` / `option.defaultSelected`). Using both events covers both
* native widgets (which fire `change`) and rich-text editors like CKEditor (which fire `input`
* when they sync their underlying textarea).
*
* Validation failures (server returns 200 with `.is-invalid` fields) are always treated as dirty:
* the submitted data was never saved, so navigating away would lose it. This removes the need for
* any snapshot mechanism the `.is-invalid` classes in the re-rendered HTML are the signal.
*
* Intercepts three navigation paths:
* 1. Any <a href> link click (capture phase)
* 2. window beforeunload
* 3. turbo:before-visit
*
* Values:
* - confirmTitle (String) dialog title
* - confirmMessage (String) dialog body text
*/
export default class extends Controller {
static values = {
confirmTitle: {type: String, default: 'Unsaved Changes'},
confirmMessage: {type: String, default: 'You have unsaved changes. Are you sure you want to leave this page?'},
};
connect() {
this._form = (this.element.tagName === 'FORM') ? this.element : this.element.querySelector('form');
this._isDirty = false;
this._submitting = false;
this._navigating = false;
this._changeHandler = this._handleChange.bind(this);
this._linkClickHandler = this._handleLinkClick.bind(this);
this._beforeUnloadHandler = this._handleBeforeUnload.bind(this);
this._turboBeforeVisitHandler = this._handleTurboBeforeVisit.bind(this);
this._turboSubmitEndHandler = this._handleTurboSubmitEnd.bind(this);
if (this._form) {
this._form.addEventListener('change', this._changeHandler);
// CKEditor (and other rich-text widgets) dispatch `input` rather than `change`
// when their underlying textarea value is updated.
this._form.addEventListener('input', this._changeHandler);
}
document.addEventListener('click', this._linkClickHandler, true);
window.addEventListener('beforeunload', this._beforeUnloadHandler);
document.addEventListener('turbo:before-visit', this._turboBeforeVisitHandler);
document.addEventListener('turbo:submit-end', this._turboSubmitEndHandler);
const modal = this.element.closest('.modal');
if (modal) {
this._modal = modal;
this._modalHideHandler = this._handleModalHide.bind(this);
modal.addEventListener('hide.bs.modal', this._modalHideHandler);
}
}
disconnect() {
if (this._form) {
this._form.removeEventListener('change', this._changeHandler);
this._form.removeEventListener('input', this._changeHandler);
}
document.removeEventListener('click', this._linkClickHandler, true);
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
document.removeEventListener('turbo:before-visit', this._turboBeforeVisitHandler);
document.removeEventListener('turbo:submit-end', this._turboSubmitEndHandler);
if (this._modal && this._modalHideHandler) {
this._modal.removeEventListener('hide.bs.modal', this._modalHideHandler);
}
}
/** data-action="submit->common--dirty-form#submit" — suppresses the guard while saving. */
submit() {
this._submitting = true;
}
/**
* data-action="reset->common--dirty-form#resetDirtyState" marks the form as clean after
* a programmatic reset. Native change events are not fired by form.reset(), so we set the
* flag directly. Turbo also calls form.reset() internally before the post-submit redirect;
* the _submitting guard prevents that from incorrectly clearing the flag.
*/
resetDirtyState() {
if (this._submitting) return;
// Wait for a frame to allow the form's DOM state to update after the reset() call, then refresh markers and update the dirty flag.
requestAnimationFrame(() => {
this._isDirty = false;
this._clearDirtyMarkers();
});
}
_handleChange(event) {
const target = event?.target;
if (target?.name) {
this._updateDirtyMarker(target);
} else {
this._refreshDirtyMarkers();
}
this._isDirty = this._form?.querySelector('[data-dirty]') !== null;
}
/**
* Walk every named form element and update its `data-dirty` attribute.
* Un-named elements (e.g. the visible TristateCheckbox whose name was removed) are
* skipped they are not submitted and are not the source of truth for form data.
*/
_refreshDirtyMarkers() {
if (!this._form) return;
for (const el of this._form.elements) {
if (!el.name) continue;
this._updateDirtyMarker(el);
}
}
/**
* Set or clear `data-dirty` on a single named form element.
* Hidden inputs are not visually rendered, so special handling applies:
* - TristateCheckbox: the hidden backing input is preceded by a nameless visual checkbox
* mark that instead.
* - Other hidden inputs (e.g. CSRF tokens): ignored.
* TomSelect hides the <select> before .ts-wrapper (sibling); CSS targets .ts-control via the
* adjacent-sibling combinator on the select's data-dirty attribute.
*/
_updateDirtyMarker(el) {
if (el.type === 'hidden') {
const visual = el.previousElementSibling;
if (visual instanceof HTMLInputElement && !visual.name) {
visual.toggleAttribute('data-dirty', el.value !== el.defaultValue);
}
return;
}
const dirty = this._isElementDirty(el);
el.toggleAttribute('data-dirty', dirty);
}
_clearDirtyMarkers() {
this._form?.querySelectorAll('[data-dirty]').forEach(el => el.removeAttribute('data-dirty'));
}
_isElementDirty(el) {
//Disabled elements are not editable, so ignore them even if their value differs from the default.
if (el.disabled) return false;
if (el.type === 'file') return false;
if (el.type === 'checkbox' || el.type === 'radio') {
return el.checked !== el.defaultChecked;
}
if (el.tagName === 'SELECT') {
// TomSelect sets data-default-value to the value at init time.
// The native option.defaultSelected approach is unreliable when no option
// carries the `selected` attribute — the browser auto-selects option[0]
// (selected=true) while defaultSelected stays false, causing a false positive.
if (el.dataset.defaultValue !== undefined) {
return el.value !== el.dataset.defaultValue;
}
for (const option of el.options) {
if (option.selected !== option.defaultSelected) return true;
}
return false;
}
let defaultValue = el.defaultValue;
//If an element has an data-default-value, use that for dirty checking instead of the DOM default Value. Set for example by the ckeditor-controller
if (el.dataset.defaultValue !== undefined) {
defaultValue = el.dataset.defaultValue;
}
return el.value !== defaultValue;
}
_isFormDirty() {
if (this._submitting) return false;
// A form with validation errors was submitted but never saved — always treat as dirty.
if (this._form?.querySelector('.is-invalid')) return true;
return this._isDirty;
}
_confirmNavigation(onConfirm) {
bootbox.confirm({
title: this.confirmTitleValue,
message: this.confirmMessageValue,
callback: (result) => { if (result) onConfirm(); }
});
}
_handleLinkClick(event) {
if (this._navigating) return;
const link = event.target.closest('a[href]');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#')) return;
if (link.target === '_blank' || link.target === '_top' || link.target === '_parent') return;
if (link.hasAttribute('data-dirty-form-ignore')) return;
if (!this._isFormDirty()) return;
event.preventDefault();
event.stopPropagation();
this._confirmNavigation(() => { this._navigating = true; link.click(); });
}
_handleBeforeUnload(event) {
if (this._navigating || !this._isFormDirty()) return;
event.preventDefault();
event.returnValue = '';
}
_handleTurboBeforeVisit(event) {
if (this._navigating || !this._isFormDirty()) return;
event.preventDefault();
const url = event.detail.url;
const frame = event.detail.frame;
this._confirmNavigation(() => {
this._navigating = true;
if (frame) { window.Turbo.visit(url, { frame }); } else { visit(url); }
});
}
_handleTurboSubmitEnd(event) {
const submittedForm = event.detail?.formSubmission?.formElement;
if (submittedForm !== this._form) return;
// For a successful save (redirect), the controller will disconnect with the Turbo
// navigation; reset is only needed for validation errors where the form stays in the DOM.
const savedSuccessfully = event.detail.success && event.detail.fetchResponse?.redirected;
if (!savedSuccessfully) {
this._submitting = false;
}
}
_handleModalHide(event) {
if (this._navigating || !this._isFormDirty()) return;
event.preventDefault();
this._confirmNavigation(() => {
this._navigating = true;
window.bootstrap?.Modal?.getInstance(this._modal)?.hide();
});
}
}

View file

@ -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();
}
}
}

View file

@ -2,6 +2,8 @@ const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
@ -33,8 +35,8 @@ export function generateCsrfToken (formElement) {
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';

View file

@ -0,0 +1,377 @@
/*
* 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';
/**
* Stimulus controller for Docker update progress tracking.
*
* Polls the health check endpoint to detect when the container restarts
* after a Watchtower-triggered update. Drives the step timeline UI
* with timestamps, matching the git update progress style.
*/
export default class extends Controller {
static values = {
healthUrl: String,
previousVersion: { type: String, default: 'unknown' },
pollInterval: { type: Number, default: 5000 },
maxWaitTime: { type: Number, default: 600000 }, // 10 minutes
// Translated UI strings (passed from Twig template)
textPulling: { type: String, default: 'Waiting for Watchtower to pull the new image...' },
textPullingDetail: { type: String, default: 'Watchtower is checking for and downloading the latest Docker image...' },
textRestarting: { type: String, default: 'Container is restarting with the new image...' },
textRestartingDetail: { type: String, default: 'The container is being recreated with the updated image. This may take a moment...' },
textSuccess: { type: String, default: 'Update Complete!' },
textSuccessDetail: { type: String, default: 'Part-DB has been updated successfully via Docker.' },
textTimeout: { type: String, default: 'Update Taking Longer Than Expected' },
textTimeoutDetail: { type: String, default: 'The update may still be in progress. Check your Docker logs for details.' },
textStepPull: { type: String, default: 'Pull Image' },
textStepRestart: { type: String, default: 'Restart Container' },
};
static targets = [
// Header
'headerWhale', 'titleIcon',
'statusText', 'statusSubtext',
'progressBar', 'elapsedTime',
// Alerts
'stepAlert', 'stepName', 'stepMessage',
'successAlert', 'timeoutAlert', 'errorAlert', 'errorMessage', 'warningAlert',
// Step timeline (multi-target arrays)
'stepRow', 'stepIcon', 'stepDetail', 'stepTime',
// Version display
'newVersion', 'previousVersion',
// Actions
'actions',
];
// Step definitions: name -> { index, progress% }
static STEPS = {
trigger: { index: 0, progress: 15 },
pull: { index: 1, progress: 30 },
stop: { index: 2, progress: 50 },
restart: { index: 3, progress: 65 },
health: { index: 4, progress: 80 },
verify: { index: 5, progress: 100 },
};
connect() {
this.serverWentDown = false;
this.serverCameBack = false;
this.startTime = Date.now();
this.timer = null;
this.currentStep = 'pull'; // trigger is already done
this.stepTimestamps = { trigger: this.formatTime(new Date()) };
this.consecutiveSuccessCount = 0;
// Set the trigger step timestamp
this.setStepTimestamp(0, this.stepTimestamps.trigger);
this.poll();
}
disconnect() {
if (this.timer) {
clearTimeout(this.timer);
}
}
createTimeoutSignal(ms) {
if (typeof AbortSignal.timeout === 'function') {
return AbortSignal.timeout(ms);
}
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
async poll() {
const elapsed = Date.now() - this.startTime;
this.updateElapsedTime(elapsed);
if (elapsed > this.maxWaitTimeValue) {
this.showTimeout();
return;
}
try {
const response = await fetch(this.healthUrlValue, {
cache: 'no-store',
signal: this.createTimeoutSignal(4000),
});
if (response.ok) {
let data;
try {
data = await response.json();
} catch (parseError) {
this.schedulePoll();
return;
}
if (this.serverWentDown) {
// Server came back! Move through health check -> verify
if (!this.serverCameBack) {
this.serverCameBack = true;
this.advanceToStep('health');
}
this.consecutiveSuccessCount++;
// Wait for 2 consecutive successes to confirm stability
if (this.consecutiveSuccessCount >= 2) {
this.showSuccess(data.version);
return;
}
} else {
// Server still up - Watchtower pulling image
this.showPulling();
}
} else if (response.status === 503) {
// Maintenance mode or shutting down
this.serverWentDown = true;
this.consecutiveSuccessCount = 0;
this.advanceToStep('stop');
} else {
if (this.serverWentDown) {
this.showRestarting();
} else {
this.showPulling();
}
}
} catch (e) {
// Connection refused = container is down
if (!this.serverWentDown) {
this.serverWentDown = true;
this.advanceToStep('stop');
}
this.consecutiveSuccessCount = 0;
this.showRestarting();
}
this.schedulePoll();
}
schedulePoll() {
this.timer = setTimeout(() => this.poll(), this.pollIntervalValue);
}
/**
* Advance the step timeline to a specific step.
* Marks all previous steps as complete with timestamps.
*/
advanceToStep(stepName) {
const steps = this.constructor.STEPS;
const targetIndex = steps[stepName]?.index;
if (targetIndex === undefined) return;
const stepNames = Object.keys(steps);
const now = this.formatTime(new Date());
for (let i = 0; i < stepNames.length; i++) {
const name = stepNames[i];
if (i < targetIndex) {
// Completed step
this.markStepComplete(i, this.stepTimestamps[name] || now);
if (!this.stepTimestamps[name]) {
this.stepTimestamps[name] = now;
}
} else if (i === targetIndex) {
// Current active step
this.markStepActive(i);
this.stepTimestamps[name] = now;
this.setStepTimestamp(i, now);
this.currentStep = name;
}
// Steps after targetIndex remain pending (no change needed)
}
// Update progress bar
this.updateProgressBar(steps[stepName].progress);
}
showPulling() {
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textPullingValue;
}
if (this.hasStepNameTarget) {
this.stepNameTarget.textContent = this.textStepPullValue;
}
if (this.hasStepMessageTarget) {
this.stepMessageTarget.textContent = this.textPullingDetailValue;
}
this.updateProgressBar(30);
}
showRestarting() {
// Advance to restart step if we haven't already
if (this.currentStep !== 'restart' && this.currentStep !== 'health' && this.currentStep !== 'verify') {
this.advanceToStep('restart');
}
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textRestartingValue;
}
if (this.hasStepNameTarget) {
this.stepNameTarget.textContent = this.textStepRestartValue;
}
if (this.hasStepMessageTarget) {
this.stepMessageTarget.textContent = this.textRestartingDetailValue;
}
}
showSuccess(newVersion) {
// Advance all steps to complete
const steps = this.constructor.STEPS;
const stepNames = Object.keys(steps);
const now = this.formatTime(new Date());
for (let i = 0; i < stepNames.length; i++) {
const name = stepNames[i];
this.markStepComplete(i, this.stepTimestamps[name] || now);
}
this.updateProgressBar(100);
// Update whale animation
if (this.hasHeaderWhaleTarget) {
this.headerWhaleTarget.classList.add('success');
}
if (this.hasTitleIconTarget) {
this.titleIconTarget.className = 'fas fa-check-circle text-success';
}
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textSuccessValue;
}
if (this.hasStatusSubtextTarget) {
this.statusSubtextTarget.textContent = this.textSuccessDetailValue;
}
// Hide step alert, show success alert
this.toggleTarget('stepAlert', false);
this.toggleTarget('successAlert', true);
this.toggleTarget('warningAlert', false);
this.toggleTarget('actions', true);
if (this.hasNewVersionTarget) {
this.newVersionTarget.textContent = newVersion || 'latest';
}
if (this.hasPreviousVersionTarget) {
this.previousVersionTarget.textContent = this.previousVersionValue;
}
}
showTimeout() {
this.updateProgressBar(0);
if (this.hasHeaderWhaleTarget) {
this.headerWhaleTarget.classList.add('timeout');
}
if (this.hasTitleIconTarget) {
this.titleIconTarget.className = 'fas fa-exclamation-triangle text-warning';
}
if (this.hasStatusTextTarget) {
this.statusTextTarget.textContent = this.textTimeoutValue;
}
if (this.hasStatusSubtextTarget) {
this.statusSubtextTarget.textContent = this.textTimeoutDetailValue;
}
this.toggleTarget('stepAlert', false);
this.toggleTarget('timeoutAlert', true);
this.toggleTarget('warningAlert', false);
this.toggleTarget('actions', true);
}
// --- Step timeline helpers ---
markStepComplete(index, timestamp) {
if (this.stepIconTargets[index]) {
this.stepIconTargets[index].className = 'fas fa-check-circle text-success me-3';
}
if (this.stepRowTargets[index]) {
this.stepRowTargets[index].classList.remove('text-muted');
}
if (timestamp) {
this.setStepTimestamp(index, timestamp);
}
}
markStepActive(index) {
if (this.stepIconTargets[index]) {
this.stepIconTargets[index].className = 'fas fa-spinner fa-spin text-primary me-3';
}
if (this.stepRowTargets[index]) {
this.stepRowTargets[index].classList.remove('text-muted');
}
}
setStepTimestamp(index, time) {
if (this.stepTimeTargets[index]) {
this.stepTimeTargets[index].textContent = time;
}
}
// --- UI helpers ---
toggleTarget(name, show) {
const hasMethod = 'has' + name.charAt(0).toUpperCase() + name.slice(1) + 'Target';
if (this[hasMethod]) {
this[name + 'Target'].classList.toggle('d-none', !show);
}
}
updateProgressBar(percent) {
if (this.hasProgressBarTarget) {
const bar = this.progressBarTarget;
// Remove all width classes
bar.classList.remove('progress-w-0', 'progress-w-15', 'progress-w-30', 'progress-w-50', 'progress-w-65', 'progress-w-80', 'progress-w-100');
bar.classList.add('progress-w-' + percent);
bar.textContent = percent + '%';
bar.setAttribute('aria-valuenow', percent);
bar.classList.remove('bg-success', 'bg-danger', 'progress-bar-striped', 'progress-bar-animated');
if (percent === 100) {
bar.classList.add('bg-success');
} else if (percent === 0) {
bar.classList.add('bg-danger');
} else {
bar.classList.add('progress-bar-striped', 'progress-bar-animated');
}
}
}
updateElapsedTime(elapsed) {
if (this.hasElapsedTimeTarget) {
const seconds = Math.floor(elapsed / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
this.elapsedTimeTarget.textContent = minutes > 0
? `${minutes}m ${remainingSeconds}s`
: `${remainingSeconds}s`;
}
}
formatTime(date) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
}

View file

@ -0,0 +1,155 @@
/*
* 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";
import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
_platformSelector;
connect() {
let dropdownParent = "body";
if (this.element.closest('.modal')) {
dropdownParent = null
}
//Try to find the platform selector
const platformSelector = document.querySelector("select[data-platform-selector-label='" + this.element.dataset.platformSelector + "']");
//Clear tomselect options, if the platform selector changes
if (platformSelector) {
this.platformSelector = platformSelector;
platformSelector.addEventListener('change', () => {
//Force reload of options by clearing the cache and options of TomSelect and triggering a search with an empty string
this._tomSelect.clearOptions();
this._tomSelect.clearCache();
this._tomSelect.load('');
});
}
let settings = {
persistent: false,
create: true,
maxItems: 1,
preload: 'focus',
createOnBlur: true,
selectOnTab: true,
clearAfterSelect: true,
shouldLoad: ((query) => true),
maxOptions: null,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
dropdownParent: dropdownParent,
render: {
item: (data, escape) => {
return '<span>' + escape(data.label) + '</span>';
},
option: (data, escape) => {
if (data.image) {
return "<div class='row m-0'><div class='col-2 pl-0 pr-1'><img class='typeahead-image' src='" + data.image + "'/></div><div class='col-10'>" + data.label + "</div></div>"
}
return '<div>' + escape(data.label) + '</div>';
}
},
plugins: {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
'restore_on_backspace': {},
'form_reset_handler': {}
}
};
if(this.element.dataset.urlTemplate) {
const base_url = this.element.dataset.urlTemplate;
settings.searchField = "label";
settings.sortField = "label";
settings.valueField = "label";
settings.load = (query, callback) => {
if (!this.platformSelector) {
console.error("Platform selector not found for AI model autocomplete");
callback();
return;
}
//Platform is the selected option
const platform = this.platformSelector.value;
if (!platform) {
callback();
return;
}
const self = this;
//Only fetch each platform once
if(self.platformLoaded === platform) {
callback();
}
const url = base_url.replace('__PLATFORM__', encodeURIComponent(platform));
fetch(url)
.then(response => response.json())
.then(json => {
self.platformLoaded = platform;
var data = [];
for (const name in json) {
data.push({
"label": name,
"capabilities": json[name].capabilities,
});
}
callback(data);
}).catch(()=>{
callback();
});
};
}
this._tomSelect = new TomSelect(this.element, settings);
}
disconnect() {
super.disconnect();
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
@ -45,6 +47,7 @@ export default class extends Controller {
maxItems: 1,
createOnBlur: true,
selectOnTab: true,
clearAfterSelect: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
dropdownParent: dropdownParent,
@ -63,7 +66,8 @@ export default class extends Controller {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
"restore_on_backspace": {}
'restore_on_backspace': {},
'form_reset_handler': {}
}
};

View file

@ -29,7 +29,7 @@ import "ckeditor5/ckeditor5.css";;
import "../../css/components/ckeditor.css";
const translationContext = require.context(
'ckeditor5/translations',
'ckeditor5-translations', //Alias defined in webpack.config.js
false,
//Only load the translation files we will really need
/(de|it|fr|ru|ja|cs|da|zh|pl|hu)\.js$/
@ -91,6 +91,9 @@ export default class extends Controller {
config.translations = [window.CKEDITOR_TRANSLATIONS, translations];
}
//Apply the default value of the source element as data attribute, so that dirty-form-controller can detect changes
this.element.dataset.defaultValue = this.element.defaultValue;
const watchdog = new EditorWatchdog();
watchdog.setCreator((elementOrData, editorConfig) => {
return EDITOR_TYPE.create(elementOrData, editorConfig)
@ -106,6 +109,26 @@ export default class extends Controller {
editor_div.classList.add(...new_classes.split(","));
}
// Automatic synchronization of source input
editor.model.document.on("change:data", () => {
editor.updateSourceElement();
// Dispatch the input event for further treatment
this.element.dispatchEvent(new Event("input", { bubbles: true }));
});
//Set an reset listener to update the editor if the source element is reset (e.g. by a reset button)
if (this.element.form && this.element.name) {
this.element.form.addEventListener("reset", () => {
if (editor.isReadOnly) {
return;
}
if (this.element.dataset.defaultValue !== undefined) {
editor.setData(this.element.dataset.defaultValue);
}
});
}
//This return is important! Otherwise we get mysterious errors in the console
//See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302
return editor;

View file

@ -21,6 +21,7 @@ import {Controller} from "@hotwired/stimulus";
import * as bootbox from "bootbox";
import "../../css/components/bootbox_extensions.css";
import accept from "attr-accept";
export default class extends Controller {
static values = {
@ -73,15 +74,33 @@ export default class extends Controller {
const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID()));
let ret = null;
//Insert new html after the last child element
//If the table has a tbody, insert it there
//Afterwards return the newly created row
if(targetTable.tBodies[0]) {
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
return targetTable.tBodies[0].lastElementChild;
ret = targetTable.tBodies[0].lastElementChild;
} else { //Otherwise just insert it
targetTable.insertAdjacentHTML('beforeend', newElementStr);
return targetTable.lastElementChild;
ret = targetTable.lastElementChild;
}
//Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it
targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true}));
this.focusNumberInput(ret);
return ret;
}
focusNumberInput(element) {
const fields = element.querySelectorAll("input[type=number]");
//Focus the first available number input field to open the numeric keyboard on mobile devices
if(fields.length > 0) {
fields[0].focus();
}
}
@ -112,6 +131,33 @@ export default class extends Controller {
dataTransfer.items.add(file);
rowInput.files = dataTransfer.files;
//Check the file extension and find the corresponding attachment type based on the data-filetype_filter attribute
const attachmentTypeSelect = newElement.querySelector("select");
if (attachmentTypeSelect) {
let foundMatch = false;
for (let j = 0; j < attachmentTypeSelect.options.length; j++) {
const option = attachmentTypeSelect.options[j];
//skip disabled options
if (option.disabled) {
continue;
}
const filter = option.getAttribute('data-filetype_filter');
if (filter) {
if (accept({name: file.name, type: file.type}, filter)) {
attachmentTypeSelect.value = option.value;
foundMatch = true;
break;
}
} else { //If no filter is set, chose this option until we find a better match
if (!foundMatch) {
attachmentTypeSelect.value = option.value;
foundMatch = true;
}
}
}
}
}
});
@ -189,4 +235,4 @@ export default class extends Controller {
del();
}
}
}
}

View file

@ -83,8 +83,6 @@ export default class extends Controller {
if (data) {
//Do not save the start value (current page), as we want to always start at the first page on a page reload
delete data.start;
//Reset the data length to the default value by deleting the length property
delete data.length;
}
return data;
@ -108,11 +106,27 @@ 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;
}
//The saved order index is visual (post-reorder). If colReorder state
//exists, map it back to the original column index so the server sorts
//the correct column. colReorder[visualIndex] == originalIndex.
let columnIndex = order[0];
if (saved_state.colReorder) {
columnIndex = saved_state.colReorder[columnIndex];
}
return {
column: order[0],
column: columnIndex,
dir: order[1]
}
});
//Remove null values from the initial_order array
settings.initial_order = settings.initial_order.filter(order => order !== null);
}
let options = {

View file

@ -0,0 +1,250 @@
import { Controller } from "@hotwired/stimulus";
import "../../css/components/autocomplete_bootstrap_theme.css";
export default class extends Controller {
static targets = ["input"];
static values = {
partId: Number,
partCategoryId: Number,
partDescription: String,
suggestions: Object,
commonSectionHeader: String, // Dynamic header for common Prefixes
partIncrementHeader: String, // Dynamic header for new possible part increment
suggestUrl: String,
};
connect() {
this.configureAutocomplete();
this.watchCategoryChanges();
this.watchDescriptionChanges();
}
templates = {
commonSectionHeader({ title, html }) {
return html`
<section class="aa-Source">
<div class="aa-SourceHeader">
<span class="aa-SourceHeaderTitle">${title}</span>
<div class="aa-SourceHeaderLine"></div>
</div>
</section>
`;
},
partIncrementHeader({ title, html }) {
return html`
<section class="aa-Source">
<div class="aa-SourceHeader">
<span class="aa-SourceHeaderTitle">${title}</span>
<div class="aa-SourceHeaderLine"></div>
</div>
</section>
`;
},
list({ html }) {
return html`
<ul class="aa-List" role="listbox"></ul>
`;
},
item({ suggestion, description, html }) {
return html`
<li class="aa-Item" role="option" data-suggestion="${suggestion}" aria-selected="false">
<div class="aa-ItemWrapper">
<div class="aa-ItemContent">
<div class="aa-ItemIcon aa-ItemIcon--noBorder">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21c4.971 0 9-4.029 9-9s-4.029-9-9-9-9 4.029-9 9 4.029 9 9 9z"></path>
</svg>
</div>
<div class="aa-ItemContentBody">
<div class="aa-ItemContentTitle">${suggestion}</div>
<div class="aa-ItemContentDescription">${description}</div>
</div>
</div>
</div>
</li>
`;
},
};
configureAutocomplete() {
const inputField = this.inputTarget;
const commonPrefixes = this.suggestionsValue.commonPrefixes || [];
const prefixesPartIncrement = this.suggestionsValue.prefixesPartIncrement || [];
const commonHeader = this.commonSectionHeaderValue;
const partIncrementHeader = this.partIncrementHeaderValue;
if (!inputField || (!commonPrefixes.length && !prefixesPartIncrement.length)) return;
// Check whether the panel should be created at the update
if (this.isPanelInitialized) {
const existingPanel = inputField.parentNode.querySelector(".aa-Panel");
if (existingPanel) {
// Only remove the panel in the update phase
existingPanel.remove();
}
}
// Create panel
const panel = document.createElement("div");
panel.classList.add("aa-Panel");
panel.style.display = "none";
// Create panel layout
const panelLayout = document.createElement("div");
panelLayout.classList.add("aa-PanelLayout", "aa-Panel--scrollable");
// Section for prefixes part increment
if (prefixesPartIncrement.length) {
const partIncrementSection = document.createElement("section");
partIncrementSection.classList.add("aa-Source");
const partIncrementHeaderHtml = this.templates.partIncrementHeader({
title: partIncrementHeader,
html: String.raw,
});
partIncrementSection.innerHTML += partIncrementHeaderHtml;
const partIncrementList = document.createElement("ul");
partIncrementList.classList.add("aa-List");
partIncrementList.setAttribute("role", "listbox");
prefixesPartIncrement.forEach((prefix) => {
const itemHTML = this.templates.item({
suggestion: prefix.title,
description: prefix.description,
html: String.raw,
});
partIncrementList.innerHTML += itemHTML;
});
partIncrementSection.appendChild(partIncrementList);
panelLayout.appendChild(partIncrementSection);
}
// Section for common prefixes
if (commonPrefixes.length) {
const commonSection = document.createElement("section");
commonSection.classList.add("aa-Source");
const commonSectionHeader = this.templates.commonSectionHeader({
title: commonHeader,
html: String.raw,
});
commonSection.innerHTML += commonSectionHeader;
const commonList = document.createElement("ul");
commonList.classList.add("aa-List");
commonList.setAttribute("role", "listbox");
commonPrefixes.forEach((prefix) => {
const itemHTML = this.templates.item({
suggestion: prefix.title,
description: prefix.description,
html: String.raw,
});
commonList.innerHTML += itemHTML;
});
commonSection.appendChild(commonList);
panelLayout.appendChild(commonSection);
}
panel.appendChild(panelLayout);
inputField.parentNode.appendChild(panel);
inputField.addEventListener("focus", () => {
panel.style.display = "block";
});
inputField.addEventListener("blur", () => {
setTimeout(() => {
panel.style.display = "none";
}, 100);
});
// Selection of an item
panelLayout.addEventListener("mousedown", (event) => {
const target = event.target.closest("li");
if (target) {
inputField.value = target.dataset.suggestion;
panel.style.display = "none";
}
});
this.isPanelInitialized = true;
};
watchCategoryChanges() {
const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]');
const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]');
this.previousCategoryId = Number(this.partCategoryIdValue);
if (categoryField) {
categoryField.addEventListener("change", () => {
const categoryId = Number(categoryField.value);
const description = String(descriptionField?.value ?? '');
// Check whether the category has changed compared to the previous ID
if (categoryId !== this.previousCategoryId) {
this.fetchNewSuggestions(categoryId, description);
this.previousCategoryId = categoryId;
}
});
}
}
watchDescriptionChanges() {
const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]');
const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]');
this.previousDescription = String(this.partDescriptionValue);
if (descriptionField) {
descriptionField.addEventListener("input", () => {
const categoryId = Number(categoryField.value);
const description = String(descriptionField?.value ?? '');
// Check whether the description has changed compared to the previous one
if (description !== this.previousDescription) {
this.fetchNewSuggestions(categoryId, description);
this.previousDescription = description;
}
});
}
}
fetchNewSuggestions(categoryId, description) {
const baseUrl = this.suggestUrlValue;
const partId = this.partIdValue;
const truncatedDescription = description.length > 150 ? description.substring(0, 150) : description;
const encodedDescription = this.base64EncodeUtf8(truncatedDescription);
const url = `${baseUrl}?partId=${partId}&categoryId=${categoryId}` + (description !== '' ? `&description=${encodedDescription}` : '');
fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
})
.then((response) => {
if (!response.ok) {
throw new Error(`Error when calling up the IPN-suggestions: ${response.status}`);
}
return response.json();
})
.then((data) => {
this.suggestionsValue = data;
this.configureAutocomplete();
})
.catch((error) => {
console.error("Errors when loading the new IPN-suggestions:", error);
});
};
base64EncodeUtf8(text) {
const utf8Bytes = new TextEncoder().encode(text);
return btoa(String.fromCharCode(...utf8Bytes));
};
}

View file

@ -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);
});
}
}

View file

@ -26,9 +26,6 @@ import {marked} from "marked";
import {
trans,
SEARCH_PLACEHOLDER,
SEARCH_SUBMIT,
STATISTICS_PARTS
} from '../../translator';
@ -82,9 +79,9 @@ export default class extends Controller {
panelPlacement: this.element.dataset.panelPlacement,
plugins: [recentSearchesPlugin],
openOnFocus: true,
placeholder: trans(SEARCH_PLACEHOLDER),
placeholder: trans("search.placeholder"),
translations: {
submitButtonTitle: trans(SEARCH_SUBMIT)
submitButtonTitle: trans("search.submit")
},
// Use a navigator compatible with turbo:
@ -153,7 +150,7 @@ export default class extends Controller {
},
templates: {
header({ html }) {
return html`<span class="aa-SourceHeaderTitle">${trans(STATISTICS_PARTS)}</span>
return html`<span class="aa-SourceHeaderTitle">${trans("part.labelp")}</span>
<div class="aa-SourceHeaderLine" />`;
},
item({item, components, html}) {
@ -197,4 +194,4 @@ export default class extends Controller {
}
}
}
}

View file

@ -4,6 +4,9 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
import {marked} from "marked";
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
@ -18,11 +21,13 @@ export default class extends Controller {
let settings = {
allowEmptyOption: true,
plugins: ['dropdown_input'],
plugins: ['dropdown_input', this.element.required ? null : 'clear_button', 'form_reset_handler'],
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",
dropdownParent: dropdownParent,
selectOnTab: true,
clearAfterSelect: true,
preload: "focus",
render: {
item: (data, escape) => {

View file

@ -25,8 +25,7 @@ import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
import * as zxcvbnDePackage from '@zxcvbn-ts/language-de';
import * as zxcvbnFrPackage from '@zxcvbn-ts/language-fr';
import * as zxcvbnJaPackage from '@zxcvbn-ts/language-ja';
import {trans, USER_PASSWORD_STRENGTH_VERY_WEAK, USER_PASSWORD_STRENGTH_WEAK, USER_PASSWORD_STRENGTH_MEDIUM,
USER_PASSWORD_STRENGTH_STRONG, USER_PASSWORD_STRENGTH_VERY_STRONG} from '../../translator.js';
import {trans} from '../../translator.js';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
@ -89,23 +88,23 @@ export default class extends Controller {
switch (level) {
case 0:
text = trans(USER_PASSWORD_STRENGTH_VERY_WEAK);
text = trans("user.password_strength.very_weak");
classes = "bg-danger badge-danger";
break;
case 1:
text = trans(USER_PASSWORD_STRENGTH_WEAK);
text = trans("user.password_strength.weak");
classes = "bg-warning badge-warning";
break;
case 2:
text = trans(USER_PASSWORD_STRENGTH_MEDIUM)
text = trans("user.password_strength.medium");
classes = "bg-info badge-info";
break;
case 3:
text = trans(USER_PASSWORD_STRENGTH_STRONG);
text = trans("user.password_strength.strong");
classes = "bg-primary badge-primary";
break;
case 4:
text = trans(USER_PASSWORD_STRENGTH_VERY_STRONG);
text = trans("user.password_strength.very_strong");
classes = "bg-success badge-success";
break;
default:
@ -120,4 +119,4 @@ export default class extends Controller {
this.badgeTarget.classList.add("badge");
this.badgeTarget.classList.add(...classes.split(" "));
}
}
}

View file

@ -21,6 +21,9 @@ import {Controller} from "@hotwired/stimulus";
import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
@ -44,11 +47,12 @@ export default class extends Controller {
}
let settings = {
plugins: ["clear_button"],
plugins: ["clear_button", "form_reset_handler"],
allowEmptyOption: true,
selectOnTab: true,
maxOptions: null,
dropdownParent: dropdownParent,
clearAfterSelect: true,
render: {
item: this.renderItem.bind(this),

View file

@ -35,6 +35,8 @@ export default class extends Controller {
maxItems: 1000,
allowEmptyOption: true,
dropdownParent: dropdownParent,
selectOnTab: true,
clearAfterSelect: true,
plugins: ['remove_button'],
});
}

View file

@ -19,6 +19,7 @@
import {Controller} from "@hotwired/stimulus";
import {default as TreeController} from "./tree_controller";
import {EVENT_INITIALIZED} from "@jbtronics/bs-treeview";
export default class extends TreeController {
static targets = [ "tree", 'sourceText' ];
@ -40,6 +41,8 @@ export default class extends TreeController {
//Check if we have a saved mode
const stored_mode = localStorage.getItem(this._storage_key);
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
//Use stored mode if possible, otherwise use default
if(stored_mode) {
try {
@ -55,6 +58,39 @@ export default class extends TreeController {
//Register an event listener which checks if the tree needs to be updated
document.addEventListener('turbo:render', this.doUpdateIfNeeded.bind(this));
//Register an event listener, to check if we end up on a page we can highlight in the tree, if so then higlight it
document.addEventListener('turbo:load', this._onTurboLoad.bind(this));
//On initial page load the tree is not available yet, so do another check after the tree is initialized
this.treeTarget.addEventListener(EVENT_INITIALIZED, (event) => {
this.selectNodeWithURL(document.location)
});
}
_onTurboLoad(event) {
this.selectNodeWithURL(event.detail.url);
}
selectNodeWithURL(url) {
//Get path from url
const path = new URL(url).pathname;
if (!this._tree) {
return;
}
//Unselect all nodes
this._tree.unselectAll({silent: true, ignorePreventUnselect: true});
//Try to find a node with this path as data-path
const nodes = this._tree.findNodes(path, "href");
if (nodes.length !== 1) {
return; //We can only work with exactly one node, if there are multiple nodes with the same path, we cannot know which one to select, so we do nothing
}
const node = nodes[0];
node.setSelected(true, {ignorePreventUnselect: true, silent: true});
this._tree.revealNode(node);
}
doUpdateIfNeeded()

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
/**
* This is the frontend controller for StaticFileAutocompleteType form element.
@ -56,6 +58,7 @@ export default class extends Controller {
searchField: 'text',
orderField: 'text',
dropdownParent: dropdownParent,
clearAfterSelect: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
@ -63,7 +66,8 @@ export default class extends Controller {
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
'restore_on_backspace': {}
'restore_on_backspace': {},
'form_reset_handler': {}
}
};

View file

@ -22,10 +22,12 @@ import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
import {Controller} from "@hotwired/stimulus";
import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js'
import {trans} from '../../translator.js'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
@ -58,6 +60,7 @@ export default class extends Controller {
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
splitOn: null,
dropdownParent: dropdownParent,
clearAfterSelect: true,
searchField: [
{field: "text", weight : 2},
@ -95,6 +98,7 @@ export default class extends Controller {
plugins: {
"autoselect_typed": {},
"form_reset_handler": {},
}
};
@ -104,6 +108,7 @@ export default class extends Controller {
}
this._tomSelect = new TomSelect(this.element, settings);
//Do not do a sync here as this breaks the initial rendering of the empty option
//this._tomSelect.sync();
}
@ -204,7 +209,7 @@ export default class extends Controller {
if (data.not_in_db_yet) {
//Not yet added items are shown italic and with a badge
name += "<i><b>" + escape(data.text) + "</b></i>" + "<span class='ms-3 badge bg-info badge-info'>" + trans(ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB) + "</span>";
name += "<i><b>" + escape(data.text) + "</b></i>" + "<span class='ms-3 badge bg-info badge-info'>" + trans("entity.select.group.new_not_added_to_DB") + "</span>";
} else {
name += "<b>" + escape(data.text) + "</b>";
}

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller {
_tomSelect;
@ -43,12 +45,14 @@ export default class extends Controller {
remove_button:{},
'autoselect_typed': {},
'click_to_edit': {},
'form_reset_handler': {},
},
persistent: false,
selectOnTab: true,
createOnBlur: true,
create: true,
dropdownParent: dropdownParent,
clearAfterSelect: true,
};
if(this.element.dataset.autocomplete) {

View file

@ -39,6 +39,8 @@ export default class extends Controller {
*/
_tree = null;
_frame = "frame";
connect() {
const treeElement = this.treeTarget;
if (!treeElement) {
@ -48,6 +50,7 @@ export default class extends Controller {
this._url = this.element.dataset.treeUrl;
this._data = this.element.dataset.treeData;
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
if(this.element.dataset.treeShowTags === "true") {
this._showTags = true;
@ -99,8 +102,18 @@ export default class extends Controller {
onNodeSelected: (event) => {
const node = event.detail.node;
if (node.href) {
window.Turbo.visit(node.href, {action: "advance"});
this._registerURLWatcher(node);
const url = node.href;
// Turbo.visit with a frame target bypasses turbo:before-visit, so dispatch it
// manually so that dirty-form guards can intercept it.
const beforeVisitEvent = new CustomEvent('turbo:before-visit', {
bubbles: true,
cancelable: true,
detail: { url, frame: this._frame },
});
document.dispatchEvent(beforeVisitEvent);
if (!beforeVisitEvent.defaultPrevented) {
window.Turbo.visit(url, {action: "advance", frame: this._frame});
}
}
},
}, [BS5Theme, BS53Theme, FAIconTheme]);
@ -110,41 +123,12 @@ export default class extends Controller {
const treeView = event.detail.treeView;
treeView.revealNode(treeView.getSelected());
//Add the url watcher to all selected nodes
for (const node of treeView.getSelected()) {
this._registerURLWatcher(node);
}
//Add contextmenu event listener to the tree, which allows us to open the links in a new tab with a right click
treeView.getTreeElement().addEventListener("contextmenu", this._onContextMenu.bind(this));
});
}
_registerURLWatcher(node)
{
//Register a watcher for a location change, which will unselect the node, if the location changes
const desired_url = node.href;
//Ensure that the node is unselected, if the location changes
const unselectNode = () => {
//Parse url so we can properly compare them
const desired = new URL(node.href, window.location.origin);
//We only compare the pathname, because the hash and parameters should not matter
if(window.location.pathname !== desired.pathname) {
//The ignore parameter is important here, otherwise the node will not be unselected
node.setSelected(false, {silent: true, ignorePreventUnselect: true});
//Unregister the watcher
document.removeEventListener('turbo:load', unselectNode);
}
};
//Register the watcher via hotwire turbo
//We must just load to have the new url in window.location
document.addEventListener('turbo:load', unselectNode);
}
_onContextMenu(event)
{
@ -198,4 +182,4 @@ export default class extends Controller {
return myResolve(this._data);
});
}
}
}

View file

@ -70,6 +70,13 @@ export default class extends Controller {
newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this))
}
// Auto-increment priority based on existing mappings
const nextPriority = this.getNextPriority()
const priorityInput = newRow.querySelector('input[name*="[priority]"]')
if (priorityInput) {
priorityInput.value = nextPriority
}
this.updateFieldOptions()
this.updateAddButtonState()
}
@ -119,6 +126,18 @@ export default class extends Controller {
}
}
getNextPriority() {
const priorityInputs = this.tbodyTarget.querySelectorAll('input[name*="[priority]"]')
let maxPriority = 0
priorityInputs.forEach(input => {
const val = parseInt(input.value, 10)
if (!isNaN(val) && val > maxPriority) {
maxPriority = val
}
})
return Math.min(maxPriority + 1, 10)
}
handleFormSubmit(event) {
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = true

View file

@ -62,6 +62,6 @@ export default class extends Controller {
element.disabled = true;
}
form.submit();
form.requestSubmit();
}
}
}

View 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();
}
}

View file

@ -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,31 +59,67 @@ 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').submit();
document.getElementById('scan_dialog_form').requestSubmit();
}
}
}

View file

@ -25,9 +25,11 @@ import "katex/dist/katex.css";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
/* stimulusFetch: 'lazy' */
export default class extends Controller
@ -63,7 +65,8 @@ export default class extends Controller
'autoselect_typed': {},
'click_to_edit': {},
'clear_button': {},
'restore_on_backspace': {}
'restore_on_backspace': {},
'form_reset_handler': {}
},
persistent: false,
maxItems: 1,
@ -75,6 +78,7 @@ export default class extends Controller
searchField: "name",
//labelField: "name",
valueField: "name",
clearAfterSelect: true,
onItemAdd: this.onItemAdd.bind(this),
render: {
option: (data, escape) => {
@ -136,4 +140,4 @@ export default class extends Controller
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}
}

View file

@ -0,0 +1,27 @@
import {Controller} from "@hotwired/stimulus";
import {Modal} from "bootstrap";
export default class extends Controller
{
connect() {
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
}
_handleModalOpen(event) {
// Button that triggered the modal
const button = event.relatedTarget;
const amountInput = this.element.querySelector('input[name="amount"]');
// Extract info from button attributes
const lotID = button.getAttribute('data-lot-id');
const lotAmount = button.getAttribute('data-lot-amount');
//Find the expected amount field and set the value to the lot amount
const expectedAmountInput = this.element.querySelector('#stocktake-modal-expected-amount');
expectedAmountInput.textContent = lotAmount;
//Set the action and lotID inputs in the form
this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID);
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,68 @@
/*
* 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 {
static targets = ['items'];
static values = {
prototype: String,
prototypeName: { type: String, default: '__name__' },
index: { type: Number, default: 0 },
};
connect() {
if (!this.hasIndexValue || Number.isNaN(this.indexValue)) {
this.indexValue = this.itemsTarget?.children.length || 0;
}
}
add(event) {
event.preventDefault();
const encodedProto = this.prototypeValue || '';
const placeholder = this.prototypeNameValue || '__name__';
if (!encodedProto || !this.itemsTarget) return;
const protoHtml = this._decodeHtmlAttribute(encodedProto);
const idx = this.indexValue;
const html = protoHtml.replace(new RegExp(placeholder, 'g'), String(idx));
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const newItem = wrapper.firstElementChild;
if (newItem) {
this.itemsTarget.appendChild(newItem);
this.indexValue = idx + 1;
}
}
remove(event) {
event.preventDefault();
const row = event.currentTarget.closest('.tc-item');
if (row) row.remove();
}
_decodeHtmlAttribute(str) {
const tmp = document.createElement('textarea');
tmp.innerHTML = str;
return tmp.value || tmp.textContent || '';
}
}

View file

@ -0,0 +1,81 @@
/*
* 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/>.
*/
import { Controller } from '@hotwired/stimulus';
/**
* Stimulus controller for update/downgrade confirmation dialogs.
* Intercepts form submission and shows a confirmation dialog before proceeding.
*/
export default class extends Controller {
static values = {
isDowngrade: { type: Boolean, default: false },
targetVersion: { type: String, default: '' },
confirmUpdate: { type: String, default: 'Are you sure you want to update Part-DB?' },
confirmDowngrade: { type: String, default: 'Are you sure you want to downgrade Part-DB?' },
downgradeWarning: { type: String, default: 'WARNING: This version does not include the Update Manager.' },
minUpdateManagerVersion: { type: String, default: '2.6.0' },
};
connect() {
this.element.addEventListener('submit', this.handleSubmit.bind(this));
}
handleSubmit(event) {
// Always prevent default first
event.preventDefault();
const targetClean = this.targetVersionValue.replace(/^v/, '');
let message;
if (this.isDowngradeValue) {
// Check if downgrading to a version without Update Manager
if (this.compareVersions(targetClean, this.minUpdateManagerVersionValue) < 0) {
message = this.confirmDowngradeValue + '\n\n⚠ ' + this.downgradeWarningValue;
} else {
message = this.confirmDowngradeValue;
}
} else {
message = this.confirmUpdateValue;
}
// Only submit if user confirms
if (confirm(message)) {
// Remove the event listener to prevent infinite loop, then submit
this.element.removeEventListener('submit', this.handleSubmit.bind(this));
this.element.submit();
}
}
/**
* Compare two version strings (e.g., "2.5.0" vs "2.6.0")
* Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2
*/
compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 < p2) return -1;
if (p1 > p2) return 1;
}
return 0;
}
}

View file

@ -58,6 +58,12 @@
object-fit: contain;
}
@media (max-width: 768px) {
.part-info-image {
max-height: 100px;
}
}
.object-fit-cover {
object-fit: cover;
}

View file

@ -133,7 +133,7 @@ showing the sidebar (on devices with md or higher)
*/
#sidebar-toggle-button {
position: fixed;
left: 3px;
left: 2px;
bottom: 50%;
}

View file

@ -125,3 +125,25 @@ Classes for Datatables export
.export-helper{
display: none;
}
/**********************************************************
* Table row highlighting tools
***********************************************************/
.row-highlight {
box-shadow: 0 4px 15px rgba(0,0,0,0.20); /* Adds depth */
position: relative;
z-index: 1; /* Ensures the shadow overlaps other rows */
border-left: 5px solid var(--bs-primary); /* Adds a vertical accent bar */
}
@keyframes pulse-highlight {
0% { outline: 2px solid transparent; }
50% { outline: 2px solid var(--bs-primary); }
100% { outline: 2px solid transparent; }
}
.row-pulse {
animation: pulse-highlight 1s ease-in-out;
animation-iteration-count: 3;
}

View file

@ -0,0 +1,61 @@
/*
* 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/>.
*/
/* Standard Bootstrap form controls */
.form-control[data-dirty],
.form-select[data-dirty],
input[data-dirty] + .form-control,
select[data-dirty] + .form-select
{
border-color: var(--bs-info);
border-width: 2px;
}
.form-control[data-dirty]:focus,
.form-select[data-dirty]:focus,
input[data-dirty] + .form-control:focus,
select[data-dirty] + .form-select:focus
{
border-color: var(--bs-info);
border-width: 2px;
box-shadow: 0 0 0 0.25rem rgba(var(--bs-info-rgb), 0.25);
}
/* Checkboxes and radios */
.form-check-input[data-dirty] {
border-color: var(--bs-info);
border-width: 2px;
}
.form-check-input[data-dirty]:checked {
background-color: var(--bs-info);
border-color: var(--bs-info);
border-width: 2px;
}
/* CKEditor: the editable sits after the hidden source textarea in the DOM */
textarea[data-dirty] ~ .ck.ck-editor .ck-editor__editable:not(.ck-focused) {
border-color: var(--bs-info) !important;
border-width: 2px !important;
}
textarea[data-dirty] ~ .ck.ck-editor .ck-editor__editable.ck-focused {
border-width: 2px !important;
box-shadow: 0 0 0 0.25rem rgba(var(--bs-info-rgb), 0.25) !important;
}

View file

@ -28,7 +28,7 @@ import '../css/app/treeview.css';
import '../css/app/images.css';
// start the Stimulus application
import '../bootstrap';
import '../stimulus_bootstrap';
// Need jQuery? Install it with "yarn add jquery", then uncomment to require it.
const $ = require('jquery');
@ -44,7 +44,7 @@ import "./register_events";
import "./tristate_checkboxes";
//Define jquery globally
window.$ = window.jQuery = require("jquery");
global.$ = global.jQuery = require("jquery");
//Use the local WASM file for the ZXing library
import {

View file

@ -83,6 +83,8 @@ export default class TristateCheckbox {
//Do a refresh to set the correct styling of the checkbox
this._refresh();
// Anchor the hidden input's default so dirty-form comparisons have a clean baseline.
this._hiddenInput.defaultValue = this._hiddenInput.value;
this._element.addEventListener('click', this.click.bind(this));
}
@ -202,6 +204,9 @@ export default class TristateCheckbox {
}
this._refresh();
// Notify change listeners (e.g. dirty-form controller) since programmatic
// value changes don't fire native change events.
this._hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
}
}

View file

@ -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(() => {

View file

@ -56,7 +56,9 @@ class TristateHelper {
document.addEventListener("turbo:load", listener);
document.addEventListener("turbo:render", listener);
document.addEventListener("turbo:frame-load", listener);
document.addEventListener("collection:elementAdded", listener);
}
}
export default new TristateHelper();
export default new TristateHelper();

View file

@ -198,6 +198,7 @@ class WebauthnTFA {
{
const resultField = document.getElementById('_auth_code');
resultField.value = JSON.stringify(data)
//requestSubmit() do not work here, probably because the submit is considered invalid. But as we do not use CSFR tokens, it should be fine.
form.submit();
}
@ -232,4 +233,4 @@ class WebauthnTFA {
}
}
window.webauthnTFA = new WebauthnTFA();
window.webauthnTFA = new WebauthnTFA();

View file

@ -0,0 +1,46 @@
/*
* 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/>.
*/
/**
* TomSelect plugin for dirty-check integration and form reset support.
*
* Sets data-default-value on the underlying <select> so the dirty-check
* controller can detect changes, and restores that value when the form is reset.
*/
export default function form_reset_handler() {
const self = this;
const input = this.input;
// Multiple selects not yet supported
if (input.multiple) {
return;
}
// Always capture the initial value, even empty string.
// Empty string is falsy, so the old `|| null` guard would silently skip it,
// leaving data-default-value unset and breaking the dirty check for blank defaults.
input.dataset.defaultValue = input.value;
if (input.form) {
input.form.addEventListener('reset', () => {
input.value = input.dataset.defaultValue ?? '';
self.sync();
});
}
}

View file

@ -1,5 +1,6 @@
import { localeFallbacks } from '../var/translations/configuration';
import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator';
import { createTranslator } from '@symfony/ux-translator';
import { messages, localeFallbacks } from '../var/translations/index.js';
/*
* This file is part of the Symfony UX Translator package.
*
@ -9,8 +10,12 @@ import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-tra
* If you use TypeScript, you can rename this file to "translator.ts" to take advantage of types checking.
*/
setLocaleFallbacks(localeFallbacks);
const translator = createTranslator({
messages,
localeFallbacks,
});
export { trans };
export * from '../var/translations';
// Wrapper function with default domain set to 'frontend'
export const trans = (id, parameters = {}, domain = 'frontend', locale = null) => {
return translator.trans(id, parameters, domain, locale);
};

View file

@ -1,23 +1,4 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 80000) {
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
} else {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
}
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

View file

@ -11,12 +11,14 @@
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-zip": "*",
"amphp/http-client": "^5.1",
"api-platform/doctrine-orm": "^4.1",
"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.17.0",
"brick/schema": "^0.2.0",
"composer/ca-bundle": "^1.5",
"composer/package-versions-deprecated": "^1.11.99.5",
"doctrine/data-fixtures": "^2.0.0",
@ -26,11 +28,12 @@
"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",
"jfcherng/php-diff": "^6.14",
"jkphl/micrometa": "^v3.4.0",
"knpuniversity/oauth2-client-bundle": "^2.15",
"league/commonmark": "^2.7",
"league/csv": "^9.8.0",
@ -43,12 +46,10 @@
"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",
"rhukster/dom-sanitizer": "^1.0",
"runtime/frankenphp-symfony": "^0.2.0",
"s9e/text-formatter": "^2.1",
"scheb/2fa-backup-code": "^v7.11.0",
"scheb/2fa-bundle": "^v7.11.0",
@ -56,39 +57,43 @@
"scheb/2fa-trusted-device": "^v7.11.0",
"shivas/versioning-bundle": "^4.0",
"spatie/db-dumper": "^3.3.1",
"symfony/ai-bundle": "^0.9.0",
"symfony/ai-lm-studio-platform": "^0.9.0",
"symfony/ai-open-router-platform": "^0.9.0",
"symfony/apache-pack": "^1.0",
"symfony/asset": "7.3.*",
"symfony/console": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/dom-crawler": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/asset": "7.4.*",
"symfony/console": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/dom-crawler": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^v2.3.1",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/http-kernel": "7.3.*",
"symfony/mailer": "7.3.*",
"symfony/monolog-bundle": "^3.1",
"symfony/polyfill-php82": "^1.28",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/rate-limiter": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/string": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-translator": "^2.10",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/http-kernel": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/monolog-bundle": "^4.0",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/string": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/type-info": "7.4.*",
"symfony/ux-translator": "^2.32.0",
"symfony/ux-turbo": "^2.0",
"symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*",
"symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^v2.0.1",
"symfony/yaml": "7.3.*",
"symplify/easy-coding-standard": "^12.5.20",
"symfony/yaml": "7.4.*",
"symplify/easy-coding-standard": "^13.0",
"tecnickcom/tc-lib-barcode": "^2.1.4",
"tiendanube/gtinvalidation": "^1.0",
"twig/cssinliner-extra": "^3.0",
"twig/extra-bundle": "^3.8",
"twig/html-extra": "^3.8",
@ -111,16 +116,23 @@
"phpunit/phpunit": "^11.5.0",
"rector/rector": "^2.0.4",
"roave/security-advisories": "dev-latest",
"symfony/browser-kit": "7.3.*",
"symfony/debug-bundle": "7.3.*",
"symfony/browser-kit": "7.4.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.13",
"symfony/phpunit-bridge": "7.3.*",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
"symfony/phpunit-bridge": "7.4.*",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
},
"replace": {
"symfony/polyfill-mbstring": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"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": {
@ -167,8 +179,13 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.3.*",
"require": "7.4.*",
"docker": true
},
"phpstan/extension-installer": {
"ignore" : [
"ekino/phpstan-banned-code"
]
}
}
}

6641
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -33,4 +33,5 @@ return [
Jbtronics\SettingsBundle\JbtronicsSettingsBundle::class => ['all' => true],
Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
];

27
config/packages/ai.yaml Normal file
View file

@ -0,0 +1,27 @@
ai:
platform:
# Inference Platform configuration
# see https://github.com/symfony/ai/tree/main/src/platform#platform-bridges
# openai:
# api_key: '%env(OPENAI_API_KEY)%'
agent:
# Agent configuration
# see https://symfony.com/doc/current/ai/bundles/ai-bundle.html
# default:
# platform: 'ai.platform.openai'
# model: 'gpt-5-mini'
# prompt: |
# You are a pirate and you write funny.
# tools:
# - 'Symfony\AI\Agent\Bridge\Clock\Clock'
store:
# Store configuration
# chromadb:
# default:
# client: 'client.service.id'
# collection: 'my_collection'

View file

@ -0,0 +1,5 @@
ai:
platform:
generic:
default:
base_url: '%env(GENERIC_BASE_URL)%'

View file

@ -0,0 +1,4 @@
ai:
platform:
lmstudio:
host_url: '%env(string:settings:ai_lmstudio:hostURL)%'

View file

@ -0,0 +1,4 @@
ai:
platform:
openrouter:
api_key: '%env(string:settings:ai_openrouter:apiKey)%'

View file

@ -23,3 +23,7 @@ framework:
info_provider.cache:
adapter: cache.app
cache.settings:
adapter: cache.system
tags: true

View file

@ -8,7 +8,7 @@ datatables:
# Set options, as documented at https://datatables.net/reference/option/
options:
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]] # We add the "All" option, when part tables are generated
lengthMenu : [[10, 25, 50, 100, 250, 500], [10, 25, 50, 100, 250, 500]] # We add the "All" option, when part tables are generated
#pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default
pageLength: 50 #TODO
dom: " <'row' <'col mb-2 input-group flex-nowrap' B l > <'col-auto mb-2' < p >>>

View file

@ -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);

View file

@ -56,6 +56,7 @@ doctrine:
natsort: App\Doctrine\Functions\Natsort
array_position: App\Doctrine\Functions\ArrayPosition
ilike: App\Doctrine\Functions\ILike
si_value_sort: App\Doctrine\Functions\SiValueSort
when@test:
doctrine:

View file

@ -1,3 +1,4 @@
# yaml-language-server: $schema=../../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
@ -8,6 +9,7 @@ framework:
# Must be set to true, to enable the change of HTTP method via _method parameter, otherwise our delete routines does not work anymore
# TODO: Rework delete routines to work without _method parameter as it is not recommended anymore (see https://github.com/symfony/symfony/issues/45278)
http_method_override: true
allowed_http_method_override: ['DELETE']
# Allow users to configure trusted hosts via .env variables
# see https://symfony.com/doc/current/reference/configuration/framework.html#trusted-hosts

View file

@ -35,4 +35,4 @@ knpu_oauth2_client:
provider_options:
urlAuthorize: 'https://identity.nexar.com/connect/authorize'
urlAccessToken: 'https://identity.nexar.com/connect/token'
urlResourceOwnerDetails: ''
urlResourceOwnerDetails: ''

View file

@ -10,14 +10,6 @@ when@dev:
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
@ -45,6 +37,7 @@ when@prod:
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream

View file

@ -3,6 +3,7 @@ jbtronics_settings:
cache:
default_cacheable: true
service: 'cache.settings'
orm_storage:
default_entity_class: App\Entity\SettingsEntry

View file

@ -1,6 +1,6 @@
twig:
default_path: '%kernel.project_dir%/templates'
form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'form/extended_bootstrap_layout.html.twig', 'form/permission_layout.html.twig', 'form/filter_types_layout.html.twig']
form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'form/extended_bootstrap_layout.html.twig', 'form/permission_layout.html.twig', 'form/filter_types_layout.html.twig', 'form/synonyms_collection.html.twig']
paths:
'%kernel.project_dir%/assets/css': css
@ -18,6 +18,11 @@ twig:
saml_enabled: '%partdb.saml.enabled%'
part_preview_generator: '@App\Services\Attachments\PartPreviewGenerator'
# Bootstrap grid classes used for horizontal form layouts
col_label: 'col-sm-3 col-lg-2' # The column classes for form labels
col_input: 'col-sm-9 col-lg-10' # The column classes for form input fields
offset_label: 'offset-sm-3 offset-lg-2' # Offset classes for elements that should be aligned with the input fields (e.g., submit buttons)
when@test:
twig:
strict_variables: true
strict_variables: true

View file

@ -1,3 +1,12 @@
ux_translator:
# The directory where the JavaScript translations are dumped
dump_directory: '%kernel.project_dir%/var/translations'
# Only include the frontend translation domain in the JavaScript bundle
domains:
- 'frontend'
when@prod:
ux_translator:
# Control whether TypeScript types are dumped alongside translations.
# Disable this if you do not use TypeScript (e.g. in production when using AssetMapper), to speed up cache warmup.
# dump_typescript: false

View file

@ -10,7 +10,7 @@ parameters:
partdb.title: '%env(string:settings:customization:instanceName)%' # The title shown inside of Part-DB (e.g. in the navbar and on homepage)
partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl', 'hu'] # The languages that are shown in user drop down menu
partdb.default_uri: '%env(string:DEFAULT_URI)%' # The default URI to use for the Part-DB instance (e.g. https://part-db.example.com/). This is used for generating links in emails
partdb.default_uri: '%env(addSlash:string:DEFAULT_URI)%' # The default URI to use for the Part-DB instance (e.g. https://part-db.example.com/). This is used for generating links in emails
partdb.db.emulate_natural_sort: '%env(bool:DATABASE_EMULATE_NATURAL_SORT)%' # If this is set to true, natural sorting is emulated on platforms that do not support it natively. This can be slow on large datasets.
@ -105,6 +105,8 @@ parameters:
env(DATABASE_EMULATE_NATURAL_SORT): 0
env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0
######################################################################################################################
# Bulk Info Provider Import Configuration
######################################################################################################################

View file

@ -18,13 +18,13 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
parts: # e.g. this maps to perms_parts in User/Group database
group: "data"
label: "perm.parts"
label: "[[Part]]"
operations: # Here are all possible operations are listed => the op name is mapped to bit value
read:
label: "perm.read"
# If a part can be read by a user, he can also see all the datastructures (except devices)
alsoSet: ['storelocations.read', 'footprints.read', 'categories.read', 'suppliers.read', 'manufacturers.read',
'currencies.read', 'attachment_types.read', 'measurement_units.read']
'currencies.read', 'attachment_types.read', 'measurement_units.read', 'part_custom_states.read']
apiTokenRole: ROLE_API_READ_ONLY
edit:
label: "perm.edit"
@ -68,10 +68,13 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
move:
label: "perm.parts_stock.move"
apiTokenRole: ROLE_API_EDIT
stocktake:
label: "perm.parts_stock.stocktake"
apiTokenRole: ROLE_API_EDIT
storelocations: &PART_CONTAINING
label: "perm.storelocations"
label: "[[Storage_location]]"
group: "data"
operations:
read:
@ -103,35 +106,39 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
footprints:
<<: *PART_CONTAINING
label: "perm.part.footprints"
label: "[[Footprint]]"
categories:
<<: *PART_CONTAINING
label: "perm.part.categories"
label: "[[Category]]"
suppliers:
<<: *PART_CONTAINING
label: "perm.part.supplier"
label: "[[Supplier]]"
manufacturers:
<<: *PART_CONTAINING
label: "perm.part.manufacturers"
label: "[[Manufacturer]]"
projects:
<<: *PART_CONTAINING
label: "perm.projects"
label: "[[Project]]"
attachment_types:
<<: *PART_CONTAINING
label: "perm.part.attachment_types"
label: "[[Attachment_type]]"
currencies:
<<: *PART_CONTAINING
label: "perm.currencies"
label: "[[Currency]]"
measurement_units:
<<: *PART_CONTAINING
label: "perm.measurement_units"
label: "[[Measurement_unit]]"
part_custom_states:
<<: *PART_CONTAINING
label: "[[Part_custom_state]]"
tools:
label: "perm.part.tools"
@ -293,6 +300,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
show_updates:
label: "perm.system.show_available_updates"
apiTokenRole: ROLE_API_ADMIN
manage_updates:
label: "perm.system.manage_updates"
alsoSet: ['show_updates', 'server_infos']
apiTokenRole: ROLE_API_ADMIN
attachments:
@ -373,4 +384,4 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
manage_tokens:
label: "perm.api.manage_tokens"
alsoSet: ['access_api']
apiTokenRole: ROLE_API_FULL
apiTokenRole: ROLE_API_FULL

3448
config/reference.php Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,12 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
# bin/console debug:router
# Redirect every url without an locale to the locale of the user/the global base locale
scan_qr:
@ -16,4 +25,4 @@ redirector:
url: ".*"
controller: App\Controller\RedirectController::addLocalePart
# Dont match localized routes (no redirection loop, if no root with that name exists) or API prefixed routes
condition: "not (request.getPathInfo() matches '/^\\\\/([a-z]{2}(_[A-Z]{2})?|api)\\\\//')"
condition: "not (request.getPathInfo() matches '/^\\\\/([a-z]{2}(_[A-Z]{2})?|api)\\\\//')"

View file

@ -1,5 +1,8 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# See also https://symfony.com/doc/current/service_container/import.html
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
@ -29,6 +32,9 @@ services:
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/Entity/'
- '../src/Helpers/'
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class
@ -227,9 +233,15 @@ services:
arguments:
$enforce_index_php: '%env(bool:NO_URL_REWRITE_AVAILABLE)%'
App\Doctrine\Purger\ResetAutoIncrementPurgerFactory:
App\Repository\PartRepository:
arguments:
$translator: '@translator'
tags: ['doctrine.repository_service']
App\EventSubscriber\UserSystem\PartUniqueIpnSubscriber:
tags:
- { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' }
- { name: doctrine.event_listener, event: onFlush, connection: default }
# We are needing this service inside a migration, where only the container is injected. So we need to define it as public, to access it from the container.
App\Services\UserSystem\PermissionPresetsHelper:
@ -262,7 +274,11 @@ services:
tags:
- { name: monolog.processor }
when@test:
App\Doctrine\Purger\ResetAutoIncrementPurgerFactory:
tags:
- { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' }
when@test: &test
services:
# Decorate the doctrine fixtures load command to use our custom purger by default
doctrine.fixtures_load_command.custom:

View file

@ -5,3 +5,5 @@ files:
translation: /translations/validators.%two_letters_code%.xlf
- source: /translations/security.en.xlf
translation: /translations/security.%two_letters_code%.xlf
- source: /translations/frontend.en.xlf
translation: /translations/frontend.%two_letters_code%.xlf

View file

@ -17,7 +17,7 @@ This allows external applications to interact with Part-DB, extend it or integra
> Some features might be missing or not working yet.
> Also be aware, that there might be security issues in the API, which could allow attackers to access or edit data via
> the API, which
> they normally should be able to access. So currently you should only use the API with trusted users and trusted
> they normally should not be able to access. So currently you should only use the API with trusted users and trusted
> applications.
Part-DB uses [API Platform](https://api-platform.com/) to provide the API, which allows for easy creation of REST APIs
@ -46,7 +46,7 @@ See [Authentication chapter]({% link api/authentication.md %}) for more details.
The API is split into different endpoints, which are reachable under the `/api/` path of your Part-DB instance (
e.g. `https://your-part-db.local/api/`).
There are various endpoints for each entity type (like `part`, `manufacturer`, etc.), which allow you to read and write data, and some special endpoints like `search` or `statistics`.
There are various endpoints for each entity type (like `parts`, `manufacturers`, etc.), which allow you to read and write data, and some special endpoints like `search` or `statistics`.
For example, all API endpoints for managing categories are available under `/api/categories/`. Depending on the exact
path and the HTTP method used, you can read, create, update or delete categories.
@ -56,7 +56,7 @@ For most entities, there are endpoints like this:
* **POST**: `/api/categories/` - Create a new category
* **GET**: `/api/categories/{id}` - Get a specific category by its ID
* **DELETE**: `/api/categories/{id}` - Delete a specific category by its ID
* **UPDATE**: `/api/categories/{id}` - Update a specific category by its ID. Only the fields which are sent in the
* **PATCH**: `/api/categories/{id}` - Update a specific category by its ID. Only the fields which are sent in the
request are updated, all other fields are left unchanged.
Be aware that you have to set the [JSON Merge Patch](https://datatracker.ietf.org/doc/html/rfc7386) content type
header (`Content-Type: application/merge-patch+json`) for this to work.
@ -106,11 +106,11 @@ This is a great way to test the API and see how it works, without having to writ
By default, all list endpoints are paginated, which means only a certain number of results is returned per request.
To get another page of the results, you have to use the `page` query parameter, which contains the page number you want
to get (e.g. `/api/categoues/?page=2`).
to get (e.g. `/api/categories/?page=2`).
When using JSONLD, the links to the next page are also included in the `hydra:view` property of the response.
To change the size of the pages (the number of items in a single page) use the `itemsPerPage` query parameter (
e.g. `/api/categoues/?itemsPerPage=50`).
e.g. `/api/categories/?itemsPerPage=50`).
See [API Platform docs](https://api-platform.com/docs/core/pagination) for more infos.

View file

@ -23,14 +23,14 @@ each other so that it does not matter which one of your 1000 things of Part you
A part entity has many fields, which can be used to describe it better. Most of the fields are optional:
* **Name** (Required): The name of the part or how you want to call it. This could be a manufacturer-provided name, or a
name you thought of yourself. Each name needs to be unique and must exist in a single category.
name you thought of yourself. Each name needs to be unique and must exist in a single category only.
* **Description**: A short (single-line) description of what this part is/does. For longer information, you should use
the comment field or the specifications
* **Category** (Required): The category (see there) to which this part belongs to.
* **Tags**: The list of tags this part belongs to. Tags can be used to group parts logically (similar to the category),
but tags are much less strict and formal (they don't have to be defined forehands) and you can assign multiple tags to
but tags are much less strict and formal (they don't have to be defined beforehand) and you can assign multiple tags to
a part. When clicking on a tag, a list with all parts which have the same tag, is shown.
* **Min Instock**: *Not really implemented yet*. Parts where the total instock is below this value, will show up for
* **Min Instock**: *Not fully implemented yet*. Parts where the total instock is below this value will show up for
ordering.
* **Footprint**: See there. Useful especially for electronic parts, which have one of the common electronic footprints (
like DIP8, SMD0805 or similar). If a part has no explicitly defined preview picture, the preview picture of its
@ -48,9 +48,9 @@ A part entity has many fields, which can be used to describe it better. Most of
completely trustworthy.
* **Favorite**: Parts with this flag are highlighted in parts lists
* **Mass**: The mass of a single piece of this part (so of a single transistor). Given in grams.
* **Internal Part number** (IPN): Each part is automatically assigned a numerical ID that identifies a part in the
database. This ID depends on when a part was created and can not be changed. If you want to assign your own unique
identifiers, or sync parts identifiers with the identifiers of another database you can use this field.
* **Internal Part Number** (IPN): Each part is automatically assigned a numerical ID that identifies a part in the
database. This ID depends on when a part was created and cannot be changed. If you want to assign your own unique
identifiers, or sync parts identifiers with the identifiers of another database, you can use this field.
### Stock / Part lot
@ -99,12 +99,12 @@ possible category tree could look like this:
### Supplier
A Supplier is a vendor/distributor where you can buy/order parts. Price information of parts is associated with a
A supplier is a vendor/distributor where you can buy/order parts. Price information of parts is associated with a
supplier.
### Manufacturer
A manufacturer represents the company that manufacturers/builds various parts (not necessarily sell them). If the
A manufacturer represents the company that manufactures/builds various parts (not necessarily sells them). If the
manufacturer also sells the parts, you have to create a supplier for that.
### Storage location

View file

@ -6,11 +6,11 @@ nav_order: 5
# Configuration
Part-DBs behavior can be configured to your needs. There are different kinds of configuration options: Options, which are
Part-DB's behavior can be configured to your needs. There are different kinds of configuration options: Options that are
user-changeable (changeable dynamically via frontend), options that can be configured by environment variables, and
options that are only configurable via Symfony config files.
## User configruation
## User configuration
The following things can be changed for every user and a user can change it for himself (if he has the correct permission
for it). Configuration is either possible via the user's own settings page (where you can also change the password) or via
@ -40,10 +40,10 @@ The following configuration options can only be changed by the server administra
variables, changing the `.env.local` file or setting env for your docker container. Here are just the most important
options listed, see `.env` file for the full list of possible env variables.
Environment variables allow to overwrite settings in the web interface. This is useful, if you want to enforce certain
settings to be unchangable by users, or if you want to configure settings in a central place in a deployed environment.
Environment variables allow you to overwrite settings in the web interface. This is useful if you want to enforce certain
settings to be unchangeable by users, or if you want to configure settings in a central place in a deployed environment.
On the settings page, you can hover over a setting to see, which environment variable can be used to overwrite it, it
is shown as tooltip. API keys or similar sensitve data which is overwritten by env variables, are redacted on the web
is shown as tooltip. API keys or similar sensitive data which is overwritten by env variables, are redacted on the web
interface, so that even administrators cannot see them (only the last 2 characters and the length).
For technical and security reasons some settings can only be configured via environment variables and not via the web
@ -86,6 +86,10 @@ 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.
* `ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK` (default `0`): When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file. This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
* `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.
@ -105,17 +109,29 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `part_delete`: Delete operation of an existing part
* `part_create`: Creation of a new part
* `part_stock_operation`: Stock operation on a part (therefore withdraw, add or move stock)
* `datastructure_edit`: Edit operation of an existing datastructure (e.g. category, manufacturer, ...)
* `datastructure_delete`: Delete operation of a existing datastructure (e.g. category, manufacturer, ...)
* `datastructure_create`: Creation of a new datastructure (e.g. category, manufacturer, ...)
* `CHECK_FOR_UPDATES` (default `1`): Set this to 0, if you do not want Part-DB to connect to GitHub to check for new
versions, or if your server can not connect to the internet.
* `datastructure_edit`: Edit operation of an existing data structure (e.g. category, manufacturer, ...)
* `datastructure_delete`: Delete operation of an existing data structure (e.g. category, manufacturer, ...)
* `datastructure_create`: Creation of a new data structure (e.g. category, manufacturer, ...)
* `CHECK_FOR_UPDATES` (default `1`): Set this to 0 if you do not want Part-DB to connect to GitHub to check for new
versions, or if your server cannot connect to the internet.
* `APP_SECRET` (env only): This variable is a configuration parameter used for various security-related purposes,
particularly for securing and protecting various aspects of your application. It's a secret key that is used for
cryptographic operations and security measures (session management, CSRF protection, etc..). Therefore this
value should be handled as confidential data and not shared publicly.
* `SHOW_PART_IMAGE_OVERLAY`: Set to 0 to disable the part image overlay, which appears if you hover over an image in the
part image gallery
* `IPN_SUGGEST_REGEX`: A global regular expression, that part IPNs have to fulfill. Enforce your own format for your users.
* `IPN_SUGGEST_REGEX_HELP`: Define your own user help text for the Regex format specification.
* `IPN_AUTO_APPEND_SUFFIX`: When enabled, an incremental suffix will be added to the user input when entering an existing
* IPN again upon saving.
* `IPN_SUGGEST_PART_DIGITS`: Defines the fixed number of digits used as the increment at the end of an IPN (Internal Part Number).
IPN prefixes, maintained within part categories and their hierarchy, form the foundation for suggesting complete IPNs.
These suggestions become accessible during IPN input of a part. The constant specifies the digits used to calculate and assign
unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation.
* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the parts 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)
@ -129,6 +145,18 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `ALLOW_EMAIL_PW_RESET`: Set this value to true, if you want to allow users to reset their password via an email
notification. You have to configure the mail provider first before via the MAILER_DSN setting.
### Update manager settings
* `DISABLE_WEB_UPDATES` (default `1`): Set this to 0 to enable web-based updates. When enabled, you can perform updates
via the web interface in the update manager. This is disabled by default for security reasons, as it can be a risk if
not used carefully. You can still use the CLI commands to perform updates, even when web updates are disabled.
* `DISABLE_BACKUP_RESTORE` (default `1`): Set this to 0 to enable backup restore via the web interface. When enabled, you can
restore backups via the web interface in the update manager. This is disabled by default for security reasons, as it can
be a risk if not used carefully. You can still use the CLI commands to perform backup restores, even when web-based
backup restore is disabled.
* `DISABLE_BACKUP_DOWNLOAD` (default `1`): Set this to 0 to enable backup download via the web interface. When enabled, you can download backups via the web interface
in the update manager. This is disabled by default for security reasons, as it can be a risk if not used carefully, as
the downloads contain sensitive data like password hashes or secrets.
### Table related settings
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set
@ -136,7 +164,7 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `TABLE_PARTS_DEFAULT_COLUMNS`: The columns in parts tables, which are visible by default (when loading table for first
time).
Also specify the default order of the columns. This is a comma separated list of column names. Available columns
are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`.
are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `partCustomState`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`.
### History/Eventlog-related settings
@ -252,10 +280,10 @@ markdown (and even some subset of HTML) syntax to format the text.
## parameters.yaml
You can also configure some options via the `config/parameters.yaml` file. This should normally not need,
and you should know what you are doing, when you change something here. You should expect, that you will have to do some
manual merge, when you have changed something here and update to a newer version of Part-DB. It is possible that
configuration options here will change or be completely removed in future versions of Part-DB.
You can also configure some options via the `config/parameters.yaml` file. This should normally not be needed,
and you should know what you are doing when you change something here. You should expect that you will have to do some
manual merges when you have changed something here and update to a newer version of Part-DB. It is possible that
configuration options here will change or be completely removed in future versions of Part-DB.
If you change something here, you have to clear the cache, before the changes will take effect with the
command `bin/console cache:clear`.

View file

@ -18,8 +18,7 @@ It is installed on a web server and so can be accessed with any browser without
> You can log in with username: **user** and password: **user**, to change/create data.
>
> Every change to the master branch gets automatically deployed, so it represents the current development progress and
> is
> maybe not completely stable. Please mind, that the free Heroku instance is used, so it can take some time when loading
> may not be completely stable. Please mind, that the free Heroku instance is used, so it can take some time when loading
> the page
> for the first time.
@ -28,32 +27,33 @@ It is installed on a web server and so can be accessed with any browser without
* Inventory management of your electronic parts. Each part can be assigned to a category, footprint, manufacturer,
and multiple store locations and price information. Parts can be grouped using tags. You can associate various files
like datasheets or pictures with the parts.
* Multi-language support (currently German, English, Russian, Japanese and French (experimental))
* Barcodes/Labels generator for parts and storage locations, scan barcodes via webcam using the builtin barcode scanner
* User system with groups and detailed (fine granular) permissions.
* Multi-language support (currently German, English, Russian, Japanese, French, Czech, Danish, and Chinese)
* Barcodes/Labels generator for parts and storage locations, scan barcodes via webcam using the built-in barcode scanner
* User system with groups and detailed (fine-grained) permissions.
Two-factor authentication is supported (Google Authenticator and Webauthn/U2F keys) and can be enforced for groups.
Password reset via email can be setup.
Password reset via email can be set up.
* Optional support for single sign-on (SSO) via SAML (using an intermediate service
like [Keycloak](https://www.keycloak.org/) you can connect Part-DB to an existing LDAP or Active Directory server)
* Import/Export system
* Project management: Create projects and assign parts to the bill of material (BOM), to show how often you could build
this project and directly withdraw all components needed from DB
* Event log: Track what changes happens to your inventory, track which user does what. Revert your parts to older
* Event log: Track what changes happen to your inventory, track which user does what. Revert your parts to older
versions.
* Responsive design: You can use Part-DB on your PC, your tablet and your smartphone using the same interface.
* Responsive design: You can use Part-DB on your PC, your tablet, and your smartphone using the same interface.
* MySQL, SQLite and PostgreSQL are supported as database backends
* Support for rich text descriptions and comments in parts
* Support for multiple currencies and automatic update of exchange rates supported
* Powerful search and filter function, including parametric search (search for parts according to some specifications)
* Easy migration from an existing PartKeepr instance (see [here]({%link partkeepr_migration.md %}))
* Use cloud providers (like Octopart, Digikey, Farnell or TME) to automatically get part information, datasheets and
* Use cloud providers (like Octopart, Digikey, Farnell, Mouser, or TME) to automatically get part information, datasheets, and
prices for parts (see [here]({% link usage/information_provider_system.md %}))
* Retrieve part information from arbitrary shop websites, using either conventional data extraction from structured metadata, or AI based data extraction
* API to access Part-DB from other applications/scripts
* [Integration with KiCad]({%link usage/eda_integration.md %}): Use Part-DB as central datasource for your
KiCad and see available parts from Part-DB directly inside KiCad.
* [Integration with KiCad]({%link usage/eda_integration.md %}): Use Part-DB as the central datasource for your
KiCad and see available parts from Part-DB directly inside KiCad.
With these features Part-DB is useful to hobbyists, who want to keep track of their private electronic parts inventory,
or makerspaces, where many users have should have (controlled) access to the shared inventory.
With these features, Part-DB is useful to hobbyists, who want to keep track of their private electronic parts inventory,
or makerspaces, where many users should have (controlled) access to the shared inventory.
Part-DB is also used by small companies and universities for managing their inventory.
@ -68,11 +68,11 @@ See [LICENSE](https://github.com/Part-DB/Part-DB-symfony/blob/master/LICENSE) fo
## Donate for development
If you want to donate to the Part-DB developer, see the sponsor button in the top bar (next to the repo name).
There you will find various methods to support development on a monthly or a one time base.
There you will find various methods to support development on a monthly or a one-time basis.
## Built with
* [Symfony 5](https://symfony.com/): The main framework used for the serverside PHP
* [Symfony 6](https://symfony.com/): The main framework used for the serverside PHP
* [Bootstrap 5](https://getbootstrap.com/) and [Bootswatch](https://bootswatch.com/): Used as website theme
* [Fontawesome](https://fontawesome.com/): Used as icon set
* [Hotwire Stimulus](https://stimulus.hotwired.dev/) and [Hotwire Turbo](https://turbo.hotwired.dev/): Frontend

View file

@ -21,8 +21,8 @@ differences between them, which might be important for you. Therefore the pros a
are listed here.
{: .important }
You have to choose between the database types before you start using Part-DB and **you can not change it (easily) after
you have started creating data**. So you should choose the database type for your use case (and possible future uses).
While you can change the database platform later (see below), it is still experimental and not recommended.
So you should choose the database type for your use case (and possible future uses).
## Comparison
@ -38,7 +38,7 @@ you have started creating data**. So you should choose the database type for you
* **Performance**: SQLite is not as fast as MySQL or PostgreSQL, especially when using complex queries or many users.
* **Emulated RegEx search**: SQLite does not support RegEx search natively. Part-DB can emulate it, however that is pretty slow.
* **Emualted natural sorting**: SQLite does not support natural sorting natively. Part-DB can emulate it, but it is pretty slow.
* **Emulated natural sorting**: SQLite does not support natural sorting natively. Part-DB can emulate it, but it is pretty slow.
* **Limitations with Unicode**: SQLite has limitations in comparisons and sorting of Unicode characters, which might lead to
unexpected behavior when using non-ASCII characters in your data. For example `µ` (micro sign) is not seen as equal to
`μ` (greek minuscule mu), therefore searching for `µ` (micro sign) will not find parts containing `μ` (mu) and vice versa.
@ -131,7 +131,7 @@ The host (here 127.0.0.1) and port should also be specified according to your My
In the `serverVersion` parameter you can specify the version of the MySQL/MariaDB server you are using, in the way the server returns it
(e.g. `8.0.37` for MySQL and `10.4.14-MariaDB`). If you do not know it, you can leave the default value.
If you want to use a unix socket for the connection instead of a TCP connnection, you can specify the socket path in the `unix_socket` parameter.
If you want to use a unix socket for the connection instead of a TCP connection, you can specify the socket path in the `unix_socket` parameter.
```shell
DATABASE_URL="mysql://user:password@localhost/database?serverVersion=8.0.37&unix_socket=/var/run/mysqld/mysqld.sock"
```
@ -150,7 +150,7 @@ In the `serverVersion` parameter you can specify the version of the PostgreSQL s
The `charset` parameter specify the character set of the database. It should be set to `utf8` to ensure that all characters are stored correctly.
If you want to use a unix socket for the connection instead of a TCP connnection, you can specify the socket path in the `host` parameter.
If you want to use a unix socket for the connection instead of a TCP connection, you can specify the socket path in the `host` parameter.
```shell
DATABASE_URL="postgresql://db_user@localhost/db_name?serverVersion=16.6&charset=utf8&host=/var/run/postgresql"
```
@ -177,6 +177,26 @@ In natural sorting, it would be sorted as:
Part-DB can sort names in part tables and tree views naturally. PostgreSQL and MariaDB 10.7+ support natural sorting natively,
and it is automatically used if available.
For SQLite and MySQL < 10.7 it has to be emulated if wanted, which is pretty slow. Therefore it has to be explicity enabled by setting the
For SQLite and MySQL < 10.7 it has to be emulated if wanted, which is pretty slow. Therefore it has to be explicitly enabled by setting the
`DATABASE_EMULATE_NATURAL_SORT` environment variable to `1`. If it is 0 the classical binary sorting is used, on these databases. The emulations
might have some quirks and issues, so it is recommended to use a database which supports natural sorting natively, if you want to use it.
## Converting between database platforms
{: .important }
The database conversion is still experimental. Therefore it is recommended to backup your database before performing a conversion, and check if everything works as expected afterwards.
If you want to change the database platform of your Part-DB installation (e.g. from SQLite to MySQL/MariaDB or PostgreSQL, or vice versa), there is the `partdb:migrations:convert-db-platform` console command, which can help you with that:
1. Make a backup of your current database to be safe if something goes wrong (see the backup documentation).
2. Ensure that your database is at the latest schema by running the migrations: `php bin/console doctrine:migrations:migrate`
3. Change the `DATABASE_URL` environment variable to the new database platform and connection information. Copy the old `DATABASE_URL` as you will need it later.
4. Run `php bin/console doctrine:migrations:migrate` again to create the database schema in the new database. You will not need the admin password, that is shown when running the migrations.
5. Run the conversion command, where you have to provide the old `DATABASE_URL` as parameter: `php bin/console partdb:migrations:convert-db-platform <OLD_DATABASE_URL>`
Replace `<OLD_DATABASE_URL` with the actual old `DATABASE_URL` value (e.g. `sqlite:///%kernel.project_dir%/var/app.db`):
```bash
php bin/console partdb:migrations:convert-db-platform sqlite:///%kernel.project_dir%/var/app.db
```
6. The command will purge all data in the new database and copy all data from the old database to the new one. This might take some time and memory depending on the size of your database.
7. Clear the cache: `php bin/console partdb:cache:clear`
8. You can login with your existing user accounts in the new database now. Check if everything works as expected.

View file

@ -15,13 +15,75 @@ To make emails work you have to properly configure a mail provider in Part-DB.
## Configuration
Part-DB uses [Symfony Mailer](https://symfony.com/doc/current/mailer.html) to send emails, which supports multiple
automatic mail providers (like MailChimp or SendGrid). If you want to use one of these providers, check the Symfony
mail providers (like Mailgun, SendGrid, or Brevo). If you want to use one of these providers, check the Symfony
Mailer documentation for more information.
We will only cover the configuration of an SMTP provider here, which is sufficient for most use-cases.
You will need an email account, which you can use send emails from via password-bases SMTP authentication, this account
You will need an email account, which you can use to send emails from via password-based SMTP authentication, this account
should be dedicated to Part-DB.
### Using specialized mail providers (Mailgun, SendGrid, etc.)
If you want to use a specialized mail provider like Mailgun, SendGrid, Brevo (formerly Sendinblue), Amazon SES, or
Postmark instead of SMTP, you need to install the corresponding Symfony mailer package first.
#### Docker installation
If you are using Part-DB in Docker, you can install additional mailer packages by setting the `COMPOSER_EXTRA_PACKAGES`
environment variable in your `docker-compose.yaml`:
```yaml
environment:
- COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer
- MAILER_DSN=mailgun+api://API_KEY:DOMAIN@default
- EMAIL_SENDER_EMAIL=noreply@yourdomain.com
- EMAIL_SENDER_NAME=Part-DB
- ALLOW_EMAIL_PW_RESET=1
```
You can install multiple packages by separating them with spaces:
```yaml
environment:
- COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
```
The packages will be installed automatically when the container starts.
Common mailer packages:
- `symfony/mailgun-mailer` - For [Mailgun](https://www.mailgun.com/)
- `symfony/sendgrid-mailer` - For [SendGrid](https://sendgrid.com/)
- `symfony/brevo-mailer` - For [Brevo](https://www.brevo.com/) (formerly Sendinblue)
- `symfony/amazon-mailer` - For [Amazon SES](https://aws.amazon.com/ses/)
- `symfony/postmark-mailer` - For [Postmark](https://postmarkapp.com/)
#### Direct installation (non-Docker)
If you have installed Part-DB directly on your server (not in Docker), you need to manually install the required
mailer package using composer.
Navigate to your Part-DB installation directory and run:
```bash
# Install the package as the web server user
sudo -u www-data composer require symfony/mailgun-mailer
# Clear the cache
sudo -u www-data php bin/console cache:clear
```
Replace `symfony/mailgun-mailer` with the package you need. You can install multiple packages at once:
```bash
sudo -u www-data composer require symfony/mailgun-mailer symfony/sendgrid-mailer
```
After installing the package, configure the `MAILER_DSN` in your `.env.local` file according to the provider's
documentation (see [Symfony Mailer documentation](https://symfony.com/doc/current/mailer.html) for DSN format for
each provider).
## SMTP Configuration
To configure the SMTP provider, you have to set the following environment variables:
`MAILER_DSN`: You have to provide the SMTP server address and the credentials for the email account here. The format is

View file

@ -8,4 +8,4 @@ has_children: true
# Installation
Below you can find some guides to install Part-DB.
For the hobbyists without much experience, we recommend the docker installation or direct installation on debian.
For hobbyists without much experience, we recommend the Docker installation or direct installation on Debian.

View file

@ -80,7 +80,11 @@ services:
#- BANNER=This is a test banner<br>with a line break
# If you use a reverse proxy in front of Part-DB, you must configure the trusted proxies IP addresses here (see reverse proxy documentation for more information):
# - TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# - TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here:
# The packages will be installed automatically when the container starts
# - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
```
4. Customize the settings by changing the environment variables (or adding new ones). See [Configuration]({% link
@ -91,6 +95,11 @@ services:
docker-compose up -d
```
{: .warning }
> If you run a root console inside the docker container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell.
> Otherwise Part-DB console might use the wrong configuration to execute commands.
6. Create the initial database with
```bash
@ -136,19 +145,22 @@ services:
# In docker env logs will be redirected to stderr
- APP_ENV=docker
# Uncomment this, if you want to use the automatic database migration feature. With this you have you do not have to
# Uncomment this, if you want to use the automatic database migration feature. With this you do not have to
# run the doctrine:migrations:migrate commands on installation or upgrade. A database backup is written to the uploads/
# folder (under .automigration-backup), so you can restore it, if the migration fails.
# This feature is currently experimental, so use it at your own risk!
# - DB_AUTOMIGRATE=true
# You can configure Part-DB using the webUI or environment variables
# However you can add add any other environment configuration you want here
# However you can add any other environment configuration you want here
# See .env file for all available options or https://docs.part-db.de/configuration.html
# Override value if you want to show to show a given text on homepage.
# When this is outcommented the webUI can be used to configure the banner
# Override value if you want to show a given text on homepage.
# When this is commented out the webUI can be used to configure the banner
#- BANNER=This is a test banner<br>with a line break
# If you need to install additional composer packages (e.g., for specific mailer transports), you can specify them here:
# - COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
database:
container_name: partdb_database
@ -169,6 +181,38 @@ services:
```
### Installing additional composer packages
If you need to use specific mailer transports or other functionality that requires additional composer packages, you can
install them automatically at container startup using the `COMPOSER_EXTRA_PACKAGES` environment variable.
For example, if you want to use Mailgun as your email provider, you need to install the `symfony/mailgun-mailer` package.
Add the following to your docker-compose.yaml environment section:
```yaml
environment:
- COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer
- MAILER_DSN=mailgun+api://API_KEY:DOMAIN@default
```
You can specify multiple packages by separating them with spaces:
```yaml
environment:
- COMPOSER_EXTRA_PACKAGES=symfony/mailgun-mailer symfony/sendgrid-mailer
```
{: .info }
> The packages will be installed when the container starts. This may increase the container startup time on the first run.
> The installed packages will persist in the container until it is recreated.
Common mailer packages you might need:
- `symfony/mailgun-mailer` - For Mailgun email service
- `symfony/sendgrid-mailer` - For SendGrid email service
- `symfony/brevo-mailer` - For Brevo (formerly Sendinblue) email service
- `symfony/amazon-mailer` - For Amazon SES email service
- `symfony/postmark-mailer` - For Postmark email service
### Update Part-DB
You can update Part-DB by pulling the latest image and restarting the container.
@ -180,6 +224,52 @@ docker-compose up -d
docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate
```
### Automatic updates via Watchtower (Web UI)
Part-DB supports triggering Docker container updates directly from the web interface using [Watchtower](https://github.com/nicholas-fedor/watchtower).
When configured, administrators can check for and apply updates from the **System > Update Manager** page.
{: .info }
> The original `containrrr/watchtower` project is no longer maintained (last release November 2023). These docs use the actively maintained community fork at [`nicholas-fedor/watchtower`](https://github.com/nicholas-fedor/watchtower), which is drop-in compatible with the original HTTP API.
To enable this feature, add a Watchtower service to your `docker-compose.yaml` and configure the connection:
```yaml
services:
partdb:
container_name: partdb
image: jbtronics/part-db1:latest
labels:
- com.centurylinklabs.watchtower.enable=true
environment:
# ... your existing environment variables ...
# Watchtower integration for web-based updates
- WATCHTOWER_API_URL=http://watchtower:8080
- WATCHTOWER_API_TOKEN=your-secret-token
# ... your existing ports/volumes ...
watchtower:
image: ghcr.io/nicholas-fedor/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_HTTP_API_UPDATE=true
- WATCHTOWER_HTTP_API_TOKEN=your-secret-token
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_CLEANUP=true
```
{: .important }
> Replace `your-secret-token` with a strong, unique token. The same token must be set in both the Part-DB (`WATCHTOWER_API_TOKEN`) and Watchtower (`WATCHTOWER_HTTP_API_TOKEN`) environment variables.
{: .info }
> `WATCHTOWER_LABEL_ENABLE=true` ensures Watchtower only manages containers with the `com.centurylinklabs.watchtower.enable=true` label, preventing it from updating other containers on the same host.
Once configured, the Update Manager page will show the Watchtower connection status and provide an **Update via Watchtower** button when a new version is available. Clicking it triggers Watchtower to pull the latest image and recreate the Part-DB container automatically.
## Direct use of docker image
You can use the `jbtronics/part-db1:master` image directly. You have to expose port 80 to a host port and configure

View file

@ -7,7 +7,7 @@ nav_order: 10
# Nginx
You can also use [nginx](https://www.nginx.com/) as webserver for Part-DB. Setup Part-DB with apache is a bit easier, so
You can also use [nginx](https://www.nginx.com/) as webserver for Part-DB. Setting up Part-DB with Apache is a bit easier, so
this is the method shown in the guides. This guide assumes that you already have a working nginx installation with PHP
configured.

View file

@ -21,7 +21,7 @@ LDAP or Active Directory server.
{: .warning }
> This feature is currently in beta. Please report any bugs you find.
> So far it has only tested with Keycloak, but it should work with any SAML 2.0 compatible identity provider.
> So far it has only been tested with Keycloak, but it should work with any SAML 2.0 compatible identity provider.
This guide will show you how to configure Part-DB with [Keycloak](https://www.keycloak.org/) as the SAML identity
provider, but it should work with any SAML 2.0 compatible identity provider.
@ -75,8 +75,8 @@ the [Keycloak Getting Started Guide](https://www.keycloak.org/docs/latest/gettin
### Configure Part-DB to use SAML
1. Open the `.env.local` file of Part-DB (or the docker-compose.yaml) for edit
2. Set the `SAMLP_SP_PRIVATE_KEY` environment variable to the content of the private key file you downloaded in the
1. Open the `.env.local` file of Part-DB (or the docker-compose.yaml) for editing
2. Set the `SAML_SP_PRIVATE_KEY` environment variable to the content of the private key file you downloaded in the
previous step. It should start with `MIEE` and end with `=`.
3. Set the `SAML_SP_X509_CERT` environment variable to the content of the Certificate field shown in the `Keys` tab of
the SAML client in Keycloak. It should start with `MIIC` and end with `=`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View file

@ -9,7 +9,7 @@ Sometimes things go wrong and Part-DB shows an error message. This page should h
## Error messages
When a common, easy fixable error occurs (like a non-up-to-date database), Part-DB will show you some short instructions
When a common, easily fixable error occurs (like a non-up-to-date database), Part-DB will show you some short instructions
on how to fix the problem. If you have a problem that is not listed here, please open an issue on GitHub.
## General procedure
@ -28,9 +28,9 @@ php bin/console cache:clear
php bin/console doctrine:migrations:migrate
```
If this does not help, please [open an issue on GitHub](https://github.com/Part-DB/Part-DB-symfony).
If this does not help, please [open an issue on GitHub](https://github.com/Part-DB/Part-DB-server).
## Search for the user and reset the password:
## Search for a user and reset the password
You can list all users with the following command: `php bin/console partdb:users:list`
To reset the password of a user you can use the following
@ -50,6 +50,21 @@ docker-compose logs -f
Please include the error logs in your issue on GitHub, if you open an issue.
## KiCad Integration Issues
### "API responded with error code: 0: Unknown"
If you get this error when trying to connect KiCad to Part-DB, it is most likely caused by KiCad not trusting your SSL/TLS certificate.
**Cause:** KiCad does not trust self-signed SSL/TLS certificates.
**Solutions:**
- Use HTTP instead of HTTPS for the `root_url` in your KiCad library configuration (only recommended for local networks)
- Use a certificate from a trusted Certificate Authority (CA) like [Let's Encrypt](https://letsencrypt.org/)
- Add your self-signed certificate to the system's trusted certificate store on the computer running KiCad (the exact steps depend on your operating system)
For more information about KiCad integration, see the [EDA / KiCad integration](../usage/eda_integration.md) documentation.
## Report Issue
If an error occurs, or you found a bug, please [open an issue on GitHub](https://github.com/Part-DB/Part-DB-symfony).
If an error occurs, or you found a bug, please [open an issue on GitHub](https://github.com/Part-DB/Part-DB-server).

View file

@ -18,7 +18,7 @@ fulfilled by the official Part-DB docker image.*
Part-DB 2.0 requires at least PHP 8.2 (newer versions are recommended). So if your existing Part-DB installation is still
running PHP 8.1, you will have to upgrade your PHP version first.
The minimum required version of node.js is now 20.0 or newer, so if you are using 18.0, you will have to upgrade it too.
The minimum required version of node.js is now 22.0 or newer, so if you are using 18.0, you will have to upgrade it too.
Most distributions should have the possibility to get backports for PHP 8.4 and modern nodejs, so you should be able to
easily upgrade your system to the new requirements. Otherwise, you can use the official Part-DB docker image, which
@ -27,7 +27,7 @@ about the requirements at all.
## Changes
* Configuration is now preferably done via a web settings interface. You can still use environment variables, these overwrite
the settings in the web interface. Existing configuration will still work, but you should consider migriting them to the
the settings in the web interface. Existing configuration will still work, but you should consider migrating them to the
web interface as described below.
* The `config/banner.md` file that could been used to customize the banner text, was removed. You can now set the banner text
directly in the admin interface, or by setting the `BANNER` environment variable. If you want to keep your existing
@ -43,7 +43,7 @@ The upgrade process works very similar to a normal (minor release) upgrade.
### Direct installation
**Be sure to execute the following steps as the user that owns the Part-DB files (e.g. `www-data`, or your webserver user). So prepend a `sudo -u wwww-data` where necessary.**
**Be sure to execute the following steps as the user that owns the Part-DB files (e.g. `www-data`, or your webserver user). So prepend a `sudo -u www-data` where necessary.**
1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and `.env.local` file.
The `php bin/console partdb:backup` command can help you with this.
@ -51,7 +51,7 @@ The `php bin/console partdb:backup` command can help you with this.
3. Remove the `var/cache/` directory inside the Part-DB installation to ensure that no old cache files remain.
4. Run `composer install --no-dev -o` to update the dependencies.
5. Run `yarn install` and `yarn build` to update the frontend assets.
6. Rund `php bin/console doctrine:migrations:migrate` to update the database schema.
6. Run `php bin/console doctrine:migrations:migrate` to update the database schema.
7. Clear the cache with `php bin/console cache:clear`.
8. Open your Part-DB instance in the browser and log in as an admin user.
9. Go to the user or group permissions page, and give yourself (and other administrators) the right to change system settings (under "System" and "Configuration").
@ -60,6 +60,8 @@ The `php bin/console partdb:backup` command can help you with this.
If you want to change them, you must migrate them to the settings interface as described below.
### Docker installation
**When running the console commands from inside a docker container's shell as root, be sure to use `sudo -E` to preserve the environment variables, so that they are correctly passed to the command.**
1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and the file where you configure the docker environment variables.
2. Stop the existing Part-DB container with `docker compose down`
3. Ensure that your docker compose file uses the new latest images (either `latest` or `2` tag).
@ -79,7 +81,7 @@ To change it, you must migrate your environment variable configuration to the ne
For this there is the new console command `settings:migrate-env-to-settings`, which reads in all environment variables used to overwrite
settings and write them to the database, so that you can safely delete them from your environment variable configuration afterwards, without
loosing your configuration.
losing your configuration.
To run the command, execute `php bin/console settings:migrate-env-to-settings --all` as webserver user (or run `docker exec --user=www-data -it partdb php bin/console settings:migrate-env-to-settings --all` for docker containers).
It will list you all environment variables, it found and ask you for confirmation to migrate them. Answer with `yes` to migrate them and hit enter.

View file

@ -5,5 +5,7 @@ nav_order: 7
has_children: true
---
# Upgrade
This section provides information on how to upgrade Part-DB to the latest version.
This is intended for major release upgrades, where requirements or things changes significantly.
This is intended for major release upgrades, where requirements or things change significantly.

Some files were not shown because too many files have changed in this diff Show more