mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-10 23:22:11 +00:00
Compare commits
60 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 |
103 changed files with 10652 additions and 4815 deletions
14
.env
14
.env
|
|
@ -76,6 +76,12 @@ DISABLE_BACKUP_RESTORE=1
|
|||
# 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
|
||||
###################################################################################
|
||||
|
|
@ -155,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 ###
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.10.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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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]
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.*",
|
||||
|
|
|
|||
1897
composer.lock
generated
1897
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)%'
|
||||
|
|
@ -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 >>>
|
||||
|
|
|
|||
2808
config/reference.php
2808
config/reference.php
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -224,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
|
||||
|
|
|
|||
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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -36,8 +36,6 @@
|
|||
"@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": "^53",
|
||||
"@ckeditor/ckeditor5-dev-utils": "^53",
|
||||
"@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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated on Mon Apr 13 05:19:27 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 Mon Apr 13 05:20:06 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
|
||||
|
|
@ -8845,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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -196,7 +197,9 @@ abstract class BaseAdminController extends AbstractController
|
|||
$this->commentHelper->setMessage($form['log_comment']->getData());
|
||||
|
||||
//In principle, the form should be disabled, if the edit permission is not granted, but for good measure, we also check it here, before saving changes.
|
||||
$this->denyAccessUnlessGranted('edit', $entity);
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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,7 +325,14 @@ 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) {
|
||||
|
|
@ -342,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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -121,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);
|
||||
|
|
@ -134,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 = '';
|
||||
|
|
@ -148,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);
|
||||
|
|
@ -219,8 +227,36 @@ class TypeaheadController extends AbstractController
|
|||
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ 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;
|
||||
|
|
@ -56,6 +57,7 @@ class UpdateManagerController extends AbstractController
|
|||
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')]
|
||||
|
|
@ -504,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
****************************************/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
];
|
||||
}
|
||||
|
||||
private function getProduct(string $code): array
|
||||
private function getProduct(string $code, bool $use_cache = true): array
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code === '') {
|
||||
|
|
@ -132,6 +132,11 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
md5($code . '|' . $this->settings->language . '|' . $this->settings->currency)
|
||||
);
|
||||
|
||||
if (!$use_cache) {
|
||||
$this->partInfoCache->deleteItem($cacheKey);
|
||||
unset($this->productCache[$cacheKey]);
|
||||
}
|
||||
|
||||
if (isset($this->productCache[$cacheKey])) {
|
||||
return $this->productCache[$cacheKey];
|
||||
}
|
||||
|
|
@ -461,9 +466,11 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
}
|
||||
|
||||
/**
|
||||
* @param string $keyword
|
||||
* @param array $options
|
||||
* @return PartDetailDTO[]
|
||||
*/
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$keyword = strtoupper(trim($keyword));
|
||||
if ($keyword === '') {
|
||||
|
|
@ -486,17 +493,18 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
|
||||
// Fallback: try direct lookup by code
|
||||
try {
|
||||
$product = $this->getProduct($keyword);
|
||||
$product = $this->getProduct($keyword, use_cache: !($options[self::OPTION_NO_CACHE] ?? false));
|
||||
return [$this->getPartDetail($product)];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
// Detail endpoint is /products/{code}/
|
||||
$response = $this->getProduct($id);
|
||||
//By default use cache for details, but allow bypassing cache with option (e.g. for refresh)
|
||||
$response = $this->getProduct($id, use_cache: !($options[self::OPTION_NO_CACHE] ?? false));
|
||||
|
||||
return $this->getPartDetail($response);
|
||||
}
|
||||
|
|
@ -588,10 +596,11 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
}
|
||||
|
||||
/**
|
||||
* @param string[] $keywords
|
||||
* @param array $keywords
|
||||
* @param array $options
|
||||
* @return array<string, SearchResultDTO[]>
|
||||
*/
|
||||
public function searchByKeywordsBatch(array $keywords): array
|
||||
public function searchByKeywordsBatch(array $keywords, array $options = []): array
|
||||
{
|
||||
/** @var array<string, SearchResultDTO[]> $results */
|
||||
$results = [];
|
||||
|
|
@ -643,27 +652,27 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv
|
|||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Inputs:
|
||||
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/
|
||||
//Inputs:
|
||||
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/
|
||||
//https://www.buerklin.com/de/p/40F1332/
|
||||
//https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/
|
||||
//https://www.buerklin.com/en/p/40F1332/
|
||||
//The ID is the last part after the manufacturer/category/mpn segment and before the final slash
|
||||
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work
|
||||
|
||||
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
|
||||
|
||||
if (!$path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Ensure it's actually a product URL
|
||||
if (strpos($path, '/p/') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
$id = basename(rtrim($path, '/'));
|
||||
|
||||
|
||||
return $id !== '' && $id !== 'p' ? $id : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ class CanopyProvider implements InfoProviderInterface
|
|||
return null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$response = $this->httpClient->request('GET', self::SEARCH_API_URL, [
|
||||
'query' => [
|
||||
|
|
@ -177,15 +177,17 @@ class CanopyProvider implements InfoProviderInterface
|
|||
return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin));
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
//Check that the id is a valid ASIN (10 characters, letters and numbers)
|
||||
if (!preg_match('/^[A-Z0-9]{10}$/', $id)) {
|
||||
throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)");
|
||||
}
|
||||
|
||||
$do_not_cache = ($options[self::OPTION_NO_CACHE] ?? false) || $this->settings->alwaysGetDetails;
|
||||
|
||||
//Use cached details if available and the settings allow it, to avoid unnecessary API requests, since the search results already contain most of the details
|
||||
if(!$this->settings->alwaysGetDetails && ($cached = $this->getFromCache($id)) !== null) {
|
||||
if(!$do_not_cache && ($cached = $this->getFromCache($id)) !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
return null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/'
|
||||
. $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage()
|
||||
|
|
@ -279,7 +279,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr
|
|||
);
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
$productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID()
|
||||
. '/product/' . $id;
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
return $this->settings->clientId !== null && $this->settings->clientId !== '' && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME);
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$request = [
|
||||
'Keywords' => $keyword,
|
||||
|
|
@ -159,7 +159,7 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
return $result;
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
try {
|
||||
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
|
||||
|
|
|
|||
|
|
@ -282,12 +282,12 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider
|
|||
};
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
return $this->queryByTerm('any:' . $keyword);
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
$tmp = $this->queryByTerm('id:' . $id);
|
||||
if (count($tmp) === 0) {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class EmptyProvider implements InfoProviderInterface
|
|||
return true;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
return [
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ class EmptyProvider implements InfoProviderInterface
|
|||
];
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
throw new \RuntimeException('No part details available');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
<?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\Providers;
|
||||
|
||||
use App\Exceptions\ProviderIDNotSupportedException;
|
||||
|
||||
trait FixAndValidateUrlTrait
|
||||
{
|
||||
private function fixAndValidateURL(string $url): string
|
||||
{
|
||||
$originalUrl = $url;
|
||||
|
||||
//Add scheme if missing
|
||||
if (!preg_match('/^https?:\/\//', $url)) {
|
||||
//Remove any leading slashes
|
||||
$url = ltrim($url, '/');
|
||||
|
||||
//If the URL starts with https:/ or http:/, add the missing slash
|
||||
//Traefik removes the double slash as secruity measure, so we want to be forgiving and add it back if needed
|
||||
//See https://github.com/Part-DB/Part-DB-server/issues/1296
|
||||
if (preg_match('/^https?:\/[^\/]/', $url)) {
|
||||
$url = preg_replace('/^(https?:)\/([^\/])/', '$1//$2', $url);
|
||||
} else {
|
||||
$url = 'https://'.$url;
|
||||
}
|
||||
}
|
||||
|
||||
//If this is not a valid URL with host, domain and path, throw an exception
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) === false ||
|
||||
parse_url($url, PHP_URL_HOST) === null ||
|
||||
parse_url($url, PHP_URL_PATH) === null) {
|
||||
throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$originalUrl);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ namespace App\Services\InfoProviderSystem\Providers;
|
|||
|
||||
use App\Exceptions\ProviderIDNotSupportedException;
|
||||
use App\Helpers\RandomizeUseragentHttpClient;
|
||||
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
|
|
@ -48,12 +49,14 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||
class GenericWebProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
use FixAndValidateUrlTrait;
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Website';
|
||||
|
||||
private readonly HttpClientInterface $httpClient;
|
||||
|
||||
public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings,
|
||||
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
|
||||
private readonly CreateFromUrlHelper $createFromUrlHelper,
|
||||
)
|
||||
{
|
||||
//Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
|
||||
|
|
@ -85,19 +88,23 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$url = $this->fixAndValidateURL($keyword);
|
||||
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->delegateToOtherProvider($url);
|
||||
if ($delegatedPart !== null) {
|
||||
return [$delegatedPart];
|
||||
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, false) //We already tried delegation
|
||||
$this->getDetails($keyword, $new_options)
|
||||
]; } catch (ProviderIDNotSupportedException $e) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -274,78 +281,16 @@ class GenericWebProvider implements InfoProviderInterface
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates the URL to another provider if possible, otherwise return null
|
||||
* @param string $url
|
||||
* @return SearchResultDTO|null
|
||||
*/
|
||||
private function delegateToOtherProvider(string $url): ?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() !== $this->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;
|
||||
}
|
||||
|
||||
private function fixAndValidateURL(string $url): string
|
||||
{
|
||||
$originalUrl = $url;
|
||||
|
||||
//Add scheme if missing
|
||||
if (!preg_match('/^https?:\/\//', $url)) {
|
||||
//Remove any leading slashes
|
||||
$url = ltrim($url, '/');
|
||||
|
||||
//If the URL starts with https:/ or http:/, add the missing slash
|
||||
//Traefik removes the double slash as secruity measure, so we want to be forgiving and add it back if needed
|
||||
//See https://github.com/Part-DB/Part-DB-server/issues/1296
|
||||
if (preg_match('/^https?:\/[^\/]/', $url)) {
|
||||
$url = preg_replace('/^(https?:)\/([^\/])/', '$1//$2', $url);
|
||||
} else {
|
||||
$url = 'https://'.$url;
|
||||
}
|
||||
}
|
||||
|
||||
//If this is not a valid URL with host, domain and path, throw an exception
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) === false ||
|
||||
parse_url($url, PHP_URL_HOST) === null ||
|
||||
parse_url($url, PHP_URL_PATH) === null) {
|
||||
throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$originalUrl);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
public function getDetails(string $id, bool $check_for_delegation = true): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
$url = $this->fixAndValidateURL($id);
|
||||
|
||||
if ($check_for_delegation) {
|
||||
if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) {
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->delegateToOtherProvider($url);
|
||||
$delegatedPart = $this->createFromUrlHelper->delegateToOtherProviderDetails($url, $this);
|
||||
if ($delegatedPart !== null) {
|
||||
return $this->infoRetriever->getDetailsForSearchResult($delegatedPart);
|
||||
return $delegatedPart;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
|||
|
||||
interface InfoProviderInterface
|
||||
{
|
||||
public const OPTION_NO_CACHE = 'no_cache'; // if set to true, the provider should not use any cache and retrieve fresh data from the source
|
||||
public const OPTION_SKIP_DELEGATION = 'skip_delegation'; // if set to true, the provider should not delegate the request to other providers, even if it supports delegation.
|
||||
|
||||
/**
|
||||
* Get information about this provider
|
||||
|
|
@ -61,16 +63,18 @@ interface InfoProviderInterface
|
|||
/**
|
||||
* Searches for a keyword and returns a list of search results
|
||||
* @param string $keyword The keyword to search for
|
||||
* @param array $options An associative array of options for the search, which can be used to pass additional parameters to the provider (e.g. filters, pagination, etc.). The content of this array is provider specific and not defined by the interface
|
||||
* @return SearchResultDTO[] A list of search results
|
||||
*/
|
||||
public function searchByKeyword(string $keyword): array;
|
||||
public function searchByKeyword(string $keyword, array $options = []): array;
|
||||
|
||||
/**
|
||||
* Returns detailed information about the part with the given id
|
||||
* @param string $id
|
||||
* @param array $options An associative array of options for the search, which can be used to pass additional parameters to the provider (e.g. filters, pagination, etc.). The content of this array is provider specific and not defined by the interface
|
||||
* @return PartDetailDTO
|
||||
*/
|
||||
public function getDetails(string $id): PartDetailDTO;
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO;
|
||||
|
||||
/**
|
||||
* A list of capabilities this provider supports (which kind of data it can provide).
|
||||
|
|
|
|||
|
|
@ -349,17 +349,18 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
return $result;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
return $this->queryByTerm($keyword, true); // Use lightweight mode for search
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch search multiple keywords asynchronously (like JavaScript Promise.all)
|
||||
* @param array $keywords Array of keywords to search
|
||||
* @param array $keywords
|
||||
* @param array $options
|
||||
* @return array Results indexed by keyword
|
||||
*/
|
||||
public function searchByKeywordsBatch(array $keywords): array
|
||||
public function searchByKeywordsBatch(array $keywords, array $options = []): array
|
||||
{
|
||||
if (empty($keywords)) {
|
||||
return [];
|
||||
|
|
@ -396,6 +397,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
// Now collect all results (like .then() in JavaScript)
|
||||
foreach ($responses as $keyword => $response) {
|
||||
try {
|
||||
$keyword = (string) $keyword;
|
||||
$arr = $response->toArray(); // This waits for the response
|
||||
$results[$keyword] = $this->processSearchResponse($arr, $keyword);
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -428,7 +430,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
|
|||
return $result;
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
$tmp = $this->queryByTerm($id, false);
|
||||
if (count($tmp) === 0) {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
return $this->settings->apiKey !== '' && $this->settings->apiKey !== null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
/*
|
||||
SearchByKeywordRequest description:
|
||||
|
|
@ -144,7 +144,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
return $this->responseToDTOArray($response);
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
/*
|
||||
SearchByPartRequest description:
|
||||
|
|
|
|||
|
|
@ -278,12 +278,13 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
* and debugging with local JSON files. The results are processed, cached, and then sorted based
|
||||
* on the keyword and specified criteria.
|
||||
*
|
||||
* @param string $keyword The part number to search for
|
||||
* @param string $keyword
|
||||
* @param array $options
|
||||
* @return array An array of processed product details, sorted by relevance and additional criteria.
|
||||
*
|
||||
* @throws \Exception If the JSON file used for debugging is not found or contains errors.
|
||||
*/
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
/*
|
||||
oemsecrets Part Search API 3.0.1
|
||||
|
|
@ -414,14 +415,20 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
* found in the cache, they are returned. If not, an exception is thrown indicating that
|
||||
* the details could not be found.
|
||||
*
|
||||
* @param string $id The unique identifier of the provider or part.
|
||||
* @param string $id
|
||||
* @param array $options
|
||||
* @return PartDetailDTO The detailed information about the part.
|
||||
*
|
||||
* @throws \Exception If no details are found for the given provider ID.
|
||||
*/
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
$cacheKey = $this->getCacheKey($id);
|
||||
|
||||
if ($options[self::OPTION_NO_CACHE] ?? false) {
|
||||
$this->partInfoCache->deleteItem($cacheKey);
|
||||
}
|
||||
|
||||
$cacheItem = $this->partInfoCache->getItem($cacheKey);
|
||||
|
||||
if ($cacheItem->isHit()) {
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ class OctopartProvider implements InfoProviderInterface
|
|||
);
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$graphQL = sprintf(<<<'GRAPHQL'
|
||||
query partSearch($keyword: String, $limit: Int, $currency: String!, $country: String!, $authorizedOnly: Boolean!) {
|
||||
|
|
@ -367,11 +367,13 @@ class OctopartProvider implements InfoProviderInterface
|
|||
return $tmp;
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
$no_cache = $options[self::OPTION_NO_CACHE] ?? false;
|
||||
|
||||
//Check if we have the part cached
|
||||
$cached = $this->getFromCache($id);
|
||||
if ($cached !== null) {
|
||||
if (!$no_cache && $cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt
|
|||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$response = $this->client->request('GET', 'https://www.pollin.de/search', [
|
||||
'query' => [
|
||||
|
|
@ -110,7 +110,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt
|
|||
};
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
//Ensure that $id is numeric
|
||||
if (!is_numeric($id)) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ enum ProviderCapabilities
|
|||
/** Provider can provide GTIN for a part */
|
||||
case GTIN;
|
||||
|
||||
/** Provider can provide parameters/specifications for a part */
|
||||
case PARAMETERS;
|
||||
|
||||
/**
|
||||
* Get the order index for displaying capabilities in a stable order.
|
||||
* @return int
|
||||
|
|
@ -59,6 +62,7 @@ enum ProviderCapabilities
|
|||
self::PRICE => 4,
|
||||
self::FOOTPRINT => 5,
|
||||
self::GTIN => 6,
|
||||
self::PARAMETERS => 7,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +75,7 @@ enum ProviderCapabilities
|
|||
self::DATASHEET => 'datasheet',
|
||||
self::PRICE => 'price',
|
||||
self::GTIN => 'gtin',
|
||||
self::PARAMETERS => 'parameters',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +88,7 @@ enum ProviderCapabilities
|
|||
self::DATASHEET => 'fa-file-alt',
|
||||
self::PRICE => 'fa-money-bill-wave',
|
||||
self::GTIN => 'fa-barcode',
|
||||
self::PARAMETERS => 'fa-list-ul',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$response = $this->client->request('GET', sprintf($this->getBaseURL() . '/shop/search/%s', $keyword));
|
||||
$html = $response->getContent();
|
||||
|
|
@ -108,7 +108,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
return $results;
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
//Check that the ID is a number
|
||||
if (!is_numeric($id)) {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
|
|||
return $this->tmeClient->isUsable();
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
$response = $this->tmeClient->makeRequest('Products/Search', [
|
||||
'Country' => $this->settings->country,
|
||||
|
|
@ -99,7 +99,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
|
|||
return $result;
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
$response = $this->tmeClient->makeRequest('Products/GetProducts', [
|
||||
'Country' => $this->settings->country,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class TestProvider implements InfoProviderInterface
|
|||
return true;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
public function searchByKeyword(string $keyword, array $options = []): array
|
||||
{
|
||||
return [
|
||||
new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element1', name: 'Element 1', description: 'fd'),
|
||||
|
|
@ -72,7 +72,7 @@ class TestProvider implements InfoProviderInterface
|
|||
];
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
public function getDetails(string $id, array $options = []): PartDetailDTO
|
||||
{
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
|
|
@ -92,4 +92,4 @@ class TestProvider implements InfoProviderInterface
|
|||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ enum InstallationType: string
|
|||
{
|
||||
return match ($this) {
|
||||
self::GIT => true,
|
||||
self::DOCKER => false,
|
||||
self::DOCKER => true,
|
||||
// ZIP_RELEASE auto-update not yet implemented
|
||||
self::ZIP_RELEASE => false,
|
||||
self::UNKNOWN => false,
|
||||
|
|
@ -57,7 +57,7 @@ enum InstallationType: string
|
|||
{
|
||||
return match ($this) {
|
||||
self::GIT => 'Run: php bin/console partdb:update',
|
||||
self::DOCKER => 'Pull the new Docker image and recreate the container: docker-compose pull && docker-compose up -d',
|
||||
self::DOCKER => 'Configure Watchtower for one-click updates, or manually: docker-compose pull && docker-compose up -d',
|
||||
self::ZIP_RELEASE => 'Download the new release ZIP from GitHub, extract it over your installation, and run: php bin/console doctrine:migrations:migrate && php bin/console cache:clear',
|
||||
self::UNKNOWN => 'Unable to determine installation type. Please update manually.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -105,6 +105,6 @@ class UpdateAvailableFacade
|
|||
return $this->updateCache->get(self::CACHE_KEY, function (ItemInterface $item) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
return $this->updateChecker->getLatestVersion();
|
||||
});
|
||||
}) ?? ['version' => '0.0.1', 'url' => 'update-checking-failed'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ class UpdateChecker
|
|||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly GitVersionInfoProvider $gitVersionInfoProvider,
|
||||
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode,
|
||||
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir)
|
||||
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir,
|
||||
private readonly ?WatchtowerClient $watchtowerClient = null)
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -284,8 +285,16 @@ class UpdateChecker
|
|||
$updateBlockers[] = 'local_changes';
|
||||
}
|
||||
|
||||
if ($installInfo['type'] === InstallationType::DOCKER) {
|
||||
$updateBlockers[] = 'docker_installation';
|
||||
// Docker installations require Watchtower for auto-update
|
||||
$watchtowerConfigured = $this->watchtowerClient !== null && $this->watchtowerClient->isConfigured();
|
||||
$watchtowerAvailable = $watchtowerConfigured && $this->watchtowerClient->isAvailable();
|
||||
|
||||
if ($installInfo['type'] === InstallationType::DOCKER && !$watchtowerConfigured) {
|
||||
$canAutoUpdate = false;
|
||||
$updateBlockers[] = 'docker_no_watchtower';
|
||||
} elseif ($installInfo['type'] === InstallationType::DOCKER && !$watchtowerAvailable) {
|
||||
$canAutoUpdate = false;
|
||||
$updateBlockers[] = 'docker_watchtower_unreachable';
|
||||
}
|
||||
|
||||
return [
|
||||
|
|
@ -301,6 +310,8 @@ class UpdateChecker
|
|||
'can_auto_update' => $canAutoUpdate,
|
||||
'update_blockers' => $updateBlockers,
|
||||
'check_enabled' => $this->privacySettings->checkForUpdates,
|
||||
'watchtower_configured' => $watchtowerConfigured,
|
||||
'watchtower_available' => $watchtowerAvailable,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -299,6 +299,23 @@ class UpdateExecutor
|
|||
);
|
||||
}
|
||||
|
||||
// Docker installations are updated via Watchtower - skip Git/Composer/Yarn checks
|
||||
if ($installType === InstallationType::DOCKER) {
|
||||
// Only check if already locked
|
||||
if ($this->isLocked()) {
|
||||
$lockInfo = $this->getLockInfo();
|
||||
$errors[] = sprintf(
|
||||
'An update is already in progress (started at %s).',
|
||||
$lockInfo['started_at'] ?? 'unknown time'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for Git installation
|
||||
if ($installType === InstallationType::GIT) {
|
||||
// Check if git is available
|
||||
|
|
|
|||
125
src/Services/System/WatchtowerClient.php
Normal file
125
src/Services/System/WatchtowerClient.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?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\System;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* HTTP client for communicating with the Watchtower container updater API.
|
||||
* Used to trigger Docker container updates from the Part-DB UI.
|
||||
*
|
||||
* @see https://containrrr.dev/watchtower/
|
||||
*/
|
||||
readonly class WatchtowerClient
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
private LoggerInterface $logger,
|
||||
#[Autowire(env: 'WATCHTOWER_API_URL')] private string $apiUrl,
|
||||
#[Autowire(env: 'WATCHTOWER_API_TOKEN')] private string $apiToken,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Watchtower integration is configured (URL and token are set).
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->apiUrl !== '' && $this->apiToken !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Watchtower API is reachable.
|
||||
* Makes a lightweight HTTP request with a short timeout.
|
||||
*/
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $this->getUpdateEndpoint(), [
|
||||
'headers' => $this->getAuthHeaders(),
|
||||
'timeout' => 3,
|
||||
]);
|
||||
|
||||
// Any response means Watchtower is reachable
|
||||
$statusCode = $response->getStatusCode();
|
||||
return $statusCode < 500;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->debug('Watchtower availability check failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a container update via the Watchtower HTTP API.
|
||||
* This is fire-and-forget: Watchtower will pull the new image and restart the container.
|
||||
*
|
||||
* @return bool True if Watchtower accepted the update request
|
||||
*/
|
||||
public function triggerUpdate(): bool
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
throw new \RuntimeException('Watchtower is not configured. Set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.');
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', $this->getUpdateEndpoint(), [
|
||||
'headers' => $this->getAuthHeaders(),
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
if ($statusCode >= 200 && $statusCode < 300) {
|
||||
$this->logger->info('Watchtower update triggered successfully.');
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->logger->error('Watchtower update request returned HTTP ' . $statusCode);
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to trigger Watchtower update: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getUpdateEndpoint(): string
|
||||
{
|
||||
return rtrim($this->apiUrl, '/') . '/v1/update';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getAuthHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer ' . $this->apiToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
43
src/Settings/AISettings/AISettings.php
Normal file
43
src/Settings/AISettings/AISettings.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Settings\AISettings;
|
||||
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
|
||||
#[Settings(label: new TM("settings.ai"))]
|
||||
#[SettingsIcon("fa-brain")]
|
||||
class AISettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?OpenRouterSettings $openRouter = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?LMStudioSettings $lmstudio = null;
|
||||
}
|
||||
53
src/Settings/AISettings/LMStudioSettings.php
Normal file
53
src/Settings/AISettings/LMStudioSettings.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Settings\AISettings;
|
||||
|
||||
use App\Form\Type\APIKeyType;
|
||||
use App\Services\AI\AIPlatformSettingsInterface;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Translation\StaticMessage;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
|
||||
#[Settings(name: 'ai_lmstudio', label: new TM("settings.ai.lmstudio"))]
|
||||
#[SettingsIcon("fa-robot")]
|
||||
class LMStudioSettings implements AIPlatformSettingsInterface
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ai.lmstudio.hosturl"),
|
||||
formType: UrlType::class,
|
||||
formOptions: ["attr" => ["placeholder" => new StaticMessage("http://localhost:1234")]],
|
||||
envVar: "AI_LMSTUDIO_HOSTURL", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
public ?string $hostURL = null;
|
||||
|
||||
public function isAIPlatformEnabled(): bool
|
||||
{
|
||||
return $this->hostURL !== null && $this->hostURL !== "";
|
||||
}
|
||||
}
|
||||
50
src/Settings/AISettings/OpenRouterSettings.php
Normal file
50
src/Settings/AISettings/OpenRouterSettings.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Settings\AISettings;
|
||||
|
||||
use App\Form\Type\APIKeyType;
|
||||
use App\Services\AI\AIPlatformSettingsInterface;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
|
||||
#[Settings(name: 'ai_openrouter', label: new TM("settings.ai.openrouter"), description: "settings.ai.openrouter.help")]
|
||||
#[SettingsIcon("fa-robot")]
|
||||
class OpenRouterSettings implements AIPlatformSettingsInterface
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.element14.apiKey"),
|
||||
formType: APIKeyType::class,
|
||||
formOptions: ["help_html" => true], envVar: "AI_OPENROUTER_KEY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
public ?string $apiKey = null;
|
||||
|
||||
public function isAIPlatformEnabled(): bool
|
||||
{
|
||||
return $this->apiKey !== null && $this->apiKey !== "";
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Settings;
|
||||
|
||||
use App\Settings\AISettings\AISettings;
|
||||
use App\Settings\BehaviorSettings\BehaviorSettings;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderSettings;
|
||||
use App\Settings\MiscSettings\MiscSettings;
|
||||
|
|
@ -50,6 +51,9 @@ class AppSettings
|
|||
#[EmbeddedSettings]
|
||||
public ?SynonymSettings $synonyms = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?AISettings $ai = null;
|
||||
|
||||
#[EmbeddedSettings()]
|
||||
public ?MiscSettings $miscSettings = null;
|
||||
|
||||
|
|
|
|||
78
src/Settings/InfoProviderSystem/AIExtractorSettings.php
Normal file
78
src/Settings/InfoProviderSystem/AIExtractorSettings.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Form\Settings\AiModelsType;
|
||||
use App\Form\Settings\AiPlatformChoiceType;
|
||||
use App\Services\AI\AIPlatforms;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\AI\Platform\Capability;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Translation\StaticMessage;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Symfony\Component\Validator\Constraints\Language;
|
||||
|
||||
#[Settings(name: "ai_extractor", label: new TM("settings.ips.ai_extractor"), description: new TM("settings.ips.ai_extractor.description"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class AIExtractorSettings
|
||||
{
|
||||
private const MODEL_SELECTOR_LABEL = 'ai_extractor';
|
||||
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.ai_extractor.ai_platform"),
|
||||
formType: AiPlatformChoiceType::class, formOptions: ['platform_selector_label' => self::MODEL_SELECTOR_LABEL],
|
||||
)]
|
||||
public ?AIPlatforms $platform = null;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.ai_extractor.model"), description: new TM("settings.ips.ai_extractor.model.help"),
|
||||
formType: AiModelsType::class, formOptions: [
|
||||
'platform_selector' => self::MODEL_SELECTOR_LABEL, 'filter_capability' => Capability::OUTPUT_STRUCTURED,
|
||||
'attr' => ['placeholder' => new StaticMessage('google/gemini-2.5-flash-lite')]
|
||||
],
|
||||
|
||||
)]
|
||||
public ?string $model = null;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.ai_extractor.max_content_length"),
|
||||
description: new TM("settings.ips.ai_extractor.max_content_length.description"),
|
||||
)]
|
||||
public int $maxContentLength = 50000;
|
||||
|
||||
#[Language]
|
||||
#[SettingsParameter(label: new TM("settings.ips.ai_extractor.output_language"), description: new TM("settings.ips.ai_extractor.output_language.description"),
|
||||
formType: LanguageType::class,
|
||||
)]
|
||||
public ?string $outputLanguage = null;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.ai_extractor.additional_instructions"), description: new TM("settings.ips.ai_extractor.additional_instructions.description"),
|
||||
formType: TextareaType::class,
|
||||
)]
|
||||
public ?string $additionalInstructions = null;
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ class CanopySettings
|
|||
/**
|
||||
* @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant
|
||||
*/
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])]
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'translation_domain' => false])]
|
||||
public string $domain = "DE";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ class InfoProviderSettings
|
|||
#[EmbeddedSettings]
|
||||
public ?GenericWebProviderSettings $genericWebProvider = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?AIExtractorSettings $aiExtractor = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?DigikeySettings $digikey = null;
|
||||
|
||||
|
|
@ -75,4 +78,5 @@ class InfoProviderSettings
|
|||
|
||||
#[EmbeddedSettings]
|
||||
public ?CanopySettings $canopy = null;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Services\InfoProviderSystem\CreateFromUrlHelper;
|
||||
use Twig\Attribute\AsTwigFunction;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
|
@ -34,7 +35,7 @@ use Twig\Extension\AbstractExtension;
|
|||
|
||||
final readonly class MiscExtension
|
||||
{
|
||||
public function __construct(private EventCommentNeededHelper $eventCommentNeededHelper)
|
||||
public function __construct(private EventCommentNeededHelper $eventCommentNeededHelper, private CreateFromUrlHelper $fromUrlHelper)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -84,4 +85,14 @@ final readonly class MiscExtension
|
|||
|
||||
return $request->getBaseUrl().$request->getPathInfo().$qs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the from url provider is active, false otherwise.
|
||||
* @return bool
|
||||
*/
|
||||
#[AsTwigFunction(name: 'create_from_url_active')]
|
||||
public function create_from_url_active(): bool
|
||||
{
|
||||
return $this->fromUrlHelper->canCreateFromUrl();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
symfony.lock
54
symfony.lock
|
|
@ -375,6 +375,54 @@
|
|||
"shivas/versioning-bundle": {
|
||||
"version": "4.0.3"
|
||||
},
|
||||
"symfony/ai-bundle": {
|
||||
"version": "0.8",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "0.1",
|
||||
"ref": "2be6ccd77335c2631fdf12d1680649b072efb8ad"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/ai.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/ai-generic-platform": {
|
||||
"version": "0.8",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "0.1",
|
||||
"ref": "f38913b87380322d4c40c302b41626e811516bc4"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/ai_generic_platform.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/ai-lm-studio-platform": {
|
||||
"version": "0.8",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "0.1",
|
||||
"ref": "e35cced28f6559fc5effccb8f22597f309fedfdf"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/ai_lm_studio_platform.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/ai-open-router-platform": {
|
||||
"version": "0.8",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "0.1",
|
||||
"ref": "c39a146c6ec3df8b874accf6ce1cccbda431a688"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/ai_open_router_platform.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/apache-pack": {
|
||||
"version": "1.0",
|
||||
"recipe": {
|
||||
|
|
@ -393,12 +441,6 @@
|
|||
"symfony/browser-kit": {
|
||||
"version": "v4.2.3"
|
||||
},
|
||||
"symfony/cache": {
|
||||
"version": "v4.2.3"
|
||||
},
|
||||
"symfony/cache-contracts": {
|
||||
"version": "v1.1.5"
|
||||
},
|
||||
"symfony/config": {
|
||||
"version": "v4.2.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
{% trans %}info_providers.search.title{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% if settings_instance('generic_web_provider').enabled %}
|
||||
{% if create_from_url_active() %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ path('info_providers_from_url') }}">
|
||||
<i class="fa-fw fa-solid fa-book-atlas"></i>
|
||||
|
|
|
|||
235
templates/admin/update_manager/docker_progress.html.twig
Normal file
235
templates/admin/update_manager/docker_progress.html.twig
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}{% trans %}update_manager.docker.progress_title{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fab fa-docker" data-docker-update-progress-target="titleIcon"></i>
|
||||
{% trans %}update_manager.docker.progress_title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<style nonce="{{ csp_nonce('style') }}">
|
||||
.docker-whale {
|
||||
display: inline-block;
|
||||
animation: whale-bob 2s ease-in-out infinite;
|
||||
}
|
||||
.docker-whale svg {
|
||||
fill: #2496ED;
|
||||
}
|
||||
.docker-whale.success {
|
||||
animation: whale-arrive 0.6s ease-out forwards;
|
||||
}
|
||||
.docker-whale.success svg { fill: #198754; }
|
||||
.docker-whale.timeout { animation: none; }
|
||||
.docker-whale.timeout svg { fill: #ffc107; }
|
||||
.docker-whale.failed { animation: none; }
|
||||
.docker-whale.failed svg { fill: #dc3545; }
|
||||
@keyframes whale-bob {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-8px) rotate(-2deg); }
|
||||
75% { transform: translateY(4px) rotate(1deg); }
|
||||
}
|
||||
@keyframes whale-arrive {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.whale-waves {
|
||||
text-align: center;
|
||||
font-size: 0.5rem;
|
||||
color: #2496ED66;
|
||||
animation: wave-flow 3s linear infinite;
|
||||
letter-spacing: -1px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
.docker-whale.success + .whale-waves,
|
||||
.docker-whale.timeout + .whale-waves,
|
||||
.docker-whale.failed + .whale-waves { animation: none; }
|
||||
@keyframes wave-flow {
|
||||
0% { opacity: 0.3; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 0.3; }
|
||||
}
|
||||
.step-timestamp { min-width: 60px; text-align: right; }
|
||||
/* Progress bar widths - CSS classes to avoid CSP inline style violations */
|
||||
.docker-progress { height: 25px; }
|
||||
.progress-w-0 { width: 0%; }
|
||||
.progress-w-15 { width: 15%; }
|
||||
.progress-w-30 { width: 30%; }
|
||||
.progress-w-50 { width: 50%; }
|
||||
.progress-w-65 { width: 65%; }
|
||||
.progress-w-80 { width: 80%; }
|
||||
.progress-w-100 { width: 100%; }
|
||||
</style>
|
||||
<div data-controller="docker-update-progress"
|
||||
data-docker-update-progress-health-url-value="{{ path('admin_update_manager_health') }}"
|
||||
data-docker-update-progress-previous-version-value="{{ previous_version }}"
|
||||
data-docker-update-progress-text-pulling-value="{% trans %}update_manager.docker.waiting_for_watchtower{% endtrans %}"
|
||||
data-docker-update-progress-text-pulling-detail-value="{% trans %}update_manager.docker.step_pull_desc{% endtrans %}"
|
||||
data-docker-update-progress-text-restarting-value="{% trans %}update_manager.docker.restarting_title{% endtrans %}"
|
||||
data-docker-update-progress-text-restarting-detail-value="{% trans %}update_manager.docker.step_restart_desc{% endtrans %}"
|
||||
data-docker-update-progress-text-success-value="{% trans %}update_manager.docker.success_title{% endtrans %}"
|
||||
data-docker-update-progress-text-success-detail-value="{% trans %}update_manager.docker.success_message{% endtrans %}"
|
||||
data-docker-update-progress-text-timeout-value="{% trans %}update_manager.docker.timeout_title{% endtrans %}"
|
||||
data-docker-update-progress-text-timeout-detail-value="{% trans %}update_manager.docker.timeout_message{% endtrans %}"
|
||||
data-docker-update-progress-text-step-pull-value="{% trans %}update_manager.docker.step_pull{% endtrans %}"
|
||||
data-docker-update-progress-text-step-restart-value="{% trans %}update_manager.docker.step_restart{% endtrans %}">
|
||||
|
||||
{# Progress Header #}
|
||||
<div class="text-center mb-4">
|
||||
<div class="mb-3">
|
||||
<div class="docker-whale-container">
|
||||
<div class="docker-whale" data-docker-update-progress-target="headerWhale">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="80" height="64">
|
||||
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="whale-waves">~ ~ ~ ~ ~</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 data-docker-update-progress-target="statusText">
|
||||
{% trans %}update_manager.docker.updating{% endtrans %}
|
||||
</h4>
|
||||
<p class="text-muted" data-docker-update-progress-target="statusSubtext">
|
||||
{% trans %}update_manager.docker.updating_via_watchtower{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Progress Bar #}
|
||||
<div class="progress mb-4 docker-progress">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated progress-w-15"
|
||||
role="progressbar"
|
||||
aria-valuenow="15"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
data-docker-update-progress-target="progressBar">
|
||||
15%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Current Step Info #}
|
||||
<div class="alert alert-info mb-4" data-docker-update-progress-target="stepAlert">
|
||||
<strong data-docker-update-progress-target="stepName">{% trans %}update_manager.docker.step_trigger{% endtrans %}</strong>:
|
||||
<span data-docker-update-progress-target="stepMessage">{% trans %}update_manager.docker.step_trigger_desc{% endtrans %}</span>
|
||||
</div>
|
||||
|
||||
{# Success Message #}
|
||||
<div class="alert alert-success mb-4 d-none" data-docker-update-progress-target="successAlert">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
{% trans %}update_manager.docker.success_message{% endtrans %}
|
||||
<br>
|
||||
<strong>{% trans %}update_manager.docker.previous_version{% endtrans %}:</strong>
|
||||
<span data-docker-update-progress-target="previousVersion">{{ previous_version }}</span>
|
||||
→
|
||||
<strong>{% trans %}update_manager.docker.new_version{% endtrans %}:</strong>
|
||||
<span class="badge bg-success" data-docker-update-progress-target="newVersion">...</span>
|
||||
</div>
|
||||
|
||||
{# Timeout Message #}
|
||||
<div class="alert alert-warning mb-4 d-none" data-docker-update-progress-target="timeoutAlert">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{% trans %}update_manager.docker.timeout_message{% endtrans %}
|
||||
</div>
|
||||
|
||||
{# Error Message #}
|
||||
<div class="alert alert-danger mb-4 d-none" data-docker-update-progress-target="errorAlert">
|
||||
<i class="fas fa-times-circle me-2"></i>
|
||||
<strong>{% trans %}update_manager.progress.error{% endtrans %}:</strong>
|
||||
<span data-docker-update-progress-target="errorMessage"></span>
|
||||
</div>
|
||||
|
||||
{# Steps Timeline - matches git progress style #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-list-ol me-2"></i>{% trans %}update_manager.docker.steps{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
{# Step 1: Trigger Watchtower #}
|
||||
<li class="list-group-item d-flex align-items-center" data-docker-update-progress-target="stepRow" data-step="trigger">
|
||||
<i class="fas fa-check-circle text-success me-3" data-docker-update-progress-target="stepIcon"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{% trans %}update_manager.docker.step_trigger{% endtrans %}</strong>
|
||||
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_trigger_desc{% endtrans %}</small>
|
||||
</div>
|
||||
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
|
||||
</li>
|
||||
|
||||
{# Step 2: Pull Image #}
|
||||
<li class="list-group-item d-flex align-items-center" data-docker-update-progress-target="stepRow" data-step="pull">
|
||||
<i class="fas fa-spinner fa-spin text-primary me-3" data-docker-update-progress-target="stepIcon"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{% trans %}update_manager.docker.step_pull{% endtrans %}</strong>
|
||||
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_pull_desc{% endtrans %}</small>
|
||||
</div>
|
||||
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
|
||||
</li>
|
||||
|
||||
{# Step 3: Stop Container #}
|
||||
<li class="list-group-item d-flex align-items-center text-muted" data-docker-update-progress-target="stepRow" data-step="stop">
|
||||
<i class="fas fa-circle me-3" data-docker-update-progress-target="stepIcon"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{% trans %}update_manager.docker.step_stop{% endtrans %}</strong>
|
||||
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_stop_desc{% endtrans %}</small>
|
||||
</div>
|
||||
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
|
||||
</li>
|
||||
|
||||
{# Step 4: Restart Container #}
|
||||
<li class="list-group-item d-flex align-items-center text-muted" data-docker-update-progress-target="stepRow" data-step="restart">
|
||||
<i class="fas fa-circle me-3" data-docker-update-progress-target="stepIcon"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{% trans %}update_manager.docker.step_restart{% endtrans %}</strong>
|
||||
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_restart_desc{% endtrans %}</small>
|
||||
</div>
|
||||
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
|
||||
</li>
|
||||
|
||||
{# Step 5: Health Check #}
|
||||
<li class="list-group-item d-flex align-items-center text-muted" data-docker-update-progress-target="stepRow" data-step="health">
|
||||
<i class="fas fa-circle me-3" data-docker-update-progress-target="stepIcon"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{% trans %}update_manager.docker.step_health{% endtrans %}</strong>
|
||||
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_health_desc{% endtrans %}</small>
|
||||
</div>
|
||||
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
|
||||
</li>
|
||||
|
||||
{# Step 6: Verify Version #}
|
||||
<li class="list-group-item d-flex align-items-center text-muted" data-docker-update-progress-target="stepRow" data-step="verify">
|
||||
<i class="fas fa-circle me-3" data-docker-update-progress-target="stepIcon"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{% trans %}update_manager.docker.step_verify{% endtrans %}</strong>
|
||||
<br><small class="text-muted" data-docker-update-progress-target="stepDetail">{% trans %}update_manager.docker.step_verify_desc{% endtrans %}</small>
|
||||
</div>
|
||||
<small class="text-muted step-timestamp" data-docker-update-progress-target="stepTime"></small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Elapsed Time #}
|
||||
<div class="text-center text-muted small mb-3">
|
||||
<i class="fas fa-clock me-1"></i>
|
||||
{% trans %}update_manager.docker.elapsed{% endtrans %}:
|
||||
<span data-docker-update-progress-target="elapsedTime">0s</span>
|
||||
</div>
|
||||
|
||||
{# Actions - shown after completion or timeout #}
|
||||
<div class="text-center d-none" data-docker-update-progress-target="actions">
|
||||
<a href="{{ path('admin_update_manager') }}" class="btn btn-secondary me-2">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.progress.back{% endtrans %}
|
||||
</a>
|
||||
<a href="/" class="btn btn-primary">
|
||||
<i class="fas fa-home me-1"></i> {% trans %}update_manager.docker.go_to_homepage{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Warning Notice #}
|
||||
<div class="alert alert-warning mt-4" data-docker-update-progress-target="warningAlert">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{% trans %}update_manager.docker.warning{% endtrans %}:</strong>
|
||||
{% trans %}update_manager.docker.do_not_close{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -75,6 +75,20 @@
|
|||
<span class="badge bg-secondary">{{ status.installation.type_name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.web_updates_allowed{% endtrans %}</th>
|
||||
<td>{{ helper.boolean_badge(not web_updates_disabled) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.backup_restore_allowed{% endtrans %}</th>
|
||||
<td>{{ helper.boolean_badge(not backup_restore_disabled) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.backup_download_allowed{% endtrans %}</th>
|
||||
<td>{{ helper.boolean_badge(not backup_download_disabled) }}</td>
|
||||
</tr>
|
||||
|
||||
{% if status.git.is_git_install %}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.git_branch{% endtrans %}</th>
|
||||
|
|
@ -99,25 +113,35 @@
|
|||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
||||
<td>
|
||||
{{ helper.boolean_badge(status.can_auto_update) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.web_updates_allowed{% endtrans %}</th>
|
||||
<td>{{ helper.boolean_badge(not web_updates_disabled) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.backup_restore_allowed{% endtrans %}</th>
|
||||
<td>{{ helper.boolean_badge(not backup_restore_disabled) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.backup_download_allowed{% endtrans %}</th>
|
||||
<td>{{ helper.boolean_badge(not backup_download_disabled) }}</td>
|
||||
</tr>
|
||||
{% if is_docker %}
|
||||
{# Docker: show Watchtower status #}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.docker.watchtower_status{% endtrans %}</th>
|
||||
<td>
|
||||
{% if status.watchtower_configured|default(false) and status.watchtower_available|default(false) %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check me-1"></i>{% trans %}update_manager.docker.watchtower_connected{% endtrans %}
|
||||
</span>
|
||||
{% elseif status.watchtower_configured|default(false) %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>{% trans %}update_manager.docker.watchtower_unreachable_short{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-times me-1"></i>{% trans %}update_manager.docker.watchtower_not_configured{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{# Git/other: show update readiness #}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
||||
<td>
|
||||
{{ helper.boolean_badge(status.can_auto_update) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -158,30 +182,63 @@
|
|||
</div>
|
||||
|
||||
{% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
|
||||
<form action="{{ path('admin_update_manager_start') }}" method="post"
|
||||
data-controller="update-confirm"
|
||||
data-update-confirm-is-downgrade-value="false"
|
||||
data-update-confirm-target-version-value="{{ status.latest_tag }}"
|
||||
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
|
||||
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
|
||||
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
|
||||
<input type="hidden" name="version" value="{{ status.latest_tag }}">
|
||||
{% if is_docker %}
|
||||
{# Docker update via Watchtower #}
|
||||
<form action="{{ path('admin_update_manager_start_docker') }}" method="post"
|
||||
data-controller="update-confirm"
|
||||
data-update-confirm-is-downgrade-value="false"
|
||||
data-update-confirm-target-version-value="{{ status.latest_tag }}"
|
||||
data-update-confirm-confirm-update-value="{{ 'update_manager.docker.confirm_update'|trans }}"
|
||||
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
|
||||
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start_docker') }}">
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-download me-2"></i>
|
||||
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
<i class="fab fa-docker me-2"></i>
|
||||
{% trans %}update_manager.docker.update_via_watchtower{% endtrans %} {{ status.latest_tag }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
|
||||
<label class="form-check-label" for="create-backup">
|
||||
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup-docker" checked>
|
||||
<label class="form-check-label" for="create-backup-docker">
|
||||
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3 mb-0 small">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
{% trans %}update_manager.docker.no_rollback_warning{% endtrans %}
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{# Git update #}
|
||||
<form action="{{ path('admin_update_manager_start') }}" method="post"
|
||||
data-controller="update-confirm"
|
||||
data-update-confirm-is-downgrade-value="false"
|
||||
data-update-confirm-target-version-value="{{ status.latest_tag }}"
|
||||
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
|
||||
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
|
||||
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
|
||||
<input type="hidden" name="version" value="{{ status.latest_tag }}">
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-download me-2"></i>
|
||||
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
|
||||
<label class="form-check-label" for="create-backup">
|
||||
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if status.published_at %}
|
||||
|
|
@ -229,12 +286,59 @@
|
|||
|
||||
{# Non-auto-update installations info #}
|
||||
{% if not status.can_auto_update %}
|
||||
<div class="alert alert-secondary">
|
||||
<h6 class="alert-heading">
|
||||
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
|
||||
</h6>
|
||||
<p class="mb-0">{{ status.installation.update_instructions }}</p>
|
||||
</div>
|
||||
{% if is_docker and not status.watchtower_configured|default(false) %}
|
||||
{# Docker without Watchtower - show setup instructions #}
|
||||
<div class="card border-info mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<i class="fab fa-docker me-2"></i>{% trans %}update_manager.docker.setup_title{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>{% trans %}update_manager.docker.setup_description{% endtrans %}</p>
|
||||
|
||||
<h6>{% trans %}update_manager.docker.setup_step1{% endtrans %}</h6>
|
||||
<pre class="bg-dark text-light p-3 rounded"><code>
|
||||
# See documentation for full example: https://docs.part-db.de/installation/installation_docker.html
|
||||
services:
|
||||
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
|
||||
</code></pre>
|
||||
|
||||
<h6>{% trans %}update_manager.docker.setup_step2{% endtrans %}</h6>
|
||||
<pre class="bg-dark text-light p-3 rounded"><code>WATCHTOWER_API_URL=http://watchtower:8080
|
||||
WATCHTOWER_API_TOKEN=your-secret-token</code></pre>
|
||||
|
||||
<div class="alert alert-warning mb-0 mt-3">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{% trans %}update_manager.docker.setup_network_hint{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elseif is_docker and status.watchtower_configured|default(false) and not status.watchtower_available|default(false) %}
|
||||
{# Docker with Watchtower configured but not reachable #}
|
||||
<div class="alert alert-warning">
|
||||
<h6 class="alert-heading">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.docker.watchtower_unreachable_title{% endtrans %}
|
||||
</h6>
|
||||
<p class="mb-0">{% trans %}update_manager.docker.watchtower_unreachable_description{% endtrans %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Other non-auto-update installations (ZIP, unknown) #}
|
||||
<div class="alert alert-secondary">
|
||||
<h6 class="alert-heading">
|
||||
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
|
||||
</h6>
|
||||
<p class="mb-0">{{ status.installation.update_instructions }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -277,6 +381,9 @@
|
|||
<i class="fas fa-file-alt"></i>
|
||||
</a>
|
||||
{% if release.version != status.current_version and status.can_auto_update and validation.valid and not web_updates_disabled %}
|
||||
{% if is_docker %}
|
||||
{# Docker: version switching not supported, only update to latest via Watchtower #}
|
||||
{% else %}
|
||||
<form action="{{ path('admin_update_manager_start') }}" method="post" class="d-inline"
|
||||
data-controller="update-confirm"
|
||||
data-update-confirm-is-downgrade-value="{{ release.version < status.current_version ? 'true' : 'false' }}"
|
||||
|
|
@ -297,6 +404,7 @@
|
|||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
Consider yourself lucky. You found some rare error code. <br> You should maybe inform your administrator about it...
|
||||
{% endblock %}
|
||||
</h3>
|
||||
{% block further_actions %}<p class="help_text">You can try to <a href="javascript:history.back()">Go Back</a> or <a href="{{ path('homepage') }}">Visit the homepage</a>.</p>{% endblock %}
|
||||
{% block further_actions %}<p class="help_text">You can try to <a href="#" onclick="history.back();return false;">Go Back</a> or <a href="{{ path('homepage') }}">Visit the homepage</a>.</p>{% endblock %}
|
||||
{% block admin_contact %}<p class="help_text">If this error persists, please contact your
|
||||
{% if error_page_admin_email is not empty %}
|
||||
<a href="mailto:{{ error_page_admin_email }}">administrator.</a>
|
||||
|
|
|
|||
|
|
@ -22,103 +22,130 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{% if jobs is not empty %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}info_providers.bulk_import.job_name{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.parts_count{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.results_count{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.progress{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.status{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.created_by{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.created_at{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.completed_at{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</strong>
|
||||
{% if job.isInProgress %}
|
||||
<span class="badge bg-info ms-2">Active</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ job.partCount }}</td>
|
||||
<td>{{ job.resultCount }}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress me-2" style="width: 80px; height: 12px;">
|
||||
<div class="progress-bar {% if job.isCompleted %}bg-success{% elseif job.isFailed %}bg-danger{% else %}bg-info{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ job.progressPercentage }}%"
|
||||
aria-valuenow="{{ job.progressPercentage }}"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">{{ job.progressPercentage }}%</small>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if job.isPending %}
|
||||
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
|
||||
{% elseif job.isInProgress %}
|
||||
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
|
||||
{% elseif job.isCompleted %}
|
||||
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
|
||||
{% elseif job.isStopped %}
|
||||
<span class="badge bg-secondary">{% trans %}info_providers.bulk_import.status.stopped{% endtrans %}</span>
|
||||
{% elseif job.isFailed %}
|
||||
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ job.createdBy.fullName(true) }}</td>
|
||||
<td>{{ job.createdAt|format_datetime('short') }}</td>
|
||||
<td>
|
||||
{% if job.completedAt %}
|
||||
{{ job.completedAt|format_datetime('short') }}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if job.isInProgress or job.isCompleted or job.isStopped %}
|
||||
<a href="{{ path('bulk_info_provider_step2', {'jobId': job.id}) }}" class="btn btn-primary">
|
||||
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_results{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if job.canBeStopped %}
|
||||
<button type="button" class="btn btn-warning" data-action="click->bulk-job-manage#stopJob" data-job-id="{{ job.id }}">
|
||||
<i class="fas fa-stop"></i> {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if job.isCompleted or job.isFailed or job.isStopped %}
|
||||
<button type="button" class="btn btn-danger" data-action="click->bulk-job-manage#deleteJob" data-job-id="{{ job.id }}">
|
||||
<i class="fas fa-trash"></i> {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if active_jobs is empty and finished_jobs is empty %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{% trans %}info_providers.bulk_import.no_jobs_found{% endtrans %}<br>
|
||||
{% trans %}info_providers.bulk_import.create_first_job{% endtrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Active Jobs #}
|
||||
{% if active_jobs is not empty %}
|
||||
<h5 class="mb-3">
|
||||
<i class="fas fa-tasks me-1"></i> {% trans %}info_providers.bulk_import.active_jobs{% endtrans %}
|
||||
<span class="badge bg-primary">{{ active_jobs|length }}</span>
|
||||
</h5>
|
||||
{{ _self.job_table(active_jobs, false) }}
|
||||
{% endif %}
|
||||
|
||||
{# Finished Jobs (History) #}
|
||||
{% if finished_jobs is not empty %}
|
||||
<h5 class="mb-3">
|
||||
<i class="fas fa-history me-1"></i> {% trans %}info_providers.bulk_import.finished_jobs{% endtrans %}
|
||||
<span class="badge bg-secondary">{{ finished_jobs|length }}</span>
|
||||
</h5>
|
||||
{{ _self.job_table(finished_jobs, true) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% macro job_table(jobs, showCompletedAt) %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}info_providers.bulk_import.job_name{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.parts_count{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.results_count{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.progress{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.status{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.created_by{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.created_at{% endtrans %}</th>
|
||||
{% if showCompletedAt %}
|
||||
<th>{% trans %}info_providers.bulk_import.completed_at{% endtrans %}</th>
|
||||
{% endif %}
|
||||
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
{{ _self.job_row(job, showCompletedAt) }}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro job_row(job, showCompletedAt) %}
|
||||
{% set showCompletedAt = showCompletedAt|default(false) %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>#{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}</strong>
|
||||
<br><small class="text-muted">{{ job.formattedTimestamp }}</small>
|
||||
</td>
|
||||
<td>{{ job.partCount }}</td>
|
||||
<td>{{ job.resultCount }}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress me-2" style="width: 80px; height: 12px;">
|
||||
<div class="progress-bar {% if job.isCompleted %}bg-success{% elseif job.isFailed %}bg-danger{% else %}bg-info{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ job.progressPercentage }}%"
|
||||
aria-valuenow="{{ job.progressPercentage }}"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">{{ job.progressPercentage }}%</small>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if job.isPending %}
|
||||
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
|
||||
{% elseif job.isInProgress %}
|
||||
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
|
||||
{% elseif job.isCompleted %}
|
||||
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
|
||||
{% elseif job.isStopped %}
|
||||
<span class="badge bg-secondary">{% trans %}info_providers.bulk_import.status.stopped{% endtrans %}</span>
|
||||
{% elseif job.isFailed %}
|
||||
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ job.createdBy.fullName(true) }}</td>
|
||||
<td>{{ job.createdAt|format_datetime('short') }}</td>
|
||||
{% if showCompletedAt %}
|
||||
<td>
|
||||
{% if job.completedAt %}
|
||||
{{ job.completedAt|format_datetime('short') }}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if job.isInProgress or job.isCompleted or job.isStopped %}
|
||||
<a href="{{ path('bulk_info_provider_step2', {'jobId': job.id}) }}" class="btn btn-primary">
|
||||
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_results{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if job.canBeStopped %}
|
||||
<button type="button" class="btn btn-warning" data-action="click->bulk-job-manage#stopJob" data-job-id="{{ job.id }}">
|
||||
<i class="fas fa-stop"></i> {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if job.isCompleted or job.isFailed or job.isStopped %}
|
||||
<button type="button" class="btn btn-danger" data-action="click->bulk-job-manage#deleteJob" data-job-id="{{ job.id }}">
|
||||
<i class="fas fa-trash"></i> {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -9,22 +9,42 @@
|
|||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-search"></i> {% trans %}info_providers.bulk_import.step2.title{% endtrans %}
|
||||
<span class="badge bg-secondary">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</span>
|
||||
<span class="badge bg-secondary">#{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a href="{{ path('bulk_info_provider_manage') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left"></i> {% trans %}info_providers.bulk_import.back_to_jobs{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ path('parts_show_all') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-list"></i> {% trans %}info_providers.bulk_import.back_to_parts{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if job.isCompleted %}
|
||||
<div class="alert alert-success mb-3" role="alert">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<strong>{% trans %}info_providers.bulk_import.job_completed{% endtrans %}</strong>
|
||||
{% trans %}info_providers.bulk_import.job_completed.description{% endtrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div {{ stimulus_controller('bulk-import', {
|
||||
'jobId': job.id,
|
||||
'researchUrl': path('bulk_info_provider_research_part', {'jobId': job.id, 'partId': '__PART_ID__'}),
|
||||
'researchAllUrl': path('bulk_info_provider_research_all', {'jobId': job.id}),
|
||||
'markCompletedUrl': path('bulk_info_provider_mark_completed', {'jobId': job.id, 'partId': '__PART_ID__'}),
|
||||
'markSkippedUrl': path('bulk_info_provider_mark_skipped', {'jobId': job.id, 'partId': '__PART_ID__'}),
|
||||
'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'})
|
||||
'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'}),
|
||||
'quickApplyUrl': path('bulk_info_provider_quick_apply', {'jobId': job.id, 'partId': '__PART_ID__'}),
|
||||
'quickApplyAllUrl': path('bulk_info_provider_quick_apply_all', {'jobId': job.id})
|
||||
}) }}>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</h5>
|
||||
<h5 class="mb-1">#{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %} •
|
||||
{{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %} •
|
||||
|
|
@ -95,6 +115,13 @@
|
|||
<span class="spinner-border spinner-border-sm me-1" style="display: none;" id="research-all-spinner"></span>
|
||||
<i class="fas fa-search"></i> {% trans %}info_providers.bulk_import.research.all_pending{% endtrans %}
|
||||
</button>
|
||||
<button type="button" class="btn btn-success btn-sm"
|
||||
data-action="click->bulk-import#quickApplyAll"
|
||||
id="quick-apply-all-btn"
|
||||
title="{% trans %}info_providers.bulk_import.quick_apply_all.tooltip{% endtrans %}">
|
||||
<span class="spinner-border spinner-border-sm me-1" style="display: none;" id="quick-apply-all-spinner"></span>
|
||||
<i class="fas fa-bolt"></i> {% trans %}info_providers.bulk_import.quick_apply_all{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -181,39 +208,74 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for result in part_result.searchResults %}
|
||||
{% set sortedResults = part_result.resultsSortedByPriority %}
|
||||
{% for result in sortedResults %}
|
||||
{# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #}
|
||||
{% set dto = result.searchResult %}
|
||||
{% set localPart = result.localPart %}
|
||||
<tr>
|
||||
{% set isTopResult = loop.first %}
|
||||
<tr{% if isTopResult and not isCompleted %} class="table-success"{% endif %}>
|
||||
<td>
|
||||
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
|
||||
class="hoverpic" style="max-width: 35px;" {{ stimulus_controller('elements/hoverpic') }}>
|
||||
{% if dto.preview_image_url %}
|
||||
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
|
||||
class="hoverpic" style="max-width: 35px;" {{ stimulus_controller('elements/hoverpic') }}
|
||||
onerror="this.style.display='none'">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{# Check for matches against source keyword (what was searched) #}
|
||||
{% set sourceKw = result.sourceKeyword|default('')|lower %}
|
||||
{% set nameMatch = sourceKw is not empty and dto.name is not null and dto.name|lower == sourceKw %}
|
||||
{% set mpnMatch = sourceKw is not empty and dto.mpn is not null and dto.mpn|lower == sourceKw %}
|
||||
{% set spnMatch = sourceKw is not empty and dto.provider_id is not null and dto.provider_id|lower == sourceKw %}
|
||||
{% set anyMatch = nameMatch or mpnMatch or spnMatch %}
|
||||
{% if dto.provider_url is not null %}
|
||||
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
|
||||
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener"{% if nameMatch %} class="fw-bold"{% endif %}>{{ dto.name }}</a>
|
||||
{% else %}
|
||||
{{ dto.name }}
|
||||
<span{% if nameMatch %} class="fw-bold"{% endif %}>{{ dto.name }}</span>
|
||||
{% endif %}
|
||||
{% if nameMatch %}
|
||||
<span class="badge bg-success ms-1" title="{% trans %}info_providers.bulk_import.exact_match{% endtrans %}"><i class="fas fa-check-circle"></i></span>
|
||||
{% endif %}
|
||||
{% if dto.mpn is not null %}
|
||||
<br><small class="text-muted">{{ dto.mpn }}</small>
|
||||
<br><small{% if mpnMatch %} class="fw-bold text-success"{% endif %}>{{ dto.mpn }}</small>
|
||||
{% if mpnMatch %}
|
||||
<span class="badge bg-success ms-1" style="font-size: 0.65em;" title="{% trans %}info_providers.bulk_import.mpn_match{% endtrans %}">MPN <i class="fas fa-check-circle"></i></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ dto.description }}</td>
|
||||
<td>{{ dto.manufacturer ?? '' }}</td>
|
||||
<td>
|
||||
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
|
||||
<br><small class="text-muted">{{ dto.provider_id }}</small>
|
||||
<br><small{% if spnMatch %} class="fw-bold text-success"{% endif %}>{{ dto.provider_id }}</small>
|
||||
{% if spnMatch %}
|
||||
<span class="badge bg-success ms-1" style="font-size: 0.65em;" title="{% trans %}info_providers.bulk_import.spn_match{% endtrans %}">SPN <i class="fas fa-check-circle"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ result.sourceField ?? 'unknown' }}</span>
|
||||
{% if anyMatch %}
|
||||
<span class="badge bg-success">{% trans %}info_providers.bulk_import.match{% endtrans %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">{{ result.sourceField ?? 'unknown' }}</span>
|
||||
{% endif %}
|
||||
{% if result.sourceKeyword %}
|
||||
<br><small class="text-muted">{{ result.sourceKeyword }}</small>
|
||||
{% endif %}
|
||||
<br><small{% if anyMatch %} class="fw-bold text-success"{% endif %}>{{ result.sourceKeyword }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group-vertical btn-group-sm" role="group">
|
||||
{% if not isCompleted %}
|
||||
<button type="button" class="btn {% if not isTopResult %} btn-outline-success{% else %}btn-success{% endif %}"
|
||||
data-action="click->bulk-import#quickApply"
|
||||
data-part-id="{{ part.id }}"
|
||||
data-provider-key="{{ dto.provider_key }}"
|
||||
data-provider-id="{{ dto.provider_id }}"
|
||||
title="{% trans %}info_providers.bulk_import.quick_apply.tooltip{% endtrans %}">
|
||||
<i class="fas fa-bolt"></i> {% trans %}info_providers.bulk_import.quick_apply{% endtrans %}
|
||||
{% if isTopResult %}<span class="badge bg-light text-success ms-1">{% trans %}info_providers.bulk_import.recommended{% endtrans %}</span>{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% set updateHref = path('info_providers_update_part',
|
||||
{'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %}
|
||||
<a class="btn btn-primary{% if isCompleted %} disabled{% endif %}" href="{% if not isCompleted %}{{ updateHref }}{% else %}#{% endif %}"{% if isCompleted %} aria-disabled="true"{% endif %}>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,22 @@
|
|||
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.url) }}
|
||||
|
||||
{{ form_row(form.method) }}
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="{{ col_input }} {{ offset_label }}">
|
||||
<a data-bs-toggle="collapse" href="#infoSearchAdvancedPanel">{% trans %}info_providers.search.advanced_options{% endtrans %}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse" id="infoSearchAdvancedPanel">
|
||||
<div class="card card-body mb-2">
|
||||
{{ form_row(form.no_cache) }}
|
||||
{{ form_row(form.skip_delegation) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ form_row(form.submit) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-2">
|
||||
<div class="{{ col_input }} {{ offset_label }}">
|
||||
<a data-bs-toggle="collapse" href="#infoSearchAdvancedPanel">{% trans %}info_providers.search.advanced_options{% endtrans %}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse" id="infoSearchAdvancedPanel">
|
||||
<div class="card card-body mb-2">
|
||||
{{ form_row(form.no_cache_search) }}
|
||||
{{ form_row(form.no_cache_details) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ form_row(form.submit) }}
|
||||
|
||||
{{ form_end(form) }}
|
||||
|
|
@ -116,16 +129,16 @@
|
|||
|
||||
{% if update_target %} {# We update an existing part #}
|
||||
{% set href = path('info_providers_update_part',
|
||||
{'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'id': update_target.iD}) %}
|
||||
{'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'id': update_target.iD, 'no_cache': no_cache_details ? 1 : null}) %}
|
||||
{% else %} {# Create a fresh part #}
|
||||
{% set href = path('info_providers_create_part',
|
||||
{'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %}
|
||||
{'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'no_cache': no_cache_details ? 1 : null}) %}
|
||||
{% endif %}
|
||||
|
||||
{# If we have no local part, then we can just show the create button #}
|
||||
{% if localPart is null %}
|
||||
<a class="btn btn-primary" href="{{ href }}"
|
||||
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
|
||||
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
|
||||
<i class="fa-solid fa-plus-square"></i>
|
||||
</a>
|
||||
{% else %} {# Otherwise add a button group with all three buttons #}
|
||||
|
|
@ -139,7 +152,7 @@
|
|||
target="_blank" title="{% trans %}info_providers.search.show_existing_part{% endtrans %}">
|
||||
<i class="fa-solid fa-search"></i>
|
||||
</a>
|
||||
<a class="btn btn-primary" href="{{ path("info_providers_update_part", {'id': localPart.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) }}"
|
||||
<a class="btn btn-primary" href="{{ path("info_providers_update_part", {'id': localPart.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'no_cache': no_cache_details ? 1 : null }) }}"
|
||||
target="_blank" title="{% trans %}info_providers.search.update_existing_part{% endtrans %}">
|
||||
<i class="fa-solid fa-arrows-rotate"></i>
|
||||
</a>
|
||||
|
|
|
|||
46
templates/parts/info/_add_lot_modal.html.twig
Normal file
46
templates/parts/info/_add_lot_modal.html.twig
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{% if add_lot_form is not null %}
|
||||
{% form_theme add_lot_form 'form/extended_bootstrap_layout.html.twig' %}
|
||||
|
||||
<div class="modal fade" id="add-lot-modal" tabindex="-1" aria-labelledby="add-lot-modal-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
{{ form_start(add_lot_form) }}
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="add-lot-modal-title">
|
||||
<i class="fas fa-plus-square fa-fw"></i>
|
||||
{% trans %}part_lot.create{% endtrans %}
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ form_row(add_lot_form.description) }}
|
||||
{{ form_row(add_lot_form.storage_location) }}
|
||||
{{ form_row(add_lot_form.amount) }}
|
||||
{{ form_row(add_lot_form.instock_unknown) }}
|
||||
{{ form_row(add_lot_form.needs_refill) }}
|
||||
{{ form_row(add_lot_form.expiration_date) }}
|
||||
|
||||
<div>
|
||||
<a class="btn btn-link btn-sm {{ offset_label }}" data-bs-toggle="collapse" href="#add-lot-advanced" role="button" aria-expanded="false" aria-controls="add-lot-advanced">
|
||||
{% trans %}part_lot.edit.advanced{% endtrans %}
|
||||
</a>
|
||||
<div class="collapse" id="add-lot-advanced">
|
||||
{{ form_row(add_lot_form.comment) }}
|
||||
{{ form_row(add_lot_form.owner) }}
|
||||
{{ form_row(add_lot_form.user_barcode) }}
|
||||
{{ form_row(add_lot_form.last_stocktake_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}modal.close{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-plus-square fa-fw"></i>
|
||||
{% trans %}part_lot.create{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
{{ form_end(add_lot_form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
{% include "parts/info/_withdraw_modal.html.twig" %}
|
||||
{% include "parts/info/_stocktake_modal.html.twig" %}
|
||||
{% include "parts/info/_add_lot_modal.html.twig" %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
|
|
@ -126,3 +127,10 @@
|
|||
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if add_lot_form is not null %}
|
||||
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#add-lot-modal">
|
||||
<i class="fas fa-plus-square fa-fw"></i>
|
||||
{% trans %}part_lot.create{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
99
tests/Services/AI/AIPlatformRegistryTest.php
Normal file
99
tests/Services/AI/AIPlatformRegistryTest.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tests for App\Services\AI\AIPlatformRegistry
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Services\AI;
|
||||
|
||||
use App\Services\AI\AIPlatformRegistry;
|
||||
use App\Services\AI\AIPlatforms;
|
||||
use App\Services\AI\AIPlatformSettingsInterface;
|
||||
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Platform\PlatformInterface;
|
||||
|
||||
class AIPlatformRegistryTest extends TestCase
|
||||
{
|
||||
public function testRegistersEnabledPlatformsAndReturnsPlatform(): void
|
||||
{
|
||||
// Create a platform mock and expose it under the service tag name (openrouter)
|
||||
$platformMock = $this->createMock(PlatformInterface::class);
|
||||
|
||||
// Settings for OpenRouter -> enabled
|
||||
$openRouterSettings = $this->createMock(AIPlatformSettingsInterface::class);
|
||||
$openRouterSettings->method('isAIPlatformEnabled')->willReturn(true);
|
||||
|
||||
// Settings for LMStudio -> disabled
|
||||
$lmSettings = $this->createMock(AIPlatformSettingsInterface::class);
|
||||
$lmSettings->method('isAIPlatformEnabled')->willReturn(false);
|
||||
|
||||
// Settings manager should return the corresponding settings object depending on the requested class name
|
||||
$settingsManager = $this->createMock(SettingsManagerInterface::class);
|
||||
$settingsManager->method('get')->willReturnMap([
|
||||
[AIPlatforms::OPENROUTER->toSettingsClass(), $openRouterSettings],
|
||||
[AIPlatforms::LMSTUDIO->toSettingsClass(), $lmSettings],
|
||||
]);
|
||||
|
||||
$platforms = new \ArrayIterator([
|
||||
AIPlatforms::OPENROUTER->toServiceTagName() => $platformMock,
|
||||
]);
|
||||
|
||||
$registry = new AIPlatformRegistry($settingsManager, $platforms);
|
||||
|
||||
// OPENROUTER should be enabled and retrievable
|
||||
$this->assertTrue($registry->isEnabled(AIPlatforms::OPENROUTER));
|
||||
$this->assertSame($platformMock, $registry->getPlatform(AIPlatforms::OPENROUTER));
|
||||
|
||||
// LMSTUDIO is either not registered or disabled -> should not be enabled
|
||||
$this->assertFalse($registry->isEnabled(AIPlatforms::LMSTUDIO));
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$registry->getPlatform(AIPlatforms::LMSTUDIO);
|
||||
}
|
||||
|
||||
public function testGetEnabledPlatformsReturnsIndexedArray(): void
|
||||
{
|
||||
$platformMock = $this->createMock(PlatformInterface::class);
|
||||
|
||||
$openRouterSettings = $this->createMock(AIPlatformSettingsInterface::class);
|
||||
$openRouterSettings->method('isAIPlatformEnabled')->willReturn(true);
|
||||
|
||||
$settingsManager = $this->createMock(SettingsManagerInterface::class);
|
||||
$settingsManager->method('get')->willReturnMap([
|
||||
[AIPlatforms::OPENROUTER->toSettingsClass(), $openRouterSettings],
|
||||
[AIPlatforms::LMSTUDIO->toSettingsClass(), $this->createMock(AIPlatformSettingsInterface::class)],
|
||||
]);
|
||||
|
||||
$platforms = new \ArrayIterator([
|
||||
AIPlatforms::OPENROUTER->toServiceTagName() => $platformMock,
|
||||
// lmstudio not registered
|
||||
]);
|
||||
|
||||
$registry = new AIPlatformRegistry($settingsManager, $platforms);
|
||||
|
||||
$enabled = $registry->getEnabledPlatforms();
|
||||
|
||||
$this->assertArrayHasKey(AIPlatforms::OPENROUTER->value, $enabled);
|
||||
$this->assertSame($platformMock, $enabled[AIPlatforms::OPENROUTER->value]);
|
||||
}
|
||||
}
|
||||
|
||||
197
tests/Services/System/WatchtowerClientTest.php
Normal file
197
tests/Services/System/WatchtowerClientTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?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\Tests\Services\System;
|
||||
|
||||
use App\Services\System\WatchtowerClient;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
final class WatchtowerClientTest extends TestCase
|
||||
{
|
||||
private function createClient(string $url = 'http://watchtower:8080', string $token = 'test-token', ?HttpClientInterface $httpClient = null): WatchtowerClient
|
||||
{
|
||||
return new WatchtowerClient(
|
||||
$httpClient ?? $this->createMock(HttpClientInterface::class),
|
||||
new NullLogger(),
|
||||
$url,
|
||||
$token,
|
||||
);
|
||||
}
|
||||
|
||||
public function testIsConfiguredReturnsTrueWhenBothSet(): void
|
||||
{
|
||||
$client = $this->createClient('http://watchtower:8080', 'my-token');
|
||||
$this->assertTrue($client->isConfigured());
|
||||
}
|
||||
|
||||
public function testIsConfiguredReturnsFalseWhenUrlEmpty(): void
|
||||
{
|
||||
$client = $this->createClient('', 'my-token');
|
||||
$this->assertFalse($client->isConfigured());
|
||||
}
|
||||
|
||||
public function testIsConfiguredReturnsFalseWhenTokenEmpty(): void
|
||||
{
|
||||
$client = $this->createClient('http://watchtower:8080', '');
|
||||
$this->assertFalse($client->isConfigured());
|
||||
}
|
||||
|
||||
public function testIsConfiguredReturnsFalseWhenBothEmpty(): void
|
||||
{
|
||||
$client = $this->createClient('', '');
|
||||
$this->assertFalse($client->isConfigured());
|
||||
}
|
||||
|
||||
public function testIsAvailableReturnsFalseWhenNotConfigured(): void
|
||||
{
|
||||
$client = $this->createClient('', '');
|
||||
$this->assertFalse($client->isAvailable());
|
||||
}
|
||||
|
||||
public function testIsAvailableReturnsTrueOnSuccessResponse(): void
|
||||
{
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
$response->method('getStatusCode')->willReturn(200);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->expects($this->once())
|
||||
->method('request')
|
||||
->with('GET', 'http://watchtower:8080/v1/update', $this->callback(function (array $options) {
|
||||
return $options['headers']['Authorization'] === 'Bearer test-token'
|
||||
&& $options['timeout'] === 3;
|
||||
}))
|
||||
->willReturn($response);
|
||||
|
||||
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
|
||||
$this->assertTrue($client->isAvailable());
|
||||
}
|
||||
|
||||
public function testIsAvailableReturnsTrueOn401(): void
|
||||
{
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
$response->method('getStatusCode')->willReturn(401);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->method('request')->willReturn($response);
|
||||
|
||||
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
|
||||
$this->assertTrue($client->isAvailable());
|
||||
}
|
||||
|
||||
public function testIsAvailableReturnsFalseOn500(): void
|
||||
{
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
$response->method('getStatusCode')->willReturn(500);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->method('request')->willReturn($response);
|
||||
|
||||
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
|
||||
$this->assertFalse($client->isAvailable());
|
||||
}
|
||||
|
||||
public function testIsAvailableReturnsFalseOnException(): void
|
||||
{
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->method('request')->willThrowException(new \RuntimeException('Connection refused'));
|
||||
|
||||
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
|
||||
$this->assertFalse($client->isAvailable());
|
||||
}
|
||||
|
||||
public function testTriggerUpdateThrowsWhenNotConfigured(): void
|
||||
{
|
||||
$client = $this->createClient('', '');
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Watchtower is not configured');
|
||||
$client->triggerUpdate();
|
||||
}
|
||||
|
||||
public function testTriggerUpdateReturnsTrueOnSuccess(): void
|
||||
{
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
$response->method('getStatusCode')->willReturn(200);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->expects($this->once())
|
||||
->method('request')
|
||||
->with('POST', 'http://watchtower:8080/v1/update', $this->callback(function (array $options) {
|
||||
return $options['headers']['Authorization'] === 'Bearer test-token'
|
||||
&& $options['timeout'] === 10;
|
||||
}))
|
||||
->willReturn($response);
|
||||
|
||||
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
|
||||
$this->assertTrue($client->triggerUpdate());
|
||||
}
|
||||
|
||||
public function testTriggerUpdateReturnsTrueOn202(): void
|
||||
{
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
$response->method('getStatusCode')->willReturn(202);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->method('request')->willReturn($response);
|
||||
|
||||
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
|
||||
$this->assertTrue($client->triggerUpdate());
|
||||
}
|
||||
|
||||
public function testTriggerUpdateReturnsFalseOnServerError(): void
|
||||
{
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
$response->method('getStatusCode')->willReturn(500);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->method('request')->willReturn($response);
|
||||
|
||||
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
|
||||
$this->assertFalse($client->triggerUpdate());
|
||||
}
|
||||
|
||||
public function testTriggerUpdateReturnsFalseOnException(): void
|
||||
{
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->method('request')->willThrowException(new \RuntimeException('Network error'));
|
||||
|
||||
$client = $this->createClient('http://watchtower:8080', 'test-token', $httpClient);
|
||||
$this->assertFalse($client->triggerUpdate());
|
||||
}
|
||||
|
||||
public function testUrlTrailingSlashIsNormalized(): void
|
||||
{
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
$response->method('getStatusCode')->willReturn(200);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->expects($this->once())
|
||||
->method('request')
|
||||
->with('GET', 'http://watchtower:8080/v1/update', $this->anything())
|
||||
->willReturn($response);
|
||||
|
||||
$client = $this->createClient('http://watchtower:8080/', 'test-token', $httpClient);
|
||||
$client->isAvailable();
|
||||
}
|
||||
}
|
||||
59
translations/frontend.pt_BR.xlf
Normal file
59
translations/frontend.pt_BR.xlf
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="pt-BR">
|
||||
<file id="frontend.en">
|
||||
<unit id="eLrezdb" name="search.placeholder">
|
||||
<segment state="translated">
|
||||
<source>search.placeholder</source>
|
||||
<target>Pesquisar</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="R4hoCqe" name="part.labelp">
|
||||
<segment state="translated">
|
||||
<source>part.labelp</source>
|
||||
<target>Componentes</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB">
|
||||
<segment state="translated">
|
||||
<source>entity.select.group.new_not_added_to_DB</source>
|
||||
<target>Novo (não adicionado ainda no DB)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9rnHbSK" name="user.password_strength.very_weak">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.very_weak</source>
|
||||
<target>Muito fraca</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="gKHmHwM" name="user.password_strength.weak">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.weak</source>
|
||||
<target>Fraca</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="c44gN8b" name="user.password_strength.medium">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.medium</source>
|
||||
<target>Média</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NwiBLHc" name="user.password_strength.strong">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.strong</source>
|
||||
<target>Forte</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Bw.iCUm" name="user.password_strength.very_strong">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.very_strong</source>
|
||||
<target>Bem forte</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="U5IhkwB" name="search.submit">
|
||||
<segment state="translated">
|
||||
<source>search.submit</source>
|
||||
<target>Vá!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de">
|
||||
<file id="messages.de">
|
||||
<file id="messages.en">
|
||||
<unit id="x_wTSQS" name="attachment_type.caption">
|
||||
<segment state="translated">
|
||||
<source>attachment_type.caption</source>
|
||||
|
|
@ -2779,7 +2779,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
|||
<target>Name</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sIvAlUe" name="part.table.si_value">
|
||||
<unit id="A1bHPnR" name="part.table.si_value">
|
||||
<segment state="translated">
|
||||
<source>part.table.si_value</source>
|
||||
<target>SI-Wert</target>
|
||||
|
|
@ -7217,13 +7217,13 @@ Element 1 -> Element 1.2</target>
|
|||
<target>Unterprojekte</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="prjTtlBP" name="project.info.total_build_price">
|
||||
<unit id="_NstC62" name="project.info.total_build_price">
|
||||
<segment state="translated">
|
||||
<source>project.info.total_build_price</source>
|
||||
<target>Gesamterstellpreis</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="prjUntBP" name="project.info.per_unit_price">
|
||||
<unit id="Oof1G0D" name="project.info.per_unit_price">
|
||||
<segment state="translated">
|
||||
<source>project.info.per_unit_price</source>
|
||||
<target>pro Einheit</target>
|
||||
|
|
@ -7253,7 +7253,7 @@ Element 1 -> Element 1.2</target>
|
|||
<target>Preis</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<unit id="gLWQ4cF" name="project.bom.ext_price">
|
||||
<segment state="translated">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Gesamtpreis</target>
|
||||
|
|
@ -10052,85 +10052,85 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
|||
<target>Wenn aktiviert, verlinkt das Datenblatt-Feld in KiCad auf die tatsächliche PDF-Datei (sofern gefunden). Wenn deaktiviert, führt es stattdessen zur Part-DB-Seite. Der Link zur Part-DB-Seite ist immer als separates "Part-DB URL"-Feld verfügbar.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="e2e7mR1" name="settings.misc.kicad_eda.editor.title">
|
||||
<unit id="h2ChJ6Y" name="settings.misc.kicad_eda.editor.title">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.title</source>
|
||||
<target>KiCad Autovervollständigungslisten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qjv1VVx" name="settings.misc.kicad_eda.editor.link">
|
||||
<unit id="C97hNXL" name="settings.misc.kicad_eda.editor.link">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.link</source>
|
||||
<target>Autovervollständigungseinstellungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="f0qkcqg" name="settings.misc.kicad_eda.editor.description">
|
||||
<unit id="pJeX5wZ" name="settings.misc.kicad_eda.editor.description">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.description</source>
|
||||
<target>Konfigurieren Sie, ob KiCad Autovervollständigung die automatisch generierten Standardlisten oder Ihre benutzerdefinierten Überschreibungsdateien verwendet. Die benutzerdefinierten Dateien sind hier bearbeitbar, während die Standarddateien nur lesbar zur Referenz angezeigt werden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="AS3yDlb" name="settings.misc.kicad_eda.editor.footprints">
|
||||
<unit id="mumlQUV" name="settings.misc.kicad_eda.editor.footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.footprints</source>
|
||||
<target>Footprint-Liste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Jj_YR7n" name="settings.misc.kicad_eda.editor.footprints.help">
|
||||
<unit id="6VCC6T8" name="settings.misc.kicad_eda.editor.footprints.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.footprints.help</source>
|
||||
<target>Ein Eintrag pro Zeile. Wird als Autovervollständigungsvorschlag für KiCad-Footprintfelder verwendet.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ELd3KQK" name="settings.misc.kicad_eda.editor.symbols">
|
||||
<unit id="3EPsJaG" name="settings.misc.kicad_eda.editor.symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.symbols</source>
|
||||
<target>Symbolliste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="A9TOJgM" name="settings.misc.kicad_eda.editor.symbols.help">
|
||||
<unit id="8JyqD1f" name="settings.misc.kicad_eda.editor.symbols.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.symbols.help</source>
|
||||
<target>Ein Eintrag pro Zeile. Wird als Autovervollständigungsvorschlag für KiCad-Symbolfelder verwendet.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tWYlL0u" name="settings.misc.kicad_eda.use_custom_list">
|
||||
<unit id="Ops1y13" name="settings.misc.kicad_eda.use_custom_list">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.use_custom_list</source>
|
||||
<target>Benutzerdefinierte Autovervollständigungslisten verwenden</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="v0LK7n6" name="settings.misc.kicad_eda.use_custom_list.help">
|
||||
<unit id="AjQJzDB" name="settings.misc.kicad_eda.use_custom_list.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.use_custom_list.help</source>
|
||||
<target>Wenn aktiviert, verwendet die KiCad Autovervollständigung public/kicad/footprints_custom.txt und public/kicad/symbols_custom.txt anstelle der automatisch generierten Standarddateien.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Yl_fqfV" name="settings.misc.kicad_eda.editor.custom_footprints">
|
||||
<unit id="TfJvNLm" name="settings.misc.kicad_eda.editor.custom_footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.custom_footprints</source>
|
||||
<target>Benutzerdefinierte Footprint-Liste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GuD2JcQ" name="settings.misc.kicad_eda.editor.custom_symbols">
|
||||
<unit id="6nsnYiB" name="settings.misc.kicad_eda.editor.custom_symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.custom_symbols</source>
|
||||
<target>Benutzerdefinierte Symbolliste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k6m9b5F" name="settings.misc.kicad_eda.editor.default_footprints">
|
||||
<unit id="bABze6_" name="settings.misc.kicad_eda.editor.default_footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_footprints</source>
|
||||
<target>Standard Footprint-Liste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bKkF8mM" name="settings.misc.kicad_eda.editor.default_symbols">
|
||||
<unit id="3Ycxg5M" name="settings.misc.kicad_eda.editor.default_symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_symbols</source>
|
||||
<target>Standardsymboliste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="mIj_i4E" name="settings.misc.kicad_eda.editor.default_files_help">
|
||||
<unit id="ADK3.8x" name="settings.misc.kicad_eda.editor.default_files_help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_files_help</source>
|
||||
<target>Automatisch generierte Datei wird nur zur Referenz angezeigt. Änderungen müssen in der benutzerdefinierten Liste vorgenommen werden.</target>
|
||||
|
|
@ -11210,6 +11210,96 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
|||
<target>Bauteil aktualisieren</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_sWGLGs" name="info_providers.bulk_import.back_to_jobs">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.back_to_jobs</source>
|
||||
<target>Zurück zu Jobs</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2DCRx_T" name="info_providers.bulk_import.back_to_parts">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.back_to_parts</source>
|
||||
<target>Zurück zu Bauteilen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9OAohXg" name="info_providers.bulk_import.job_completed">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.job_completed</source>
|
||||
<target>Auftrag abgeschlossen!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hwkbU38" name="info_providers.bulk_import.job_completed.description">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.job_completed.description</source>
|
||||
<target>Alle Bauteile wurden verarbeitet. Sie können die Ergebnisse unten überprüfen oder zur Bauteileliste zurückkehren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ahbWfwA" name="info_providers.bulk_import.recommended">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.recommended</source>
|
||||
<target>Top</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tFJOMYX" name="info_providers.bulk_import.exact_match">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.exact_match</source>
|
||||
<target>Exakte Namensübereinstimmung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="mBAxdTx" name="info_providers.bulk_import.mpn_match">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.mpn_match</source>
|
||||
<target>MPN-Übereinstimmungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="W1HbYWX" name="info_providers.bulk_import.active_jobs">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.active_jobs</source>
|
||||
<target>Aktive Jobs</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tZSOzU1" name="info_providers.bulk_import.finished_jobs">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.finished_jobs</source>
|
||||
<target>Verlauf</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="noEU4s7" name="info_providers.bulk_import.spn_match">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.spn_match</source>
|
||||
<target>SPN-Übereinstimmungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="RiHOuLh" name="info_providers.bulk_import.match">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.match</source>
|
||||
<target>Übereinstimmung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="UCKGkQ3" name="info_providers.bulk_import.quick_apply">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.quick_apply</source>
|
||||
<target>Schnellanwendung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4uMgGbn" name="info_providers.bulk_import.quick_apply.tooltip">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.quick_apply.tooltip</source>
|
||||
<target>Dieses Anbietergebnis auf das Bauteil anwenden, ohne das Bearbeitungsformular zu öffnen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="a8kwuvb" name="info_providers.bulk_import.quick_apply_all">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.quick_apply_all</source>
|
||||
<target>Alle anwenden (Top Ergebnisse)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".iZc63I" name="info_providers.bulk_import.quick_apply_all.tooltip">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.quick_apply_all.tooltip</source>
|
||||
<target>Das bestplatzierte Suchergebnis auf alle ausstehenden Teile ohne einzelne Überprüfung anwenden</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="e_DDQ2u" name="info_providers.bulk_import.prefetch_details">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.prefetch_details</source>
|
||||
|
|
@ -13053,6 +13143,312 @@ Buerklin-API-Authentication-Server:
|
|||
<target>Backup-Download erlaubt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ld698rE" name="update_manager.docker.setup_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_title</source>
|
||||
<target>Aktivieren Sie Docker-Updates mit einem Klick über Watchtower</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="A_UDLkn" name="update_manager.docker.setup_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_description</source>
|
||||
<target>Part-DB kann Ihre Docker-Container automatisch mit Watchtower aktualisieren, einem Open-Source-Container-Updater. Fügen Sie Watchtower als Begleitcontainer hinzu und konfigurieren Sie die Verbindung unten.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="p092aHv" name="update_manager.docker.setup_step1">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_step1</source>
|
||||
<target>1. Fügen Sie Watchtower zu Ihrer docker-compose.yml hinzu:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VVejRLi" name="update_manager.docker.setup_step2">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_step2</source>
|
||||
<target>2. Fügen Sie diese Umgebungsvariablen zu Ihrem Part-DB Container hinzu:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3tlczB." name="update_manager.docker.setup_network_hint">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_network_hint</source>
|
||||
<target>Stellen Sie sicher, dass Part-DB und Watchtower im selben Docker-Netzwerk sind. Wenn Sie label-basierte Filterung in Watchtower verwenden (WATCHTOWER_LABEL_ENABLE=true), fügen Sie dem Part-DB Container das Label "com.centurylinklabs.watchtower.enable=true" hinzu.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5ZI3hh9" name="update_manager.docker.watchtower_unreachable_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_unreachable_title</source>
|
||||
<target>Watchtower nicht erreichbar</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".0wFRZ." name="update_manager.docker.watchtower_unreachable_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_unreachable_description</source>
|
||||
<target>Watchtower ist konfiguriert, kann aber nicht erreicht werden. Bitte prüfen Sie, ob der Watchtower-Container läuft und ob URL und Token korrekt sind.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PXadA3E" name="update_manager.docker.confirm_update">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.confirm_update</source>
|
||||
<target>Sind Sie sicher, dass Sie Part-DB über Watchtower aktualisieren möchten? Der Container wird mit dem neuen Image neu gestartet. Im Gegensatz zu Git-Updates können Docker-Updates nicht automatisch zurückgesetzt werden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zVnBU_y" name="update_manager.docker.update_via_watchtower">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.update_via_watchtower</source>
|
||||
<target>Update über Watchtower zu</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="rygIXG_" name="update_manager.docker.no_rollback_warning">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.no_rollback_warning</source>
|
||||
<target>Docker-Updates können nicht automatisch zurückgesetzt werden. Vor dem Update wird eine Datenbanksicherung erstellt, damit Sie Ihre Daten bei Bedarf wiederherstellen können.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6YSYc7u" name="update_manager.docker.progress_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.progress_title</source>
|
||||
<target>Docker-Update läuft</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4zjA6Z2" name="update_manager.docker.waiting_for_watchtower">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.waiting_for_watchtower</source>
|
||||
<target>Warte darauf, dass Watchtower das neue Image zieht...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="N4ayoEp" name="update_manager.docker.elapsed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.elapsed</source>
|
||||
<target>Verstrichen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="B2piu_D" name="update_manager.docker.waiting_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.waiting_title</source>
|
||||
<target>Update gestartet</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="K7uq04F" name="update_manager.docker.waiting_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.waiting_description</source>
|
||||
<target>Watchtower wurde benachrichtigt. Es wird das neueste Docker-Image ziehen und den Part-DB-Container neu starten.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nYC4Dw9" name="update_manager.docker.watchtower_working">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_working</source>
|
||||
<target>Watchtower verarbeitet das Update...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="IPd5mwk" name="update_manager.docker.watchtower_working_hint">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_working_hint</source>
|
||||
<target>Je nach Internetgeschwindigkeit und Imagegröße kann dies einige Minuten dauern.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Q_XPcbn" name="update_manager.docker.restarting_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.restarting_title</source>
|
||||
<target>Container wird neu gestartet</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="fxJe7Uc" name="update_manager.docker.restarting_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.restarting_description</source>
|
||||
<target>Watchtower hat das neue Image gezogen und startet den Part-DB-Container neu.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="fgtq0OK" name="update_manager.docker.restarting_hint">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.restarting_hint</source>
|
||||
<target>Die Seite erkennt automatisch, wenn der Server wieder online ist. Dies dauert normalerweise 10-30 Sekunden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZskYT6q" name="update_manager.docker.success_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.success_title</source>
|
||||
<target>Update abgeschlossen!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="lIi.I9w" name="update_manager.docker.success_message">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.success_message</source>
|
||||
<target>Part-DB wurde erfolgreich über Watchtower aktualisiert.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="RqtjuI7" name="update_manager.docker.previous_version">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.previous_version</source>
|
||||
<target>Vorherige Version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="KLNv9FX" name="update_manager.docker.new_version">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.new_version</source>
|
||||
<target>Neue Version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="EyK.Bvs" name="update_manager.docker.back_to_update_manager">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.back_to_update_manager</source>
|
||||
<target>Zurück zum Update-Manager</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qXEyzbC" name="update_manager.docker.go_to_homepage">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.go_to_homepage</source>
|
||||
<target>Zur Startseite</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zLbfHFP" name="update_manager.docker.timeout_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.timeout_title</source>
|
||||
<target>Update dauert länger als erwartet</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="fbpbM4e" name="update_manager.docker.timeout_message">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.timeout_message</source>
|
||||
<target>Das Update dauert länger als erwartet. Prüfen Sie die Watchtower-Container-Logs für Details. Das Update kann noch laufen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="eTDf1ym" name="update_manager.docker.retry">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.retry</source>
|
||||
<target>Erneut versuchen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BTQH5SL" name="update_manager.docker.warning">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.warning</source>
|
||||
<target>Warnung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5A6K7gA" name="update_manager.docker.do_not_close">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.do_not_close</source>
|
||||
<target>Schließen Sie diese Seite nicht. Sie erkennt automatisch, wenn das Update abgeschlossen ist.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="JOGLw48" name="update_manager.docker.updating_via_watchtower">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.updating_via_watchtower</source>
|
||||
<target>Update über Watchtower wird durchgeführt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="L1aNTz2" name="update_manager.docker.step_waiting">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_waiting</source>
|
||||
<target>Image wird gezogen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="K8G9Omp" name="update_manager.docker.steps">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.steps</source>
|
||||
<target>Update-Schritte</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="rwPMohr" name="update_manager.docker.step_trigger">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_trigger</source>
|
||||
<target>Update auslösen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="C6T6dki" name="update_manager.docker.step_trigger_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_trigger_desc</source>
|
||||
<target>Watchtower wurde benachrichtigt, nach Updates zu suchen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sHx0nZN" name="update_manager.docker.step_pull">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_pull</source>
|
||||
<target>Neues Image ziehen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VC7sifr" name="update_manager.docker.step_pull_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_pull_desc</source>
|
||||
<target>Lade das neueste Docker-Image aus dem Register</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="H5.XUFa" name="update_manager.docker.step_restart">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_restart</source>
|
||||
<target>Container neu starten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="KRNQZKS" name="update_manager.docker.step_restart_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_restart_desc</source>
|
||||
<target>Alten Container stoppen und neuen starten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="y5ibGqB" name="update_manager.docker.step_verify">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_verify</source>
|
||||
<target>Verifizieren</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".D286hQ" name="update_manager.docker.step_verify_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_verify_desc</source>
|
||||
<target>Bestätige, dass Part-DB mit der neuen Version läuft</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="s72BGA8" name="update_manager.docker.watchtower_status">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_status</source>
|
||||
<target>Watchtower</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="HRw317s" name="update_manager.docker.watchtower_connected">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_connected</source>
|
||||
<target>Verbunden</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="HcEk0Mh" name="update_manager.docker.watchtower_unreachable_short">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_unreachable_short</source>
|
||||
<target>Nicht erreichbar</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="lMwRNf9" name="update_manager.docker.watchtower_not_configured">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_not_configured</source>
|
||||
<target>Nicht konfiguriert</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="IceG9y3" name="update_manager.docker.step_stop">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_stop</source>
|
||||
<target>Container stoppen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="0sq_TgW" name="update_manager.docker.step_stop_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_stop_desc</source>
|
||||
<target>Aktuellen Container vor der Neuerstellung ordentlich stoppen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="et8F1eO" name="update_manager.docker.step_health">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_health</source>
|
||||
<target>Health Check</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="v5tHPJK" name="update_manager.docker.step_health_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_health_desc</source>
|
||||
<target>Warte darauf, dass der neue Container den Health Check besteht</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZLRHnm_" name="update_manager.docker.updating">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.updating</source>
|
||||
<target>Part-DB wird über Docker aktualisiert...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="b8JxfcX" name="part.create_from_info_provider.lot_filled_from_barcode">
|
||||
<segment state="translated">
|
||||
<source>part.create_from_info_provider.lot_filled_from_barcode</source>
|
||||
|
|
@ -13065,5 +13461,149 @@ Buerklin-API-Authentication-Server:
|
|||
<target>Zuordnungsfehler: Bitte prüfen Sie, ob Sie das richtige Trennzeichen ausgewählt haben!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zl1XJq0" name="settings.ai">
|
||||
<segment state="translated">
|
||||
<source>settings.ai</source>
|
||||
<target>KI</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NqxqdyX" name="settings.ai.openrouter">
|
||||
<segment state="translated">
|
||||
<source>settings.ai.openrouter</source>
|
||||
<target>OpenRouter</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tWtvoDT" name="settings.ai.lmstudio">
|
||||
<segment state="translated">
|
||||
<source>settings.ai.lmstudio</source>
|
||||
<target>LMStudio</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="foBzBG2" name="settings.ips.ai_extractor.model">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.model</source>
|
||||
<target>KI-Modell</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PbXBTZO" name="settings.ips.ai_extractor.ai_platform">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.ai_platform</source>
|
||||
<target>KI-Plattform</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="axA7_TL" name="settings.ips.ai_extractor.model.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.model.help</source>
|
||||
<target>Das KI-Modell, das für die Extraktion verwendet werden soll. Muss strukturierte Ausgaben unterstützen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="H1SYgGs" name="settings.ips.ai_extractor.max_content_length">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.max_content_length</source>
|
||||
<target>Maximale Webseitentextlänge</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="SZWiZE3" name="settings.ips.ai_extractor.max_content_length.description">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.max_content_length.description</source>
|
||||
<target>Die maximale Anzahl an Zeichen der Webseite, die an den KI-Dienst gesendet werden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="pCsAHOv" name="settings.ips.ai_extractor.output_language">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.output_language</source>
|
||||
<target>Ausgabesprache</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NVHHgpD" name="settings.ips.ai_extractor.output_language.description">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.output_language.description</source>
|
||||
<target>Standardmäßig liefern die Anbieter Informationen in der gleichen Sprache wie die Webseite. Mit dieser Option können Sie die KI bitten, für Sie zu übersetzen. Funktioniert möglicherweise nur mit bestimmten Modellen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="CAeeZlL" name="settings.ips.ai_extractor.additional_instructions">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.additional_instructions</source>
|
||||
<target>Zusätzliche Anweisungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".UaUMk1" name="settings.ips.ai_extractor.additional_instructions.description">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.additional_instructions.description</source>
|
||||
<target>Die zusätzlichen Anweisungen werden an den System-Prompt angehängt.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ycfssj2" name="info_providers.search.advanced_options">
|
||||
<segment state="translated">
|
||||
<source>info_providers.search.advanced_options</source>
|
||||
<target>Erweiterte Optionen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xfxZrXn" name="info_providers.no_cache_search">
|
||||
<segment state="translated">
|
||||
<source>info_providers.no_cache_search</source>
|
||||
<target>Suchergebnisse nicht zwischenspeichern / Neue Suche erzwingen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="b_oc3T1" name="info_providers.no_cache_details">
|
||||
<segment state="translated">
|
||||
<source>info_providers.no_cache_details</source>
|
||||
<target>Ergebnisdetails nicht zwischenspeichern / Neue Detailabfrage erzwingen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ja8CCDb" name="info_providers.from_url.method.generic_web">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.method.generic_web</source>
|
||||
<target>Klassischer Web-Scraper</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="s0kUzFW" name="info_providers.from_url.method.ai_web">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.method.ai_web</source>
|
||||
<target>KI Web-Scraper</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="HEFB1OC" name="info_providers.from_url.method">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.method</source>
|
||||
<target>Methode</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9pG0VtU" name="info_providers.from_url.no_cache">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.no_cache</source>
|
||||
<target>Cache ignorieren / Neue Infoabfrage erzwingen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="302Jgvm" name="info_providers.from_url.skip_delegation">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.skip_delegation</source>
|
||||
<target>Nicht an spezialisierte Infoanbieter delegieren</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="pruvlK8" name="settings.ips.ai_extractor">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor</source>
|
||||
<target>KI Web Extraktor</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wILs7pS" name="settings.ips.ai_extractor.description">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.description</source>
|
||||
<target>Dieser Infoanbieter verwendet ein großes Sprachmodell (LLM), um detaillierte Teileinformationen von beliebigen Shop-URLs zu extrahieren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="1ShZ.1i" name="settings.ai.openrouter.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ai.openrouter.help</source>
|
||||
<target>Zugriff auf viele KI-Modelle über openrouter.ai</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kRcgIf0" name="settings.ai.lmstudio.hosturl">
|
||||
<segment state="translated">
|
||||
<source>settings.ai.lmstudio.hosturl</source>
|
||||
<target>Host-URL</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
|
|||
|
|
@ -2780,7 +2780,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
|
|||
<target>Name</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sIvAlUe" name="part.table.si_value">
|
||||
<unit id="A1bHPnR" name="part.table.si_value">
|
||||
<segment state="translated">
|
||||
<source>part.table.si_value</source>
|
||||
<target>SI Value</target>
|
||||
|
|
@ -7218,13 +7218,13 @@ Element 1 -> Element 1.2</target>
|
|||
<target>Subprojects</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="prjTtlBP" name="project.info.total_build_price">
|
||||
<unit id="_NstC62" name="project.info.total_build_price">
|
||||
<segment state="translated">
|
||||
<source>project.info.total_build_price</source>
|
||||
<target>Total build price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="prjUntBP" name="project.info.per_unit_price">
|
||||
<unit id="Oof1G0D" name="project.info.per_unit_price">
|
||||
<segment state="translated">
|
||||
<source>project.info.per_unit_price</source>
|
||||
<target>per unit</target>
|
||||
|
|
@ -7254,7 +7254,7 @@ Element 1 -> Element 1.2</target>
|
|||
<target>Price</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||
<unit id="gLWQ4cF" name="project.bom.ext_price">
|
||||
<segment state="translated">
|
||||
<source>project.bom.ext_price</source>
|
||||
<target>Extended Price</target>
|
||||
|
|
@ -10053,85 +10053,85 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
|||
<target>When enabled, the datasheet field in KiCad will link to the actual PDF file (if found). When disabled, it will link to the Part-DB page instead. The Part-DB page link is always available as a separate "Part-DB URL" field.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="e2e7mR1" name="settings.misc.kicad_eda.editor.title">
|
||||
<unit id="h2ChJ6Y" name="settings.misc.kicad_eda.editor.title">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.title</source>
|
||||
<target>KiCad autocomplete lists</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qjv1VVx" name="settings.misc.kicad_eda.editor.link">
|
||||
<unit id="C97hNXL" name="settings.misc.kicad_eda.editor.link">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.link</source>
|
||||
<target>Autocomplete settings</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="f0qkcqg" name="settings.misc.kicad_eda.editor.description">
|
||||
<unit id="pJeX5wZ" name="settings.misc.kicad_eda.editor.description">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.description</source>
|
||||
<target>Configure whether KiCad autocomplete uses the autogenerated default lists or your custom override files. The custom files are editable here, while the default files are shown read-only for reference.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="AS3yDlb" name="settings.misc.kicad_eda.editor.footprints">
|
||||
<unit id="mumlQUV" name="settings.misc.kicad_eda.editor.footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.footprints</source>
|
||||
<target>Footprints list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Jj_YR7n" name="settings.misc.kicad_eda.editor.footprints.help">
|
||||
<unit id="6VCC6T8" name="settings.misc.kicad_eda.editor.footprints.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.footprints.help</source>
|
||||
<target>One entry per line. Used as autocomplete suggestions for KiCad footprint fields.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ELd3KQK" name="settings.misc.kicad_eda.editor.symbols">
|
||||
<unit id="3EPsJaG" name="settings.misc.kicad_eda.editor.symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.symbols</source>
|
||||
<target>Symbols list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="A9TOJgM" name="settings.misc.kicad_eda.editor.symbols.help">
|
||||
<unit id="8JyqD1f" name="settings.misc.kicad_eda.editor.symbols.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.symbols.help</source>
|
||||
<target>One entry per line. Used as autocomplete suggestions for KiCad symbol fields.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tWYlL0u" name="settings.misc.kicad_eda.use_custom_list">
|
||||
<unit id="Ops1y13" name="settings.misc.kicad_eda.use_custom_list">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.use_custom_list</source>
|
||||
<target>Use custom autocomplete lists</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="v0LK7n6" name="settings.misc.kicad_eda.use_custom_list.help">
|
||||
<unit id="AjQJzDB" name="settings.misc.kicad_eda.use_custom_list.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.use_custom_list.help</source>
|
||||
<target>When enabled, KiCad autocomplete uses public/kicad/footprints_custom.txt and public/kicad/symbols_custom.txt instead of the autogenerated default files.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Yl_fqfV" name="settings.misc.kicad_eda.editor.custom_footprints">
|
||||
<unit id="TfJvNLm" name="settings.misc.kicad_eda.editor.custom_footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.custom_footprints</source>
|
||||
<target>Custom footprints list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="GuD2JcQ" name="settings.misc.kicad_eda.editor.custom_symbols">
|
||||
<unit id="6nsnYiB" name="settings.misc.kicad_eda.editor.custom_symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.custom_symbols</source>
|
||||
<target>Custom symbols list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k6m9b5F" name="settings.misc.kicad_eda.editor.default_footprints">
|
||||
<unit id="bABze6_" name="settings.misc.kicad_eda.editor.default_footprints">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_footprints</source>
|
||||
<target>Default footprints list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="bKkF8mM" name="settings.misc.kicad_eda.editor.default_symbols">
|
||||
<unit id="3Ycxg5M" name="settings.misc.kicad_eda.editor.default_symbols">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_symbols</source>
|
||||
<target>Default symbols list</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="mIj_i4E" name="settings.misc.kicad_eda.editor.default_files_help">
|
||||
<unit id="ADK3.8x" name="settings.misc.kicad_eda.editor.default_files_help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.editor.default_files_help</source>
|
||||
<target>Autogenerated file shown for reference only. Changes must be made in the custom list.</target>
|
||||
|
|
@ -11211,6 +11211,96 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
|||
<target>Update Part</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_sWGLGs" name="info_providers.bulk_import.back_to_jobs">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.back_to_jobs</source>
|
||||
<target>Back to Jobs</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2DCRx_T" name="info_providers.bulk_import.back_to_parts">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.back_to_parts</source>
|
||||
<target>Back to Parts</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9OAohXg" name="info_providers.bulk_import.job_completed">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.job_completed</source>
|
||||
<target>Job completed!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hwkbU38" name="info_providers.bulk_import.job_completed.description">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.job_completed.description</source>
|
||||
<target>All parts have been processed. You can review the results below or navigate back to the parts list.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ahbWfwA" name="info_providers.bulk_import.recommended">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.recommended</source>
|
||||
<target>Top</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tFJOMYX" name="info_providers.bulk_import.exact_match">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.exact_match</source>
|
||||
<target>Exact name match</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="mBAxdTx" name="info_providers.bulk_import.mpn_match">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.mpn_match</source>
|
||||
<target>MPN matches</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="W1HbYWX" name="info_providers.bulk_import.active_jobs">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.active_jobs</source>
|
||||
<target>Active Jobs</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tZSOzU1" name="info_providers.bulk_import.finished_jobs">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.finished_jobs</source>
|
||||
<target>History</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="noEU4s7" name="info_providers.bulk_import.spn_match">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.spn_match</source>
|
||||
<target>SPN matches</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="RiHOuLh" name="info_providers.bulk_import.match">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.match</source>
|
||||
<target>Match</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="UCKGkQ3" name="info_providers.bulk_import.quick_apply">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.quick_apply</source>
|
||||
<target>Quick Apply</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4uMgGbn" name="info_providers.bulk_import.quick_apply.tooltip">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.quick_apply.tooltip</source>
|
||||
<target>Apply this provider result to the part without opening the edit form</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="a8kwuvb" name="info_providers.bulk_import.quick_apply_all">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.quick_apply_all</source>
|
||||
<target>Apply All (Top Results)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".iZc63I" name="info_providers.bulk_import.quick_apply_all.tooltip">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.quick_apply_all.tooltip</source>
|
||||
<target>Apply the top-ranked search result to all pending parts without individual review</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="e_DDQ2u" name="info_providers.bulk_import.prefetch_details">
|
||||
<segment state="translated">
|
||||
<source>info_providers.bulk_import.prefetch_details</source>
|
||||
|
|
@ -13055,6 +13145,312 @@ Buerklin-API Authentication server:
|
|||
<target>Backup download allowed</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ld698rE" name="update_manager.docker.setup_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_title</source>
|
||||
<target>Enable One-Click Docker Updates with Watchtower</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="A_UDLkn" name="update_manager.docker.setup_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_description</source>
|
||||
<target>Part-DB can update your Docker container automatically using Watchtower, an open-source container updater. Add Watchtower as a companion container and configure the connection below.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="p092aHv" name="update_manager.docker.setup_step1">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_step1</source>
|
||||
<target>1. Add Watchtower to your docker-compose.yml:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VVejRLi" name="update_manager.docker.setup_step2">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_step2</source>
|
||||
<target>2. Add these environment variables to your Part-DB container:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3tlczB." name="update_manager.docker.setup_network_hint">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.setup_network_hint</source>
|
||||
<target>Make sure Part-DB and Watchtower are on the same Docker network. If you use label-based filtering in Watchtower (WATCHTOWER_LABEL_ENABLE=true), add the label "com.centurylinklabs.watchtower.enable=true" to your Part-DB container.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5ZI3hh9" name="update_manager.docker.watchtower_unreachable_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_unreachable_title</source>
|
||||
<target>Watchtower Not Reachable</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".0wFRZ." name="update_manager.docker.watchtower_unreachable_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_unreachable_description</source>
|
||||
<target>Watchtower is configured but cannot be reached. Please verify that the Watchtower container is running and that the API URL and token are correct.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PXadA3E" name="update_manager.docker.confirm_update">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.confirm_update</source>
|
||||
<target>Are you sure you want to update Part-DB via Watchtower? The container will be restarted with the new image. Unlike Git updates, Docker updates cannot be automatically rolled back.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zVnBU_y" name="update_manager.docker.update_via_watchtower">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.update_via_watchtower</source>
|
||||
<target>Update via Watchtower to</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="rygIXG_" name="update_manager.docker.no_rollback_warning">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.no_rollback_warning</source>
|
||||
<target>Docker updates cannot be automatically rolled back. A database backup will be created before updating so you can restore your data if needed.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6YSYc7u" name="update_manager.docker.progress_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.progress_title</source>
|
||||
<target>Docker Update in Progress</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4zjA6Z2" name="update_manager.docker.waiting_for_watchtower">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.waiting_for_watchtower</source>
|
||||
<target>Waiting for Watchtower to pull the new image...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="N4ayoEp" name="update_manager.docker.elapsed">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.elapsed</source>
|
||||
<target>Elapsed</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="B2piu_D" name="update_manager.docker.waiting_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.waiting_title</source>
|
||||
<target>Update Triggered</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="K7uq04F" name="update_manager.docker.waiting_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.waiting_description</source>
|
||||
<target>Watchtower has been notified. It will pull the latest Docker image and restart the Part-DB container.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nYC4Dw9" name="update_manager.docker.watchtower_working">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_working</source>
|
||||
<target>Watchtower is processing the update...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="IPd5mwk" name="update_manager.docker.watchtower_working_hint">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_working_hint</source>
|
||||
<target>This may take a few minutes depending on your internet speed and image size.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Q_XPcbn" name="update_manager.docker.restarting_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.restarting_title</source>
|
||||
<target>Container Restarting</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="fxJe7Uc" name="update_manager.docker.restarting_description">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.restarting_description</source>
|
||||
<target>Watchtower has pulled the new image and is restarting the Part-DB container.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="fgtq0OK" name="update_manager.docker.restarting_hint">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.restarting_hint</source>
|
||||
<target>The page will automatically detect when the server comes back online. This usually takes 10-30 seconds.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZskYT6q" name="update_manager.docker.success_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.success_title</source>
|
||||
<target>Update Complete!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="lIi.I9w" name="update_manager.docker.success_message">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.success_message</source>
|
||||
<target>Part-DB has been successfully updated via Watchtower.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="RqtjuI7" name="update_manager.docker.previous_version">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.previous_version</source>
|
||||
<target>Previous version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="KLNv9FX" name="update_manager.docker.new_version">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.new_version</source>
|
||||
<target>New version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="EyK.Bvs" name="update_manager.docker.back_to_update_manager">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.back_to_update_manager</source>
|
||||
<target>Back to Update Manager</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qXEyzbC" name="update_manager.docker.go_to_homepage">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.go_to_homepage</source>
|
||||
<target>Go to Homepage</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zLbfHFP" name="update_manager.docker.timeout_title">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.timeout_title</source>
|
||||
<target>Update Taking Longer Than Expected</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="fbpbM4e" name="update_manager.docker.timeout_message">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.timeout_message</source>
|
||||
<target>The update is taking longer than expected. Check the Watchtower container logs for details. The update may still be in progress.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="eTDf1ym" name="update_manager.docker.retry">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.retry</source>
|
||||
<target>Retry</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BTQH5SL" name="update_manager.docker.warning">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.warning</source>
|
||||
<target>Warning</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5A6K7gA" name="update_manager.docker.do_not_close">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.do_not_close</source>
|
||||
<target>Do not close this page. It will automatically detect when the update is complete.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="JOGLw48" name="update_manager.docker.updating_via_watchtower">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.updating_via_watchtower</source>
|
||||
<target>Updating via Watchtower</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="L1aNTz2" name="update_manager.docker.step_waiting">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_waiting</source>
|
||||
<target>Pulling Image</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="K8G9Omp" name="update_manager.docker.steps">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.steps</source>
|
||||
<target>Update Steps</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="rwPMohr" name="update_manager.docker.step_trigger">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_trigger</source>
|
||||
<target>Trigger Update</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="C6T6dki" name="update_manager.docker.step_trigger_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_trigger_desc</source>
|
||||
<target>Watchtower has been notified to check for updates</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sHx0nZN" name="update_manager.docker.step_pull">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_pull</source>
|
||||
<target>Pull New Image</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VC7sifr" name="update_manager.docker.step_pull_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_pull_desc</source>
|
||||
<target>Downloading the latest Docker image from the registry</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="H5.XUFa" name="update_manager.docker.step_restart">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_restart</source>
|
||||
<target>Restart Container</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="KRNQZKS" name="update_manager.docker.step_restart_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_restart_desc</source>
|
||||
<target>Stopping old container and starting new one</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="y5ibGqB" name="update_manager.docker.step_verify">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_verify</source>
|
||||
<target>Verify</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".D286hQ" name="update_manager.docker.step_verify_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_verify_desc</source>
|
||||
<target>Confirming Part-DB is running on the new version</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="s72BGA8" name="update_manager.docker.watchtower_status">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_status</source>
|
||||
<target>Watchtower</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="HRw317s" name="update_manager.docker.watchtower_connected">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_connected</source>
|
||||
<target>Connected</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="HcEk0Mh" name="update_manager.docker.watchtower_unreachable_short">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_unreachable_short</source>
|
||||
<target>Unreachable</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="lMwRNf9" name="update_manager.docker.watchtower_not_configured">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.watchtower_not_configured</source>
|
||||
<target>Not configured</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="IceG9y3" name="update_manager.docker.step_stop">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_stop</source>
|
||||
<target>Stop Container</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="0sq_TgW" name="update_manager.docker.step_stop_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_stop_desc</source>
|
||||
<target>Gracefully stopping the current container before recreation</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="et8F1eO" name="update_manager.docker.step_health">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_health</source>
|
||||
<target>Health Check</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="v5tHPJK" name="update_manager.docker.step_health_desc">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.step_health_desc</source>
|
||||
<target>Waiting for the new container to pass health checks</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZLRHnm_" name="update_manager.docker.updating">
|
||||
<segment state="translated">
|
||||
<source>update_manager.docker.updating</source>
|
||||
<target>Updating Part-DB via Docker...</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="b8JxfcX" name="part.create_from_info_provider.lot_filled_from_barcode">
|
||||
<segment state="translated">
|
||||
<source>part.create_from_info_provider.lot_filled_from_barcode</source>
|
||||
|
|
@ -13067,5 +13463,149 @@ Buerklin-API Authentication server:
|
|||
<target>Mapping error: Check if you have selected the right delimiter!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zl1XJq0" name="settings.ai">
|
||||
<segment state="translated">
|
||||
<source>settings.ai</source>
|
||||
<target>AI</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NqxqdyX" name="settings.ai.openrouter">
|
||||
<segment state="translated">
|
||||
<source>settings.ai.openrouter</source>
|
||||
<target>OpenRouter</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tWtvoDT" name="settings.ai.lmstudio">
|
||||
<segment state="translated">
|
||||
<source>settings.ai.lmstudio</source>
|
||||
<target>LMStudio</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="foBzBG2" name="settings.ips.ai_extractor.model">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.model</source>
|
||||
<target>AI Model</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="PbXBTZO" name="settings.ips.ai_extractor.ai_platform">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.ai_platform</source>
|
||||
<target>AI Platform</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="axA7_TL" name="settings.ips.ai_extractor.model.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.model.help</source>
|
||||
<target>The AI model that should be used for extraction. Must support structured output.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="H1SYgGs" name="settings.ips.ai_extractor.max_content_length">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.max_content_length</source>
|
||||
<target>Max. Website Content length</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="SZWiZE3" name="settings.ips.ai_extractor.max_content_length.description">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.max_content_length.description</source>
|
||||
<target>The maximum number of characters of the website that are sent to the AI service.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="pCsAHOv" name="settings.ips.ai_extractor.output_language">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.output_language</source>
|
||||
<target>Output language</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NVHHgpD" name="settings.ips.ai_extractor.output_language.description">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.output_language.description</source>
|
||||
<target>By default, the providers returns information in the same language as the website. With that option you can ask the AI to translate it for you. Might only work with certain models.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="CAeeZlL" name="settings.ips.ai_extractor.additional_instructions">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.additional_instructions</source>
|
||||
<target>Additional instructions</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".UaUMk1" name="settings.ips.ai_extractor.additional_instructions.description">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.additional_instructions.description</source>
|
||||
<target>The additional instructions will be appended to the system prompt.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ycfssj2" name="info_providers.search.advanced_options">
|
||||
<segment state="translated">
|
||||
<source>info_providers.search.advanced_options</source>
|
||||
<target>Advanced options</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xfxZrXn" name="info_providers.no_cache_search">
|
||||
<segment state="translated">
|
||||
<source>info_providers.no_cache_search</source>
|
||||
<target>Do not cache search results / Force fresh search</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="b_oc3T1" name="info_providers.no_cache_details">
|
||||
<segment state="translated">
|
||||
<source>info_providers.no_cache_details</source>
|
||||
<target>Do not cache result details / Force fresh part detail retrieval</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ja8CCDb" name="info_providers.from_url.method.generic_web">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.method.generic_web</source>
|
||||
<target>Classic Web Scraper</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="s0kUzFW" name="info_providers.from_url.method.ai_web">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.method.ai_web</source>
|
||||
<target>AI Web Scraper</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="HEFB1OC" name="info_providers.from_url.method">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.method</source>
|
||||
<target>Method</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9pG0VtU" name="info_providers.from_url.no_cache">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.no_cache</source>
|
||||
<target>Ignore cache / Force fresh info retrieval</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="302Jgvm" name="info_providers.from_url.skip_delegation">
|
||||
<segment state="translated">
|
||||
<source>info_providers.from_url.skip_delegation</source>
|
||||
<target>Do not delegate to specialized info providers</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="pruvlK8" name="settings.ips.ai_extractor">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor</source>
|
||||
<target>AI Web Extractor</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wILs7pS" name="settings.ips.ai_extractor.description">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.ai_extractor.description</source>
|
||||
<target>This info provider uses an large language model (LLM) to extract detailed part information from arbitary shop URLs.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="1ShZ.1i" name="settings.ai.openrouter.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ai.openrouter.help</source>
|
||||
<target>Access to many AI models via openrouter.ai</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kRcgIf0" name="settings.ai.lmstudio.hosturl">
|
||||
<segment state="translated">
|
||||
<source>settings.ai.lmstudio.hosturl</source>
|
||||
<target>Host URL</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
|
|||
23
translations/security.pt_BR.xlf
Normal file
23
translations/security.pt_BR.xlf
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="pt-BR">
|
||||
<file id="security.en">
|
||||
<unit id="GrLNa9P" name="user.login_error.user_disabled">
|
||||
<segment state="translated">
|
||||
<source>user.login_error.user_disabled</source>
|
||||
<target>Sua conta está desativada! Fale com um administrador se você acredita ser um erro.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="IFQ5XrG" name="saml.error.cannot_login_local_user_per_saml">
|
||||
<segment state="translated">
|
||||
<source>saml.error.cannot_login_local_user_per_saml</source>
|
||||
<target>Você não pode fazer o login na conta com um usuário local por SSO! Use sua senha de usuário local.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wOYPZmb" name="saml.error.cannot_login_saml_user_locally">
|
||||
<segment state="translated">
|
||||
<source>saml.error.cannot_login_saml_user_locally</source>
|
||||
<target>Você não pode usar autenticação local como login como usuário SAML! Use seu login SSO.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
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