mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-10 23:22:11 +00:00
Compare commits
150 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb669ad4ec | ||
|
|
2e8ab8190a | ||
|
|
98c978ff1b | ||
|
|
38779740ec | ||
|
|
9c6f9a25c5 | ||
|
|
28fc2a5a2c | ||
|
|
71fbbddbbe | ||
|
|
b50617bd10 | ||
|
|
6045b50af2 | ||
|
|
3ef4a83f4a | ||
|
|
2a6f6f4ed5 | ||
|
|
19d138632a | ||
|
|
83074a2403 | ||
|
|
1d1e3008aa | ||
|
|
ce2b7d11a9 | ||
|
|
0ddf4f903e | ||
|
|
673d5b5e83 | ||
|
|
d346708150 | ||
|
|
91bf8371ad | ||
|
|
3c9866e90d | ||
|
|
fcd598286a | ||
|
|
c09fc7d483 | ||
|
|
54cb43d235 | ||
|
|
b7cfdc3100 | ||
|
|
45ed095509 | ||
|
|
801e23e63b | ||
|
|
a15a5efdce | ||
|
|
21bad81262 | ||
|
|
db86b8c330 | ||
|
|
9c317db260 | ||
|
|
e437bb0b7b | ||
|
|
889aa08b4e | ||
|
|
aac5b8e0be | ||
|
|
a2b9ee764d | ||
|
|
e77b67445c | ||
|
|
fe4dc1f1e4 | ||
|
|
4137bde194 | ||
|
|
f13413a104 | ||
|
|
e576ded86b | ||
|
|
4cbb167e5c | ||
|
|
4f67f21b33 | ||
|
|
cf34de6772 | ||
|
|
5edcc60d41 | ||
|
|
ad096aa6ff | ||
|
|
0ca5a41298 | ||
|
|
7117926584 | ||
|
|
4a45b5d5a9 | ||
|
|
4dbd92ac4d | ||
|
|
af98fc1079 | ||
|
|
368dd14785 | ||
|
|
9d389309fc | ||
|
|
67cb6fb8a2 | ||
|
|
25ced0d660 | ||
|
|
18bf07b19f | ||
|
|
c9d2044949 | ||
|
|
2631ff4bee | ||
|
|
c0017d29a7 | ||
|
|
9cf16248e6 | ||
|
|
90d327fdaa | ||
|
|
6330b71bfb | ||
|
|
a82d515034 | ||
|
|
6a30b41688 | ||
|
|
ec05f9d8ab | ||
|
|
1c3dfa26bb | ||
|
|
766665f9e5 | ||
|
|
29db029d69 | ||
|
|
146e85f84c | ||
|
|
c17cf5e83c | ||
|
|
5b86d6f652 | ||
|
|
58a34e3628 | ||
|
|
35dcb298e7 | ||
|
|
0140c9a7b9 | ||
|
|
d25ac2622e | ||
|
|
cee7e2a077 | ||
|
|
2a6e5435e1 | ||
|
|
05b1965957 | ||
|
|
57ef3e06a7 | ||
|
|
7d8a7ab471 | ||
|
|
ad35ae6e9e | ||
|
|
f12f808b34 | ||
|
|
0080aa9f25 | ||
|
|
dc522d4795 | ||
|
|
f07eabd85a | ||
|
|
70454e3a6d | ||
|
|
8b3bebca7b | ||
|
|
4d296d8f3a | ||
|
|
96da2b9f1f | ||
|
|
f9a8818e69 | ||
|
|
52df554b29 | ||
|
|
991daf0ead | ||
|
|
34a84bce8f | ||
|
|
4206b702ff | ||
|
|
abf0ba5301 | ||
|
|
9ce215c8f9 | ||
|
|
753ecee849 | ||
|
|
8f6ed74d93 | ||
|
|
17f11c02f3 | ||
|
|
a070ebb2ce | ||
|
|
44bb132de1 | ||
|
|
95f3fc66c2 | ||
|
|
74e5102943 | ||
|
|
60c5e24c94 | ||
|
|
de371877b9 | ||
|
|
baeef1228a | ||
|
|
45da6dacff | ||
|
|
c4d8192e76 | ||
|
|
dca0cb8a16 | ||
|
|
3abc0d8b38 | ||
|
|
9ea3ead246 | ||
|
|
1de440d71e | ||
|
|
5243f90dd8 | ||
|
|
343c078b7d | ||
|
|
37b98adc6e | ||
|
|
4f12fd7390 | ||
|
|
13b98cc0b1 | ||
|
|
7f8f5990a7 | ||
|
|
bcbbb1ecb9 | ||
|
|
8727d83097 | ||
|
|
70919d953a | ||
|
|
a722608ae8 | ||
|
|
12a760d27e | ||
|
|
b8d1414403 | ||
|
|
463d7b89f6 | ||
|
|
6e4d252617 | ||
|
|
3ed27f6c0f | ||
|
|
0d58262e19 | ||
|
|
db8881621c | ||
|
|
ceda91488c | ||
|
|
e84bae2807 | ||
|
|
e8d90487d2 | ||
|
|
598cf3ed80 | ||
|
|
30e3bc3153 | ||
|
|
f95a58087b | ||
|
|
83608fffcf | ||
|
|
78b1d41cf8 | ||
|
|
616c3a6742 | ||
|
|
d24a50a696 | ||
|
|
3480dd146e | ||
|
|
dbe49b5f00 | ||
|
|
1c28efb12e | ||
|
|
a6ee68d75a | ||
|
|
30ece64423 | ||
|
|
77ef77961d | ||
|
|
a629949479 | ||
|
|
af6ddffa1d | ||
|
|
f15979ed11 | ||
|
|
df3262a3f7 | ||
|
|
a071701870 | ||
|
|
c549665578 | ||
|
|
2137eecddf |
235 changed files with 24735 additions and 8396 deletions
23
.env
23
.env
|
|
@ -71,6 +71,17 @@ DISABLE_WEB_UPDATES=1
|
|||
# Restoring backups is a destructive operation that could overwrite your database.
|
||||
DISABLE_BACKUP_RESTORE=1
|
||||
|
||||
# Disable backup download from the Update Manager UI (0=enabled, 1=disabled).
|
||||
# Backups contain sensitive data including password hashes and secrets.
|
||||
# When enabled, users must confirm their password before downloading.
|
||||
DISABLE_BACKUP_DOWNLOAD=1
|
||||
|
||||
# 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
|
||||
###################################################################################
|
||||
|
|
@ -116,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
|
||||
|
|
@ -146,3 +161,11 @@ 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 ###
|
||||
|
|
|
|||
18
.github/workflows/assets_artifact_build.yml
vendored
18
.github/workflows/assets_artifact_build.yml
vendored
|
|
@ -8,6 +8,9 @@ on:
|
|||
branches:
|
||||
- '*'
|
||||
- "!l10n_*" # Dont test localization branches
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- 'v*.*.*-**'
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
|
@ -17,6 +20,8 @@ jobs:
|
|||
assets_artifact_build:
|
||||
name: Build assets artifact
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
APP_ENV: prod
|
||||
|
|
@ -62,7 +67,7 @@ jobs:
|
|||
- name: Setup node
|
||||
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@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Only dependencies and built assets
|
||||
path: /tmp/partdb_assets.zip
|
||||
|
||||
- name: Upload full artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Full Part-DB including dependencies and built assets
|
||||
path: /tmp/partdb_with_assets.zip
|
||||
|
||||
- name: Upload assets as release attachment
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" /tmp/partdb_assets.zip /tmp/partdb_with_assets.zip --clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
|
|||
18
.github/workflows/docker_build.yml
vendored
18
.github/workflows/docker_build.yml
vendored
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
-
|
||||
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: |
|
||||
|
|
@ -66,11 +66,11 @@ jobs:
|
|||
|
||||
-
|
||||
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 }}
|
||||
|
|
@ -78,7 +78,7 @@ jobs:
|
|||
-
|
||||
name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
|
|
@ -98,7 +98,7 @@ jobs:
|
|||
-
|
||||
name: Upload digest
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ matrix.platform-slug }}
|
||||
path: /tmp/digests/*
|
||||
|
|
@ -113,7 +113,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Download digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
|
|
@ -121,12 +121,12 @@ jobs:
|
|||
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
-
|
||||
name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
jbtronics/part-db1
|
||||
|
|
@ -142,7 +142,7 @@ jobs:
|
|||
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
|
|
|||
18
.github/workflows/docker_frankenphp.yml
vendored
18
.github/workflows/docker_frankenphp.yml
vendored
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
-
|
||||
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: |
|
||||
|
|
@ -66,11 +66,11 @@ jobs:
|
|||
|
||||
-
|
||||
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 }}
|
||||
|
|
@ -78,7 +78,7 @@ jobs:
|
|||
-
|
||||
name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile-frankenphp
|
||||
|
|
@ -99,7 +99,7 @@ jobs:
|
|||
-
|
||||
name: Upload digest
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ matrix.platform-slug }}
|
||||
path: /tmp/digests/*
|
||||
|
|
@ -114,7 +114,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Download digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
|
|
@ -122,12 +122,12 @@ jobs:
|
|||
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
-
|
||||
name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
partdborg/part-db
|
||||
|
|
@ -143,7 +143,7 @@ jobs:
|
|||
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
|
|
|||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
|
|
@ -106,7 +106,7 @@ jobs:
|
|||
- name: Setup node
|
||||
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
13
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ RUN yarn build
|
|||
RUN yarn cache clean && rm -rf node_modules/
|
||||
|
||||
# FrankenPHP base stage
|
||||
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||
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 \
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ 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
|
||||
* 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
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.8.0
|
||||
2.11.1
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
377
assets/controllers/docker_update_progress_controller.js
Normal file
377
assets/controllers/docker_update_progress_controller.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
152
assets/controllers/elements/ai_model_autocomplete_controller.js
Normal file
152
assets/controllers/elements/ai_model_autocomplete_controller.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* 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'
|
||||
|
||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||
|
||||
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": {}
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -45,6 +45,7 @@ export default class extends Controller {
|
|||
maxItems: 1,
|
||||
createOnBlur: true,
|
||||
selectOnTab: true,
|
||||
clearAfterSelect: true,
|
||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||
dropdownParent: dropdownParent,
|
||||
|
|
|
|||
|
|
@ -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$/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -113,8 +111,16 @@ export default class extends Controller {
|
|||
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]
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export default class extends Controller {
|
|||
valueField: "id",
|
||||
labelField: "name",
|
||||
dropdownParent: dropdownParent,
|
||||
selectOnTab: true,
|
||||
clearAfterSelect: true,
|
||||
preload: "focus",
|
||||
render: {
|
||||
item: (data, escape) => {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export default class extends Controller {
|
|||
selectOnTab: true,
|
||||
maxOptions: null,
|
||||
dropdownParent: dropdownParent,
|
||||
clearAfterSelect: true,
|
||||
|
||||
render: {
|
||||
item: this.renderItem.bind(this),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export default class extends Controller {
|
|||
maxItems: 1000,
|
||||
allowEmptyOption: true,
|
||||
dropdownParent: dropdownParent,
|
||||
selectOnTab: true,
|
||||
clearAfterSelect: true,
|
||||
plugins: ['remove_button'],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
import {default as TreeController} from "./tree_controller";
|
||||
import {EVENT_INITIALIZED} from "@jbtronics/bs-treeview";
|
||||
|
||||
export default class extends TreeController {
|
||||
static targets = [ "tree", 'sourceText' ];
|
||||
|
|
@ -40,6 +41,8 @@ export default class extends TreeController {
|
|||
//Check if we have a saved mode
|
||||
const stored_mode = localStorage.getItem(this._storage_key);
|
||||
|
||||
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
|
||||
|
||||
//Use stored mode if possible, otherwise use default
|
||||
if(stored_mode) {
|
||||
try {
|
||||
|
|
@ -55,6 +58,39 @@ export default class extends TreeController {
|
|||
|
||||
//Register an event listener which checks if the tree needs to be updated
|
||||
document.addEventListener('turbo:render', this.doUpdateIfNeeded.bind(this));
|
||||
|
||||
//Register an event listener, to check if we end up on a page we can highlight in the tree, if so then higlight it
|
||||
document.addEventListener('turbo:load', this._onTurboLoad.bind(this));
|
||||
//On initial page load the tree is not available yet, so do another check after the tree is initialized
|
||||
this.treeTarget.addEventListener(EVENT_INITIALIZED, (event) => {
|
||||
this.selectNodeWithURL(document.location)
|
||||
});
|
||||
}
|
||||
|
||||
_onTurboLoad(event) {
|
||||
this.selectNodeWithURL(event.detail.url);
|
||||
}
|
||||
|
||||
selectNodeWithURL(url) {
|
||||
//Get path from url
|
||||
const path = new URL(url).pathname;
|
||||
|
||||
if (!this._tree) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Unselect all nodes
|
||||
this._tree.unselectAll({silent: true, ignorePreventUnselect: true});
|
||||
|
||||
//Try to find a node with this path as data-path
|
||||
const nodes = this._tree.findNodes(path, "href");
|
||||
if (nodes.length !== 1) {
|
||||
return; //We can only work with exactly one node, if there are multiple nodes with the same path, we cannot know which one to select, so we do nothing
|
||||
}
|
||||
const node = nodes[0];
|
||||
|
||||
node.setSelected(true, {ignorePreventUnselect: true, silent: true});
|
||||
this._tree.revealNode(node);
|
||||
}
|
||||
|
||||
doUpdateIfNeeded()
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export default class extends Controller {
|
|||
searchField: 'text',
|
||||
orderField: 'text',
|
||||
dropdownParent: dropdownParent,
|
||||
clearAfterSelect: true,
|
||||
|
||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export default class extends Controller {
|
|||
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
|
||||
splitOn: null,
|
||||
dropdownParent: dropdownParent,
|
||||
clearAfterSelect: true,
|
||||
|
||||
searchField: [
|
||||
{field: "text", weight : 2},
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export default class extends Controller {
|
|||
createOnBlur: true,
|
||||
create: true,
|
||||
dropdownParent: dropdownParent,
|
||||
clearAfterSelect: true,
|
||||
};
|
||||
|
||||
if(this.element.dataset.autocomplete) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export default class extends Controller {
|
|||
*/
|
||||
_tree = null;
|
||||
|
||||
_frame = "frame";
|
||||
|
||||
connect() {
|
||||
const treeElement = this.treeTarget;
|
||||
if (!treeElement) {
|
||||
|
|
@ -48,6 +50,7 @@ export default class extends Controller {
|
|||
|
||||
this._url = this.element.dataset.treeUrl;
|
||||
this._data = this.element.dataset.treeData;
|
||||
this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined
|
||||
|
||||
if(this.element.dataset.treeShowTags === "true") {
|
||||
this._showTags = true;
|
||||
|
|
@ -99,8 +102,7 @@ export default class extends Controller {
|
|||
onNodeSelected: (event) => {
|
||||
const node = event.detail.node;
|
||||
if (node.href) {
|
||||
window.Turbo.visit(node.href, {action: "advance"});
|
||||
this._registerURLWatcher(node);
|
||||
window.Turbo.visit(node.href, {action: "advance", frame: this._frame});
|
||||
}
|
||||
},
|
||||
}, [BS5Theme, BS53Theme, FAIconTheme]);
|
||||
|
|
@ -110,41 +112,12 @@ export default class extends Controller {
|
|||
const treeView = event.detail.treeView;
|
||||
treeView.revealNode(treeView.getSelected());
|
||||
|
||||
//Add the url watcher to all selected nodes
|
||||
for (const node of treeView.getSelected()) {
|
||||
this._registerURLWatcher(node);
|
||||
}
|
||||
|
||||
//Add contextmenu event listener to the tree, which allows us to open the links in a new tab with a right click
|
||||
treeView.getTreeElement().addEventListener("contextmenu", this._onContextMenu.bind(this));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
_registerURLWatcher(node)
|
||||
{
|
||||
//Register a watcher for a location change, which will unselect the node, if the location changes
|
||||
const desired_url = node.href;
|
||||
|
||||
//Ensure that the node is unselected, if the location changes
|
||||
const unselectNode = () => {
|
||||
//Parse url so we can properly compare them
|
||||
const desired = new URL(node.href, window.location.origin);
|
||||
|
||||
//We only compare the pathname, because the hash and parameters should not matter
|
||||
if(window.location.pathname !== desired.pathname) {
|
||||
//The ignore parameter is important here, otherwise the node will not be unselected
|
||||
node.setSelected(false, {silent: true, ignorePreventUnselect: true});
|
||||
|
||||
//Unregister the watcher
|
||||
document.removeEventListener('turbo:load', unselectNode);
|
||||
}
|
||||
};
|
||||
|
||||
//Register the watcher via hotwire turbo
|
||||
//We must just load to have the new url in window.location
|
||||
document.addEventListener('turbo:load', unselectNode);
|
||||
}
|
||||
|
||||
_onContextMenu(event)
|
||||
{
|
||||
|
|
@ -198,4 +171,4 @@ export default class extends Controller {
|
|||
return myResolve(this._data);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export default class extends Controller
|
|||
searchField: "name",
|
||||
//labelField: "name",
|
||||
valueField: "name",
|
||||
clearAfterSelect: true,
|
||||
onItemAdd: this.onItemAdd.bind(this),
|
||||
render: {
|
||||
option: (data, escape) => {
|
||||
|
|
@ -136,4 +137,4 @@ export default class extends Controller
|
|||
//Destroy the TomSelect instance
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"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",
|
||||
|
|
@ -56,6 +57,9 @@
|
|||
"scheb/2fa-trusted-device": "^v7.11.0",
|
||||
"shivas/versioning-bundle": "^4.0",
|
||||
"spatie/db-dumper": "^3.3.1",
|
||||
"symfony/ai-bundle": "^0.8.0",
|
||||
"symfony/ai-lm-studio-platform": "^0.8.0",
|
||||
"symfony/ai-open-router-platform": "^0.8.0",
|
||||
"symfony/apache-pack": "^1.0",
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
|
|
@ -177,6 +181,11 @@
|
|||
"allow-contrib": false,
|
||||
"require": "7.4.*",
|
||||
"docker": true
|
||||
},
|
||||
"phpstan/extension-installer": {
|
||||
"ignore" : [
|
||||
"ekino/phpstan-banned-code"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3090
composer.lock
generated
3090
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
27
config/packages/ai.yaml
Normal 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'
|
||||
5
config/packages/ai_generic_platform.yaml
Normal file
5
config/packages/ai_generic_platform.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ai:
|
||||
platform:
|
||||
generic:
|
||||
default:
|
||||
base_url: '%env(GENERIC_BASE_URL)%'
|
||||
4
config/packages/ai_lm_studio_platform.yaml
Normal file
4
config/packages/ai_lm_studio_platform.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ai:
|
||||
platform:
|
||||
lmstudio:
|
||||
host_url: '%env(string:settings:ai_lmstudio:hostURL)%'
|
||||
4
config/packages/ai_open_router_platform.yaml
Normal file
4
config/packages/ai_open_router_platform.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ai:
|
||||
platform:
|
||||
openrouter:
|
||||
api_key: '%env(string:settings:ai_openrouter:apiKey)%'
|
||||
|
|
@ -25,5 +25,5 @@ framework:
|
|||
adapter: cache.app
|
||||
|
||||
cache.settings:
|
||||
adapter: cache.app
|
||||
adapter: cache.system
|
||||
tags: true
|
||||
|
|
|
|||
|
|
@ -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 >>>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ parameters:
|
|||
|
||||
env(DATABASE_EMULATE_NATURAL_SORT): 0
|
||||
|
||||
env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0
|
||||
|
||||
######################################################################################################################
|
||||
# Bulk Info Provider Import Configuration
|
||||
######################################################################################################################
|
||||
|
|
|
|||
2813
config/reference.php
2813
config/reference.php
File diff suppressed because it is too large
Load diff
|
|
@ -86,6 +86,7 @@ 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.
|
||||
|
|
@ -144,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
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ It is installed on a web server and so can be accessed with any browser without
|
|||
* Easy migration from an existing PartKeepr instance (see [here]({%link partkeepr_migration.md %}))
|
||||
* 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 the central datasource for your
|
||||
KiCad and see available parts from Part-DB directly inside KiCad.
|
||||
|
|
|
|||
|
|
@ -95,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
|
||||
|
|
@ -219,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
27
docs/usage/ai.md
Normal file
27
docs/usage/ai.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
layout: default
|
||||
title: AI features
|
||||
nav_order: 6
|
||||
parent: Usage
|
||||
---
|
||||
|
||||
# AI features
|
||||
|
||||
Part-DB can utilize large language Models (LLMs) to provide AI-powered features that can assist you in managing your parts and projects.
|
||||
For now this is mostly the ability to extract part information from websites without any structured data.
|
||||
|
||||
## AI platforms
|
||||
|
||||
Part-DB is platform agnostic and can work with different AI platforms, both locally and in the cloud. They can be configured in the "AI" tab in the system settings.
|
||||
Currently, the following platforms are supported:
|
||||
|
||||
### OpenRouter
|
||||
|
||||
[OpenRouter](https://openrouter.ai/) is a platform that provides access to various LLMs, including models from OpenAI, Anthropic, and more.
|
||||
You can use OpenRouter to connect to different LLMs and use them for Part-DB's AI features.
|
||||
You need to supply an API key for OpenRouter to use it as an AI platform in Part-DB.
|
||||
|
||||
### LMStudio
|
||||
|
||||
[LMStudio](https://lmstudio.ai/) is a local LLM hosting solution that allows you to run LLMs on your own hardware. You can use LMStudio to host your own LLM and connect it to Part-DB for AI features.
|
||||
Currently only LMStudio without any authentication is supported. Supply your LMStudio instance URL (including the port) to use it as an AI platform in Part-DB.
|
||||
|
|
@ -67,6 +67,7 @@ You can define this on a per-part basis using the KiCad symbol and KiCad footpri
|
|||
For example, to configure the values for a BC547 transistor you would put `Transistor_BJT:BC547` in the part's KiCad symbol field to give it the right schematic symbol in Eeschema and `Package_TO_SOT_THT:TO-92` to give it the right footprint in Pcbnew.
|
||||
|
||||
If you type in a character, you will get an autocomplete list of all symbols and footprints available in the KiCad standard library. You can also input your own value.
|
||||
If you want to keep custom suggestions across updates, open the server settings page and use the "Autocomplete settings" page. There you can edit `public/kicad/footprints_custom.txt` and `public/kicad/symbols_custom.txt` and enable the "Use custom autocomplete lists" option to use those files instead of the autogenerated defaults.
|
||||
|
||||
### Parts and category visibility
|
||||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,19 @@ may have privacy and security implications.
|
|||
Following env configuration options are available:
|
||||
* `PROVIDER_GENERIC_WEB_ENABLED`: Set this to `1` to enable the Generic Web URL Provider (optional, default: `0`)
|
||||
|
||||
### AI Web Extractor
|
||||
The AI web extractor provider can extract part information from any webpage using AI-based techniques. It is designed to handle unstructured data and can extract relevant information even from websites that do not use structured data formats like Schema.org.
|
||||
This provider can be particularly useful for extracting information from websites that have complex layouts or do not follow standard e-commerce practices.
|
||||
It also potentially extracts more detailed information than the Generic Web URL Provider, as it is not limited to the fields defined in the Schema.org format.
|
||||
|
||||
To use the AI Web Extractor, you need to setup an AI platform, in the AI settings tab, and chose a model, which support structured output.
|
||||
For many use cases a small and cheap model like `google/gemini-2.5-flash-lite` will be sufficient, coming down to costs like 0.001$ per request.
|
||||
For more complex websites, or if you wanna use the LLM for translation purposes too, you should consider a more powerful model.
|
||||
|
||||
You can add some additional instructions for the model, which gets added to the system prompt, to tweak the output of the model.
|
||||
|
||||
The provider will download the HTML of the given URL, convert it to markdown and send it to the LLM toghether with structured data extracted from the webpage via conventional methods.
|
||||
|
||||
### Octopart
|
||||
|
||||
The Octopart provider uses the [Octopart / Nexar API](https://nexar.com/api) to search for parts and get information.
|
||||
|
|
|
|||
73
migrations/Version20260307204859.php
Normal file
73
migrations/Version20260307204859.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260307204859 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Increase the length of the vendor_barcode field in part_lots to 1000 characters and update the index accordingly';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode(100))');
|
||||
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode LONGTEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode)');
|
||||
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM part_lots');
|
||||
$this->addSql('DROP TABLE part_lots');
|
||||
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, vendor_barcode CLOB DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES storelocations (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO part_lots (id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM __temp__part_lots');
|
||||
$this->addSql('DROP TABLE __temp__part_lots');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots');
|
||||
$this->addSql('DROP TABLE part_lots');
|
||||
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots');
|
||||
$this->addSql('DROP TABLE __temp__part_lots');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX part_lots_idx_barcode');
|
||||
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE TEXT');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX part_lots_idx_barcode');
|
||||
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE VARCHAR(255)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
}
|
||||
18
package.json
18
package.json
|
|
@ -9,16 +9,16 @@
|
|||
"@symfony/stimulus-bridge": "^4.0.0",
|
||||
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
|
||||
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
|
||||
"@symfony/webpack-encore": "^5.1.0",
|
||||
"@symfony/webpack-encore": "^6.0.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"core-js": "^3.38.0",
|
||||
"intl-messageformat": "^10.2.5",
|
||||
"intl-messageformat": "^10.5.11",
|
||||
"jquery": "^3.5.1",
|
||||
"popper.js": "^1.14.7",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-bundle-analyzer": "^5.1.1",
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-cli": "^6.0.0",
|
||||
"webpack-notifier": "^1.15.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
|
@ -30,14 +30,12 @@
|
|||
"build": "encore production --progress"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@algolia/autocomplete-js": "^1.17.0",
|
||||
"@algolia/autocomplete-plugin-recent-searches": "^1.17.0",
|
||||
"@algolia/autocomplete-theme-classic": "^1.17.0",
|
||||
"@ckeditor/ckeditor5-dev-translations": "^43.0.1",
|
||||
"@ckeditor/ckeditor5-dev-utils": "^43.0.1",
|
||||
"@jbtronics/bs-treeview": "^1.0.1",
|
||||
"@part-db/html5-qrcode": "^4.0.0",
|
||||
"@zxcvbn-ts/core": "^3.0.2",
|
||||
|
|
@ -51,7 +49,7 @@
|
|||
"bootbox": "^6.0.0",
|
||||
"bootswatch": "^5.1.3",
|
||||
"bs-custom-file-input": "^1.3.4",
|
||||
"ckeditor5": "^47.0.0",
|
||||
"ckeditor5": "^48.0.0",
|
||||
"clipboard": "^2.0.4",
|
||||
"compression-webpack-plugin": "^11.1.0",
|
||||
"datatables.net": "^2.0.0",
|
||||
|
|
@ -69,11 +67,11 @@
|
|||
"marked": "^17.0.1",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
"marked-mangle": "^1.0.1",
|
||||
"pdfmake": "^0.2.2",
|
||||
"pdfmake": "^0.3.7",
|
||||
"stimulus-use": "^0.52.0",
|
||||
"tom-select": "^2.1.0",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"jquery": "^3.5.1"
|
||||
|
|
|
|||
86
phpstan.banned_code.neon
Normal file
86
phpstan.banned_code.neon
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Manually configure ekino/phpstan-banned-code to detect usage of echo, eval, die/exit, print, shell execution and a set of functions that should not be used in production code.
|
||||
|
||||
parametersSchema:
|
||||
banned_code: structure([
|
||||
nodes: listOf(structure([
|
||||
type: string()
|
||||
functions: schema(listOf(string()), nullable())
|
||||
]))
|
||||
use_from_tests: bool()
|
||||
non_ignorable: bool()
|
||||
])
|
||||
|
||||
parameters:
|
||||
banned_code:
|
||||
nodes:
|
||||
# enable detection of echo
|
||||
-
|
||||
type: Stmt_Echo
|
||||
functions: null
|
||||
|
||||
# enable detection of eval
|
||||
-
|
||||
type: Expr_Eval
|
||||
functions: null
|
||||
|
||||
# enable detection of die/exit
|
||||
-
|
||||
type: Expr_Exit
|
||||
functions: null
|
||||
|
||||
# enable detection of a set of functions
|
||||
-
|
||||
type: Expr_FuncCall
|
||||
functions:
|
||||
- dd
|
||||
- debug_backtrace
|
||||
- dump
|
||||
- exec
|
||||
- passthru
|
||||
- phpinfo
|
||||
- print_r
|
||||
- proc_open
|
||||
- shell_exec
|
||||
- system
|
||||
- var_dump
|
||||
|
||||
# enable detection of print statements
|
||||
-
|
||||
type: Expr_Print
|
||||
functions: null
|
||||
|
||||
# enable detection of shell execution by backticks
|
||||
-
|
||||
type: Expr_ShellExec
|
||||
functions: null
|
||||
|
||||
# enable detection of empty()
|
||||
#-
|
||||
# type: Expr_Empty
|
||||
# functions: null
|
||||
|
||||
# enable detection of `use Tests\Foo\Bar` in a non-test file
|
||||
use_from_tests: true
|
||||
|
||||
# when true, errors cannot be excluded
|
||||
non_ignorable: false
|
||||
|
||||
services:
|
||||
-
|
||||
class: Ekino\PHPStanBannedCode\Rules\BannedNodesRule
|
||||
tags:
|
||||
- phpstan.rules.rule
|
||||
arguments:
|
||||
- '%banned_code.nodes%'
|
||||
|
||||
-
|
||||
class: Ekino\PHPStanBannedCode\Rules\BannedUseTestRule
|
||||
tags:
|
||||
- phpstan.rules.rule
|
||||
arguments:
|
||||
- '%banned_code.use_from_tests%'
|
||||
|
||||
-
|
||||
class: Ekino\PHPStanBannedCode\Rules\BannedNodesErrorBuilder
|
||||
arguments:
|
||||
- '%banned_code.non_ignorable%'
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
includes:
|
||||
- phpstan.banned_code.neon
|
||||
|
||||
parameters:
|
||||
|
||||
level: 5
|
||||
|
|
@ -6,9 +9,6 @@ parameters:
|
|||
- src
|
||||
# - tests
|
||||
|
||||
banned_code:
|
||||
non_ignorable: false # Allow to ignore some banned code
|
||||
|
||||
excludePaths:
|
||||
- src/DataTables/Adapter/*
|
||||
- src/Configuration/*
|
||||
|
|
@ -59,6 +59,9 @@ parameters:
|
|||
- '#expects .*PartParameter, .*AbstractParameter given.#'
|
||||
- '#Part::getParameters\(\) should return .*AbstractParameter#'
|
||||
|
||||
# Fix some weird issue with how covariance with collections is solved
|
||||
- '#Method App\\Entity\\Base\\AbstractStructuralDBElement::getParameters\(\) should return Doctrine\\Common\\Collections\\Collection<int, App\\Entity\\Parameters\\AbstractParameter> but returns#'
|
||||
|
||||
# Ignore doctrine type mapping mismatch
|
||||
- '#Property .* type mapping mismatch: property can contain .* but database expects .*#'
|
||||
|
||||
|
|
@ -70,3 +73,6 @@ parameters:
|
|||
|
||||
- message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#'
|
||||
path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
|
||||
|
||||
-
|
||||
identifier: nullCoalesce.property
|
||||
|
|
|
|||
3
public/kicad/.gitignore
vendored
Normal file
3
public/kicad/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# They are user generated and should not be tracked by git
|
||||
footprints_custom.txt
|
||||
symbols_custom.txt
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated on Sun Mar 1 11:46:09 UTC 2026
|
||||
# Generated on Mon May 4 05:40:05 UTC 2026
|
||||
# This file contains all footprints available in the offical KiCAD library
|
||||
Audio_Module:Reverb_BTDR-1H
|
||||
Audio_Module:Reverb_BTDR-1V
|
||||
|
|
@ -8366,6 +8366,7 @@ Converter_DCDC:Converter_DCDC_TRACO_TMR-1SM_SMD
|
|||
Converter_DCDC:Converter_DCDC_TRACO_TMR10-24xxWIR_48xxWIR_72xxWIR_THT
|
||||
Converter_DCDC:Converter_DCDC_TRACO_TMR2-xxxxWI_THT
|
||||
Converter_DCDC:Converter_DCDC_TRACO_TMR4-xxxxWI_THT
|
||||
Converter_DCDC:Converter_DCDC_TRACO_TMR8-xxxxWI_THT
|
||||
Converter_DCDC:Converter_DCDC_TRACO_TMU3-05xx_12xx_THT
|
||||
Converter_DCDC:Converter_DCDC_TRACO_TMU3-24xx_THT
|
||||
Converter_DCDC:Converter_DCDC_TRACO_TMV-051xD_121xD_Dual_THT
|
||||
|
|
@ -11978,6 +11979,8 @@ Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm
|
|||
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm_ThermalVias
|
||||
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm
|
||||
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm_ThermalVias
|
||||
Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm
|
||||
Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm_ThermalVias
|
||||
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm
|
||||
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm_ThermalVias
|
||||
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.5x5.06mm
|
||||
|
|
@ -12028,6 +12031,8 @@ Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm
|
|||
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm_ThermalVias
|
||||
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm
|
||||
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm_ThermalVias
|
||||
Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm
|
||||
Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm_ThermalVias
|
||||
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm
|
||||
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm_ThermalVias
|
||||
Package_DFN_QFN:WQFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated on Sun Mar 1 11:46:51 UTC 2026
|
||||
# Generated on Mon May 4 05:40:43 UTC 2026
|
||||
# This file contains all symbols available in the offical KiCAD library
|
||||
4xxx:14528
|
||||
4xxx:14529
|
||||
|
|
@ -899,6 +899,7 @@ Amplifier_Buffer:BUF634AxD
|
|||
Amplifier_Buffer:BUF634AxDDA
|
||||
Amplifier_Buffer:BUF634AxDRB
|
||||
Amplifier_Buffer:BUF634U
|
||||
Amplifier_Buffer:BUF802
|
||||
Amplifier_Buffer:EL2001CN
|
||||
Amplifier_Buffer:LH0002H
|
||||
Amplifier_Buffer:LM6321H
|
||||
|
|
@ -1667,7 +1668,6 @@ Analog_ADC:CA3300
|
|||
Analog_ADC:HX711
|
||||
Analog_ADC:ICL7106CPL
|
||||
Analog_ADC:ICL7107CPL
|
||||
Analog_ADC:INA234AxYBJ
|
||||
Analog_ADC:LTC1406CGN
|
||||
Analog_ADC:LTC1406IGN
|
||||
Analog_ADC:LTC1594CS
|
||||
|
|
@ -2198,6 +2198,7 @@ Audio:WM8731SEDS
|
|||
Audio:YM2149
|
||||
Audio:YM2612
|
||||
Audio:YM3438
|
||||
Auxiliary_Items:Generic_Outline
|
||||
Auxiliary_Items:Jumper_Shunt
|
||||
Auxiliary_Items:MountingScrew
|
||||
Battery_Management:ADP5063
|
||||
|
|
@ -2254,6 +2255,11 @@ Battery_Management:BQ76200PW
|
|||
Battery_Management:BQ76920PW
|
||||
Battery_Management:BQ76930DBT
|
||||
Battery_Management:BQ76940DBT
|
||||
Battery_Management:BQ7695201PFBR
|
||||
Battery_Management:BQ7695202PFBR
|
||||
Battery_Management:BQ7695203PFBR
|
||||
Battery_Management:BQ7695204PFBR
|
||||
Battery_Management:BQ76952PFBR
|
||||
Battery_Management:BQ78350DBT
|
||||
Battery_Management:BQ78350DBT-R1
|
||||
Battery_Management:CN3063
|
||||
|
|
@ -2763,6 +2769,8 @@ Connector:DIN41612_02x32_AC
|
|||
Connector:DIN41612_02x32_AE
|
||||
Connector:DIN41612_02x32_ZB
|
||||
Connector:DIN41612_03x32_C_Split
|
||||
Connector:DP_Sink
|
||||
Connector:DP_Source
|
||||
Connector:DVI-D_Dual_Link
|
||||
Connector:DVI-I_Dual_Link
|
||||
Connector:ExpressCard
|
||||
|
|
@ -2901,6 +2909,7 @@ Connector:TestPoint_Alt
|
|||
Connector:TestPoint_Flag
|
||||
Connector:TestPoint_Probe
|
||||
Connector:TestPoint_Small
|
||||
Connector:TestPoint_Square
|
||||
Connector:UEXT_Host
|
||||
Connector:UEXT_Slave
|
||||
Connector:USB3_A
|
||||
|
|
@ -7772,6 +7781,7 @@ FPGA_Lattice:ICE40HX1K-TQ144
|
|||
FPGA_Lattice:ICE40HX4K-BG121
|
||||
FPGA_Lattice:ICE40HX4K-TQ144
|
||||
FPGA_Lattice:ICE40HX8K-BG121
|
||||
FPGA_Lattice:ICE40LP384-SG32
|
||||
FPGA_Lattice:ICE40UL1K-SWG16
|
||||
FPGA_Lattice:ICE40UP5K-SG48ITR
|
||||
FPGA_Lattice:ICE5LP1K-SG48
|
||||
|
|
@ -8835,6 +8845,7 @@ Interface_USB:CH343G
|
|||
Interface_USB:CH343P
|
||||
Interface_USB:CH344Q
|
||||
Interface_USB:CH9102F
|
||||
Interface_USB:CP2102C-Axx-xQFN24
|
||||
Interface_USB:CP2102N-Axx-xQFN20
|
||||
Interface_USB:CP2102N-Axx-xQFN24
|
||||
Interface_USB:CP2102N-Axx-xQFN28
|
||||
|
|
@ -15731,6 +15742,7 @@ Power_Management:RT9742AGJ5F
|
|||
Power_Management:RT9742ANGJ5F
|
||||
Power_Management:RT9742BGJ5F
|
||||
Power_Management:RT9742BNGJ5F
|
||||
Power_Management:RT9742SNGV
|
||||
Power_Management:SN6505ADBV
|
||||
Power_Management:SN6505BDBV
|
||||
Power_Management:SN6507DGQ
|
||||
|
|
@ -18692,6 +18704,7 @@ Regulator_Linear:TPS7A0530PDBZ
|
|||
Regulator_Linear:TPS7A0531PDBV
|
||||
Regulator_Linear:TPS7A0533PDBV
|
||||
Regulator_Linear:TPS7A0533PDBZ
|
||||
Regulator_Linear:TPS7A20xxxDBV
|
||||
Regulator_Linear:TPS7A20xxxDQN
|
||||
Regulator_Linear:TPS7A3301RGW
|
||||
Regulator_Linear:TPS7A39
|
||||
|
|
@ -20301,7 +20314,6 @@ Sensor:BME280
|
|||
Sensor:BME680
|
||||
Sensor:CHT11
|
||||
Sensor:DHT11
|
||||
Sensor:INA260
|
||||
Sensor:LTC2990
|
||||
Sensor:MAX30102
|
||||
Sensor:Nuclear-Radiation_Detector
|
||||
|
|
@ -20588,9 +20600,12 @@ Sensor_Energy:INA219BxD
|
|||
Sensor_Energy:INA219BxDCN
|
||||
Sensor_Energy:INA226
|
||||
Sensor_Energy:INA228
|
||||
Sensor_Energy:INA229
|
||||
Sensor_Energy:INA233
|
||||
Sensor_Energy:INA234AxYBJ
|
||||
Sensor_Energy:INA237
|
||||
Sensor_Energy:INA238
|
||||
Sensor_Energy:INA260
|
||||
Sensor_Energy:LTC4151xMS
|
||||
Sensor_Energy:MCP39F521
|
||||
Sensor_Energy:PAC1931x-xJ6CX
|
||||
|
|
@ -20842,6 +20857,9 @@ Sensor_Pressure:40PC015G
|
|||
Sensor_Pressure:40PC100G
|
||||
Sensor_Pressure:40PC150G
|
||||
Sensor_Pressure:40PC250G
|
||||
Sensor_Pressure:ABPxxxxxxxxx0
|
||||
Sensor_Pressure:ABPxxxxxxxxxA
|
||||
Sensor_Pressure:ABPxxxxxxxxxS
|
||||
Sensor_Pressure:BMP280
|
||||
Sensor_Pressure:ILPS28QSW
|
||||
Sensor_Pressure:LPS22DF
|
||||
|
|
@ -20869,6 +20887,7 @@ Sensor_Proximity:BPR-105
|
|||
Sensor_Proximity:BPR-105F
|
||||
Sensor_Proximity:BPR-205
|
||||
Sensor_Proximity:CNY70
|
||||
Sensor_Proximity:FDC1004DGS
|
||||
Sensor_Proximity:GP2S700HCP
|
||||
Sensor_Proximity:ITR1201SR10AR
|
||||
Sensor_Proximity:ITR8307
|
||||
|
|
@ -21788,6 +21807,7 @@ Transistor_BJT:Q_NPN_Darlington_ECBC
|
|||
Transistor_BJT:Q_NPN_EBC
|
||||
Transistor_BJT:Q_NPN_ECB
|
||||
Transistor_BJT:Q_NPN_ECBC
|
||||
Transistor_BJT:Q_PNP_ACAB
|
||||
Transistor_BJT:Q_PNP_BCE
|
||||
Transistor_BJT:Q_PNP_BCEC
|
||||
Transistor_BJT:Q_PNP_BEC
|
||||
|
|
@ -22321,6 +22341,7 @@ Transistor_FET:PSMN5R2-60YL
|
|||
Transistor_FET:QM6006D
|
||||
Transistor_FET:QM6015D
|
||||
Transistor_FET:Q_Dual_NMOS_G1S2G2D2S1D1
|
||||
Transistor_FET:Q_Dual_NMOS_PMOS_G1S2G2D2S1D1
|
||||
Transistor_FET:Q_Dual_NMOS_S1G1D2S2G2D1
|
||||
Transistor_FET:Q_Dual_NMOS_S1G1S2G2D2D1
|
||||
Transistor_FET:Q_Dual_NMOS_S1G1S2G2D2D2D1D1
|
||||
|
|
|
|||
|
|
@ -201,6 +201,10 @@ class BackupCommand extends Command
|
|||
$config_dir = $this->project_dir.'/config';
|
||||
$zip->addFile($config_dir.'/parameters.yaml', 'config/parameters.yaml');
|
||||
$zip->addFile($config_dir.'/banner.md', 'config/banner.md');
|
||||
|
||||
//Add kicad custom footprints and symbols files
|
||||
$zip->addFile($this->project_dir . '/public/kicad/footprints_custom.txt', 'public/kicad/footprints_custom.txt');
|
||||
$zip->addFile($this->project_dir . '/public/kicad/symbols_custom.txt', 'public/kicad/symbols_custom.txt');
|
||||
}
|
||||
|
||||
protected function backupAttachments(ZipFile $zip, SymfonyStyle $io): void
|
||||
|
|
|
|||
|
|
@ -56,13 +56,16 @@ class LoadFixturesCommand extends Command
|
|||
}
|
||||
|
||||
$factory = new ResetAutoIncrementPurgerFactory();
|
||||
$purger = $factory->createForEntityManager(null, $this->entityManager);
|
||||
|
||||
//Use truncate purging to fix compatibility with postgresql
|
||||
$purger = $factory->createForEntityManager(null, $this->entityManager, purgeWithTruncate: true);
|
||||
|
||||
$purger->purge();
|
||||
|
||||
//Afterwards run the load fixtures command as normal, but with the --append option
|
||||
$new_input = new ArrayInput([
|
||||
'command' => 'doctrine:fixtures:load',
|
||||
'--purge-with-truncate' => true,
|
||||
'--append' => true,
|
||||
]);
|
||||
|
||||
|
|
@ -70,4 +73,4 @@ class LoadFixturesCommand extends Command
|
|||
|
||||
return $returnCode ?? Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,24 +229,37 @@ class DBPlatformConvertCommand extends Command
|
|||
|
||||
if ($platform instanceof PostgreSQLPlatform) {
|
||||
$connection->executeStatement(
|
||||
//From: https://wiki.postgresql.org/wiki/Fixing_Sequences
|
||||
//See https://github.com/Part-DB/Part-DB-server/issues/1362
|
||||
<<<SQL
|
||||
SELECT 'SELECT SETVAL(' ||
|
||||
quote_literal(quote_ident(PGT.schemaname) || '.' || quote_ident(S.relname)) ||
|
||||
', COALESCE(MAX(' ||quote_ident(C.attname)|| '), 1) ) FROM ' ||
|
||||
quote_ident(PGT.schemaname)|| '.'||quote_ident(T.relname)|| ';'
|
||||
FROM pg_class AS S,
|
||||
pg_depend AS D,
|
||||
pg_class AS T,
|
||||
pg_attribute AS C,
|
||||
pg_tables AS PGT
|
||||
WHERE S.relkind = 'S'
|
||||
AND S.oid = D.objid
|
||||
AND D.refobjid = T.oid
|
||||
AND D.refobjid = C.attrelid
|
||||
AND D.refobjsubid = C.attnum
|
||||
AND T.relname = PGT.tablename
|
||||
ORDER BY S.relname;
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
max_id BIGINT;
|
||||
seq TEXT;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT c.table_name
|
||||
FROM information_schema.columns c
|
||||
JOIN pg_tables t
|
||||
ON t.tablename = c.table_name AND t.schemaname = 'public'
|
||||
WHERE c.column_name = 'id'
|
||||
AND c.table_schema = 'public'
|
||||
LOOP
|
||||
BEGIN
|
||||
seq := pg_get_serial_sequence(rec.table_name, 'id');
|
||||
IF seq IS NOT NULL THEN
|
||||
EXECUTE format('SELECT MAX(id) FROM %I', rec.table_name) INTO max_id;
|
||||
IF max_id IS NOT NULL THEN
|
||||
PERFORM setval(seq, max_id);
|
||||
RAISE NOTICE 'Reset: %.id → %', rec.table_name, max_id;
|
||||
END IF;
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'Skipped %: %', rec.table_name, SQLERRM;
|
||||
END;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ use App\Entity\Base\PartsContainingRepositoryInterface;
|
|||
use App\Entity\LabelSystem\LabelProcessMode;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Exceptions\AttachmentDownloadException;
|
||||
use App\Exceptions\TwigModeException;
|
||||
use App\Form\AdminPages\ImportType;
|
||||
|
|
@ -195,6 +196,10 @@ abstract class BaseAdminController extends AbstractController
|
|||
|
||||
$this->commentHelper->setMessage($form['log_comment']->getData());
|
||||
|
||||
//In principle, the form should be disabled, if the edit permission is not granted, but for good measure, we also check it here, before saving changes.
|
||||
if (!$entity instanceof User) { //Users entities does not have a simple edit permission, so we skip the check for them
|
||||
$this->denyAccessUnlessGranted('edit', $entity);
|
||||
}
|
||||
$em->persist($entity);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'entity.edit_flash');
|
||||
|
|
|
|||
|
|
@ -29,11 +29,14 @@ use App\Entity\Parts\Part;
|
|||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
|
||||
use App\Services\EntityMergers\Mergers\PartMerger;
|
||||
use App\Services\InfoProviderSystem\BulkInfoProviderService;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\ORMInvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
|
@ -66,6 +69,10 @@ class BulkInfoProviderImportController extends AbstractController
|
|||
{
|
||||
$dtos = [];
|
||||
foreach ($fieldMappings as $mapping) {
|
||||
// Skip entries where field is null/empty (e.g. user added a row but didn't select a field)
|
||||
if (empty($mapping['field'])) {
|
||||
continue;
|
||||
}
|
||||
$dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1);
|
||||
}
|
||||
return $dtos;
|
||||
|
|
@ -276,8 +283,8 @@ class BulkInfoProviderImportController extends AbstractController
|
|||
$updatedJobs = true;
|
||||
}
|
||||
|
||||
// Mark jobs with no results for deletion (failed searches)
|
||||
if ($job->getResultCount() === 0 && $job->isInProgress()) {
|
||||
// Mark jobs with no results for deletion (failed searches or stale pending)
|
||||
if ($job->getResultCount() === 0 && ($job->isInProgress() || $job->isPending())) {
|
||||
$jobsToDelete[] = $job;
|
||||
}
|
||||
}
|
||||
|
|
@ -297,9 +304,23 @@ class BulkInfoProviderImportController extends AbstractController
|
|||
}
|
||||
}
|
||||
|
||||
// Refetch after cleanup and split into active vs finished
|
||||
$allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy([], ['createdAt' => 'DESC']);
|
||||
|
||||
$activeJobs = [];
|
||||
$finishedJobs = [];
|
||||
foreach ($allJobs as $job) {
|
||||
if ($job->isCompleted() || $job->isFailed() || $job->isStopped()) {
|
||||
$finishedJobs[] = $job;
|
||||
} else {
|
||||
$activeJobs[] = $job;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('info_providers/bulk_import/manage.html.twig', [
|
||||
'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
|
||||
'active_jobs' => $activeJobs,
|
||||
'finished_jobs' => $finishedJobs,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -470,22 +491,13 @@ class BulkInfoProviderImportController extends AbstractController
|
|||
$fieldMappingDtos = $job->getFieldMappings();
|
||||
$prefetchDetails = $job->isPrefetchDetails();
|
||||
|
||||
try {
|
||||
$searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
|
||||
} catch (\Exception $searchException) {
|
||||
// Handle "no search results found" as a normal case, not an error
|
||||
if (str_contains($searchException->getMessage(), 'No search results found')) {
|
||||
$searchResultsDto = null;
|
||||
} else {
|
||||
throw $searchException;
|
||||
}
|
||||
}
|
||||
$searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
|
||||
|
||||
// Update the job's search results for this specific part efficiently
|
||||
$this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
|
||||
|
||||
// Prefetch details if requested
|
||||
if ($prefetchDetails && $searchResultsDto !== null) {
|
||||
if ($prefetchDetails) {
|
||||
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
|
||||
}
|
||||
|
||||
|
|
@ -515,6 +527,191 @@ class BulkInfoProviderImportController extends AbstractController
|
|||
}
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/quick-apply', name: 'bulk_info_provider_quick_apply', methods: ['POST'])]
|
||||
public function quickApply(
|
||||
int $jobId,
|
||||
int $partId,
|
||||
Request $request,
|
||||
PartInfoRetriever $infoRetriever,
|
||||
PartMerger $partMerger
|
||||
): JsonResponse {
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
/** @var Part $part */
|
||||
$part = $this->entityManager->getRepository(Part::class)->find($partId);
|
||||
if (!$part) {
|
||||
return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]);
|
||||
}
|
||||
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
|
||||
// Get provider key/id from request body, or fall back to top search result
|
||||
$body = $request->toArray();
|
||||
$providerKey = $body['providerKey'] ?? null;
|
||||
$providerId = $body['providerId'] ?? null;
|
||||
|
||||
if (!$providerKey || !$providerId) {
|
||||
$searchResults = $job->getSearchResults($this->entityManager);
|
||||
foreach ($searchResults->partResults as $partResult) {
|
||||
if ($partResult->part->getId() === $partId) {
|
||||
$sorted = $partResult->getResultsSortedByPriority();
|
||||
if (!empty($sorted)) {
|
||||
$providerKey = $sorted[0]->searchResult->provider_key;
|
||||
$providerId = $sorted[0]->searchResult->provider_id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$providerKey || !$providerId) {
|
||||
return $this->createErrorResponse('No search result available for this part', 400, ['part_id' => $partId]);
|
||||
}
|
||||
|
||||
try {
|
||||
$dto = $infoRetriever->getDetails($providerKey, $providerId);
|
||||
$providerPart = $infoRetriever->dtoToPart($dto);
|
||||
$partMerger->merge($part, $providerPart);
|
||||
|
||||
//Persist part manufacturer and supplier if they are new, to avoid issues with detached entities during merge
|
||||
//Do not footprints here, as it might pollute the database with unwanted formatting footprints from the provider,
|
||||
$this->entityManager->persist($part->getManufacturer());
|
||||
foreach ($part->getOrderdetails() as $orderdetail) {
|
||||
$this->entityManager->persist($orderdetail->getSupplier());
|
||||
}
|
||||
|
||||
try {
|
||||
$this->entityManager->flush();
|
||||
} catch (ORMInvalidArgumentException $exception) {
|
||||
if (str_contains($exception->getMessage(), 'not configured to cascade persist operations')) {
|
||||
throw new \RuntimeException('Failed to persist merged part, as it would create new datastructures! Review the provider data by yourself.');
|
||||
}
|
||||
|
||||
throw $exception; // Re-throw if it's a different ORM error
|
||||
}
|
||||
|
||||
$job->markPartAsCompleted($partId);
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'message' => sprintf('Applied provider data to "%s"', $part->getName()),
|
||||
'part_id' => $partId,
|
||||
'provider_key' => $providerKey,
|
||||
'provider_id' => $providerId,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error($e);
|
||||
|
||||
return $this->createErrorResponse(
|
||||
'Quick apply failed: ' . $e->getMessage(),
|
||||
500,
|
||||
['job_id' => $jobId, 'part_id' => $partId, 'exception' => $e->getMessage()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/quick-apply-all', name: 'bulk_info_provider_quick_apply_all', methods: ['POST'])]
|
||||
public function quickApplyAll(
|
||||
int $jobId,
|
||||
PartInfoRetriever $infoRetriever,
|
||||
PartMerger $partMerger
|
||||
): JsonResponse {
|
||||
set_time_limit(600);
|
||||
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
$searchResults = $job->getSearchResults($this->entityManager);
|
||||
$applied = 0;
|
||||
$failed = 0;
|
||||
$noResults = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($job->getJobParts() as $jobPart) {
|
||||
if ($jobPart->isCompleted() || $jobPart->isSkipped()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$part = $jobPart->getPart();
|
||||
|
||||
if (!$this->isGranted('edit', $part)) {
|
||||
$errors[] = sprintf('No edit permission for "%s"', $part->getName());
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find top search result for this part
|
||||
$providerKey = null;
|
||||
$providerId = null;
|
||||
foreach ($searchResults->partResults as $partResult) {
|
||||
if ($partResult->part->getId() === $part->getId()) {
|
||||
$sorted = $partResult->getResultsSortedByPriority();
|
||||
if (!empty($sorted)) {
|
||||
$providerKey = $sorted[0]->searchResult->provider_key;
|
||||
$providerId = $sorted[0]->searchResult->provider_id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$providerKey || !$providerId) {
|
||||
$noResults++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$dto = $infoRetriever->getDetails($providerKey, $providerId);
|
||||
$providerPart = $infoRetriever->dtoToPart($dto);
|
||||
$partMerger->merge($part, $providerPart);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$job->markPartAsCompleted($part->getId());
|
||||
$applied++;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Quick apply failed for part', [
|
||||
'part_id' => $part->getId(),
|
||||
'part_name' => $part->getName(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$errors[] = sprintf('Failed for "%s": %s', $part->getName(), $e->getMessage());
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'applied' => $applied,
|
||||
'failed' => $failed,
|
||||
'no_results' => $noResults,
|
||||
'errors' => $errors,
|
||||
'message' => sprintf('Applied to %d parts, %d failed, %d had no results', $applied, $failed, $noResults),
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])]
|
||||
public function researchAllParts(int $jobId): JsonResponse
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,11 +26,14 @@ namespace App\Controller;
|
|||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Exceptions\OAuthReconnectRequiredException;
|
||||
use App\Form\InfoProviderSystem\FromURLFormType;
|
||||
use App\Form\InfoProviderSystem\PartSearchType;
|
||||
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Services\InfoProviderSystem\Providers\GenericWebProvider;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Settings\AppSettings;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
|
@ -172,10 +175,15 @@ class InfoProviderController extends AbstractController
|
|||
$keyword = $form->get('keyword')->getData();
|
||||
$providers = $form->get('providers')->getData();
|
||||
|
||||
$no_cache_search = $form->get('no_cache_search')->getData();
|
||||
$no_cache_details = $form->get('no_cache_details')->getData();
|
||||
|
||||
$dtos = [];
|
||||
|
||||
try {
|
||||
$dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers);
|
||||
$dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers, options: [
|
||||
InfoProviderInterface::OPTION_NO_CACHE => $no_cache_search
|
||||
]);
|
||||
} catch (ClientException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.client_exception'));
|
||||
$this->addFlash('error',$e->getMessage());
|
||||
|
|
@ -207,40 +215,41 @@ class InfoProviderController extends AbstractController
|
|||
return $this->render('info_providers/search/part_search.html.twig', [
|
||||
'form' => $form,
|
||||
'results' => $results,
|
||||
'update_target' => $update_target
|
||||
'update_target' => $update_target,
|
||||
'no_cache_details' => $no_cache_details ?? false,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/from_url', name: 'info_providers_from_url')]
|
||||
public function fromURL(Request $request, GenericWebProvider $provider): Response
|
||||
public function fromURL(Request $request, GenericWebProvider $provider, CreateFromUrlHelper $fromUrlHelper): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
if (!$provider->isActive()) {
|
||||
if (!$fromUrlHelper->canCreateFromUrl()) {
|
||||
$this->addFlash('error', "Generic Web Provider is not active. Please enable it in the provider settings.");
|
||||
return $this->redirectToRoute('info_providers_list');
|
||||
}
|
||||
|
||||
$formBuilder = $this->createFormBuilder();
|
||||
$formBuilder->add('url', UrlType::class, [
|
||||
'label' => 'info_providers.from_url.url.label',
|
||||
'required' => true,
|
||||
]);
|
||||
$formBuilder->add('submit', SubmitType::class, [
|
||||
'label' => 'info_providers.search.submit',
|
||||
]);
|
||||
|
||||
$form = $formBuilder->getForm();
|
||||
$form = $this->createForm(FromURLFormType::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$partDetail = null;
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
//Try to retrieve the part detail from the given URL
|
||||
$url = $form->get('url')->getData();
|
||||
|
||||
$method = $form->get('method')->getData();
|
||||
$no_cache = $form->get('no_cache')->getData();
|
||||
$skip_delegation = $form->get('skip_delegation')->getData();
|
||||
|
||||
try {
|
||||
//It's okay if we use the cached results here, as its just for convenience
|
||||
$searchResult = $this->infoRetriever->searchByKeyword(
|
||||
keyword: $url,
|
||||
providers: [$provider]
|
||||
providers: [$method],
|
||||
options: [
|
||||
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
|
||||
]
|
||||
);
|
||||
|
||||
if (count($searchResult) === 0) {
|
||||
|
|
@ -251,6 +260,8 @@ class InfoProviderController extends AbstractController
|
|||
return $this->redirectToRoute('info_providers_create_part', [
|
||||
'providerKey' => $searchResult->provider_key,
|
||||
'providerId' => $searchResult->provider_id,
|
||||
'no_cache' => $no_cache ? 1 : null,
|
||||
'skip_delegation' => $skip_delegation ? 1 : null,
|
||||
]);
|
||||
}
|
||||
} catch (ExceptionInterface $e) {
|
||||
|
|
|
|||
88
src/Controller/KicadListEditorController.php
Normal file
88
src/Controller/KicadListEditorController.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Form\Settings\KicadListEditorType;
|
||||
use App\Settings\MiscSettings\KiCadEDASettings;
|
||||
use App\Services\EDA\KicadListFileManager;
|
||||
use Jbtronics\SettingsBundle\Exception\SettingsNotValidException;
|
||||
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
final class KicadListEditorController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsManagerInterface $settingsManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/settings/misc/kicad-lists', name: 'settings_kicad_lists')]
|
||||
public function __invoke(Request $request, KicadListFileManager $fileManager): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@config.change_system_settings');
|
||||
|
||||
/** @var KiCadEDASettings $settings */
|
||||
$settings = $this->settingsManager->createTemporaryCopy(KiCadEDASettings::class);
|
||||
$form = $this->createForm(KicadListEditorType::class, [
|
||||
'useCustomList' => $settings->useCustomList,
|
||||
'customFootprints' => $fileManager->getCustomFootprintsContent(),
|
||||
'customSymbols' => $fileManager->getCustomSymbolsContent(),
|
||||
], [
|
||||
'default_footprints' => $fileManager->getFootprintsContent(),
|
||||
'default_symbols' => $fileManager->getSymbolsContent(),
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$data = $form->getData();
|
||||
|
||||
try {
|
||||
$fileManager->saveCustom($data['customFootprints'], $data['customSymbols']);
|
||||
$settings->useCustomList = (bool) $data['useCustomList'];
|
||||
$this->settingsManager->mergeTemporaryCopy($settings);
|
||||
$this->settingsManager->save($settings);
|
||||
$this->addFlash('success', t('settings.flash.saved'));
|
||||
|
||||
return $this->redirectToRoute('settings_kicad_lists');
|
||||
} catch (RuntimeException|SettingsNotValidException $exception) {
|
||||
$this->addFlash('error', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$this->addFlash('error', t('settings.flash.invalid'));
|
||||
}
|
||||
|
||||
return $this->render('settings/kicad_list_editor.html.twig', [
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,10 +36,12 @@ use App\Entity\PriceInformations\Orderdetail;
|
|||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Exceptions\AttachmentDownloadException;
|
||||
use App\Form\Part\PartBaseType;
|
||||
use App\Form\Part\PartLotType;
|
||||
use App\Services\Attachments\AttachmentSubmitHandler;
|
||||
use App\Services\Attachments\PartPreviewGenerator;
|
||||
use App\Services\EntityMergers\Mergers\PartMerger;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\LogSystem\EventCommentHelper;
|
||||
use App\Services\LogSystem\HistoryHelper;
|
||||
use App\Services\LogSystem\TimeTravel;
|
||||
|
|
@ -127,6 +129,17 @@ final class PartController extends AbstractController
|
|||
$table = null;
|
||||
}
|
||||
|
||||
// Build the add-lot form for the INFO page modal (only when not in time-travel mode)
|
||||
$addLotForm = null;
|
||||
if ($timeTravel_timestamp === null && $this->isGranted('edit', $part)) {
|
||||
$newLot = new PartLot();
|
||||
$newLot->setPart($part);
|
||||
$addLotForm = $this->createForm(PartLotType::class, $newLot, [
|
||||
'measurement_unit' => $part->getPartUnit(),
|
||||
'action' => $this->generateUrl('part_lot_add', ['id' => $part->getID()]),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->render(
|
||||
'parts/info/show_part_info.html.twig',
|
||||
[
|
||||
|
|
@ -139,10 +152,39 @@ final class PartController extends AbstractController
|
|||
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
|
||||
'withdraw_add_helper' => $withdrawAddHelper,
|
||||
'highlightLotId' => $request->query->getInt('highlightLot', 0),
|
||||
'add_lot_form' => $addLotForm,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/add_lot', name: 'part_lot_add', methods: ['POST'])]
|
||||
public function addLot(Part $part, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
|
||||
$newLot = new PartLot();
|
||||
$newLot->setPart($part);
|
||||
|
||||
$form = $this->createForm(PartLotType::class, $newLot, [
|
||||
'measurement_unit' => $part->getPartUnit(),
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$em->persist($newLot);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'part.edited_flash');
|
||||
return $this->redirectToRoute('part_info', [
|
||||
'id' => $part->getID(),
|
||||
'highlightLot' => $newLot->getID(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->addFlash('error', 'part.created_flash.invalid');
|
||||
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/edit', name: 'part_edit')]
|
||||
public function edit(Part $part, Request $request): Response
|
||||
{
|
||||
|
|
@ -283,13 +325,37 @@ final class PartController extends AbstractController
|
|||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
$dto = $infoRetriever->getDetails($providerKey, $providerId);
|
||||
//Force info providers to not use cache, when retrieving part details for creating a new part, because otherwise we might end up with outdated information
|
||||
$no_cache = $request->query->getBoolean('no_cache', false);
|
||||
$skip_delegation = $request->query->getBoolean('skip_delegation', false);
|
||||
|
||||
$dto = $infoRetriever->getDetails($providerKey, $providerId, [
|
||||
InfoProviderInterface::OPTION_NO_CACHE => $no_cache,
|
||||
InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation,
|
||||
]);
|
||||
$new_part = $infoRetriever->dtoToPart($dto);
|
||||
|
||||
if ($new_part->getCategory() === null || $new_part->getCategory()->getID() === null) {
|
||||
$this->addFlash('warning', t("part.create_from_info_provider.no_category_yet"));
|
||||
}
|
||||
|
||||
$lotAmount = $request->query->get('lotAmount');
|
||||
$lotName = $request->query->get('lotName');
|
||||
$lotUserBarcode = $request->query->get('lotUserBarcode');
|
||||
|
||||
if ($lotAmount !== null || $lotName !== null || $lotUserBarcode !== null) {
|
||||
$partLot = new PartLot();
|
||||
$partLot->setAmount($lotAmount !== null ? (float)$lotAmount : 0);
|
||||
$partLot->setDescription($lotName !== null ? (string)$lotName : '');
|
||||
$partLot->setUserBarcode($lotUserBarcode !== null ? (string)$lotUserBarcode : '');
|
||||
|
||||
$new_part->addPartLot($partLot);
|
||||
|
||||
$this->addFlash('notice', t('part.create_from_info_provider.lot_filled_from_barcode'));
|
||||
|
||||
}
|
||||
|
||||
|
||||
return $this->renderPartForm('new', $request, $new_part, [
|
||||
'info_provider_dto' => $dto,
|
||||
]);
|
||||
|
|
@ -325,10 +391,13 @@ final class PartController extends AbstractController
|
|||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
//Force info providers to not use cache, when retrieving part details for creating a new part, because otherwise we might end up with outdated information
|
||||
$no_cache = $request->query->getBoolean('no_cache', false);
|
||||
|
||||
//Save the old name of the target part for the template
|
||||
$old_name = $part->getName();
|
||||
|
||||
$dto = $infoRetriever->getDetails($providerKey, $providerId);
|
||||
$dto = $infoRetriever->getDetails($providerKey, $providerId, [InfoProviderInterface::OPTION_NO_CACHE => $no_cache]);
|
||||
$provider_part = $infoRetriever->dtoToPart($dto);
|
||||
|
||||
$part = $partMerger->merge($part, $provider_part);
|
||||
|
|
|
|||
|
|
@ -69,10 +69,13 @@ class ProjectController extends AbstractController
|
|||
return $table->getResponse();
|
||||
}
|
||||
|
||||
$number_of_builds = max(1, $request->query->getInt('n', 1));
|
||||
|
||||
return $this->render('projects/info/info.html.twig', [
|
||||
'buildHelper' => $buildHelper,
|
||||
'datatable' => $table,
|
||||
'project' => $project,
|
||||
'number_of_builds' => $number_of_builds,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -240,7 +243,8 @@ class ProjectController extends AbstractController
|
|||
}
|
||||
|
||||
// Detect fields and get suggestions
|
||||
$detected_fields = $BOMImporter->detectFields($file_content);
|
||||
$detected_delimiter = $BOMImporter->detectDelimiter($file_content);
|
||||
$detected_fields = $BOMImporter->detectFields($file_content, $detected_delimiter);
|
||||
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
|
||||
|
||||
// Create mapping of original field names to sanitized field names for template
|
||||
|
|
@ -257,7 +261,7 @@ class ProjectController extends AbstractController
|
|||
$builder->add('delimiter', ChoiceType::class, [
|
||||
'label' => 'project.bom_import.delimiter',
|
||||
'required' => true,
|
||||
'data' => ',',
|
||||
'data' => $detected_delimiter,
|
||||
'choices' => [
|
||||
'project.bom_import.delimiter.comma' => ',',
|
||||
'project.bom_import.delimiter.semicolon' => ';',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ namespace App\Controller;
|
|||
use App\Entity\UserSystem\User;
|
||||
use App\Events\SecurityEvent;
|
||||
use App\Events\SecurityEvents;
|
||||
use App\Form\Security\LoginFormType;
|
||||
use App\Services\UserSystem\PasswordResetManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Gregwar\CaptchaBundle\Type\CaptchaType;
|
||||
|
|
@ -61,7 +62,12 @@ class SecurityController extends AbstractController
|
|||
// last username entered by the user
|
||||
$lastUsername = $authenticationUtils->getLastUsername();
|
||||
|
||||
$form = $this->createForm(LoginFormType::class, [
|
||||
'_username' => $lastUsername,
|
||||
]);
|
||||
|
||||
return $this->render('security/login.html.twig', [
|
||||
'form' => $form,
|
||||
'last_username' => $lastUsername,
|
||||
'error' => $error,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class SettingsController extends AbstractController
|
|||
public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@config.change_system_settings');
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
|
||||
//Create a clone of the settings object
|
||||
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
|
||||
|
|
|
|||
|
|
@ -22,38 +22,43 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\Parameters\AttachmentTypeParameter;
|
||||
use App\Entity\Parameters\CategoryParameter;
|
||||
use App\Entity\Parameters\ProjectParameter;
|
||||
use App\Entity\Parameters\FootprintParameter;
|
||||
use App\Entity\Parameters\GroupParameter;
|
||||
use App\Entity\Parameters\ManufacturerParameter;
|
||||
use App\Entity\Parameters\MeasurementUnitParameter;
|
||||
use App\Entity\Parameters\PartParameter;
|
||||
use App\Entity\Parameters\ProjectParameter;
|
||||
use App\Entity\Parameters\StorageLocationParameter;
|
||||
use App\Entity\Parameters\SupplierParameter;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Repository\ParameterRepository;
|
||||
use App\Services\AI\AIPlatformRegistry;
|
||||
use App\Services\AI\AIPlatforms;
|
||||
use App\Services\Attachments\AttachmentURLGenerator;
|
||||
use App\Services\Attachments\BuiltinAttachmentsFinder;
|
||||
use App\Services\Attachments\PartPreviewGenerator;
|
||||
use App\Services\Tools\TagFinder;
|
||||
use App\Settings\MiscSettings\IpnSuggestSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\AI\Platform\Capability;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Asset\Packages;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* In this controller the endpoints for the typeaheads are collected.
|
||||
|
|
@ -71,7 +76,10 @@ class TypeaheadController extends AbstractController
|
|||
#[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')]
|
||||
public function builtInResources(Request $request, BuiltinAttachmentsFinder $finder): JsonResponse
|
||||
{
|
||||
$query = $request->get('query');
|
||||
//Ensure that the user can access Part-DB at all
|
||||
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
|
||||
|
||||
$query = $request->query->getString('query');
|
||||
$array = $finder->find($query);
|
||||
|
||||
$result = [];
|
||||
|
|
@ -118,9 +126,12 @@ class TypeaheadController extends AbstractController
|
|||
}
|
||||
|
||||
#[Route(path: '/parts/search/{query}', name: 'typeahead_parts')]
|
||||
public function parts(EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator,
|
||||
AttachmentURLGenerator $attachmentURLGenerator, string $query = ""): JsonResponse
|
||||
{
|
||||
public function parts(
|
||||
EntityManagerInterface $entityManager,
|
||||
PartPreviewGenerator $previewGenerator,
|
||||
AttachmentURLGenerator $attachmentURLGenerator,
|
||||
string $query = ""
|
||||
): JsonResponse {
|
||||
$this->denyAccessUnlessGranted('@parts.read');
|
||||
|
||||
$repo = $entityManager->getRepository(Part::class);
|
||||
|
|
@ -131,7 +142,7 @@ class TypeaheadController extends AbstractController
|
|||
foreach ($parts as $part) {
|
||||
//Determine the picture to show:
|
||||
$preview_attachment = $previewGenerator->getTablePreviewAttachment($part);
|
||||
if($preview_attachment instanceof Attachment) {
|
||||
if ($preview_attachment instanceof Attachment) {
|
||||
$preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm');
|
||||
} else {
|
||||
$preview_url = '';
|
||||
|
|
@ -145,7 +156,7 @@ class TypeaheadController extends AbstractController
|
|||
'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '',
|
||||
'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
|
||||
'image' => $preview_url,
|
||||
];
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResponse($data);
|
||||
|
|
@ -205,12 +216,47 @@ class TypeaheadController extends AbstractController
|
|||
/** @var Category|null $category */
|
||||
$category = $entityManager->getRepository(Category::class)->find($categoryId);
|
||||
|
||||
//Ensure the user has access to both the part and the category
|
||||
$this->denyAccessUnlessGranted('read', $part);
|
||||
if ($category !== null) {
|
||||
$this->denyAccessUnlessGranted('read', $category);
|
||||
}
|
||||
|
||||
$clonedPart = clone $part;
|
||||
$clonedPart->setCategory($category);
|
||||
|
||||
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits);
|
||||
$ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description,
|
||||
$this->ipnSuggestSettings->suggestPartDigits);
|
||||
|
||||
return new JsonResponse($ipnSuggestions);
|
||||
}
|
||||
|
||||
#[Route(path: '/ai/{platform}/models', name: 'typeahead_ai_models', requirements: ['platform' => '.+'])]
|
||||
public function aiModels(
|
||||
AIPlatforms $platform,
|
||||
Request $request,
|
||||
AIPlatformRegistry $platformRegistry,
|
||||
CacheInterface $cache,
|
||||
): JsonResponse {
|
||||
$this->denyAccessUnlessGranted('@config.change_system_settings');
|
||||
|
||||
$capability_filter = $request->query->getEnum('capability', Capability::class);
|
||||
|
||||
$models = $cache->get('ai_models_'.$platform->value.'_'.($capability_filter->value ?? 'all'),
|
||||
function (ItemInterface $item) use ($platformRegistry, $platform, $capability_filter) {
|
||||
$item->expiresAfter(3600); //Cache for 1 hour
|
||||
if ($capability_filter === null) {
|
||||
return $platformRegistry->getPlatform($platform)->getModelCatalog()->getModels();
|
||||
}
|
||||
|
||||
//Otherwise filter the models by the capability
|
||||
return array_filter($platformRegistry->getPlatform($platform)->getModelCatalog()->getModels(),
|
||||
static fn(array $model) => in_array($capability_filter, $model['capabilities'], true)
|
||||
);
|
||||
});
|
||||
|
||||
return new JsonResponse($models);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,16 +23,22 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\System\BackupManager;
|
||||
use App\Services\System\InstallationTypeDetector;
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use App\Services\System\WatchtowerClient;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
|
|
@ -49,10 +55,15 @@ class UpdateManagerController extends AbstractController
|
|||
private readonly UpdateExecutor $updateExecutor,
|
||||
private readonly VersionManagerInterface $versionManager,
|
||||
private readonly BackupManager $backupManager,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly WatchtowerClient $watchtowerClient,
|
||||
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
||||
private readonly bool $webUpdatesDisabled = false,
|
||||
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
||||
private readonly bool $backupRestoreDisabled = false,
|
||||
#[Autowire(env: 'bool:DISABLE_BACKUP_DOWNLOAD')]
|
||||
private readonly bool $backupDownloadDisabled = false,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +87,16 @@ class UpdateManagerController extends AbstractController
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if backup download is disabled and throw exception if so.
|
||||
*/
|
||||
private function denyIfBackupDownloadDisabled(): void
|
||||
{
|
||||
if ($this->backupDownloadDisabled) {
|
||||
throw new AccessDeniedHttpException('Backup download is disabled by server configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main update manager page.
|
||||
*/
|
||||
|
|
@ -101,6 +122,8 @@ class UpdateManagerController extends AbstractController
|
|||
'backups' => $this->backupManager->getBackups(),
|
||||
'web_updates_disabled' => $this->webUpdatesDisabled,
|
||||
'backup_restore_disabled' => $this->backupRestoreDisabled,
|
||||
'backup_download_disabled' => $this->backupDownloadDisabled,
|
||||
'is_docker' => $this->installationTypeDetector->isDocker(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +229,7 @@ class UpdateManagerController extends AbstractController
|
|||
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
|
||||
public function startUpdate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfWebUpdatesDisabled();
|
||||
|
||||
|
|
@ -314,12 +338,126 @@ class UpdateManagerController extends AbstractController
|
|||
return $this->json($details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a manual backup.
|
||||
*/
|
||||
#[Route('/backup', name: 'admin_update_manager_backup', methods: ['POST'])]
|
||||
public function createBackup(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
if (!$this->isCsrfTokenValid('update_manager_backup', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
if ($this->updateExecutor->isLocked()) {
|
||||
$this->addFlash('error', 'Cannot create backup while an update is in progress.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->backupManager->createBackup(null, 'manual');
|
||||
$this->addFlash('success', 'update_manager.backup.created');
|
||||
} catch (\Exception $e) {
|
||||
$this->addFlash('error', 'Backup failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup file.
|
||||
*/
|
||||
#[Route('/backup/delete', name: 'admin_update_manager_backup_delete', methods: ['POST'])]
|
||||
public function deleteBackup(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$filename = $request->request->get('filename');
|
||||
if ($filename && $this->backupManager->deleteBackup($filename)) {
|
||||
$this->addFlash('success', 'update_manager.backup.deleted');
|
||||
} else {
|
||||
$this->addFlash('error', 'update_manager.backup.delete_error');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an update log file.
|
||||
*/
|
||||
#[Route('/log/delete', name: 'admin_update_manager_log_delete', methods: ['POST'])]
|
||||
public function deleteLog(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$filename = $request->request->get('filename');
|
||||
if ($filename && $this->updateExecutor->deleteLog($filename)) {
|
||||
$this->addFlash('success', 'update_manager.log.deleted');
|
||||
} else {
|
||||
$this->addFlash('error', 'update_manager.log.delete_error');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a backup file.
|
||||
* Requires password confirmation as backups contain sensitive data (password hashes, secrets, etc.).
|
||||
*/
|
||||
#[Route('/backup/download', name: 'admin_update_manager_backup_download', methods: ['POST'])]
|
||||
public function downloadBackup(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfBackupDownloadDisabled();
|
||||
|
||||
if (!$this->isCsrfTokenValid('update_manager_download', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
$password = $request->request->get('password', '');
|
||||
$user = $this->getUser();
|
||||
if (!$user instanceof User || !$this->passwordHasher->isPasswordValid($user, $password)) {
|
||||
$this->addFlash('error', 'update_manager.backup.download.invalid_password');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$filename = $request->request->get('filename', '');
|
||||
$details = $this->backupManager->getBackupDetails($filename);
|
||||
if (!$details) {
|
||||
throw $this->createNotFoundException('Backup not found');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($details['path']);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $details['file']);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a backup.
|
||||
*/
|
||||
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
|
||||
public function restore(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfBackupRestoreDisabled();
|
||||
|
||||
|
|
@ -368,4 +506,100 @@ class UpdateManagerController extends AbstractController
|
|||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Docker update via Watchtower.
|
||||
*/
|
||||
#[Route('/start-docker', name: 'admin_update_manager_start_docker', methods: ['POST'])]
|
||||
public function startDockerUpdate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfWebUpdatesDisabled();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_start_docker', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if Watchtower is configured and available
|
||||
if (!$this->watchtowerClient->isConfigured()) {
|
||||
$this->addFlash('error', 'Watchtower is not configured. Please set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
if (!$this->watchtowerClient->isAvailable()) {
|
||||
$this->addFlash('error', 'Watchtower is not reachable. Please check that the Watchtower container is running and accessible.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Create backup if requested
|
||||
$createBackup = $request->request->getBoolean('backup', true);
|
||||
if ($createBackup) {
|
||||
try {
|
||||
$this->backupManager->createBackup();
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('error', 'Failed to create backup before update: ' . $e->getMessage());
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger Watchtower update
|
||||
$success = $this->watchtowerClient->triggerUpdate();
|
||||
|
||||
if (!$success) {
|
||||
$this->addFlash('error', 'Failed to trigger Watchtower update. Check the logs for details.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$currentVersion = $this->versionManager->getVersion()->toString();
|
||||
|
||||
// Redirect to Docker progress page
|
||||
return $this->redirectToRoute('admin_update_manager_docker_progress', [
|
||||
'previous_version' => $currentVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker update progress page.
|
||||
* This page contains client-side JavaScript that polls until the container restarts.
|
||||
*/
|
||||
#[Route('/progress/docker', name: 'admin_update_manager_docker_progress', methods: ['GET'])]
|
||||
public function dockerProgress(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
$previousVersion = $request->query->get('previous_version', 'unknown');
|
||||
|
||||
return $this->render('admin/update_manager/docker_progress.html.twig', [
|
||||
'previous_version' => $previousVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight health check endpoint used by Docker update progress page.
|
||||
* Returns current version so the client-side JS can detect when the container restarts with a new version.
|
||||
*
|
||||
* Intentionally unauthenticated: after a Docker container restart, the user's session may not survive
|
||||
* (depends on session storage backend). The version string is non-sensitive public information.
|
||||
* This endpoint is also whitelisted in MaintenanceModeSubscriber.
|
||||
*/
|
||||
#[Route('/health', name: 'admin_update_manager_health', methods: ['GET'])]
|
||||
public function healthCheck(): JsonResponse
|
||||
{
|
||||
//Only show version if user is logged in and has permission
|
||||
|
||||
$response = [
|
||||
'status' => 'ok',
|
||||
];
|
||||
|
||||
if ($this->isGranted('@system.show_updates')) {
|
||||
$response['version'] = $this->versionManager->getVersion()->toString();
|
||||
} else {
|
||||
$response['version'] = "not authorized";
|
||||
}
|
||||
|
||||
return $this->json($response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ use App\DataTables\Filters\PartFilter;
|
|||
use App\DataTables\Filters\PartSearchFilter;
|
||||
use App\DataTables\Helpers\ColumnSortHelper;
|
||||
use App\DataTables\Helpers\PartDataTableHelper;
|
||||
use App\Doctrine\Functions\SiValueSort;
|
||||
use App\Doctrine\Helpers\FieldHelper;
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
|
|
@ -59,7 +60,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
|
||||
final class PartsDataTable implements DataTableTypeInterface
|
||||
{
|
||||
const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]];
|
||||
public const LENGTH_MENU = [[10, 25, 50, 100, 250, 500, -1], [10, 25, 50, 100, 250, 500, "All"]];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityURLGenerator $urlGenerator,
|
||||
|
|
@ -118,6 +119,18 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
|
||||
'orderField' => 'NATSORT(part.name)'
|
||||
])
|
||||
->add('si_value', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.si_value'),
|
||||
'render' => function ($value, Part $context): string {
|
||||
$siValue = SiValueSort::sqliteSiValue($context->getName());
|
||||
if ($siValue !== null) {
|
||||
//Output it as scientific number with a big E
|
||||
return htmlspecialchars(sprintf('%G', $siValue));
|
||||
}
|
||||
return '';
|
||||
},
|
||||
'orderField' => 'SI_VALUE_SORT(part.name)',
|
||||
])
|
||||
->add('id', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.id'),
|
||||
])
|
||||
|
|
@ -484,6 +497,19 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
//$builder->addGroupBy('_bulkImportJob');
|
||||
}
|
||||
|
||||
//When sorting by SI value, add NATSORT as a secondary sort so that parts without
|
||||
//an SI-prefixed value fall back to natural string ordering seamlessly.
|
||||
$orderByParts = $builder->getDQLPart('orderBy');
|
||||
foreach ($orderByParts as $orderBy) {
|
||||
foreach ($orderBy->getParts() as $part) {
|
||||
if (str_contains($part, 'SI_VALUE_SORT')) {
|
||||
$direction = str_contains($part, 'DESC') ? 'DESC' : 'ASC';
|
||||
$builder->addOrderBy('NATSORT(part.name)', $direction);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
/**
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
|
||||
|
|
@ -20,21 +17,31 @@ declare(strict_types=1);
|
|||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataTables;
|
||||
|
||||
use App\DataTables\Adapters\TwoStepORMAdapter;
|
||||
use App\DataTables\Column\EntityColumn;
|
||||
use App\DataTables\Column\EnumColumn;
|
||||
use App\DataTables\Column\LocaleDateTimeColumn;
|
||||
use App\DataTables\Column\MarkdownColumn;
|
||||
use App\DataTables\Helpers\PartDataTableHelper;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Doctrine\Helpers\FieldHelper;
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use App\Services\Formatters\MoneyFormatter;
|
||||
use App\Services\ProjectSystem\ProjectBuildHelper;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Doctrine\ORM\AbstractQuery;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
|
||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
|
||||
use Omines\DataTablesBundle\Column\TextColumn;
|
||||
use Omines\DataTablesBundle\DataTable;
|
||||
use Omines\DataTablesBundle\DataTableTypeInterface;
|
||||
|
|
@ -42,9 +49,14 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
|
||||
class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
{
|
||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper,
|
||||
protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityURLGenerator $entityURLGenerator,
|
||||
protected TranslatorInterface $translator,
|
||||
protected AmountFormatter $amountFormatter,
|
||||
protected PartDataTableHelper $partDataTableHelper,
|
||||
protected ProjectBuildHelper $projectBuildHelper,
|
||||
protected MoneyFormatter $moneyFormatter,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -60,7 +72,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
return '';
|
||||
}
|
||||
return $this->partDataTableHelper->renderPicture($context->getPart());
|
||||
},
|
||||
}
|
||||
])
|
||||
|
||||
->add('id', TextColumn::class, [
|
||||
|
|
@ -131,18 +143,32 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
->add('category', EntityColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.category'),
|
||||
'property' => 'part.category',
|
||||
'orderField' => 'NATSORT(category.name)',
|
||||
'orderField' => 'NATSORT(category.name)'
|
||||
])
|
||||
->add('footprint', EntityColumn::class, [
|
||||
'property' => 'part.footprint',
|
||||
'label' => $this->translator->trans('part.table.footprint'),
|
||||
'orderField' => 'NATSORT(footprint.name)',
|
||||
'orderField' => 'NATSORT(footprint.name)'
|
||||
])
|
||||
|
||||
->add('manufacturer', EntityColumn::class, [
|
||||
'property' => 'part.manufacturer',
|
||||
'label' => $this->translator->trans('part.table.manufacturer'),
|
||||
'orderField' => 'NATSORT(manufacturer.name)',
|
||||
'orderField' => 'NATSORT(manufacturer.name)'
|
||||
])
|
||||
|
||||
->add('manufacturing_status', EnumColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
||||
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
|
||||
'orderField' => 'part.manufacturing_status',
|
||||
'class' => ManufacturingStatus::class,
|
||||
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
|
||||
if ($status === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->translator->trans($status->toTranslationKey());
|
||||
},
|
||||
])
|
||||
|
||||
->add('mountnames', TextColumn::class, [
|
||||
|
|
@ -168,8 +194,10 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
return '';
|
||||
}
|
||||
])
|
||||
->add('storageLocations', TextColumn::class, [
|
||||
'label' => 'part.table.storeLocations',
|
||||
->add('storelocation', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.storeLocations'),
|
||||
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
|
||||
'orderField' => 'NATSORT(MIN(_storelocations.name))',
|
||||
'visible' => false,
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
if ($context->getPart() !== null) {
|
||||
|
|
@ -179,6 +207,27 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
return '';
|
||||
}
|
||||
])
|
||||
->add('price', TextColumn::class, [
|
||||
'label' => 'project.bom.price',
|
||||
'visible' => false,
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
|
||||
return $this->moneyFormatter->format($price->toScale(2, RoundingMode::UP)->toFloat(), null, 2, true);
|
||||
},
|
||||
])
|
||||
->add('ext_price', TextColumn::class, [
|
||||
'label' => 'project.bom.ext_price',
|
||||
'visible' => false,
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
|
||||
return $this->moneyFormatter->format(
|
||||
$price->multipliedBy($context->getQuantity())->toScale(2, RoundingMode::UP)->toFloat(),
|
||||
null,
|
||||
2,
|
||||
true
|
||||
);
|
||||
},
|
||||
])
|
||||
|
||||
->add('addedDate', LocaleDateTimeColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.addedDate'),
|
||||
|
|
@ -192,11 +241,13 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
|
||||
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
|
||||
|
||||
$dataTable->createAdapter(ORMAdapter::class, [
|
||||
'entity' => Attachment::class,
|
||||
'query' => function (QueryBuilder $builder) use ($options): void {
|
||||
$this->getQuery($builder, $options);
|
||||
$dataTable->createAdapter(TwoStepORMAdapter::class, [
|
||||
'entity' => ProjectBOMEntry::class,
|
||||
'hydrate' => AbstractQuery::HYDRATE_OBJECT,
|
||||
'filter_query' => function (QueryBuilder $builder) use ($options): void {
|
||||
$this->getFilterQuery($builder, $options);
|
||||
},
|
||||
'detail_query' => $this->getDetailQuery(...),
|
||||
'criteria' => [
|
||||
function (QueryBuilder $builder) use ($options): void {
|
||||
$this->buildCriteria($builder, $options);
|
||||
|
|
@ -206,20 +257,71 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
|||
]);
|
||||
}
|
||||
|
||||
private function getQuery(QueryBuilder $builder, array $options): void
|
||||
private function getFilterQuery(QueryBuilder $builder, array $options): void
|
||||
{
|
||||
$builder->select('bom_entry')
|
||||
->addSelect('part')
|
||||
$builder
|
||||
->select('bom_entry.id')
|
||||
->from(ProjectBOMEntry::class, 'bom_entry')
|
||||
->leftJoin('bom_entry.part', 'part')
|
||||
->leftJoin('part.category', 'category')
|
||||
->leftJoin('part.partLots', '_partLots')
|
||||
->leftJoin('_partLots.storage_location', '_storelocations')
|
||||
->leftJoin('part.footprint', 'footprint')
|
||||
->leftJoin('part.manufacturer', 'manufacturer')
|
||||
->leftJoin('part.partCustomState', 'partCustomState')
|
||||
->where('bom_entry.project = :project')
|
||||
->setParameter('project', $options['project'])
|
||||
->addGroupBy('bom_entry')
|
||||
->addGroupBy('part')
|
||||
->addGroupBy('category')
|
||||
->addGroupBy('footprint')
|
||||
->addGroupBy('manufacturer')
|
||||
->addGroupBy('partCustomState')
|
||||
;
|
||||
}
|
||||
|
||||
private function getDetailQuery(QueryBuilder $builder, array $filter_results): void
|
||||
{
|
||||
$ids = array_map(static fn (array $row) => $row['id'], $filter_results);
|
||||
if ($ids === []) {
|
||||
$ids = [-1];
|
||||
}
|
||||
|
||||
$builder
|
||||
->select('bom_entry')
|
||||
->addSelect('part')
|
||||
->addSelect('category')
|
||||
->addSelect('partLots')
|
||||
->addSelect('storelocations')
|
||||
->addSelect('footprint')
|
||||
->addSelect('manufacturer')
|
||||
->addSelect('partCustomState')
|
||||
->from(ProjectBOMEntry::class, 'bom_entry')
|
||||
->leftJoin('bom_entry.part', 'part')
|
||||
->leftJoin('part.category', 'category')
|
||||
->leftJoin('part.partLots', 'partLots')
|
||||
->leftJoin('partLots.storage_location', 'storelocations')
|
||||
->leftJoin('part.footprint', 'footprint')
|
||||
->leftJoin('part.manufacturer', 'manufacturer')
|
||||
->leftJoin('part.partCustomState', 'partCustomState')
|
||||
->where('bom_entry.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->addGroupBy('bom_entry')
|
||||
->addGroupBy('part')
|
||||
->addGroupBy('partLots')
|
||||
->addGroupBy('category')
|
||||
->addGroupBy('storelocations')
|
||||
->addGroupBy('footprint')
|
||||
->addGroupBy('manufacturer')
|
||||
->addGroupBy('partCustomState')
|
||||
|
||||
->setHint(Query::HINT_READ_ONLY, true)
|
||||
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
|
||||
;
|
||||
|
||||
FieldHelper::addOrderByFieldParam($builder, 'bom_entry.id', 'ids');
|
||||
}
|
||||
|
||||
private function buildCriteria(QueryBuilder $builder, array $options): void
|
||||
{
|
||||
|
||||
|
|
|
|||
196
src/Doctrine/Functions/SiValueSort.php
Normal file
196
src/Doctrine/Functions/SiValueSort.php
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Doctrine\Functions;
|
||||
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\SQLitePlatform;
|
||||
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||
use Doctrine\ORM\Query\AST\Node;
|
||||
use Doctrine\ORM\Query\Parser;
|
||||
use Doctrine\ORM\Query\SqlWalker;
|
||||
use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
/**
|
||||
* Custom DQL function that extracts the first numeric value with an optional SI prefix
|
||||
* from a string and returns the scaled numeric value for sorting.
|
||||
*
|
||||
* Usage: SI_VALUE_SORT(part.name)
|
||||
*
|
||||
* This enables sorting parts by their physical value. For example, capacitors
|
||||
* named "100nF", "1uF", "10pF" will be sorted by actual value: 10pF < 100nF < 1uF.
|
||||
*
|
||||
* Supported SI prefixes: p (pico, 1e-12), n (nano, 1e-9), u/µ (micro, 1e-6),
|
||||
* m (milli, 1e-3), k/K (kilo, 1e3), M (mega, 1e6), G (giga, 1e9), T (tera, 1e12).
|
||||
*
|
||||
* Only matches numbers at the very beginning of the string (ignoring leading whitespace).
|
||||
* Names like "Crystal 20MHz" will NOT match since the number is not at the start.
|
||||
* Names without a recognizable numeric+prefix pattern return NULL and sort last.
|
||||
*/
|
||||
class SiValueSort extends FunctionNode
|
||||
{
|
||||
private ?Node $field = null;
|
||||
|
||||
/**
|
||||
* SI prefix multipliers. Used by the SQLite PHP callback.
|
||||
*/
|
||||
private const SI_MULTIPLIERS = [
|
||||
'p' => 1e-12,
|
||||
'n' => 1e-9,
|
||||
'u' => 1e-6,
|
||||
'µ' => 1e-6,
|
||||
'm' => 1e-3,
|
||||
'k' => 1e3,
|
||||
'K' => 1e3,
|
||||
'M' => 1e6,
|
||||
'G' => 1e9,
|
||||
'T' => 1e12,
|
||||
];
|
||||
|
||||
public function parse(Parser $parser): void
|
||||
{
|
||||
$parser->match(TokenType::T_IDENTIFIER);
|
||||
$parser->match(TokenType::T_OPEN_PARENTHESIS);
|
||||
|
||||
$this->field = $parser->ArithmeticExpression();
|
||||
|
||||
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
|
||||
}
|
||||
|
||||
public function getSql(SqlWalker $sqlWalker): string
|
||||
{
|
||||
assert($this->field !== null, 'Field is not set');
|
||||
|
||||
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
|
||||
$rawField = $this->field->dispatch($sqlWalker);
|
||||
|
||||
// Normalize comma decimal separator to dot for SQL platforms (European locale support)
|
||||
$fieldSql = "REPLACE({$rawField}, ',', '.')";
|
||||
|
||||
if ($platform instanceof PostgreSQLPlatform) {
|
||||
return $this->getPostgreSQLSql($fieldSql);
|
||||
}
|
||||
|
||||
if ($platform instanceof AbstractMySQLPlatform) {
|
||||
return $this->getMySQLSql($fieldSql);
|
||||
}
|
||||
|
||||
// SQLite: comma normalization is handled in the PHP callback
|
||||
$fieldSql = $rawField;
|
||||
|
||||
if ($platform instanceof SQLitePlatform) {
|
||||
return "SI_VALUE({$fieldSql})";
|
||||
}
|
||||
|
||||
// Fallback: return NULL (no SI sorting available)
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL implementation using substring() with POSIX regex.
|
||||
*/
|
||||
private function getPostgreSQLSql(string $field): string
|
||||
{
|
||||
// Extract the numeric part using POSIX regex, anchored at start (with optional leading whitespace)
|
||||
$numericPart = "CAST(substring({$field} FROM '^\\s*(\\d+\\.?\\d*)\\s*[pnuµmkKMGT]?') AS DOUBLE PRECISION)";
|
||||
|
||||
// Extract the SI prefix character
|
||||
$prefixPart = "substring({$field} FROM '^\\s*\\d+\\.?\\d*\\s*([pnuµmkKMGT])')";
|
||||
|
||||
return $this->buildCaseExpression($numericPart, $prefixPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL/MariaDB implementation using REGEXP_SUBSTR.
|
||||
*/
|
||||
private function getMySQLSql(string $field): string
|
||||
{
|
||||
// Extract the numeric part, anchored at start (with optional leading whitespace)
|
||||
$numericPart = "CAST(REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*') AS DECIMAL(30,15))";
|
||||
|
||||
// Extract the prefix: get the full number+prefix match anchored at start, then take the last char
|
||||
$fullMatch = "REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*[[:space:]]*[pnuµmkKMGT]')";
|
||||
$prefixPart = "RIGHT({$fullMatch}, 1)";
|
||||
|
||||
return $this->buildCaseExpression($numericPart, $prefixPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CASE expression that maps an SI prefix character to a multiplier
|
||||
* and multiplies it with the numeric value.
|
||||
*
|
||||
* @param string $numericExpr SQL expression that evaluates to the numeric part
|
||||
* @param string $prefixExpr SQL expression that evaluates to the SI prefix character
|
||||
* @return string SQL CASE expression
|
||||
*/
|
||||
private function buildCaseExpression(string $numericExpr, string $prefixExpr): string
|
||||
{
|
||||
return "(CASE" .
|
||||
" WHEN {$numericExpr} IS NULL THEN NULL" .
|
||||
" WHEN {$prefixExpr} = 'p' THEN {$numericExpr} * 1e-12" .
|
||||
" WHEN {$prefixExpr} = 'n' THEN {$numericExpr} * 1e-9" .
|
||||
" WHEN {$prefixExpr} = 'u' THEN {$numericExpr} * 1e-6" .
|
||||
" WHEN {$prefixExpr} = 'µ' THEN {$numericExpr} * 1e-6" .
|
||||
" WHEN {$prefixExpr} = 'm' THEN {$numericExpr} * 1e-3" .
|
||||
" WHEN {$prefixExpr} = 'k' THEN {$numericExpr} * 1e3" .
|
||||
" WHEN {$prefixExpr} = 'K' THEN {$numericExpr} * 1e3" .
|
||||
" WHEN {$prefixExpr} = 'M' THEN {$numericExpr} * 1e6" .
|
||||
" WHEN {$prefixExpr} = 'G' THEN {$numericExpr} * 1e9" .
|
||||
" WHEN {$prefixExpr} = 'T' THEN {$numericExpr} * 1e12" .
|
||||
" ELSE {$numericExpr} * 1" .
|
||||
" END)";
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP callback for SQLite's SI_VALUE function.
|
||||
* Extracts the first numeric value with an optional SI prefix and returns the scaled value.
|
||||
*
|
||||
* @param string|null $value The input string
|
||||
* @return float|null The scaled numeric value, or null if no number found
|
||||
*/
|
||||
public static function sqliteSiValue(?string $value): ?float
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize comma decimal separator to dot (European locale support)
|
||||
$value = str_replace(',', '.', $value);
|
||||
|
||||
// Match a number at the very start (allowing leading whitespace), optionally followed by an SI prefix
|
||||
if (!preg_match('/^\s*(\d+\.?\d*)\s*([pnuµmkKMGT])?/u', $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$number = (float) $matches[1];
|
||||
$prefix = $matches[2] ?? '';
|
||||
|
||||
if ($prefix === '') {
|
||||
return $number;
|
||||
}
|
||||
|
||||
$multiplier = self::SI_MULTIPLIERS[$prefix] ?? 1.0; //@phpstan-ignore-line - fallback to 1.0 if prefix is not recognized (should not happen due to regex)
|
||||
|
||||
return $number * $multiplier;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Doctrine\Middleware;
|
||||
|
||||
use App\Doctrine\Functions\SiValueSort;
|
||||
use App\Exceptions\InvalidRegexException;
|
||||
use Doctrine\DBAL\Driver\Connection;
|
||||
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
|
||||
|
|
@ -51,6 +52,9 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
|
|||
|
||||
//Create a new collation for natural sorting
|
||||
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
|
||||
|
||||
//Create a function for SI prefix value sorting
|
||||
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,15 @@ class Category extends AbstractPartsContainingDBElement
|
|||
$this->eda_info = new EDACategoryInfo();
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
//Clone EDA info to prevent changes to the original EDA info when changing the cloned category
|
||||
$this->eda_info = clone $this->eda_info;
|
||||
}
|
||||
parent::__clone();
|
||||
}
|
||||
|
||||
public function getPartnameHint(): string
|
||||
{
|
||||
return $this->partname_hint;
|
||||
|
|
|
|||
|
|
@ -152,6 +152,15 @@ class Footprint extends AbstractPartsContainingDBElement
|
|||
$this->eda_info = new EDAFootprintInfo();
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
//Clone EDA info to prevent changes to the original EDA info when changing the cloned category
|
||||
$this->eda_info = clone $this->eda_info;
|
||||
}
|
||||
parent::__clone();
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* Getters
|
||||
****************************************/
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
#[ORM\Table(name: 'part_lots')]
|
||||
#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')]
|
||||
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
|
||||
#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
|
||||
#[ORM\Index(name: 'part_lots_idx_barcode', columns: ['vendor_barcode'], options: ['lengths' => [100]])]
|
||||
#[ValidPartLot]
|
||||
#[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
|
||||
#[ApiResource(
|
||||
|
|
@ -81,7 +81,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
denormalizationContext: ['groups' => ['part_lot:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["description", "comment"])]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["description", "comment", "user_barcode"])]
|
||||
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['instock_unknown', 'needs_refill'])]
|
||||
#[ApiFilter(RangeFilter::class, properties: ['amount'])]
|
||||
|
|
@ -166,9 +166,8 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
|||
/**
|
||||
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
|
||||
*/
|
||||
#[ORM\Column(name: "vendor_barcode", type: Types::STRING, nullable: true)]
|
||||
#[ORM\Column(name: "vendor_barcode", type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['part_lot:read', 'part_lot:write'])]
|
||||
#[Length(max: 255)]
|
||||
protected ?string $user_barcode = null;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ readonly class MaintenanceModeSubscriber implements EventSubscriberInterface
|
|||
return;
|
||||
}
|
||||
|
||||
//Allow to view the progress page
|
||||
if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) {
|
||||
//Allow to view the progress page and health check endpoint
|
||||
if (preg_match('#^/[a-z]{2}(?:_[A-Z]{2})?/system/update-manager/(progress|health)#', $event->getRequest()->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ class BaseEntityAdminForm extends AbstractType
|
|||
'label' => 'entity.edit.alternative_names.label',
|
||||
'help' => 'entity.edit.alternative_names.help',
|
||||
'empty_data' => null,
|
||||
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
|
||||
'attr' => [
|
||||
'class' => 'tagsinput',
|
||||
'data-controller' => 'elements--tagsinput',
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ final class TogglePasswordTypeExtension extends AbstractTypeExtension
|
|||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'toggle' => false,
|
||||
'toggle' => true,
|
||||
'hidden_label' => new TranslatableMessage('password_toggle.hide'),
|
||||
'visible_label' => new TranslatableMessage('password_toggle.show'),
|
||||
'hidden_icon' => 'Default',
|
||||
|
|
|
|||
87
src/Form/InfoProviderSystem/FromURLFormType.php
Normal file
87
src/Form/InfoProviderSystem/FromURLFormType.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\InfoProviderSystem;
|
||||
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
class FromURLFormType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly ProviderRegistry $providerRegistry)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('url', UrlType::class, [
|
||||
'label' => 'info_providers.from_url.url.label',
|
||||
'required' => true,
|
||||
]);
|
||||
|
||||
|
||||
$builder->add('method', ChoiceType::class, [
|
||||
'expanded' => true,
|
||||
'data' => 'generic_web', //Default value
|
||||
'label' => 'info_providers.from_url.method',
|
||||
'choices' => [
|
||||
'info_providers.from_url.method.generic_web' => 'generic_web',
|
||||
'info_providers.from_url.method.ai_web' => 'ai_web',
|
||||
],
|
||||
'choice_attr' => function ($choice, $key, $value) {
|
||||
//Disable all providers that are not active
|
||||
$provider = $this->providerRegistry->getProviderByKey($value);
|
||||
if (!$provider->isActive()) {
|
||||
return ['disabled' => 'disabled'];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
//Render the choices as inline radio buttons
|
||||
'label_attr' => [
|
||||
'class' => 'radio-inline',
|
||||
],
|
||||
]);
|
||||
|
||||
$builder->add('no_cache', CheckboxType::class, [
|
||||
'label' => 'info_providers.from_url.no_cache',
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('skip_delegation', CheckboxType::class, [
|
||||
'label' => 'info_providers.from_url.skip_delegation',
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'info_providers.search.submit',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ declare(strict_types=1);
|
|||
namespace App\Form\InfoProviderSystem;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SearchType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
|
@ -40,8 +41,17 @@ class PartSearchType extends AbstractType
|
|||
'help' => 'info_providers.search.providers.help',
|
||||
]);
|
||||
|
||||
$builder->add('no_cache_search', CheckboxType::class, [
|
||||
'label' => 'info_providers.no_cache_search',
|
||||
'required' => false,
|
||||
]);
|
||||
$builder->add('no_cache_details', CheckboxType::class, [
|
||||
'label' => 'info_providers.no_cache_details',
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'info_providers.search.submit'
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ declare(strict_types=1);
|
|||
namespace App\Form\Part\EDA;
|
||||
|
||||
use App\Form\Type\StaticFileAutocompleteType;
|
||||
use App\Settings\MiscSettings\KiCadEDASettings;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
|
@ -39,6 +40,13 @@ class KicadFieldAutocompleteType extends AbstractType
|
|||
//Do not use a leading slash here! otherwise it will not work under prefixed reverse proxies
|
||||
public const FOOTPRINT_PATH = 'kicad/footprints.txt';
|
||||
public const SYMBOL_PATH = 'kicad/symbols.txt';
|
||||
public const CUSTOM_FOOTPRINT_PATH = 'kicad/footprints_custom.txt';
|
||||
public const CUSTOM_SYMBOL_PATH = 'kicad/symbols_custom.txt';
|
||||
|
||||
public function __construct(
|
||||
private readonly KiCadEDASettings $kiCadEDASettings,
|
||||
) {
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
|
|
@ -47,8 +55,8 @@ class KicadFieldAutocompleteType extends AbstractType
|
|||
|
||||
$resolver->setDefaults([
|
||||
'file' => fn(Options $options) => match ($options['type']) {
|
||||
self::TYPE_FOOTPRINT => self::FOOTPRINT_PATH,
|
||||
self::TYPE_SYMBOL => self::SYMBOL_PATH,
|
||||
self::TYPE_FOOTPRINT => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_FOOTPRINT_PATH : self::FOOTPRINT_PATH,
|
||||
self::TYPE_SYMBOL => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_SYMBOL_PATH : self::SYMBOL_PATH,
|
||||
default => throw new \InvalidArgumentException('Invalid type'),
|
||||
}
|
||||
]);
|
||||
|
|
@ -58,4 +66,4 @@ class KicadFieldAutocompleteType extends AbstractType
|
|||
{
|
||||
return StaticFileAutocompleteType::class;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
src/Form/Security/LoginFormType.php
Normal file
83
src/Form/Security/LoginFormType.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\Security;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
class LoginFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('_username', TextType::class, [
|
||||
'label' => t('login.username.label'),
|
||||
'attr' => [
|
||||
'autofocus' => 'autofocus',
|
||||
'autocomplete' => 'username',
|
||||
'placeholder' => t('login.username.placeholder'),
|
||||
]
|
||||
])
|
||||
->add('_password', PasswordType::class, [
|
||||
'label' => t('login.password.label'),
|
||||
'attr' => [
|
||||
'autocomplete' => 'current-password',
|
||||
'placeholder' => t('login.password.placeholder'),
|
||||
]
|
||||
])
|
||||
->add('_remember_me', CheckboxType::class, [
|
||||
'label' => t('login.rememberme'),
|
||||
'required' => false,
|
||||
])
|
||||
->add('submit', \Symfony\Component\Form\Extension\Core\Type\SubmitType::class, [
|
||||
'label' => t('login.btn'),
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
// This ensures CSRF protection is active for the login
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_csrf_token',
|
||||
'csrf_token_id' => 'authenticate',
|
||||
'attr' => [
|
||||
'data-turbo' => 'false', // Disable Turbo for the login form to ensure proper redirection after login
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function getBlockPrefix(): string
|
||||
{
|
||||
// This removes the "login_form_" prefix from field names
|
||||
// so that Security can find "_username" directly.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
72
src/Form/Settings/AiModelsType.php
Normal file
72
src/Form/Settings/AiModelsType.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\Settings;
|
||||
|
||||
use Symfony\AI\Platform\Capability;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* An text input with autocomplete for AI models from the given platform.
|
||||
* The platform is determined by the value of another form field, which is specified by the "platform_selector" option. This allows to filter the available models based on the selected platform.
|
||||
*/
|
||||
final class AiModelsType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly UrlGeneratorInterface $urlGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return TextType::class;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
//The target label of the platform select, which is used to filter the models for the selected platform.
|
||||
$resolver->setRequired('platform_selector');
|
||||
$resolver->setAllowedTypes('platform_selector', 'string');
|
||||
|
||||
//Only show models, that have the given capability. This is used to only show models that support structured output for the AI extractor settings.
|
||||
$resolver->setDefault('filter_capability', null);
|
||||
$resolver->setAllowedTypes('filter_capability', ['null', Capability::class]);
|
||||
}
|
||||
|
||||
public function finishView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$urlOptions = ['platform' => '__PLATFORM__'];
|
||||
if ($options['filter_capability'] !== null) {
|
||||
$urlOptions['capability'] = $options['filter_capability']->value;
|
||||
}
|
||||
|
||||
$view->vars['attr']['data-url-template'] = $this->urlGenerator->generate('typeahead_ai_models', $urlOptions);
|
||||
$view->vars['attr']['data-controller'] = 'elements--ai-model-autocomplete';
|
||||
|
||||
$view->vars['attr']['data-platform-selector'] = $options['platform_selector'];
|
||||
}
|
||||
}
|
||||
65
src/Form/Settings/AiPlatformChoiceType.php
Normal file
65
src/Form/Settings/AiPlatformChoiceType.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\Settings;
|
||||
|
||||
use App\Services\AI\AIPlatformRegistry;
|
||||
use App\Services\AI\AIPlatforms;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Allow to choose an AI platform from the enabled platforms in the system. This is used in the settings to choose the default platform for AI features.
|
||||
*/
|
||||
final class AiPlatformChoiceType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly AIPlatformRegistry $platformRegistry)
|
||||
{
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return EnumType::class;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$choices = array_map(static fn(string $val) => AIPlatforms::from($val), array_keys($this->platformRegistry->getEnabledPlatforms()));
|
||||
|
||||
$resolver->setDefaults([
|
||||
'class' => AIPlatforms::class,
|
||||
'choices' => $choices,
|
||||
'required' => false,
|
||||
'platform_selector_label' => null
|
||||
]);
|
||||
}
|
||||
|
||||
public function finishView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$view->vars['attr']['data-platform-selector-label'] = $options['platform_selector_label'] ?? $view->vars['id'].'_label';
|
||||
}
|
||||
}
|
||||
103
src/Form/Settings/KicadListEditorType.php
Normal file
103
src/Form/Settings/KicadListEditorType.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Settings;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Form type for editing the custom KiCad footprints and symbols lists.
|
||||
*/
|
||||
final class KicadListEditorType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('useCustomList', CheckboxType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.use_custom_list',
|
||||
'help' => 'settings.misc.kicad_eda.use_custom_list.help',
|
||||
'required' => false,
|
||||
])
|
||||
->add('customFootprints', TextareaType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.editor.custom_footprints',
|
||||
'help' => 'settings.misc.kicad_eda.editor.footprints.help',
|
||||
'attr' => [
|
||||
'rows' => 16,
|
||||
'spellcheck' => 'false',
|
||||
'class' => 'font-monospace',
|
||||
],
|
||||
])
|
||||
->add('defaultFootprints', TextareaType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.editor.default_footprints',
|
||||
'help' => 'settings.misc.kicad_eda.editor.default_files_help',
|
||||
'disabled' => true,
|
||||
'mapped' => false,
|
||||
'data' => $options['default_footprints'],
|
||||
'attr' => [
|
||||
'rows' => 16,
|
||||
'spellcheck' => 'false',
|
||||
'class' => 'font-monospace',
|
||||
'readonly' => 'readonly',
|
||||
],
|
||||
])
|
||||
->add('customSymbols', TextareaType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.editor.custom_symbols',
|
||||
'help' => 'settings.misc.kicad_eda.editor.symbols.help',
|
||||
'attr' => [
|
||||
'rows' => 16,
|
||||
'spellcheck' => 'false',
|
||||
'class' => 'font-monospace',
|
||||
],
|
||||
])
|
||||
->add('defaultSymbols', TextareaType::class, [
|
||||
'label' => 'settings.misc.kicad_eda.editor.default_symbols',
|
||||
'help' => 'settings.misc.kicad_eda.editor.default_files_help',
|
||||
'disabled' => true,
|
||||
'mapped' => false,
|
||||
'data' => $options['default_symbols'],
|
||||
'attr' => [
|
||||
'rows' => 16,
|
||||
'spellcheck' => 'false',
|
||||
'class' => 'font-monospace',
|
||||
'readonly' => 'readonly',
|
||||
],
|
||||
])
|
||||
->add('save', SubmitType::class, [
|
||||
'label' => 'save',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'default_footprints' => '',
|
||||
'default_symbols' => '',
|
||||
]);
|
||||
$resolver->setAllowedTypes('default_footprints', 'string');
|
||||
$resolver->setAllowedTypes('default_symbols', 'string');
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ class TypeSynonymRowType extends AbstractType
|
|||
*/
|
||||
private function getPreferredLocales(): array
|
||||
{
|
||||
$fromSettings = $this->localizationSettings->languageMenuEntries ?? [];
|
||||
$fromSettings = $this->localizationSettings->languageMenuEntries;
|
||||
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,60 +29,137 @@ use Symfony\Contracts\HttpClient\ResponseStreamInterface;
|
|||
|
||||
/**
|
||||
* HttpClient wrapper that randomizes the user agent for each request, to make it harder for servers to detect and block us.
|
||||
* It also sets some other headers to make the requests look more like real browser requests.
|
||||
* When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent, until we run out of retries.
|
||||
*/
|
||||
final class RandomizeUseragentHttpClient implements HttpClientInterface
|
||||
{
|
||||
public const USER_AGENTS = [
|
||||
"Mozilla/5.0 (Windows; U; Windows NT 10.0; Win64; x64) AppleWebKit/534.16 (KHTML, like Gecko) Chrome/52.0.1359.302 Safari/600.6 Edge/15.25690",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299",
|
||||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_8_3) Gecko/20100101 Firefox/51.6",
|
||||
"Mozilla/5.0 (Android; Android 4.4.4; E:number:20-23:00 Build/24.0.B.1.34) AppleWebKit/603.18 (KHTML, like Gecko) Chrome/47.0.1559.384 Mobile Safari/600.5",
|
||||
"Mozilla/5.0 (compatible; MSIE 9.0; Windows; Windows NT 6.3; WOW64 Trident/5.0)",
|
||||
"Mozilla/5.0 (Windows; Windows NT 6.0; Win64; x64) AppleWebKit/602.21 (KHTML, like Gecko) Chrome/51.0.3187.154 Safari/536",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_4_2; like Mac OS X) AppleWebKit/537.24 (KHTML, like Gecko) Chrome/51.0.2432.275 Mobile Safari/535.6",
|
||||
"Mozilla/5.0 (U; Linux i680 ) Gecko/20100101 Firefox/57.5",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 8_8_6; en-US) Gecko/20100101 Firefox/53.9",
|
||||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_6_7) AppleWebKit/534.46 (KHTML, like Gecko) Chrome/55.0.3276.345 Safari/535",
|
||||
"Mozilla/5.0 (Windows; Windows NT 10.5;) AppleWebKit/535.42 (KHTML, like Gecko) Chrome/53.0.1176.353 Safari/534.0 Edge/11.95743",
|
||||
"Mozilla/5.0 (Linux; Android 5.1.1; MOTO G Build/LPH223) AppleWebKit/600.27 (KHTML, like Gecko) Chrome/47.0.1604.204 Mobile Safari/535.1",
|
||||
"Mozilla/5.0 (iPod; CPU iPod OS 7_4_8; like Mac OS X) AppleWebKit/534.17 (KHTML, like Gecko) Chrome/50.0.1632.146 Mobile Safari/600.4",
|
||||
"Mozilla/5.0 (Linux; U; Linux i570 ; en-US) Gecko/20100101 Firefox/49.9",
|
||||
"Mozilla/5.0 (Windows NT 10.2; WOW64; en-US) AppleWebKit/603.2 (KHTML, like Gecko) Chrome/55.0.1299.311 Safari/535",
|
||||
"Mozilla/5.0 (Windows; Windows NT 10.5; x64; en-US) AppleWebKit/603.39 (KHTML, like Gecko) Chrome/52.0.1443.139 Safari/536.6 Edge/13.79436",
|
||||
"Mozilla/5.0 (Linux; U; Android 5.1; SM-G9350T Build/MMB29M) AppleWebKit/537.15 (KHTML, like Gecko) Chrome/55.0.2552.307 Mobile Safari/600.8",
|
||||
"Mozilla/5.0 (Android; Android 6.0; SAMSUNG SM-D9350V Build/MDB08L) AppleWebKit/535.30 (KHTML, like Gecko) Chrome/53.0.1345.278 Mobile Safari/537.4",
|
||||
"Mozilla/5.0 (Windows; Windows NT 10.0;) AppleWebKit/534.44 (KHTML, like Gecko) Chrome/47.0.3503.387 Safari/601",
|
||||
private const PROFILES = [
|
||||
// --- CHROME ON WINDOWS ---
|
||||
'chrome_windows' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36',
|
||||
'Sec-Ch-Ua' => '"Google Chrome";v="142", "Chromium";v="142", "Not=A?Brand";v="99"',
|
||||
'Sec-Ch-Ua-Mobile' => '?0',
|
||||
'Sec-Ch-Ua-Platform' => '"Windows"',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
],
|
||||
|
||||
// --- CHROME ON MACOS ---
|
||||
'chrome_mac' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
|
||||
'Sec-Ch-Ua' => '"Google Chrome";v="141", "Chromium";v="141", "Not=A?Brand";v="99"',
|
||||
'Sec-Ch-Ua-Mobile' => '?0',
|
||||
'Sec-Ch-Ua-Platform' => '"macOS"',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
],
|
||||
|
||||
// --- EDGE ON WINDOWS ---
|
||||
'edge_windows' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0',
|
||||
'Sec-Ch-Ua' => '"Microsoft Edge";v="142", "Chromium";v="142", "Not=A?Brand";v="99"',
|
||||
'Sec-Ch-Ua-Mobile' => '?0',
|
||||
'Sec-Ch-Ua-Platform' => '"Windows"',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
],
|
||||
|
||||
// --- FIREFOX ON WINDOWS ---
|
||||
'firefox_windows' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
|
||||
'Accept-Language' => 'en-US,en;q=0.5',
|
||||
// Firefox does not send Sec-Ch-Ua headers by default
|
||||
],
|
||||
|
||||
// --- FIREFOX ON LINUX ---
|
||||
'firefox_linux' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
|
||||
'Accept-Language' => 'en-US,en;q=0.5',
|
||||
],
|
||||
|
||||
// --- SAFARI ON MACOS ---
|
||||
'safari_mac' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language' => 'en-US,en;q=0.9',
|
||||
],
|
||||
|
||||
// --- CHROME ON ANDROID (Mobile) ---
|
||||
'chrome_android' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Mobile Safari/537.36',
|
||||
'Sec-Ch-Ua' => '"Google Chrome";v="142", "Chromium";v="142", "Not=A?Brand";v="99"',
|
||||
'Sec-Ch-Ua-Mobile' => '?1',
|
||||
'Sec-Ch-Ua-Platform' => '"Android"',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
],
|
||||
|
||||
// --- SAFARI ON IPHONE (Mobile) ---
|
||||
'safari_iphone' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language' => 'en-US,en;q=0.9',
|
||||
],
|
||||
];
|
||||
|
||||
private const COMMON_HEADERS = [
|
||||
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language' => 'en-US,en;q=0.9',
|
||||
'Sec-Fetch-Dest' => 'document',
|
||||
'Sec-Fetch-Mode' => 'navigate',
|
||||
'Sec-Fetch-Site' => 'none',
|
||||
'Sec-Fetch-User' => '?1',
|
||||
'Upgrade-Insecure-Requests' => '1',
|
||||
];
|
||||
|
||||
private const ENTRY_REFERERS = [
|
||||
'https://www.google.com/',
|
||||
'https://www.bing.com/',
|
||||
'https://duckduckgo.com/',
|
||||
'https://t.co/', // Twitter/X shortener
|
||||
'https://www.reddit.com/',
|
||||
];
|
||||
|
||||
private ?string $lastUrl = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly array $userAgents = self::USER_AGENTS,
|
||||
private readonly int $repeatOnFailure = 1,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRandomUserAgent(): string
|
||||
{
|
||||
return $this->userAgents[array_rand($this->userAgents)];
|
||||
}
|
||||
|
||||
public function request(string $method, string $url, array $options = []): ResponseInterface
|
||||
{
|
||||
$repeatsLeft = $this->repeatOnFailure;
|
||||
do {
|
||||
$modifiedOptions = $options;
|
||||
if (!isset($modifiedOptions['headers']['User-Agent'])) {
|
||||
$modifiedOptions['headers']['User-Agent'] = $this->getRandomUserAgent();
|
||||
$profile = self::PROFILES[array_rand(self::PROFILES)];
|
||||
|
||||
// Merge common headers with the specific browser profile
|
||||
$headers = array_merge(self::COMMON_HEADERS, $profile);
|
||||
|
||||
//Add a Referer header if not already set, to make it look more like a real browser request. We use the last URL we visited as the referer, to simulate internal navigation. If we don't have a last URL (first request), we pick a random entry point from common referers.
|
||||
if (!isset($options['headers']['Referer'])) {
|
||||
if ($this->lastUrl !== null) {
|
||||
// If we have a previous URL, use it (Internal Navigation)
|
||||
$headers['Referer'] = $this->lastUrl;
|
||||
} else {
|
||||
// First request? Pick an entry point (External Entry)
|
||||
$headers['Referer'] = self::ENTRY_REFERERS[array_rand(self::ENTRY_REFERERS)];
|
||||
}
|
||||
}
|
||||
$response = $this->client->request($method, $url, $modifiedOptions);
|
||||
|
||||
// Allow manual overrides from $options
|
||||
$options['headers'] = array_merge($headers, $options['headers'] ?? []);
|
||||
|
||||
$response = $this->client->request($method, $url, $options);
|
||||
|
||||
//When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent
|
||||
if (!in_array($response->getStatusCode(), [403, 429, 503], true)) {
|
||||
$this->lastUrl = $url; // Update last visited URL for referer in the next request
|
||||
return $response;
|
||||
}
|
||||
|
||||
//Otherwise we try again with a different user agent, until we run out of retries
|
||||
usleep(5000); // Sleep for 5ms to avoid hammering the server too hard in case of multiple retries
|
||||
} while ($repeatsLeft-- > 0);
|
||||
|
||||
return $response;
|
||||
|
|
@ -95,6 +172,6 @@ final class RandomizeUseragentHttpClient implements HttpClientInterface
|
|||
|
||||
public function withOptions(array $options): static
|
||||
{
|
||||
return new self($this->client->withOptions($options), $this->userAgents, $this->repeatOnFailure);
|
||||
return new self($this->client->withOptions($options), $this->repeatOnFailure);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
|
|||
|
||||
private const ALREADY_CALLED = 'STRUCTURAL_DENORMALIZER_ALREADY_CALLED';
|
||||
|
||||
private const PARENT_ELEMENT = 'STRUCTURAL_DENORMALIZER_PARENT_ELEMENT';
|
||||
|
||||
private array $object_cache = [];
|
||||
|
||||
public function __construct(
|
||||
|
|
@ -89,37 +91,59 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
|
|||
|
||||
$context[self::ALREADY_CALLED][] = $data;
|
||||
|
||||
//In the first step, denormalize without children
|
||||
$context_without_children = $context;
|
||||
$context_without_children['groups'] = array_filter(
|
||||
$context_without_children['groups'] ?? [],
|
||||
static fn($group) => $group !== 'include_children',
|
||||
);
|
||||
//Also unset any parent element, to avoid infinite loops. We will set the parent element in the next step, when we denormalize the children
|
||||
unset($context_without_children[self::PARENT_ELEMENT]);
|
||||
/** @var AbstractStructuralDBElement $entity */
|
||||
$entity = $this->denormalizer->denormalize($data, $type, $format, $context_without_children);
|
||||
|
||||
/** @var AbstractStructuralDBElement $deserialized_entity */
|
||||
$deserialized_entity = $this->denormalizer->denormalize($data, $type, $format, $context);
|
||||
//Assign the parent element to the denormalized entity, so it can be used in the denormalization of the children (e.g. for path generation)
|
||||
if (isset($context[self::PARENT_ELEMENT]) && $context[self::PARENT_ELEMENT] instanceof $entity && $entity->getID() === null) {
|
||||
$entity->setParent($context[self::PARENT_ELEMENT]);
|
||||
}
|
||||
|
||||
//Check if we already have the entity in the database (via path)
|
||||
/** @var StructuralDBElementRepository<T> $repo */
|
||||
$repo = $this->entityManager->getRepository($type);
|
||||
|
||||
$path = $deserialized_entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
|
||||
$path = $entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
|
||||
$db_elements = $repo->getEntityByPath($path, AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
|
||||
if ($db_elements !== []) {
|
||||
//We already have the entity in the database, so we can return it
|
||||
return end($db_elements);
|
||||
$entity = end($db_elements);
|
||||
}
|
||||
|
||||
|
||||
//Check if we have created the entity in this request before (so we don't create multiple entities for the same path)
|
||||
//Entities get saved in the cache by type and path
|
||||
//We use a different cache for this then the objects created by a string value (saved in repo). However, that should not be a problem
|
||||
//unless the user data has mixed structure between json data and a string path
|
||||
//unless the user data has mixed structure between JSON data and a string path
|
||||
if (isset($this->object_cache[$type][$path])) {
|
||||
return $this->object_cache[$type][$path];
|
||||
$entity = $this->object_cache[$type][$path];
|
||||
} else {
|
||||
//Save the entity in the cache
|
||||
$this->object_cache[$type][$path] = $entity;
|
||||
}
|
||||
|
||||
//Save the entity in the cache
|
||||
$this->object_cache[$type][$path] = $deserialized_entity;
|
||||
//In the next step we can denormalize the children, and add our children to the entity.
|
||||
if (in_array('include_children', $context['groups'], true) && isset($data['children']) && is_array($data['children'])) {
|
||||
foreach ($data['children'] as $child_data) {
|
||||
$child_entity = $this->denormalize($child_data, $type, $format, array_merge($context, [self::PARENT_ELEMENT => $entity]));
|
||||
if ($child_entity !== null && !$entity->getChildren()->contains($child_entity)) {
|
||||
$entity->addChild($child_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//We don't have the entity in the database, so we have to persist it
|
||||
$this->entityManager->persist($deserialized_entity);
|
||||
$this->entityManager->persist($entity);
|
||||
|
||||
return $deserialized_entity;
|
||||
return $entity;
|
||||
}
|
||||
|
||||
public function getSupportedTypes(?string $format): array
|
||||
|
|
|
|||
94
src/Services/AI/AIPlatformRegistry.php
Normal file
94
src/Services/AI/AIPlatformRegistry.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\AI;
|
||||
|
||||
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
|
||||
use Symfony\AI\Platform\PlatformInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
final readonly class AIPlatformRegistry
|
||||
{
|
||||
/**
|
||||
* All registered platforms, indexed by their service tag name (e.g. "openrouter", "lmstudio")
|
||||
* @var array<string, PlatformInterface> $allPlatforms
|
||||
*/
|
||||
private array $allPlatforms;
|
||||
|
||||
/**
|
||||
* All registered platforms, indexed by their AIPlatforms enum value (e.g. AIPlatforms::OPENROUTER->value)
|
||||
* @var array<string, PlatformInterface> $enabledPlatforms
|
||||
*/
|
||||
private array $enabledPlatforms;
|
||||
|
||||
public function __construct(
|
||||
SettingsManagerInterface $settingsManager,
|
||||
#[AutowireIterator(tag: 'ai.platform', indexAttribute: 'name')]
|
||||
iterable $platforms,
|
||||
) {
|
||||
$this->allPlatforms = iterator_to_array($platforms);
|
||||
|
||||
//Check which platforms are active based on the settings and store them in $activePlatforms
|
||||
$tmp = [];
|
||||
foreach (AIPlatforms::cases() as $platform) {
|
||||
if (isset($this->allPlatforms[$platform->toServiceTagName()])) {
|
||||
//Check if the platform is active by calling its isActive() on the settings class
|
||||
$settings = $settingsManager->get($platform->toSettingsClass());
|
||||
if (!$settings->isAIPlatformEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tmp[$platform->value] = $this->allPlatforms[$platform->toServiceTagName()];
|
||||
}
|
||||
}
|
||||
$this->enabledPlatforms = $tmp;
|
||||
}
|
||||
|
||||
public function getPlatform(AIPlatforms $platform): PlatformInterface
|
||||
{
|
||||
if (!isset($this->enabledPlatforms[$platform->value])) {
|
||||
throw new \InvalidArgumentException(sprintf('AI platform "%s" is not active or does not exist.', $platform->name));
|
||||
}
|
||||
|
||||
return $this->enabledPlatforms[$platform->value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given platform is active (i.e. it is registered and its settings are properly configured)
|
||||
* @param AIPlatforms $platform
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled(AIPlatforms $platform): bool
|
||||
{
|
||||
return isset($this->enabledPlatforms[$platform->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all active platforms, indexed by their AIPlatforms enum value (e.g. AIPlatforms::OPENROUTER->value)
|
||||
* @return PlatformInterface[]
|
||||
*/
|
||||
public function getEnabledPlatforms(): array
|
||||
{
|
||||
return $this->enabledPlatforms;
|
||||
}
|
||||
}
|
||||
33
src/Services/AI/AIPlatformSettingsInterface.php
Normal file
33
src/Services/AI/AIPlatformSettingsInterface.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\AI;
|
||||
|
||||
interface AIPlatformSettingsInterface
|
||||
{
|
||||
/**
|
||||
* Returns true, if the AI platform is enabled in the settings and can be used, false otherwise.
|
||||
* @return bool
|
||||
*/
|
||||
public function isAIPlatformEnabled(): bool;
|
||||
}
|
||||
64
src/Services/AI/AIPlatforms.php
Normal file
64
src/Services/AI/AIPlatforms.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\AI;
|
||||
|
||||
use App\Settings\AISettings\LMStudioSettings;
|
||||
use App\Settings\AISettings\OpenRouterSettings;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
enum AIPlatforms: string implements TranslatableInterface
|
||||
{
|
||||
case OPENROUTER = 'openrouter';
|
||||
case LMSTUDIO = 'lmstudio';
|
||||
|
||||
/**
|
||||
* Returns the name attribute of the service tag for this platform, which is used to register the platform in the AIPlatformRegistry
|
||||
* @return string
|
||||
*/
|
||||
public function toServiceTagName(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the class name of the settings class for this platform, which implements AIPlatformSettingsInterface
|
||||
* @return string
|
||||
* @phpstan-return class-string<AIPlatformSettingsInterface>
|
||||
*/
|
||||
public function toSettingsClass(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::LMSTUDIO => LMStudioSettings::class,
|
||||
self::OPENROUTER => OpenRouterSettings::class,
|
||||
};
|
||||
}
|
||||
|
||||
public function trans(TranslatorInterface $translator, ?string $locale = null): string
|
||||
{
|
||||
$key = 'settings.ai.' . $this->value;
|
||||
|
||||
return $translator->trans($key, locale: $locale);
|
||||
}
|
||||
}
|
||||
61
src/Services/AI/AcceptAllModelsCatalog.php
Normal file
61
src/Services/AI/AcceptAllModelsCatalog.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\AI;
|
||||
|
||||
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
|
||||
use Symfony\AI\Platform\Exception\ModelNotFoundException;
|
||||
use Symfony\AI\Platform\Model;
|
||||
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
|
||||
/**
|
||||
* This is a wrapper, to allow accepting all models, even if they are not contained in the decorated ModelCatalogInterface.
|
||||
* This is a workaround for outdated/incomplete model catalogs provided by AI platforms, which do not contain all available models, or do not update their catalogs frequently enough.
|
||||
*/
|
||||
#[AsDecorator('ai.platform.model_catalog.lmstudio')]
|
||||
#[AsDecorator('ai.platform.model_catalog.openrouter')]
|
||||
final readonly class AcceptAllModelsCatalog implements ModelCatalogInterface
|
||||
{
|
||||
|
||||
public function __construct(private ModelCatalogInterface $decorated)
|
||||
{
|
||||
}
|
||||
|
||||
public function getModel(string $modelName): Model
|
||||
{
|
||||
//Use the actual values when its available.
|
||||
try {
|
||||
return $this->decorated->getModel($modelName);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
//If the model is not found, return a generic model with the given name and no capabilities.
|
||||
return new CompletionsModel($modelName, []);
|
||||
}
|
||||
}
|
||||
|
||||
public function getModels(): array
|
||||
{
|
||||
//Return the actual models catalog here for correct autocompletition
|
||||
return $this->decorated->getModels();
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,8 @@ use App\Exceptions\AttachmentDownloadException;
|
|||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
|
||||
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
|
@ -76,6 +78,8 @@ class AttachmentSubmitHandler
|
|||
protected FileTypeFilterTools $filterTools,
|
||||
protected AttachmentsSettings $settings,
|
||||
protected readonly SVGSanitizer $SVGSanitizer,
|
||||
#[Autowire(env: "bool:ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK")]
|
||||
private readonly bool $allow_local_network_downloads = false,
|
||||
)
|
||||
{
|
||||
//The mapping used to determine which folder will be used for an attachment type
|
||||
|
|
@ -95,6 +99,10 @@ class AttachmentSubmitHandler
|
|||
UserAttachment::class => 'user',
|
||||
LabelAttachment::class => 'label_profile',
|
||||
];
|
||||
|
||||
if (!$this->allow_local_network_downloads) {
|
||||
$this->httpClient = new NoPrivateNetworkHttpClient($this->httpClient);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -373,6 +381,7 @@ class AttachmentSubmitHandler
|
|||
],
|
||||
|
||||
];
|
||||
|
||||
$response = $this->httpClient->request('GET', $url, $opts);
|
||||
//Digikey wants TLSv1.3, so try again with that if we get a 403
|
||||
if ($response->getStatusCode() === 403) {
|
||||
|
|
@ -434,8 +443,8 @@ class AttachmentSubmitHandler
|
|||
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
|
||||
//Save the path to the attachment
|
||||
$attachment->setInternalPath($new_path);
|
||||
} catch (TransportExceptionInterface) {
|
||||
throw new AttachmentDownloadException('Transport error!');
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw new AttachmentDownloadException('Transport error: '.$exception->getMessage());
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ class KiCadHelper
|
|||
"exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
|
||||
"exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
|
||||
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
|
||||
"description" => $part->getDescription(),
|
||||
"fields" => []
|
||||
];
|
||||
|
||||
|
|
|
|||
158
src/Services/EDA/KicadListFileManager.php
Normal file
158
src/Services/EDA/KicadListFileManager.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EDA;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
|
||||
|
||||
/**
|
||||
* Manages the KiCad footprints and symbols list files, including reading, writing and ensuring their existence.
|
||||
*/
|
||||
final class KicadListFileManager implements CacheWarmerInterface
|
||||
{
|
||||
private const FOOTPRINTS_PATH = '/public/kicad/footprints.txt';
|
||||
private const SYMBOLS_PATH = '/public/kicad/symbols.txt';
|
||||
private const CUSTOM_FOOTPRINTS_PATH = '/public/kicad/footprints_custom.txt';
|
||||
private const CUSTOM_SYMBOLS_PATH = '/public/kicad/symbols_custom.txt';
|
||||
|
||||
private const CUSTOM_TEMPLATE = <<<'EOT'
|
||||
# Custom KiCad autocomplete entries. One entry per line.
|
||||
|
||||
EOT;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFootprintsContent(): string
|
||||
{
|
||||
return $this->readFile(self::FOOTPRINTS_PATH);
|
||||
}
|
||||
|
||||
public function getCustomFootprintsContent(): string
|
||||
{
|
||||
//Ensure that the custom file exists, so that the UI can always display it without error.
|
||||
$this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH);
|
||||
return $this->readFile(self::CUSTOM_FOOTPRINTS_PATH);
|
||||
}
|
||||
|
||||
public function getSymbolsContent(): string
|
||||
{
|
||||
return $this->readFile(self::SYMBOLS_PATH);
|
||||
}
|
||||
|
||||
public function getCustomSymbolsContent(): string
|
||||
{
|
||||
//Ensure that the custom file exists, so that the UI can always display it without error.
|
||||
$this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH);
|
||||
return $this->readFile(self::CUSTOM_SYMBOLS_PATH);
|
||||
}
|
||||
|
||||
public function saveCustom(string $footprints, string $symbols): void
|
||||
{
|
||||
$this->writeFile(self::CUSTOM_FOOTPRINTS_PATH, $this->normalizeContent($footprints));
|
||||
$this->writeFile(self::CUSTOM_SYMBOLS_PATH, $this->normalizeContent($symbols));
|
||||
}
|
||||
|
||||
private function readFile(string $path): string
|
||||
{
|
||||
$fullPath = $this->projectDir . $path;
|
||||
|
||||
if (!is_file($fullPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$content = file_get_contents($fullPath);
|
||||
if ($content === false) {
|
||||
throw new RuntimeException(sprintf('Failed to read KiCad list file "%s".', $fullPath));
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function writeFile(string $path, string $content): void
|
||||
{
|
||||
$fullPath = $this->projectDir . $path;
|
||||
$tmpPath = $fullPath . '.tmp';
|
||||
|
||||
if (file_put_contents($tmpPath, $content, LOCK_EX) === false) {
|
||||
throw new RuntimeException(sprintf('Failed to write KiCad list file "%s".', $fullPath));
|
||||
}
|
||||
|
||||
if (!rename($tmpPath, $fullPath)) {
|
||||
@unlink($tmpPath);
|
||||
throw new RuntimeException(sprintf('Failed to replace KiCad list file "%s".', $fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeContent(string $content): string
|
||||
{
|
||||
$normalized = str_replace(["\r\n", "\r"], "\n", $content);
|
||||
|
||||
if ($normalized !== '' && !str_ends_with($normalized, "\n")) {
|
||||
$normalized .= "\n";
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function createCustomFileIfNotExists(string $path): void
|
||||
{
|
||||
$fullPath = $this->projectDir . $path;
|
||||
|
||||
if (!is_file($fullPath)) {
|
||||
if (file_put_contents($fullPath, self::CUSTOM_TEMPLATE, LOCK_EX) === false) {
|
||||
throw new RuntimeException(sprintf('Failed to create custom footprints file "%s".', $fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the custom footprints and symbols files exist, so that the UI can always display them without error.
|
||||
* @return void
|
||||
*/
|
||||
public function createCustomFilesIfNotExist(): void
|
||||
{
|
||||
$this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH);
|
||||
$this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH);
|
||||
}
|
||||
|
||||
|
||||
public function isOptional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the custom footprints and symbols files exist and generate them on cache warmup, so that the frontend
|
||||
* can always display them without error, even if the user has not yet visited the settings page.
|
||||
*/
|
||||
public function warmUp(string $cacheDir, ?string $buildDir = null): array
|
||||
{
|
||||
$this->createCustomFilesIfNotExist();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -721,26 +721,36 @@ class BOMImporter
|
|||
return $mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to detect the separator used in the CSV data by analyzing the first line and counting occurrences of common delimiters.
|
||||
* @param string $data
|
||||
* @return string
|
||||
*/
|
||||
public function detectDelimiter(string $data): string
|
||||
{
|
||||
$delimiters = [',', ';', "\t"];
|
||||
$lines = explode("\n", $data, 2);
|
||||
$header_line = $lines[0] ?? '';
|
||||
$delimiter_counts = [];
|
||||
foreach ($delimiters as $delim) {
|
||||
$delimiter_counts[$delim] = substr_count($header_line, $delim);
|
||||
}
|
||||
// Choose the delimiter with the highest count, default to comma if all are zero
|
||||
$max_count = max($delimiter_counts);
|
||||
$delimiter = array_search($max_count, $delimiter_counts, true);
|
||||
if ($max_count === 0 || $delimiter === false) {
|
||||
$delimiter = ',';
|
||||
}
|
||||
return $delimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect available fields in CSV data for field mapping UI
|
||||
*/
|
||||
public function detectFields(string $data, ?string $delimiter = null): array
|
||||
{
|
||||
if ($delimiter === null) {
|
||||
// Detect delimiter by counting occurrences in the first row (header)
|
||||
$delimiters = [',', ';', "\t"];
|
||||
$lines = explode("\n", $data, 2);
|
||||
$header_line = $lines[0] ?? '';
|
||||
$delimiter_counts = [];
|
||||
foreach ($delimiters as $delim) {
|
||||
$delimiter_counts[$delim] = substr_count($header_line, $delim);
|
||||
}
|
||||
// Choose the delimiter with the highest count, default to comma if all are zero
|
||||
$max_count = max($delimiter_counts);
|
||||
$delimiter = array_search($max_count, $delimiter_counts, true);
|
||||
if ($max_count === 0 || $delimiter === false) {
|
||||
$delimiter = ',';
|
||||
}
|
||||
$delimiter = $this->detectDelimiter($data);
|
||||
}
|
||||
// Handle potential BOM (Byte Order Mark) at the beginning
|
||||
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
|
||||
|
|
|
|||
|
|
@ -219,11 +219,6 @@ class EntityImporter
|
|||
$entities = [$entities];
|
||||
}
|
||||
|
||||
//The serializer has only set the children attributes. We also have to change the parent value (the real value in DB)
|
||||
if ($entities[0] instanceof AbstractStructuralDBElement) {
|
||||
$this->correctParentEntites($entities, null);
|
||||
}
|
||||
|
||||
//Set the parent of the imported elements to the given options
|
||||
foreach ($entities as $entity) {
|
||||
if ($entity instanceof AbstractStructuralDBElement) {
|
||||
|
|
@ -297,6 +292,14 @@ class EntityImporter
|
|||
return $resolver;
|
||||
}
|
||||
|
||||
private function persistRecursively(AbstractStructuralDBElement $entity): void
|
||||
{
|
||||
$this->em->persist($entity);
|
||||
foreach ($entity->getChildren() as $child) {
|
||||
$this->persistRecursively($child);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method deserializes the given file and writes the entities to the database (and flush the db).
|
||||
* The imported elements will be checked (validated) before written to database.
|
||||
|
|
@ -322,7 +325,11 @@ class EntityImporter
|
|||
|
||||
//Iterate over each $entity write it to DB (the invalid entities were already filtered out).
|
||||
foreach ($entities as $entity) {
|
||||
$this->em->persist($entity);
|
||||
if ($entity instanceof AbstractStructuralDBElement) {
|
||||
$this->persistRecursively($entity);
|
||||
} else {
|
||||
$this->em->persist($entity);
|
||||
}
|
||||
}
|
||||
|
||||
//Save changes to database, when no error happened, or we should continue on error.
|
||||
|
|
@ -400,7 +407,7 @@ class EntityImporter
|
|||
*
|
||||
* @param File $file The Excel file to convert
|
||||
* @param string $delimiter The CSV delimiter to use
|
||||
*
|
||||
*
|
||||
* @return string The CSV data as string
|
||||
*/
|
||||
protected function convertExcelToCsv(File $file, string $delimiter = ';'): string
|
||||
|
|
@ -421,7 +428,7 @@ class EntityImporter
|
|||
]);
|
||||
|
||||
$highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
|
||||
|
||||
|
||||
for ($row = 1; $row <= $highestRow; $row++) {
|
||||
$rowData = [];
|
||||
|
||||
|
|
@ -431,7 +438,7 @@ class EntityImporter
|
|||
try {
|
||||
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
|
||||
$rowData[] = $cellValue ?? '';
|
||||
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Error reading cell value', [
|
||||
'cell' => "{$col}{$row}",
|
||||
|
|
@ -484,21 +491,4 @@ class EntityImporter
|
|||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This functions corrects the parent setting based on the children value of the parent.
|
||||
*
|
||||
* @param iterable $entities the list of entities that should be fixed
|
||||
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
|
||||
*/
|
||||
protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void
|
||||
{
|
||||
foreach ($entities as $entity) {
|
||||
/** @var AbstractStructuralDBElement $entity */
|
||||
$entity->setParent($parent);
|
||||
//Do the same for the children of entity
|
||||
$this->correctParentEntites($entity->getChildren(), $entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ final class BulkInfoProviderService
|
|||
}
|
||||
|
||||
$partResults = [];
|
||||
$hasAnyResults = false;
|
||||
|
||||
// Group providers by batch capability
|
||||
$batchProviders = [];
|
||||
|
|
@ -88,7 +87,6 @@ final class BulkInfoProviderService
|
|||
);
|
||||
|
||||
if (!empty($allResults)) {
|
||||
$hasAnyResults = true;
|
||||
$searchResults = $this->formatSearchResults($allResults);
|
||||
}
|
||||
|
||||
|
|
@ -99,10 +97,6 @@ final class BulkInfoProviderService
|
|||
);
|
||||
}
|
||||
|
||||
if (!$hasAnyResults) {
|
||||
throw new \RuntimeException('No search results found for any of the selected parts');
|
||||
}
|
||||
|
||||
$response = new BulkSearchResponseDTO($partResults);
|
||||
|
||||
// Prefetch details if requested
|
||||
|
|
|
|||
109
src/Services/InfoProviderSystem/CreateFromUrlHelper.php
Normal file
109
src/Services/InfoProviderSystem/CreateFromUrlHelper.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Exceptions\ProviderIDNotSupportedException;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
final readonly class CreateFromUrlHelper
|
||||
{
|
||||
public function __construct(private Security $security,
|
||||
private ProviderRegistry $providerRegistry,
|
||||
private PartInfoRetriever $infoRetriever,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if at least one provider can create parts from an URL and the current user is allowed to use it.
|
||||
* This is used to determine if the "From URL" feature should be shown to the user.
|
||||
* @return bool
|
||||
*/
|
||||
public function canCreateFromUrl(): bool
|
||||
{
|
||||
if (!$this->security->isGranted('@info_providers.create_parts')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//Check if either the generic web provider or the ai web provider is active
|
||||
$genericWebProvider = $this->providerRegistry->getProviderByKey('generic_web');
|
||||
$aiWebProvider = $this->providerRegistry->getProviderByKey('ai_web');
|
||||
|
||||
return $genericWebProvider->isActive() || $aiWebProvider->isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates the URL to another provider if possible, otherwise return null
|
||||
* @param string $url
|
||||
* @return SearchResultDTO|null
|
||||
*/
|
||||
public function delegateToOtherProvider(string $url, InfoProviderInterface $callingInfoProvider): ?SearchResultDTO
|
||||
{
|
||||
//Extract domain from url:
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === false || $host === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = $this->providerRegistry->getProviderHandlingDomain($host);
|
||||
|
||||
if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $callingInfoProvider->getProviderKey()) {
|
||||
try {
|
||||
$id = $provider->getIDFromURL($url);
|
||||
if ($id !== null) {
|
||||
$results = $this->infoRetriever->searchByKeyword($id, [$provider]);
|
||||
if (count($results) > 0) {
|
||||
return $results[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (ProviderIDNotSupportedException $e) {
|
||||
//Ignore and continue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates the URL to another provider if possible and returns the details, otherwise return null
|
||||
* @param string $url
|
||||
* @param InfoProviderInterface $callingInfoProvider
|
||||
* @return PartDetailDTO|null
|
||||
*/
|
||||
public function delegateToOtherProviderDetails(string $url, InfoProviderInterface $callingInfoProvider): ?PartDetailDTO
|
||||
{
|
||||
$delegatedResult = $this->delegateToOtherProvider($url, $callingInfoProvider);
|
||||
if ($delegatedResult !== null) {
|
||||
return $this->infoRetriever->getDetailsForSearchResult($delegatedResult);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
252
src/Services/InfoProviderSystem/DTOJsonSchemaConverter.php
Normal file
252
src/Services/InfoProviderSystem/DTOJsonSchemaConverter.php
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
|
||||
/**
|
||||
* This class allows to convert the JSON data returned by an LLM into the DTOs used by the info provider system later.
|
||||
*/
|
||||
final class DTOJsonSchemaConverter
|
||||
{
|
||||
/**
|
||||
* Returns the JSON schema, that defines the expected structure of the JSON data returned by the LLM.
|
||||
* @return array
|
||||
*/
|
||||
public function getJSONSchema(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'clock',
|
||||
'strict' => true,
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string', 'description' => 'Product name'],
|
||||
'description' => ['type' => 'string', 'description' => 'A short description of the product, maybe containing the most important things. Onnly One line.'],
|
||||
'manufacturer' => ['type' => ['string', 'null'], 'description' => 'Manufacturer name'],
|
||||
'mpn' => ['type' => ['string', 'null'], 'description' => 'Manufacturer Part Number'],
|
||||
'category' => ['type' => ['string', 'null'], 'description' => 'Product category, e.g. "Passive components -> Resistors"'],
|
||||
'manufacturing_status' => ['type' => ['string', 'null'], 'enum' => ['active', 'obsolete', 'nrfnd', 'discontinued', null], 'description' => 'Manufacturing status'],
|
||||
'footprint' => ['type' => ['string', 'null'], 'description' => 'Package/footprint type, like "SOT-23", "DIP-8", "QFN-32" etc.'],
|
||||
'mass' => ['type' => ['number', 'null'], 'description' => 'Mass of the product in grams'],
|
||||
'gtin' => ['type' => ['string', 'null'], 'description' => 'Global Trade Item Number (GTIN) / EAN / UPC code for barcodes'],
|
||||
'notes' => ['type' => ['string', 'null'], 'description' => 'Optional long description of the part with more details than description. Can be markdown formatted.'],
|
||||
'parameters' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
'symbol' => ['type' => ['string', 'null'], 'description' => 'An optional quantity symbol for the parameter in latex code, like R_1'],
|
||||
'value_typical' => ['type' => ['number', 'null'], 'description' => 'The typical value of the parameter. For example, for a resistor this could be 100 for a 100 Ohm resistor. Also used if only one numeric value is given. If used an unit should be given'],
|
||||
'value_min' => ['type' => ['number', 'null'], 'description' => 'If a range is given for the parameter, this is the minimum value. Null if no range is given.'],
|
||||
'value_max' => ['type' => ['number', 'null'], 'description' => 'If a range is given for the parameter, this is the maximum value. Null if not a range.'],
|
||||
'value_text' => ['type' => ['string', 'null'], 'description' => 'When a value is not numeric it can be put here as text. Only use if it does not fit in value_min, value_typical or value_max. E.g. "Yes", "Red", etc.'],
|
||||
'group' => ['type' => ['string', 'null'], 'description' => 'An optional group name for the parameter, e.g. "Electrical parameters", "Mechanical parameters" etc.'],
|
||||
'unit' => ['type' => ['string', 'null'], 'description' => 'The unit of the parameter values, e.g. kg, Ohm, V, etc.'],
|
||||
],
|
||||
'required' => ['name', 'value_typical', 'value_min', 'value_max', 'value_text']
|
||||
],
|
||||
],
|
||||
'datasheets' => [
|
||||
'description' => 'A list of datasheets, manuals, or other technical documents related to the product. Not images, but actual documents, preferably PDFs.',
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'url' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['url'],
|
||||
],
|
||||
],
|
||||
'images' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'url' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['url'],
|
||||
],
|
||||
],
|
||||
'vendor_infos' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'distributor_name' => ['type' => 'string', 'description' => 'Name of the distributor or vendor. Typically the shop name'],
|
||||
'order_number' => ['type' => ['string', 'null'], 'description' => 'The order number or SKU used by the distributor. Optional, but can help to find the product on the distributor website.'],
|
||||
'product_url' => ['type' => 'string'],
|
||||
'prices_include_vat' => ['type' => ['boolean', 'null'], 'description' => 'Whether the prices include VAT or not. Null if unknown.'],
|
||||
'prices' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'minimum_quantity' => ['type' => 'integer', 'description' => 'Minimum quantity for this price tier. 1 when no tiered pricing is available.'],
|
||||
'price' => ['type' => 'number', 'description' => 'Price for the given minimum quantity.'],
|
||||
'currency' => ['type' => 'string', 'description' => 'Currency ISO code, e.g. USD'],
|
||||
],
|
||||
'required' => ['minimum_quantity', 'price', 'currency'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'required' => ['distributor_name', 'product_url'],
|
||||
],
|
||||
],
|
||||
'manufacturer_product_url' => ['type' => ['string', 'null'], 'description' => 'Manufacturer product page URL'],
|
||||
],
|
||||
'required' => ['name', 'description'],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function jsonToDTO(array $data, string $providerKey, string $providerId, ?string $productUrl = null, string $distributorNameFallback = '???'): PartDetailDTO
|
||||
{
|
||||
// Map manufacturing status
|
||||
$manufacturingStatus = null;
|
||||
if (!empty($data['manufacturing_status'])) {
|
||||
$status = strtolower((string) $data['manufacturing_status']);
|
||||
$manufacturingStatus = match ($status) {
|
||||
'active' => ManufacturingStatus::ACTIVE,
|
||||
'obsolete', 'discontinued' => ManufacturingStatus::DISCONTINUED,
|
||||
'nrfnd', 'not recommended for new designs' => ManufacturingStatus::NRFND,
|
||||
'eol' => ManufacturingStatus::EOL,
|
||||
'announced' => ManufacturingStatus::ANNOUNCED,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Build parameters
|
||||
$parameters = null;
|
||||
if (!empty($data['parameters']) && is_array($data['parameters'])) {
|
||||
$parameters = [];
|
||||
foreach ($data['parameters'] as $p) {
|
||||
if (!empty($p['name'])) {
|
||||
$parameters[] = new ParameterDTO(
|
||||
name: $p['name'],
|
||||
value_text: $p['value_text'] ?? null,
|
||||
value_typ: isset($p['value_typical']) && is_numeric($p['value_typical']) ? (float) $p['value_typical'] : null,
|
||||
value_min: isset($p['value_min']) && is_numeric($p['value_min']) ? (float) $p['value_min'] : null,
|
||||
value_max: isset($p['value_max']) && is_numeric($p['value_max']) ? (float) $p['value_max'] : null,
|
||||
unit: $p['unit'] ?? null,
|
||||
symbol: $p['symbol'] ?? null,
|
||||
group: $p['group'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build datasheets
|
||||
$datasheets = null;
|
||||
if (!empty($data['datasheets']) && is_array($data['datasheets'])) {
|
||||
$datasheets = [];
|
||||
foreach ($data['datasheets'] as $d) {
|
||||
if (!empty($d['url'])) {
|
||||
$datasheets[] = new FileDTO(
|
||||
url: $d['url'],
|
||||
name: $d['description'] ?? 'Datasheet'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build images
|
||||
$images = null;
|
||||
if (!empty($data['images']) && is_array($data['images'])) {
|
||||
$images = [];
|
||||
foreach ($data['images'] as $i) {
|
||||
if (!empty($i['url'])) {
|
||||
$images[] = new FileDTO(
|
||||
url: $i['url'],
|
||||
name: $i['description'] ?? 'Image'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build vendor infos
|
||||
$vendorInfos = null;
|
||||
if (!empty($data['vendor_infos']) && is_array($data['vendor_infos'])) {
|
||||
$vendorInfos = [];
|
||||
foreach ($data['vendor_infos'] as $v) {
|
||||
$prices = [];
|
||||
if (!empty($v['prices']) && is_array($v['prices'])) {
|
||||
foreach ($v['prices'] as $p) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: (int) ($p['minimum_quantity'] ?? 1),
|
||||
price: (string) ($p['price'] ?? 0),
|
||||
currency_iso_code: $p['currency'] ?? null,
|
||||
price_related_quantity: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$vendorInfos[] = new PurchaseInfoDTO(
|
||||
distributor_name: $v['distributor_name'] ?? $distributorNameFallback,
|
||||
order_number: $v['order_number'] ?? 'Unknown',
|
||||
prices: $prices,
|
||||
product_url: $v['product_url'] ?? $productUrl,
|
||||
prices_include_vat: $v['prices_include_vat'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get preview image URL
|
||||
$previewImageUrl = null;
|
||||
if (!empty($data['images']) && is_array($data['images']) && !empty($data['images'][0]['url'])) {
|
||||
$previewImageUrl = $data['images'][0]['url'];
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $providerKey,
|
||||
provider_id: $providerId,
|
||||
name: $data['name'] ?? 'Unknown',
|
||||
description: $data['description'] ?? '',
|
||||
category: $data['category'] ?? null,
|
||||
manufacturer: $data['manufacturer'] ?? null,
|
||||
mpn: $data['mpn'] ?? null,
|
||||
preview_image_url: $previewImageUrl,
|
||||
manufacturing_status: $manufacturingStatus,
|
||||
provider_url: $productUrl,
|
||||
footprint: $data['footprint'] ?? null,
|
||||
gtin: $data['gtin'] ?? null,
|
||||
notes: $data['notes'] ?? null,
|
||||
datasheets: $datasheets,
|
||||
images: $images,
|
||||
parameters: $parameters,
|
||||
vendor_infos: $vendorInfos,
|
||||
mass: isset($data['mass']) && is_numeric($data['mass']) ? (float) $data['mass'] : null,
|
||||
manufacturer_product_url: $data['manufacturer_product_url'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ final class PartInfoRetriever
|
|||
* Search for a keyword in the given providers. The results can be cached
|
||||
* @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances
|
||||
* @param string $keyword The keyword to search for
|
||||
* @param array<string, mixed> $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation.
|
||||
* @return SearchResultDTO[] The search results
|
||||
* @throws InfoProviderNotActiveException if any of the given providers is not active
|
||||
* @throws ClientException if any of the providers throws an exception during the search
|
||||
|
|
@ -60,7 +61,7 @@ final class PartInfoRetriever
|
|||
* @throws TransportException if any of the providers throws an exception during the search
|
||||
* @throws OAuthReconnectRequiredException if any of the providers throws an exception during the search that indicates that the OAuth token needs to be refreshed
|
||||
*/
|
||||
public function searchByKeyword(string $keyword, array $providers): array
|
||||
public function searchByKeyword(string $keyword, array $providers, array $options = []): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ final class PartInfoRetriever
|
|||
}
|
||||
|
||||
/** @noinspection SlowArrayOperationsInLoopInspection */
|
||||
$results = array_merge($results, $this->searchInProvider($provider, $keyword));
|
||||
$results = array_merge($results, $this->searchInProvider($provider, $keyword, $options));
|
||||
}
|
||||
|
||||
return $results;
|
||||
|
|
@ -89,15 +90,31 @@ final class PartInfoRetriever
|
|||
* Search for a keyword in the given provider. The result is cached for 7 days.
|
||||
* @return SearchResultDTO[]
|
||||
*/
|
||||
protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array
|
||||
protected function searchInProvider(InfoProviderInterface $provider, string $keyword, array $options = []): array
|
||||
{
|
||||
//Generate key and escape reserved characters from the provider id
|
||||
$escaped_keyword = hash('xxh3', $keyword);
|
||||
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
|
||||
|
||||
$no_cache = $options[InfoProviderInterface::OPTION_NO_CACHE] ?? false;
|
||||
|
||||
//Exclude the no_cache option from the options hash, since it should not affect the cache key, as it only determines whether to bypass the cache or not, but does not change the actual search results
|
||||
$options_without_cache = $options;
|
||||
unset($options_without_cache[InfoProviderInterface::OPTION_NO_CACHE]);
|
||||
//Generate a hash for the options, to ensure that different options result in different cache entries
|
||||
$options_hash = hash('xxh3', json_encode($options_without_cache, JSON_THROW_ON_ERROR));
|
||||
|
||||
$cache_key = "search_{$provider->getProviderKey()}_{$escaped_keyword}_{$options_hash}";
|
||||
|
||||
//If no_cache is set, bypass the cache and get fresh results from the provider
|
||||
if ($no_cache) {
|
||||
$this->partInfoCache->delete($cache_key);
|
||||
}
|
||||
|
||||
return $this->partInfoCache->get($cache_key, function (ItemInterface $item) use ($provider, $keyword, $options) {
|
||||
//Set the expiration time
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10);
|
||||
|
||||
return $provider->searchByKeyword($keyword);
|
||||
return $provider->searchByKeyword($keyword, $options);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -106,10 +123,11 @@ final class PartInfoRetriever
|
|||
* The result is cached for 4 days.
|
||||
* @param string $provider_key
|
||||
* @param string $part_id
|
||||
* @param array<string, mixed> $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation.
|
||||
* @return PartDetailDTO
|
||||
* @throws InfoProviderNotActiveException if the the given providers is not active
|
||||
*/
|
||||
public function getDetails(string $provider_key, string $part_id): PartDetailDTO
|
||||
public function getDetails(string $provider_key, string $part_id, array $options = []): PartDetailDTO
|
||||
{
|
||||
$provider = $this->provider_registry->getProviderByKey($provider_key);
|
||||
|
||||
|
|
@ -118,13 +136,26 @@ final class PartInfoRetriever
|
|||
throw InfoProviderNotActiveException::fromProvider($provider);
|
||||
}
|
||||
|
||||
//Exclude the no_cache option from the options hash, since it should not affect the cache key, as it only determines whether to bypass the cache or not, but does not change the actual search results
|
||||
$options_without_cache = $options;
|
||||
unset($options_without_cache[InfoProviderInterface::OPTION_NO_CACHE]);
|
||||
//Generate a hash for the options, to ensure that different options result in different cache entries
|
||||
$options_hash = hash('xxh3', json_encode($options_without_cache, JSON_THROW_ON_ERROR));
|
||||
|
||||
//Generate key and escape reserved characters from the provider id
|
||||
$escaped_part_id = hash('xxh3', $part_id);
|
||||
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
|
||||
$cache_key = "details_{$provider_key}_{$escaped_part_id}_{$options_hash}";
|
||||
|
||||
//Delete the cache entry if no_cache is set, to ensure that the next get call will fetch fresh data from the provider, instead of returning stale data from the cache.
|
||||
if ($options[InfoProviderInterface::OPTION_NO_CACHE] ?? false) {
|
||||
$this->partInfoCache->delete($cache_key);
|
||||
}
|
||||
|
||||
return $this->partInfoCache->get($cache_key, function (ItemInterface $item) use ($provider, $part_id, $options) {
|
||||
//Set the expiration time
|
||||
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10);
|
||||
|
||||
return $provider->getDetails($part_id);
|
||||
return $provider->getDetails($part_id, $options);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
312
src/Services/InfoProviderSystem/Providers/AIWebProvider.php
Normal file
312
src/Services/InfoProviderSystem/Providers/AIWebProvider.php
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
* Copyright (C) 2026 Rahul Singh (https://github.com/rahools)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Exceptions\ProviderIDNotSupportedException;
|
||||
use App\Helpers\RandomizeUseragentHttpClient;
|
||||
use App\Services\AI\AIPlatformRegistry;
|
||||
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
||||
use App\Services\InfoProviderSystem\DTOJsonSchemaConverter;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Settings\InfoProviderSystem\AIExtractorSettings;
|
||||
use Brick\Schema\SchemaReader;
|
||||
use Imagine\Image\Format;
|
||||
use Jkphl\Micrometa;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\AI\Platform\Message\Message;
|
||||
use Symfony\AI\Platform\Message\MessageBag;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Component\DomCrawler\UriResolver;
|
||||
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||
use Symfony\Component\Intl\Languages;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
|
||||
final class AIWebProvider implements InfoProviderInterface
|
||||
{
|
||||
use FixAndValidateUrlTrait;
|
||||
|
||||
private const DISTRIBUTOR_NAME = 'Website';
|
||||
|
||||
private readonly HttpClientInterface $httpClient;
|
||||
|
||||
public function __construct(
|
||||
HttpClientInterface $httpClient,
|
||||
private readonly AIExtractorSettings $settings,
|
||||
private readonly AIPlatformRegistry $AIPlatformRegistry,
|
||||
private readonly DTOJsonSchemaConverter $jsonSchemaConverter,
|
||||
private readonly CacheItemPoolInterface $partInfoCache,
|
||||
private readonly CreateFromUrlHelper $createFromUrlHelper,
|
||||
) {
|
||||
//Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
|
||||
$this->httpClient = (new RandomizeUseragentHttpClient(new NoPrivateNetworkHttpClient($httpClient)))->withOptions(
|
||||
[
|
||||
'timeout' => 15,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'AI Web Extractor',
|
||||
'description' => 'Extract part info from any URL using LLM',
|
||||
//'url' => 'https://openrouter.ai',
|
||||
'disabled_help' => 'Configure AI settings',
|
||||
'settings_class' => AIExtractorSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'ai_web';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->settings->platform !== null && $this->settings->model !== null && $this->settings->model !== '';
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$url = $this->fixAndValidateURL($keyword);
|
||||
|
||||
if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) {
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->createFromUrlHelper->delegateToOtherProvider($url, $this);
|
||||
if ($delegatedPart !== null) {
|
||||
return [$delegatedPart];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
$new_options = $options;
|
||||
$new_options[self::OPTION_SKIP_DELEGATION] = true; //Skip delegation for the getDetails call to prevent infinite loops
|
||||
|
||||
return [
|
||||
$this->getDetails($keyword, $new_options)
|
||||
]; } catch (ProviderIDNotSupportedException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
$url = $this->fixAndValidateURL($id);
|
||||
|
||||
if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) {
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->createFromUrlHelper->delegateToOtherProviderDetails($url, $this);
|
||||
if ($delegatedPart !== null) {
|
||||
return $delegatedPart;
|
||||
}
|
||||
}
|
||||
|
||||
//Check if we have a cached result for this URL, to avoid unnecessary LLM calls, which can be slow and costly.
|
||||
$cacheKey = 'ai_web_'.hash('xxh3', $url);
|
||||
|
||||
//If ignore cache option is set, skip cache and fetch fresh data
|
||||
if ($options[self::OPTION_NO_CACHE] ?? false) {
|
||||
$this->partInfoCache->deleteItem($cacheKey);
|
||||
}
|
||||
|
||||
//Return cached result if available
|
||||
$cacheItem = $this->partInfoCache->getItem($cacheKey);
|
||||
if ($cacheItem->isHit()) {
|
||||
return $cacheItem->get();
|
||||
}
|
||||
|
||||
// Fetch HTML content
|
||||
$response = $this->httpClient->request('GET', $url);
|
||||
$html = $response->getContent();
|
||||
|
||||
//Convert html to markdown, to provide a cleaner input to the LLM.
|
||||
$markdown = $this->htmlToMarkdown($html, $url);
|
||||
//Truncate markdown to max content length, if needed
|
||||
$markdown = u($markdown)->truncate($this->settings->maxContentLength, '... [truncated]')->toString();
|
||||
|
||||
//Extract structured data using traditional methods, to provide additional context to the LLM. This can help improve accuracy, especially for technical specifications that might be in tables or specific formats.
|
||||
$structuredData = $this->extractStructuredData($html, $url);
|
||||
|
||||
// Call LLM
|
||||
$llmResponse = $this->callLLM($markdown, $url, $structuredData);
|
||||
|
||||
// Build and return PartDetailDTO
|
||||
$result = $this->jsonSchemaConverter->jsonToDTO($llmResponse, $this->getProviderKey(), $url, $url, self::DISTRIBUTOR_NAME);
|
||||
|
||||
// Cache the result for future use, to improve performance and reduce costs.
|
||||
$cacheItem->set($result);
|
||||
$cacheItem->expiresAfter(3600 * 2); //Cache for 2 hours, as web content can change frequently, but we still want to benefit from caching for repeated accesses.
|
||||
$this->partInfoCache->save($cacheItem);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts structured data from the HTML using microformats.
|
||||
* @param string $html
|
||||
* @param string $url
|
||||
* @return string JSON encoded structured data
|
||||
*/
|
||||
private function extractStructuredData(string $html, string $url): string
|
||||
{
|
||||
//Only parse microdata, json-ld and rdfa, as they are the most common formats for structured data on product pages. Links and microformat only create clutter for the LLM
|
||||
$micrometa = new Micrometa\Ports\Parser(Micrometa\Ports\Format::JSON_LD | Micrometa\Ports\Format::MICRODATA | Micrometa\Ports\Format::RDFA_LITE);
|
||||
$items = $micrometa($url, $html);
|
||||
|
||||
return json_encode($items->toObject(), JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function htmlToMarkdown(string $html, string $url): string
|
||||
{
|
||||
|
||||
$crawler = new Crawler($html);
|
||||
|
||||
//Replace relative URLs with absolute URLs, to ensure that the LLM has full context and can access the links if needed.
|
||||
$baseUrl = $crawler->getBaseHref() ?? $url;
|
||||
|
||||
//Replace all relative links with their absolute counnterparts, to provide more context to the LLM and to ensure that any links included in the markdown are valid and can be accessed if needed.
|
||||
$crawler->filter('a')->each(function (Crawler $node) use ($baseUrl) {
|
||||
$href = $node->attr('href');
|
||||
if ($href) {
|
||||
$absoluteUrl = UriResolver::resolve($href, $baseUrl);
|
||||
//@phpstan-ignore-next-line we know that getNode(0) will always return a DOMElement, because the crawler is initialized with valid HTML and we are filtering for 'a' tags, which are always DOMElements.
|
||||
$node->getNode(0)->setAttribute('href', $absoluteUrl);
|
||||
}
|
||||
});
|
||||
|
||||
$crawler->filter('img')->each(function (Crawler $node) use ($baseUrl) {
|
||||
$src = $node->attr('src');
|
||||
if ($src) {
|
||||
$absoluteUrl = UriResolver::resolve($src, $baseUrl);
|
||||
//@phpstan-ignore-next-line we know that getNode(0) will always return a DOMElement, because the crawler is initialized with valid HTML and we are filtering for 'a' tags, which are always DOMElements.
|
||||
$node->getNode(0)->setAttribute('src', $absoluteUrl);
|
||||
}
|
||||
});
|
||||
|
||||
//Extract only the main content of the page to avoid overwhelming the LLM with irrelevant information.
|
||||
$mainContent = $crawler->filter('main, article, #content');
|
||||
|
||||
// If we found a specific content area, get its HTML; otherwise, use the whole body.
|
||||
//Concat the html of all matched nodes, to provide more context to the LLM, especially for pages that use multiple sections for product info.
|
||||
if ($mainContent->count() > 0) {
|
||||
$htmlToConvert = '';
|
||||
foreach ($mainContent as $node) {
|
||||
$htmlToConvert .= $node->ownerDocument->saveHTML($node);
|
||||
$htmlToConvert .= "\n\n"; // Add some spacing between sections
|
||||
}
|
||||
} else {
|
||||
//Use the whole body content, as it might contain relevant information, especially for simpler pages that don't have a clear main/content section.
|
||||
$htmlToConvert = $crawler->outerHtml();
|
||||
}
|
||||
|
||||
|
||||
//Concert to markdown
|
||||
$converter = new HtmlConverter([
|
||||
'strip_tags' => true, // Removes tags that aren't Markdown-compatible (like <div>)
|
||||
'hard_break' => true, // Preserves line breaks
|
||||
'remove_nodes' => 'nav footer script style' // Extra safety layer
|
||||
]);
|
||||
|
||||
return $converter->convert($htmlToConvert);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::PARAMETERS,
|
||||
];
|
||||
}
|
||||
|
||||
private function callLLM(string $htmlContent, string $url, ?string $structuredData = null): array
|
||||
{
|
||||
$input = new MessageBag(
|
||||
Message::forSystem($this->buildSystemPrompt()),
|
||||
Message::ofUser("Extract part information from this webpage content:\n\nURL: $url\n\n$htmlContent")
|
||||
);
|
||||
|
||||
if ($structuredData) {
|
||||
$input->add(Message::ofUser("Following data was extracted using traditional methods, but might be incomplete or inaccurate.
|
||||
Enrich it with the actual website data:\n\n".$structuredData));
|
||||
}
|
||||
|
||||
try {
|
||||
$aiPlatform = $this->AIPlatformRegistry->getPlatform($this->settings->platform ?? throw new \RuntimeException('No AI platform selected') );
|
||||
|
||||
//'openai/gpt-5-mini'
|
||||
$result = $aiPlatform->invoke($this->settings->model ?? throw new \RuntimeException('No model selected'), $input, [
|
||||
'response_format' => [
|
||||
'type' => 'json_schema',
|
||||
'json_schema' => $this->jsonSchemaConverter->getJSONSchema(),
|
||||
]
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('LLM invocation failed: '.$e->getMessage(), previous: $e);
|
||||
}
|
||||
|
||||
return $result->getResult()->getContent();
|
||||
}
|
||||
|
||||
private function buildSystemPrompt(): string
|
||||
{
|
||||
$tmp = <<<'PROMPT'
|
||||
You are an expert at extracting electronic component information from web pages. Extract structured data in JSON format, from markdown extracted from a product page.
|
||||
Focus on the main content of the page, such as product descriptions, specifications, and tables. Ignore navigation menus, footers, and sidebars.
|
||||
|
||||
Rules:
|
||||
- manufacturing_status: Use "active", "obsolete", "nrfnd" (not recommended for new designs), "discontinued", or null
|
||||
- parameters: Extract technical specs like voltage, current, temperature, etc. and put them into the fields according to the JSON schema. Include units if available.
|
||||
- prices: Extract pricing tiers with minimum_quantity, price, and currency code
|
||||
- URLs must be absolute (include https://...)
|
||||
- If information is not found, use null
|
||||
- Try to avoid duplicating parameters, if the same parameter is mentioned multiple times, or if it is already used in another field.
|
||||
- Include only the 1 to 3 most relevant images, such as the main product image or important diagrams. Ignore decorative images, logos, or icons.
|
||||
- Extract GTIN / EAN if available, as it can be useful for matching parts across different sources, even if the part number is different.
|
||||
- Include detailed product description into notes field, as it can contain important information that doesn't fit into other fields, such as features, applications, or unique selling points.
|
||||
|
||||
PROMPT;
|
||||
|
||||
if ($this->settings->outputLanguage === null) {
|
||||
$tmp .= "\n\nProvide the response in the same language of the webpage.";
|
||||
} else {
|
||||
$tmp .= "\n\nThe response must be in ". Languages::getName($this->settings->outputLanguage, 'en') ." language. Translate texts if needed.";
|
||||
}
|
||||
|
||||
if ($this->settings->additionalInstructions) {
|
||||
$tmp .= "\n\nAdditional instructions:\n" . $this->settings->additionalInstructions;
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -34,7 +34,8 @@ interface BatchInfoProviderInterface extends InfoProviderInterface
|
|||
* Search for multiple keywords in a single batch operation and return the results, ordered by the keywords.
|
||||
* This allows for a more efficient search compared to running multiple single searches.
|
||||
* @param string[] $keywords
|
||||
* @param array<string, mixed> $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation.
|
||||
* @return array<string, SearchResultDTO[]> An associative array where the key is the keyword and the value is the search results for that keyword
|
||||
*/
|
||||
public function searchByKeywordsBatch(array $keywords): array;
|
||||
public function searchByKeywordsBatch(array $keywords, array $options = []): array;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue