mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-20 16:52:41 +01:00
Compare commits
188 Commits
copilot/re
...
v2.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a51e57b7 | ||
|
|
cae0cd8ac1 | ||
|
|
f5841cc697 | ||
|
|
8104c474b7 | ||
|
|
dcdc990af1 | ||
|
|
aec53bd1dd | ||
|
|
81dde6fa68 | ||
|
|
b144f5e383 | ||
|
|
fd4eb72eb2 | ||
|
|
44204b9dbb | ||
|
|
7bffe66b73 | ||
|
|
061af28c48 | ||
|
|
851055bdb4 | ||
|
|
7d19ed3ca8 | ||
|
|
b48de83a32 | ||
|
|
518953ad45 | ||
|
|
ea748dc469 | ||
|
|
c027f9ab03 | ||
|
|
bc28eb9473 | ||
|
|
7eafa7da14 | ||
|
|
1601382b41 | ||
|
|
5ceadc8353 | ||
|
|
36e105afa8 | ||
|
|
c34acfe523 | ||
|
|
e83e7398a2 | ||
|
|
984529bc79 | ||
|
|
cad5261aba | ||
|
|
a755287c3b | ||
|
|
9ca1834d9b | ||
|
|
1a06432cec | ||
|
|
58d574a33a | ||
|
|
1adfec16e2 | ||
|
|
903716ad62 | ||
|
|
427778e4eb | ||
|
|
9b0841081b | ||
|
|
f327688f0a | ||
|
|
0e5a73b6f4 | ||
|
|
d06df4410d | ||
|
|
883e3b271d | ||
|
|
29a08d152a | ||
|
|
2b94ff952c | ||
|
|
7a856bf6f1 | ||
|
|
720c1e51e8 | ||
|
|
1ccc3ad440 | ||
|
|
68ff0721ce | ||
|
|
6dbead6d10 | ||
|
|
7ff07a7ab4 | ||
|
|
1bfd36ccf5 | ||
|
|
7e486a93c9 | ||
|
|
599145886b | ||
|
|
0826acbd52 | ||
|
|
04e8229799 | ||
|
|
a1396c6696 | ||
|
|
24f0f0d23c | ||
|
|
10acc2e130 | ||
|
|
47295bda29 | ||
|
|
f369e14f2f | ||
|
|
10c192edd1 | ||
|
|
6b27f3aa14 | ||
|
|
79f88c66d6 | ||
|
|
47c7ee9f07 | ||
|
|
909cab0044 | ||
|
|
722eb7ddab | ||
|
|
071f6f8591 | ||
|
|
7feba634b8 | ||
|
|
1213f82cdf | ||
|
|
d868225260 | ||
|
|
52be548170 | ||
|
|
73dbe64a83 | ||
|
|
b89e878871 | ||
|
|
14981200c8 | ||
|
|
8aadc0bb53 | ||
|
|
0eba4738ed | ||
|
|
a78ca675b3 | ||
|
|
6ac7a42cca | ||
|
|
a355bda9da | ||
|
|
584643d4ca | ||
|
|
2534c84039 | ||
|
|
ed39710f7f | ||
|
|
df3f069a76 | ||
|
|
c0babfa401 | ||
|
|
cd7cd6cdd3 | ||
|
|
6d224a4a9f | ||
|
|
fa04fface3 | ||
|
|
2f8553303d | ||
|
|
f168b2a83c | ||
|
|
98937974c9 | ||
|
|
6f4dad98d9 | ||
|
|
22cf04585b | ||
|
|
6628333675 | ||
|
|
fa4ae6345c | ||
|
|
1637fd63f4 | ||
|
|
0bfbbc961d | ||
|
|
97e3b0aa09 | ||
|
|
87352ca6f7 | ||
|
|
42fe781ef8 | ||
|
|
3ed62f5cee | ||
|
|
7ab33c859b | ||
|
|
705e71f1eb | ||
|
|
ae4c0786b2 | ||
|
|
3aad70934b | ||
|
|
e15d12c0bf | ||
|
|
ff7fa67682 | ||
|
|
2b723e05ff | ||
|
|
a8d2204c7f | ||
|
|
29050178bd | ||
|
|
af61772c88 | ||
|
|
b91cd44926 | ||
|
|
c476c98d56 | ||
|
|
fe458b7ff1 | ||
|
|
7b8f3aaf62 | ||
|
|
d93dfd577e | ||
|
|
4095d0fd49 | ||
|
|
6d3197497e | ||
|
|
f438a8b4cd | ||
|
|
56fa2a9396 | ||
|
|
3975a3ba61 | ||
|
|
aa9aedc5fd | ||
|
|
766ba07105 | ||
|
|
d0b827c2c3 | ||
|
|
cd7dbd5f7b | ||
|
|
8efbca798a | ||
|
|
dd6c20780b | ||
|
|
af81e15ef2 | ||
|
|
09cc2ba8ff | ||
|
|
131023da67 | ||
|
|
4636aa4e0d | ||
|
|
006cfd7b5d | ||
|
|
86f53b2956 | ||
|
|
1923abecdf | ||
|
|
a3d992a016 | ||
|
|
6402cfe619 | ||
|
|
ea71fcd120 | ||
|
|
82e3e31277 | ||
|
|
0d4f935b43 | ||
|
|
0205dd523b | ||
|
|
0a8199d81f | ||
|
|
3f6a6cc767 | ||
|
|
33a3dc6203 | ||
|
|
1cd0b459be | ||
|
|
6828ce5803 | ||
|
|
644a44e8e9 | ||
|
|
6c3e4d7880 | ||
|
|
aefb69c51e | ||
|
|
300ee33be2 | ||
|
|
64efca4786 | ||
|
|
ddbfc87ce1 | ||
|
|
3454fa51de | ||
|
|
343ad6beff | ||
|
|
d385303a52 | ||
|
|
00b35e3306 | ||
|
|
e0a25009d9 | ||
|
|
3f0e4b09ce | ||
|
|
96a37a0cb0 | ||
|
|
3e071f2b74 | ||
|
|
2157916e9b | ||
|
|
be35c36c58 | ||
|
|
7116c2ceb9 | ||
|
|
89322d329c | ||
|
|
c1d4ce77db | ||
|
|
bba3bd90a9 | ||
|
|
eaaf3ac75c | ||
|
|
8957e55a9e | ||
|
|
a232671302 | ||
|
|
5a53423594 | ||
|
|
390206f529 | ||
|
|
74862c7bb8 | ||
|
|
0e61a84ea6 | ||
|
|
3e380f82d2 | ||
|
|
a5d7a5f1d3 | ||
|
|
876cfc0375 | ||
|
|
641c8388c1 | ||
|
|
2f580c92d1 | ||
|
|
402edf096d | ||
|
|
f467002619 | ||
|
|
98b8c5b788 | ||
|
|
e0feda4e46 | ||
|
|
6fcdc0b0c3 | ||
|
|
225e347c24 | ||
|
|
fb805e2e0a | ||
|
|
8548237522 | ||
|
|
77819af9a8 | ||
|
|
68217f50c4 | ||
|
|
d42f728fad | ||
|
|
b1210bc3b5 | ||
|
|
045362de0e | ||
|
|
6a5039326c | ||
|
|
bee1542cce |
11
.env
11
.env
@@ -59,6 +59,17 @@ ERROR_PAGE_ADMIN_EMAIL=''
|
||||
# If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them...
|
||||
ERROR_PAGE_SHOW_HELP=1
|
||||
|
||||
###################################################################################
|
||||
# Update Manager settings
|
||||
###################################################################################
|
||||
|
||||
# Disable web-based updates from the Update Manager UI (0=enabled, 1=disabled).
|
||||
# When disabled, use the CLI command "php bin/console partdb:update" instead.
|
||||
DISABLE_WEB_UPDATES=1
|
||||
|
||||
# Disable backup restore from the Update Manager UI (0=enabled, 1=disabled).
|
||||
# Restoring backups is a destructive operation that could overwrite your database.
|
||||
DISABLE_BACKUP_RESTORE=1
|
||||
|
||||
###################################################################################
|
||||
# SAML Single sign on-settings
|
||||
|
||||
8
.github/workflows/assets_artifact_build.yml
vendored
8
.github/workflows/assets_artifact_build.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
@@ -80,13 +80,13 @@ jobs:
|
||||
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
|
||||
|
||||
- name: Upload assets artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Only dependencies and built assets
|
||||
path: /tmp/partdb_assets.zip
|
||||
|
||||
- name: Upload full artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Full Part-DB including dependencies and built assets
|
||||
path: /tmp/partdb_with_assets.zip
|
||||
|
||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
|
||||
51
Dockerfile
51
Dockerfile
@@ -1,40 +1,6 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
ARG PHP_VERSION=8.4
|
||||
|
||||
# ---
|
||||
# Build assets stage - runs on native platform (not emulated)
|
||||
# This stage builds the frontend assets (JavaScript, CSS) using Node.js and Yarn
|
||||
# The --platform=$BUILDPLATFORM ensures this stage runs on the native build platform (amd64)
|
||||
# and not under emulation for ARM builds
|
||||
FROM --platform=$BUILDPLATFORM composer:latest AS composer-deps
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy entire project to install dependencies and generate translations
|
||||
COPY . .
|
||||
|
||||
# Install composer dependencies (needed for Symfony UX assets and cache warmup)
|
||||
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs && \
|
||||
composer dump-autoload --no-dev --classmap-authoritative && \
|
||||
php bin/console cache:clear --no-warmup && \
|
||||
php bin/console cache:warmup
|
||||
|
||||
# ---
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS assets
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy entire project with vendor and generated translations from composer-deps stage
|
||||
COPY --from=composer-deps /build ./
|
||||
|
||||
# Install dependencies and build assets
|
||||
RUN yarn install --network-timeout 600000 && \
|
||||
yarn build && \
|
||||
yarn cache clean
|
||||
|
||||
# ---
|
||||
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
ARG PHP_VERSION
|
||||
|
||||
@@ -70,7 +36,6 @@ RUN apt-get update && apt-get -y install \
|
||||
php${PHP_VERSION}-sqlite3 \
|
||||
php${PHP_VERSION}-mysql \
|
||||
php${PHP_VERSION}-pgsql \
|
||||
git \
|
||||
gpg \
|
||||
sudo \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* \
|
||||
@@ -80,6 +45,13 @@ RUN apt-get update && apt-get -y install \
|
||||
# delete the "index.html" that installing Apache drops in here
|
||||
&& rm -rvf /var/www/html/*
|
||||
|
||||
# Install node and yarn
|
||||
RUN curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get update && apt-get install -y \
|
||||
nodejs \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* && \
|
||||
npm install -g yarn
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -175,13 +147,14 @@ RUN a2dissite 000-default.conf && \
|
||||
a2enconf docker-php && \
|
||||
a2enmod rewrite
|
||||
|
||||
# Install composer dependencies for Part-DB
|
||||
# Install composer and yarn dependencies for Part-DB
|
||||
USER www-data
|
||||
RUN composer install -a --no-dev && \
|
||||
composer clear-cache
|
||||
|
||||
# Copy pre-built assets from the assets stage
|
||||
COPY --from=assets /build/public/build ./public/build
|
||||
RUN yarn install --network-timeout 600000 && \
|
||||
yarn build && \
|
||||
yarn cache clean && \
|
||||
rm -rf node_modules/
|
||||
|
||||
# Use docker env to output logs to stdout
|
||||
ENV APP_ENV=docker
|
||||
|
||||
@@ -1,37 +1,3 @@
|
||||
# ---
|
||||
# Build assets stage - runs on native platform (not emulated)
|
||||
# This stage builds the frontend assets (JavaScript, CSS) using Node.js and Yarn
|
||||
# The --platform=$BUILDPLATFORM ensures this stage runs on the native build platform (amd64)
|
||||
# and not under emulation for ARM builds
|
||||
FROM --platform=$BUILDPLATFORM composer:latest AS composer-deps
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy entire project to install dependencies and generate translations
|
||||
COPY . .
|
||||
|
||||
# Install composer dependencies (needed for Symfony UX assets and cache warmup)
|
||||
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs && \
|
||||
composer dump-autoload --no-dev --classmap-authoritative && \
|
||||
php bin/console cache:clear --no-warmup && \
|
||||
php bin/console cache:warmup
|
||||
|
||||
# ---
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS assets
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy entire project with vendor and generated translations from composer-deps stage
|
||||
COPY --from=composer-deps /build ./
|
||||
|
||||
# Install dependencies and build assets
|
||||
RUN yarn install --network-timeout 600000 && \
|
||||
yarn build && \
|
||||
yarn cache clean
|
||||
|
||||
# ---
|
||||
|
||||
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||
|
||||
RUN apt-get update && apt-get -y install \
|
||||
@@ -47,6 +13,23 @@ RUN apt-get update && apt-get -y install \
|
||||
zip \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
RUN set -eux; \
|
||||
# Run NodeSource setup script
|
||||
curl -sL https://deb.nodesource.com/setup_22.x | bash -; \
|
||||
\
|
||||
# Install Node.js
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
nodejs; \
|
||||
\
|
||||
# Cleanup
|
||||
apt-get -y autoremove; \
|
||||
apt-get clean autoclean; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
\
|
||||
# Install Yarn via npm
|
||||
npm install -g yarn
|
||||
|
||||
|
||||
# Install PHP
|
||||
RUN set -eux; \
|
||||
@@ -97,8 +80,10 @@ RUN set -eux; \
|
||||
composer run-script --no-dev post-install-cmd; \
|
||||
chmod +x bin/console; sync;
|
||||
|
||||
# Copy pre-built assets from the assets stage
|
||||
COPY --from=assets /build/public/build ./public/build
|
||||
RUN yarn install --network-timeout 600000 && \
|
||||
yarn build && \
|
||||
yarn cache clean && \
|
||||
rm -rf node_modules/
|
||||
|
||||
# Use docker env to output logs to stdout
|
||||
ENV APP_ENV=docker
|
||||
|
||||
55
assets/controllers/backup_restore_controller.js
Normal file
55
assets/controllers/backup_restore_controller.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/**
|
||||
* Stimulus controller for backup restore confirmation dialogs.
|
||||
* Shows a confirmation dialog with backup details before allowing restore.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
filename: { type: String, default: '' },
|
||||
date: { type: String, default: '' },
|
||||
confirmTitle: { type: String, default: 'Restore Backup' },
|
||||
confirmMessage: { type: String, default: 'Are you sure you want to restore from this backup?' },
|
||||
confirmWarning: { type: String, default: 'This will overwrite your current database. This action cannot be undone!' },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.element.addEventListener('submit', this.handleSubmit.bind(this));
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
// Always prevent default first
|
||||
event.preventDefault();
|
||||
|
||||
// Build confirmation message
|
||||
const message = this.confirmTitleValue + '\n\n' +
|
||||
'Backup: ' + this.filenameValue + '\n' +
|
||||
'Date: ' + this.dateValue + '\n\n' +
|
||||
this.confirmMessageValue + '\n\n' +
|
||||
'⚠️ ' + this.confirmWarningValue;
|
||||
|
||||
// Only submit if user confirms
|
||||
if (confirm(message)) {
|
||||
this.element.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
import * as bootbox from "bootbox";
|
||||
import "../../css/components/bootbox_extensions.css";
|
||||
import accept from "attr-accept";
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
@@ -112,6 +113,33 @@ export default class extends Controller {
|
||||
dataTransfer.items.add(file);
|
||||
|
||||
rowInput.files = dataTransfer.files;
|
||||
|
||||
//Check the file extension and find the corresponding attachment type based on the data-filetype_filter attribute
|
||||
const attachmentTypeSelect = newElement.querySelector("select");
|
||||
if (attachmentTypeSelect) {
|
||||
let foundMatch = false;
|
||||
for (let j = 0; j < attachmentTypeSelect.options.length; j++) {
|
||||
const option = attachmentTypeSelect.options[j];
|
||||
//skip disabled options
|
||||
if (option.disabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filter = option.getAttribute('data-filetype_filter');
|
||||
if (filter) {
|
||||
if (accept({name: file.name, type: file.type}, filter)) {
|
||||
attachmentTypeSelect.value = option.value;
|
||||
foundMatch = true;
|
||||
break;
|
||||
}
|
||||
} else { //If no filter is set, chose this option until we find a better match
|
||||
if (!foundMatch) {
|
||||
attachmentTypeSelect.value = option.value;
|
||||
foundMatch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -189,4 +217,4 @@ export default class extends Controller {
|
||||
del();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,6 @@ import {marked} from "marked";
|
||||
|
||||
import {
|
||||
trans,
|
||||
SEARCH_PLACEHOLDER,
|
||||
SEARCH_SUBMIT,
|
||||
STATISTICS_PARTS
|
||||
} from '../../translator';
|
||||
|
||||
|
||||
@@ -82,9 +79,9 @@ export default class extends Controller {
|
||||
panelPlacement: this.element.dataset.panelPlacement,
|
||||
plugins: [recentSearchesPlugin],
|
||||
openOnFocus: true,
|
||||
placeholder: trans(SEARCH_PLACEHOLDER),
|
||||
placeholder: trans("search.placeholder"),
|
||||
translations: {
|
||||
submitButtonTitle: trans(SEARCH_SUBMIT)
|
||||
submitButtonTitle: trans("search.submit")
|
||||
},
|
||||
|
||||
// Use a navigator compatible with turbo:
|
||||
@@ -153,7 +150,7 @@ export default class extends Controller {
|
||||
},
|
||||
templates: {
|
||||
header({ html }) {
|
||||
return html`<span class="aa-SourceHeaderTitle">${trans(STATISTICS_PARTS)}</span>
|
||||
return html`<span class="aa-SourceHeaderTitle">${trans("part.labelp")}</span>
|
||||
<div class="aa-SourceHeaderLine" />`;
|
||||
},
|
||||
item({item, components, html}) {
|
||||
@@ -197,4 +194,4 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default class extends Controller {
|
||||
|
||||
let settings = {
|
||||
allowEmptyOption: true,
|
||||
plugins: ['dropdown_input'],
|
||||
plugins: ['dropdown_input', this.element.required ? null : 'clear_button'],
|
||||
searchField: ["name", "description", "category", "footprint"],
|
||||
valueField: "id",
|
||||
labelField: "name",
|
||||
|
||||
@@ -25,8 +25,7 @@ import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
|
||||
import * as zxcvbnDePackage from '@zxcvbn-ts/language-de';
|
||||
import * as zxcvbnFrPackage from '@zxcvbn-ts/language-fr';
|
||||
import * as zxcvbnJaPackage from '@zxcvbn-ts/language-ja';
|
||||
import {trans, USER_PASSWORD_STRENGTH_VERY_WEAK, USER_PASSWORD_STRENGTH_WEAK, USER_PASSWORD_STRENGTH_MEDIUM,
|
||||
USER_PASSWORD_STRENGTH_STRONG, USER_PASSWORD_STRENGTH_VERY_STRONG} from '../../translator.js';
|
||||
import {trans} from '../../translator.js';
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
@@ -89,23 +88,23 @@ export default class extends Controller {
|
||||
|
||||
switch (level) {
|
||||
case 0:
|
||||
text = trans(USER_PASSWORD_STRENGTH_VERY_WEAK);
|
||||
text = trans("user.password_strength.very_weak");
|
||||
classes = "bg-danger badge-danger";
|
||||
break;
|
||||
case 1:
|
||||
text = trans(USER_PASSWORD_STRENGTH_WEAK);
|
||||
text = trans("user.password_strength.weak");
|
||||
classes = "bg-warning badge-warning";
|
||||
break;
|
||||
case 2:
|
||||
text = trans(USER_PASSWORD_STRENGTH_MEDIUM)
|
||||
text = trans("user.password_strength.medium");
|
||||
classes = "bg-info badge-info";
|
||||
break;
|
||||
case 3:
|
||||
text = trans(USER_PASSWORD_STRENGTH_STRONG);
|
||||
text = trans("user.password_strength.strong");
|
||||
classes = "bg-primary badge-primary";
|
||||
break;
|
||||
case 4:
|
||||
text = trans(USER_PASSWORD_STRENGTH_VERY_STRONG);
|
||||
text = trans("user.password_strength.very_strong");
|
||||
classes = "bg-success badge-success";
|
||||
break;
|
||||
default:
|
||||
@@ -120,4 +119,4 @@ export default class extends Controller {
|
||||
this.badgeTarget.classList.add("badge");
|
||||
this.badgeTarget.classList.add(...classes.split(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import '../../css/components/tom-select_extensions.css';
|
||||
import TomSelect from "tom-select";
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js'
|
||||
import {trans} from '../../translator.js'
|
||||
|
||||
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
|
||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||
@@ -204,7 +204,7 @@ export default class extends Controller {
|
||||
|
||||
if (data.not_in_db_yet) {
|
||||
//Not yet added items are shown italic and with a badge
|
||||
name += "<i><b>" + escape(data.text) + "</b></i>" + "<span class='ms-3 badge bg-info badge-info'>" + trans(ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB) + "</span>";
|
||||
name += "<i><b>" + escape(data.text) + "</b></i>" + "<span class='ms-3 badge bg-info badge-info'>" + trans("entity.select.group.new_not_added_to_DB") + "</span>";
|
||||
} else {
|
||||
name += "<b>" + escape(data.text) + "</b>";
|
||||
}
|
||||
|
||||
@@ -62,6 +62,6 @@ export default class extends Controller {
|
||||
element.disabled = true;
|
||||
}
|
||||
|
||||
form.submit();
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,6 @@ export default class extends Controller {
|
||||
//Put our decoded Text into the input box
|
||||
document.getElementById('scan_dialog_input').value = decodedText;
|
||||
//Submit form
|
||||
document.getElementById('scan_dialog_form').submit();
|
||||
document.getElementById('scan_dialog_form').requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
81
assets/controllers/update_confirm_controller.js
Normal file
81
assets/controllers/update_confirm_controller.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/**
|
||||
* Stimulus controller for update/downgrade confirmation dialogs.
|
||||
* Intercepts form submission and shows a confirmation dialog before proceeding.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
isDowngrade: { type: Boolean, default: false },
|
||||
targetVersion: { type: String, default: '' },
|
||||
confirmUpdate: { type: String, default: 'Are you sure you want to update Part-DB?' },
|
||||
confirmDowngrade: { type: String, default: 'Are you sure you want to downgrade Part-DB?' },
|
||||
downgradeWarning: { type: String, default: 'WARNING: This version does not include the Update Manager.' },
|
||||
minUpdateManagerVersion: { type: String, default: '2.6.0' },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.element.addEventListener('submit', this.handleSubmit.bind(this));
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
// Always prevent default first
|
||||
event.preventDefault();
|
||||
|
||||
const targetClean = this.targetVersionValue.replace(/^v/, '');
|
||||
let message;
|
||||
|
||||
if (this.isDowngradeValue) {
|
||||
// Check if downgrading to a version without Update Manager
|
||||
if (this.compareVersions(targetClean, this.minUpdateManagerVersionValue) < 0) {
|
||||
message = this.confirmDowngradeValue + '\n\n⚠️ ' + this.downgradeWarningValue;
|
||||
} else {
|
||||
message = this.confirmDowngradeValue;
|
||||
}
|
||||
} else {
|
||||
message = this.confirmUpdateValue;
|
||||
}
|
||||
|
||||
// Only submit if user confirms
|
||||
if (confirm(message)) {
|
||||
// Remove the event listener to prevent infinite loop, then submit
|
||||
this.element.removeEventListener('submit', this.handleSubmit.bind(this));
|
||||
this.element.submit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version strings (e.g., "2.5.0" vs "2.6.0")
|
||||
* Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
*/
|
||||
compareVersions(v1, v2) {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const p1 = parts1[i] || 0;
|
||||
const p2 = parts2[i] || 0;
|
||||
if (p1 < p2) return -1;
|
||||
if (p1 > p2) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -125,3 +125,25 @@ Classes for Datatables export
|
||||
.export-helper{
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**********************************************************
|
||||
* Table row highlighting tools
|
||||
***********************************************************/
|
||||
|
||||
.row-highlight {
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.20); /* Adds depth */
|
||||
position: relative;
|
||||
z-index: 1; /* Ensures the shadow overlaps other rows */
|
||||
border-left: 5px solid var(--bs-primary); /* Adds a vertical accent bar */
|
||||
}
|
||||
|
||||
@keyframes pulse-highlight {
|
||||
0% { outline: 2px solid transparent; }
|
||||
50% { outline: 2px solid var(--bs-primary); }
|
||||
100% { outline: 2px solid transparent; }
|
||||
}
|
||||
|
||||
.row-pulse {
|
||||
animation: pulse-highlight 1s ease-in-out;
|
||||
animation-iteration-count: 3;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import "./register_events";
|
||||
import "./tristate_checkboxes";
|
||||
|
||||
//Define jquery globally
|
||||
window.$ = window.jQuery = require("jquery");
|
||||
global.$ = global.jQuery = require("jquery");
|
||||
|
||||
//Use the local WASM file for the ZXing library
|
||||
import {
|
||||
|
||||
@@ -198,6 +198,7 @@ class WebauthnTFA {
|
||||
{
|
||||
const resultField = document.getElementById('_auth_code');
|
||||
resultField.value = JSON.stringify(data)
|
||||
//requestSubmit() do not work here, probably because the submit is considered invalid. But as we do not use CSFR tokens, it should be fine.
|
||||
form.submit();
|
||||
}
|
||||
|
||||
@@ -232,4 +233,4 @@ class WebauthnTFA {
|
||||
}
|
||||
}
|
||||
|
||||
window.webauthnTFA = new WebauthnTFA();
|
||||
window.webauthnTFA = new WebauthnTFA();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { localeFallbacks } from '../var/translations/configuration';
|
||||
import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator';
|
||||
import { createTranslator } from '@symfony/ux-translator';
|
||||
import { messages, localeFallbacks } from '../var/translations/index.js';
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony UX Translator package.
|
||||
*
|
||||
@@ -9,8 +10,12 @@ import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-tra
|
||||
* If you use TypeScript, you can rename this file to "translator.ts" to take advantage of types checking.
|
||||
*/
|
||||
|
||||
setLocaleFallbacks(localeFallbacks);
|
||||
const translator = createTranslator({
|
||||
messages,
|
||||
localeFallbacks,
|
||||
});
|
||||
|
||||
export { trans };
|
||||
|
||||
export * from '../var/translations';
|
||||
// Wrapper function with default domain set to 'frontend'
|
||||
export const trans = (id, parameters = {}, domain = 'frontend', locale = null) => {
|
||||
return translator.trans(id, parameters, domain, locale);
|
||||
};
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-zip": "*",
|
||||
"amphp/http-client": "^5.1",
|
||||
"api-platform/doctrine-orm": "^4.1",
|
||||
"api-platform/json-api": "^4.0.0",
|
||||
"api-platform/symfony": "^4.0.0",
|
||||
"beberlei/doctrineextensions": "^1.2",
|
||||
"brick/math": "^0.13.1",
|
||||
"brick/schema": "^0.2.0",
|
||||
"composer/ca-bundle": "^1.5",
|
||||
"composer/package-versions-deprecated": "^1.11.99.5",
|
||||
"doctrine/data-fixtures": "^2.0.0",
|
||||
@@ -79,7 +81,8 @@
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/ux-translator": "^2.10",
|
||||
"symfony/type-info": "7.4.*",
|
||||
"symfony/ux-translator": "^2.32.0",
|
||||
"symfony/ux-turbo": "^2.0",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/web-link": "7.4.*",
|
||||
|
||||
2193
composer.lock
generated
2193
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -23,3 +23,7 @@ framework:
|
||||
|
||||
info_provider.cache:
|
||||
adapter: cache.app
|
||||
|
||||
cache.settings:
|
||||
adapter: cache.app
|
||||
tags: true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# yaml-language-server: $schema=../../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
@@ -8,6 +9,7 @@ framework:
|
||||
# Must be set to true, to enable the change of HTTP method via _method parameter, otherwise our delete routines does not work anymore
|
||||
# TODO: Rework delete routines to work without _method parameter as it is not recommended anymore (see https://github.com/symfony/symfony/issues/45278)
|
||||
http_method_override: true
|
||||
allowed_http_method_override: ['DELETE']
|
||||
|
||||
# Allow users to configure trusted hosts via .env variables
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html#trusted-hosts
|
||||
|
||||
@@ -35,4 +35,4 @@ knpu_oauth2_client:
|
||||
provider_options:
|
||||
urlAuthorize: 'https://identity.nexar.com/connect/authorize'
|
||||
urlAccessToken: 'https://identity.nexar.com/connect/token'
|
||||
urlResourceOwnerDetails: ''
|
||||
urlResourceOwnerDetails: ''
|
||||
|
||||
@@ -3,6 +3,7 @@ jbtronics_settings:
|
||||
|
||||
cache:
|
||||
default_cacheable: true
|
||||
service: 'cache.settings'
|
||||
|
||||
orm_storage:
|
||||
default_entity_class: App\Entity\SettingsEntry
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
framework:
|
||||
default_locale: 'en'
|
||||
# Just enable the locales we need for performance reasons.
|
||||
enabled_locale: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl']
|
||||
enabled_locale: '%partdb.locale_menu%'
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
fallbacks:
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
ux_translator:
|
||||
# The directory where the JavaScript translations are dumped
|
||||
dump_directory: '%kernel.project_dir%/var/translations'
|
||||
# Only include the frontend translation domain in the JavaScript bundle
|
||||
domains:
|
||||
- 'frontend'
|
||||
|
||||
when@prod:
|
||||
ux_translator:
|
||||
# Control whether TypeScript types are dumped alongside translations.
|
||||
# Disable this if you do not use TypeScript (e.g. in production when using AssetMapper), to speed up cache warmup.
|
||||
# dump_typescript: false
|
||||
|
||||
@@ -297,6 +297,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
||||
show_updates:
|
||||
label: "perm.system.show_available_updates"
|
||||
apiTokenRole: ROLE_API_ADMIN
|
||||
manage_updates:
|
||||
label: "perm.system.manage_updates"
|
||||
alsoSet: ['show_updates', 'server_infos']
|
||||
apiTokenRole: ROLE_API_ADMIN
|
||||
|
||||
|
||||
attachments:
|
||||
|
||||
3282
config/reference.php
3282
config/reference.php
File diff suppressed because it is too large
Load Diff
@@ -5,3 +5,5 @@ files:
|
||||
translation: /translations/validators.%two_letters_code%.xlf
|
||||
- source: /translations/security.en.xlf
|
||||
translation: /translations/security.%two_letters_code%.xlf
|
||||
- source: /translations/frontend.en.xlf
|
||||
translation: /translations/frontend.%two_letters_code%.xlf
|
||||
|
||||
@@ -21,8 +21,8 @@ differences between them, which might be important for you. Therefore the pros a
|
||||
are listed here.
|
||||
|
||||
{: .important }
|
||||
You have to choose between the database types before you start using Part-DB and **you can not change it (easily) after
|
||||
you have started creating data**. So you should choose the database type for your use case (and possible future uses).
|
||||
While you can change the database platform later (see below), it is still experimental and not recommended.
|
||||
So you should choose the database type for your use case (and possible future uses).
|
||||
|
||||
## Comparison
|
||||
|
||||
@@ -180,3 +180,23 @@ and it is automatically used if available.
|
||||
For SQLite and MySQL < 10.7 it has to be emulated if wanted, which is pretty slow. Therefore it has to be explicitly enabled by setting the
|
||||
`DATABASE_EMULATE_NATURAL_SORT` environment variable to `1`. If it is 0 the classical binary sorting is used, on these databases. The emulations
|
||||
might have some quirks and issues, so it is recommended to use a database which supports natural sorting natively, if you want to use it.
|
||||
|
||||
## Converting between database platforms
|
||||
|
||||
{: .important }
|
||||
The database conversion is still experimental. Therefore it is recommended to backup your database before performing a conversion, and check if everything works as expected afterwards.
|
||||
|
||||
If you want to change the database platform of your Part-DB installation (e.g. from SQLite to MySQL/MariaDB or PostgreSQL, or vice versa), there is the `partdb:migrations:convert-db-platform` console command, which can help you with that:
|
||||
|
||||
1. Make a backup of your current database to be safe if something goes wrong (see the backup documentation).
|
||||
2. Ensure that your database is at the latest schema by running the migrations: `php bin/console doctrine:migrations:migrate`
|
||||
3. Change the `DATABASE_URL` environment variable to the new database platform and connection information. Copy the old `DATABASE_URL` as you will need it later.
|
||||
4. Run `php bin/console doctrine:migrations:migrate` again to create the database schema in the new database. You will not need the admin password, that is shown when running the migrations.
|
||||
5. Run the conversion command, where you have to provide the old `DATABASE_URL` as parameter: `php bin/console partdb:migrations:convert-db-platform <OLD_DATABASE_URL>`
|
||||
Replace `<OLD_DATABASE_URL` with the actual old `DATABASE_URL` value (e.g. `sqlite:///%kernel.project_dir%/var/app.db`):
|
||||
```bash
|
||||
php bin/console partdb:migrations:convert-db-platform sqlite:///%kernel.project_dir%/var/app.db
|
||||
```
|
||||
6. The command will purge all data in the new database and copy all data from the old database to the new one. This might take some time and memory depending on the size of your database.
|
||||
7. Clear the cache: `php bin/console partdb:cache:clear`
|
||||
8. You can login with your existing user accounts in the new database now. Check if everything works as expected.
|
||||
|
||||
BIN
docs/screenshots/update-manager-interface.png
Normal file
BIN
docs/screenshots/update-manager-interface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -50,6 +50,14 @@ docker exec --user=www-data partdb php bin/console cache:clear
|
||||
* `php bin/console partdb:currencies:update-exchange-rates`: Update the exchange rates of all currencies from the
|
||||
internet
|
||||
|
||||
## Update Manager commands
|
||||
|
||||
{: .note }
|
||||
> The Update Manager is an experimental feature. See the [Update Manager documentation](update_manager.md) for details.
|
||||
|
||||
* `php bin/console partdb:update`: Check for and perform updates to Part-DB. Use `--check` to only check for updates without installing.
|
||||
* `php bin/console partdb:maintenance-mode`: Enable, disable, or check the status of maintenance mode. Use `--enable`, `--disable`, or `--status`.
|
||||
|
||||
## Installation/Maintenance commands
|
||||
|
||||
* `php bin/console partdb:backup`: Backup the database and the attachments
|
||||
@@ -68,6 +76,7 @@ docker exec --user=www-data partdb php bin/console cache:clear
|
||||
deleted!*
|
||||
* `settings:migrate-env-to-settings`: Migrate configuration from environment variables to the settings interface.
|
||||
The value of the environment variable is copied to the settings database, so the environment variable can be removed afterwards without losing the configuration.
|
||||
* `partdb:migrations:convert-db-platform`: Convert the database platform (e.g. from SQLite to MySQL/MariaDB or PostgreSQL, or vice versa).
|
||||
|
||||
## Database commands
|
||||
|
||||
|
||||
@@ -96,6 +96,21 @@ The following providers are currently available and shipped with Part-DB:
|
||||
|
||||
(All trademarks are property of their respective owners. Part-DB is not affiliated with any of the companies.)
|
||||
|
||||
### Generic Web URL Provider
|
||||
The Generic Web URL Provider can extract part information from any webpage that contains structured data in the form of
|
||||
[Schema.org](https://schema.org/) format. Many e-commerce websites use this format to provide detailed product information
|
||||
for search engines and other services. Therefore it allows Part-DB to retrieve rudimentary part information (like name, image and price)
|
||||
from a wide range of websites without the need for a dedicated API integration.
|
||||
To use the Generic Web URL Provider, simply enable it in the information provider settings. No additional configuration
|
||||
is required. Afterwards you can enter any product URL in the search field, and Part-DB will attempt to extract the relevant part information
|
||||
from the webpage.
|
||||
|
||||
Please note that if this provider is enabled, Part-DB will make HTTP requests to external websites to fetch product data, which
|
||||
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`)
|
||||
|
||||
### Octopart
|
||||
|
||||
The Octopart provider uses the [Octopart / Nexar API](https://nexar.com/api) to search for parts and get information.
|
||||
@@ -260,6 +275,34 @@ This is not an official API and could break at any time. So use it at your own r
|
||||
The following env configuration options are available:
|
||||
* `PROVIDER_POLLIN_ENABLED`: Set this to `1` to enable the Pollin provider
|
||||
|
||||
### Buerklin
|
||||
|
||||
The Buerklin provider uses the [Buerklin API](https://www.buerklin.com/en/services/eprocurement/) to search for parts and get information.
|
||||
To use it you have to request access to the API.
|
||||
You will get an e-mail with the client ID and client secret, which you have to put in the Part-DB configuration (see below).
|
||||
|
||||
Please note that the Buerklin API is limited to 100 requests/minute per IP address and
|
||||
access to the Authentication server is limited to 10 requests/minute per IP address
|
||||
|
||||
The following env configuration options are available:
|
||||
|
||||
* `PROVIDER_BUERKLIN_CLIENT_ID`: The client ID you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_SECRET`: The client secret you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_USERNAME`: The username you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_PASSWORD`: The password you got from Buerklin (mandatory)
|
||||
* `PROVIDER_BUERKLIN_CURRENCY`: The currency you want to get prices in if available (optional, 3 letter ISO-code, default: `EUR`).
|
||||
* `PROVIDER_BUERKLIN_LANGUAGE`: The language you want to get the descriptions in. Possible values: `de` = German, `en` = English. (optional, default: `en`)
|
||||
|
||||
### Conrad
|
||||
|
||||
The conrad provider the [Conrad API](https://developer.conrad.com/) to search for parts and retried their information.
|
||||
To use it you have to request access to the API, however it seems currently your mail address needs to be allowlisted before you can register for an account.
|
||||
The conrad webpages uses the API key in the requests, so you might be able to extract a working API key by listening to browser requests.
|
||||
That method is not officially supported nor encouraged by Part-DB, and might break at any moment.
|
||||
|
||||
The following env configuration options are available:
|
||||
* `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory)
|
||||
|
||||
### Custom provider
|
||||
|
||||
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long
|
||||
|
||||
170
docs/usage/update_manager.md
Normal file
170
docs/usage/update_manager.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
title: Update Manager
|
||||
layout: default
|
||||
parent: Usage
|
||||
---
|
||||
|
||||
# Update Manager (Experimental)
|
||||
|
||||
{: .warning }
|
||||
> The Update Manager is currently an **experimental feature**. It is disabled by default while user experience data is being gathered. Use with caution and always ensure you have proper backups before updating.
|
||||
|
||||
Part-DB includes an Update Manager that can automatically update Git-based installations to newer versions. The Update Manager provides both a web interface and CLI commands for managing updates, backups, and maintenance mode.
|
||||
|
||||
## Supported Installation Types
|
||||
|
||||
The Update Manager currently supports automatic updates only for **Git clone** installations. Other installation types show manual update instructions:
|
||||
|
||||
| Installation Type | Auto-Update | Instructions |
|
||||
|-------------------|-------------|--------------|
|
||||
| Git Clone | Yes | Automatic via CLI or Web UI |
|
||||
| Docker | No | Pull new image: `docker-compose pull && docker-compose up -d` |
|
||||
| ZIP Release | No | Download and extract new release manually |
|
||||
|
||||
## Enabling the Update Manager
|
||||
|
||||
By default, web-based updates and backup restore are **disabled** for security reasons. To enable them, add these settings to your `.env.local` file:
|
||||
|
||||
```bash
|
||||
# Enable web-based updates (default: disabled)
|
||||
DISABLE_WEB_UPDATES=0
|
||||
|
||||
# Enable backup restore via web interface (default: disabled)
|
||||
DISABLE_BACKUP_RESTORE=0
|
||||
```
|
||||
|
||||
{: .note }
|
||||
> Even with web updates disabled, you can still use the CLI commands to perform updates.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Update Command
|
||||
|
||||
Check for updates or perform an update:
|
||||
|
||||
```bash
|
||||
# Check for available updates
|
||||
php bin/console partdb:update --check
|
||||
|
||||
# Update to the latest version
|
||||
php bin/console partdb:update
|
||||
|
||||
# Update to a specific version
|
||||
php bin/console partdb:update v2.6.0
|
||||
|
||||
# Update without creating a backup first
|
||||
php bin/console partdb:update --no-backup
|
||||
|
||||
# Force update without confirmation prompt
|
||||
php bin/console partdb:update --force
|
||||
```
|
||||
|
||||
### Maintenance Mode Command
|
||||
|
||||
Manually enable or disable maintenance mode:
|
||||
|
||||
```bash
|
||||
# Enable maintenance mode with default message
|
||||
php bin/console partdb:maintenance-mode --enable
|
||||
|
||||
# Enable with custom message
|
||||
php bin/console partdb:maintenance-mode --enable "System maintenance until 6 PM"
|
||||
php bin/console partdb:maintenance-mode --enable --message="Updating to v2.6.0"
|
||||
|
||||
# Disable maintenance mode
|
||||
php bin/console partdb:maintenance-mode --disable
|
||||
|
||||
# Check current status
|
||||
php bin/console partdb:maintenance-mode --status
|
||||
```
|
||||
|
||||
## Web Interface
|
||||
|
||||
When web updates are enabled, the Update Manager is accessible at **System > Update Manager** (URL: `/system/update-manager`).
|
||||
|
||||
The web interface shows:
|
||||
- Current version and installation type
|
||||
- Available updates with release notes
|
||||
- Precondition validation (Git, Composer, Yarn, permissions)
|
||||
- Update history and logs
|
||||
- Backup management
|
||||
|
||||
### Required Permissions
|
||||
|
||||
Users need the following permissions to access the Update Manager:
|
||||
|
||||
| Permission | Description |
|
||||
|------------|-------------|
|
||||
| `@system.show_updates` | View update status and available versions |
|
||||
| `@system.manage_updates` | Perform updates and restore backups |
|
||||
|
||||
## Update Process
|
||||
|
||||
When an update is performed, the following steps are executed:
|
||||
|
||||
1. **Lock** - Acquire exclusive lock to prevent concurrent updates
|
||||
2. **Maintenance Mode** - Enable maintenance mode to block user access
|
||||
3. **Rollback Tag** - Create a Git tag for potential rollback
|
||||
4. **Backup** - Create a full backup (optional but recommended)
|
||||
5. **Git Fetch** - Fetch latest changes from origin
|
||||
6. **Git Checkout** - Checkout the target version
|
||||
7. **Composer Install** - Install/update PHP dependencies
|
||||
8. **Yarn Install** - Install frontend dependencies
|
||||
9. **Yarn Build** - Compile frontend assets
|
||||
10. **Database Migrations** - Run any new migrations
|
||||
11. **Cache Clear** - Clear the application cache
|
||||
12. **Cache Warmup** - Rebuild the cache
|
||||
13. **Maintenance Off** - Disable maintenance mode
|
||||
14. **Unlock** - Release the update lock
|
||||
|
||||
If any step fails, the system automatically attempts to rollback to the previous version.
|
||||
|
||||
## Backup Management
|
||||
|
||||
The Update Manager automatically creates backups before updates. These backups are stored in `var/backups/` and include:
|
||||
|
||||
- Database dump (SQL file or SQLite database)
|
||||
- Configuration files (`.env.local`, `parameters.yaml`, `banner.md`)
|
||||
- Attachment files (`uploads/`, `public/media/`)
|
||||
|
||||
### Restoring from Backup
|
||||
|
||||
{: .warning }
|
||||
> Backup restore is a destructive operation that will overwrite your current database. Only use this if you need to recover from a failed update.
|
||||
|
||||
If web restore is enabled (`DISABLE_BACKUP_RESTORE=0`), you can restore backups from the web interface. The restore process:
|
||||
|
||||
1. Enables maintenance mode
|
||||
2. Extracts the backup
|
||||
3. Restores the database
|
||||
4. Optionally restores config and attachments
|
||||
5. Clears and warms up the cache
|
||||
6. Disables maintenance mode
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Precondition Errors
|
||||
|
||||
Before updating, the system validates:
|
||||
|
||||
- **Git available**: Git must be installed and in PATH
|
||||
- **No local changes**: Uncommitted changes must be committed or stashed
|
||||
- **Composer available**: Composer must be installed and in PATH
|
||||
- **Yarn available**: Yarn must be installed and in PATH
|
||||
- **Write permissions**: `var/`, `vendor/`, and `public/` must be writable
|
||||
- **Not already locked**: No other update can be in progress
|
||||
|
||||
### Stale Lock
|
||||
|
||||
If an update was interrupted and the lock file remains, it will automatically be removed after 1 hour. You can also manually delete `var/update.lock`.
|
||||
|
||||
### Viewing Update Logs
|
||||
|
||||
Update logs are stored in `var/log/updates/` and can be viewed from the web interface or directly on the server.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Disable web updates in production** unless you specifically need them
|
||||
- The Update Manager requires shell access to run Git, Composer, and Yarn
|
||||
- Backup files may contain sensitive data (database, config) - secure the `var/backups/` directory
|
||||
- Consider running updates during maintenance windows with low user activity
|
||||
91
makefile
91
makefile
@@ -1,91 +0,0 @@
|
||||
# PartDB Makefile for Test Environment Management
|
||||
|
||||
.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \
|
||||
test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \
|
||||
section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset
|
||||
|
||||
# Default target
|
||||
help: ## Show this help
|
||||
@awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
# Dependencies
|
||||
deps-install: ## Install PHP dependencies with unlimited memory
|
||||
@echo "📦 Installing PHP dependencies..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 composer install
|
||||
yarn install
|
||||
@echo "✅ Dependencies installed"
|
||||
|
||||
# Complete test environment setup
|
||||
test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures)
|
||||
@echo "✅ Test environment setup complete!"
|
||||
|
||||
# Clean test environment
|
||||
test-clean: ## Clean test cache and database files
|
||||
@echo "🧹 Cleaning test environment..."
|
||||
rm -rf var/cache/test
|
||||
rm -f var/app_test.db
|
||||
@echo "✅ Test environment cleaned"
|
||||
|
||||
# Create test database
|
||||
test-db-create: ## Create test database (if not exists)
|
||||
@echo "🗄️ Creating test database..."
|
||||
-php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
||||
|
||||
# Run database migrations for test environment
|
||||
test-db-migrate: ## Run database migrations for test environment
|
||||
@echo "🔄 Running database migrations..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
|
||||
|
||||
# Clear test cache
|
||||
test-cache-clear: ## Clear test cache
|
||||
@echo "🗑️ Clearing test cache..."
|
||||
rm -rf var/cache/test
|
||||
@echo "✅ Test cache cleared"
|
||||
|
||||
# Load test fixtures
|
||||
test-fixtures: ## Load test fixtures
|
||||
@echo "📦 Loading test fixtures..."
|
||||
php bin/console partdb:fixtures:load -n --env test
|
||||
|
||||
# Run PHPUnit tests
|
||||
test-run: ## Run PHPUnit tests
|
||||
@echo "🧪 Running tests..."
|
||||
php bin/phpunit
|
||||
|
||||
# Quick test reset (clean + migrate + fixtures, skip DB creation)
|
||||
test-reset: test-cache-clear test-db-migrate test-fixtures
|
||||
@echo "✅ Test environment reset complete!"
|
||||
|
||||
test-typecheck: ## Run static analysis (PHPStan)
|
||||
@echo "🧪 Running type checks..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
|
||||
|
||||
# Development helpers
|
||||
dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup)
|
||||
@echo "✅ Development environment setup complete!"
|
||||
|
||||
dev-clean: ## Clean development cache and database files
|
||||
@echo "🧹 Cleaning development environment..."
|
||||
rm -rf var/cache/dev
|
||||
rm -f var/app_dev.db
|
||||
@echo "✅ Development environment cleaned"
|
||||
|
||||
dev-db-create: ## Create development database (if not exists)
|
||||
@echo "🗄️ Creating development database..."
|
||||
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
||||
|
||||
dev-db-migrate: ## Run database migrations for development environment
|
||||
@echo "🔄 Running database migrations..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
|
||||
|
||||
dev-cache-clear: ## Clear development cache
|
||||
@echo "🗑️ Clearing development cache..."
|
||||
rm -rf var/cache/dev
|
||||
@echo "✅ Development cache cleared"
|
||||
|
||||
dev-warmup: ## Warm up development cache
|
||||
@echo "🔥 Warming up development cache..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n
|
||||
|
||||
dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate)
|
||||
@echo "✅ Development environment reset complete!"
|
||||
@@ -17,7 +17,7 @@
|
||||
"popper.js": "^1.14.7",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-bundle-analyzer": "^4.3.0",
|
||||
"webpack-bundle-analyzer": "^5.1.1",
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-notifier": "^1.15.0"
|
||||
},
|
||||
@@ -46,6 +46,7 @@
|
||||
"@zxcvbn-ts/language-en": "^3.0.1",
|
||||
"@zxcvbn-ts/language-fr": "^3.0.1",
|
||||
"@zxcvbn-ts/language-ja": "^3.0.1",
|
||||
"attr-accept": "^2.2.5",
|
||||
"barcode-detector": "^3.0.5",
|
||||
"bootbox": "^6.0.0",
|
||||
"bootswatch": "^5.1.3",
|
||||
@@ -65,7 +66,7 @@
|
||||
"json-formatter-js": "^2.3.4",
|
||||
"jszip": "^3.2.0",
|
||||
"katex": "^0.16.0",
|
||||
"marked": "^16.1.1",
|
||||
"marked": "^17.0.1",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
"marked-mangle": "^1.0.1",
|
||||
"pdfmake": "^0.2.2",
|
||||
@@ -73,5 +74,8 @@
|
||||
"tom-select": "^2.1.0",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"jquery": "^3.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ parameters:
|
||||
- src
|
||||
# - tests
|
||||
|
||||
banned_code:
|
||||
non_ignorable: false # Allow to ignore some banned code
|
||||
|
||||
excludePaths:
|
||||
- src/DataTables/Adapter/*
|
||||
- src/Configuration/*
|
||||
@@ -61,3 +64,9 @@ parameters:
|
||||
|
||||
# Ignore error of unused WithPermPresetsTrait, as it is used in the migrations which are not analyzed by Phpstan
|
||||
- '#Trait App\\Migration\\WithPermPresetsTrait is used zero times and is not analysed#'
|
||||
-
|
||||
message: '#Should not use function "shell_exec"#'
|
||||
path: src/Services/System/UpdateExecutor.php
|
||||
|
||||
- message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#'
|
||||
path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
|
||||
|
||||
141
src/Command/MaintenanceModeCommand.php
Normal file
141
src/Command/MaintenanceModeCommand.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?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\Command;
|
||||
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand('partdb:maintenance-mode', 'Enable/disable maintenance mode and set a message')]
|
||||
class MaintenanceModeCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UpdateExecutor $updateExecutor
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDefinition([
|
||||
new InputOption('enable', null, InputOption::VALUE_NONE, 'Enable maintenance mode'),
|
||||
new InputOption('disable', null, InputOption::VALUE_NONE, 'Disable maintenance mode'),
|
||||
new InputOption('status', null, InputOption::VALUE_NONE, 'Show current maintenance mode status'),
|
||||
new InputOption('message', null, InputOption::VALUE_REQUIRED, 'Optional maintenance message (explicit option)'),
|
||||
new InputArgument('message_arg', InputArgument::OPTIONAL, 'Optional maintenance message as a positional argument (preferred when writing message directly)')
|
||||
]);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$enable = (bool)$input->getOption('enable');
|
||||
$disable = (bool)$input->getOption('disable');
|
||||
$status = (bool)$input->getOption('status');
|
||||
|
||||
// Accept message either via --message option or as positional argument
|
||||
$optionMessage = $input->getOption('message');
|
||||
$argumentMessage = $input->getArgument('message_arg');
|
||||
|
||||
// Prefer explicit --message option, otherwise use positional argument if provided
|
||||
$message = null;
|
||||
if (is_string($optionMessage) && $optionMessage !== '') {
|
||||
$message = $optionMessage;
|
||||
} elseif (is_string($argumentMessage) && $argumentMessage !== '') {
|
||||
$message = $argumentMessage;
|
||||
}
|
||||
|
||||
// If no action provided, show help
|
||||
if (!$enable && !$disable && !$status) {
|
||||
$io->text('Maintenance mode command. See usage below:');
|
||||
$this->printHelp($io);
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($enable && $disable) {
|
||||
$io->error('Conflicting options: specify either --enable or --disable, not both.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($status) {
|
||||
if ($this->updateExecutor->isMaintenanceMode()) {
|
||||
$info = $this->updateExecutor->getMaintenanceInfo();
|
||||
$reason = $info['reason'] ?? 'Unknown reason';
|
||||
$enabledAt = $info['enabled_at'] ?? 'Unknown time';
|
||||
|
||||
$io->success(sprintf('Maintenance mode is ENABLED (since %s).', $enabledAt));
|
||||
$io->text(sprintf('Reason: %s', $reason));
|
||||
} else {
|
||||
$io->success('Maintenance mode is DISABLED.');
|
||||
}
|
||||
|
||||
// If only status requested, exit
|
||||
if (!$enable && !$disable) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
if ($enable) {
|
||||
// Use provided message or fallback to a default English message
|
||||
$reason = is_string($message)
|
||||
? $message
|
||||
: 'The system is temporarily unavailable due to maintenance.';
|
||||
|
||||
$this->updateExecutor->enableMaintenanceMode($reason);
|
||||
|
||||
$io->success(sprintf('Maintenance mode enabled. Reason: %s', $reason));
|
||||
}
|
||||
|
||||
if ($disable) {
|
||||
$this->updateExecutor->disableMaintenanceMode();
|
||||
$io->success('Maintenance mode disabled.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$io->error(sprintf('Unexpected error: %s', $e->getMessage()));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function printHelp(SymfonyStyle $io): void
|
||||
{
|
||||
$io->writeln('');
|
||||
$io->writeln('Usage:');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --enable [--message="Maintenance message"]');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --enable "Maintenance message"');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --disable');
|
||||
$io->writeln(' php bin/console partdb:maintenance_mode --status');
|
||||
$io->writeln('');
|
||||
}
|
||||
|
||||
}
|
||||
253
src/Command/Migrations/DBPlatformConvertCommand.php
Normal file
253
src/Command/Migrations/DBPlatformConvertCommand.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?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\Command\Migrations;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\ImportExportSystem\PartKeeprImporter\PKImportHelper;
|
||||
use Doctrine\Bundle\DoctrineBundle\ConnectionFactory;
|
||||
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager;
|
||||
use Doctrine\Migrations\Configuration\Migration\ExistingConfiguration;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Migrations\DependencyFactory;
|
||||
use Doctrine\ORM\Id\AssignedGenerator;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
#[AsCommand('partdb:migrations:convert-db-platform', 'Convert the database to a different platform')]
|
||||
class DBPlatformConvertCommand extends Command
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $targetEM,
|
||||
private readonly PKImportHelper $importHelper,
|
||||
private readonly DependencyFactory $dependencyFactory,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $kernelProjectDir,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp('This command allows you to migrate the database from one database platform to another (e.g. from MySQL to PostgreSQL).')
|
||||
->addArgument('url', InputArgument::REQUIRED, 'The database connection URL of the source database to migrate from');
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$sourceEM = $this->getSourceEm($input->getArgument('url'));
|
||||
|
||||
//Check that both databases are not using the same driver
|
||||
if ($sourceEM->getConnection()->getDatabasePlatform()::class === $this->targetEM->getConnection()->getDatabasePlatform()::class) {
|
||||
$io->warning('Source and target database are using the same database platform / driver. This command is only intended to migrate between different database platforms (e.g. from MySQL to PostgreSQL).');
|
||||
if (!$io->confirm('Do you want to continue anyway?', false)) {
|
||||
$io->info('Aborting migration process.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$this->ensureVersionUpToDate($sourceEM);
|
||||
|
||||
$io->note('This command is still in development. If you encounter any problems, please report them to the issue tracker on GitHub.');
|
||||
$io->warning(sprintf('This command will delete all existing data in the target database "%s". Make sure that you have no important data in the database before you continue!',
|
||||
$this->targetEM->getConnection()->getDatabase() ?? 'unknown'
|
||||
));
|
||||
|
||||
//$users = $sourceEM->getRepository(User::class)->findAll();
|
||||
//dump($users);
|
||||
|
||||
$io->ask('Please type "DELETE ALL DATA" to continue.', '', function ($answer) {
|
||||
if (strtoupper($answer) !== 'DELETE ALL DATA') {
|
||||
throw new \RuntimeException('You did not type "DELETE ALL DATA"!');
|
||||
}
|
||||
return $answer;
|
||||
});
|
||||
|
||||
|
||||
// Example migration logic (to be replaced with actual migration code)
|
||||
$io->info('Starting database migration...');
|
||||
|
||||
//Disable all event listeners on target EM to avoid unwanted side effects
|
||||
$eventManager = $this->targetEM->getEventManager();
|
||||
foreach ($eventManager->getAllListeners() as $event => $listeners) {
|
||||
foreach ($listeners as $listener) {
|
||||
$eventManager->removeEventListener($event, $listener);
|
||||
}
|
||||
}
|
||||
|
||||
$io->info('Clear target database...');
|
||||
$this->importHelper->purgeDatabaseForImport($this->targetEM, ['internal', 'migration_versions']);
|
||||
|
||||
$metadata = $this->targetEM->getMetadataFactory()->getAllMetadata();
|
||||
|
||||
$io->info('Modifying entity metadata for migration...');
|
||||
//First we modify each entity metadata to have an persist cascade on all relations
|
||||
foreach ($metadata as $metadatum) {
|
||||
$entityClass = $metadatum->getName();
|
||||
$io->writeln('Modifying cascade and ID settings for entity: ' . $entityClass, OutputInterface::VERBOSITY_VERBOSE);
|
||||
|
||||
foreach ($metadatum->getAssociationNames() as $fieldName) {
|
||||
$mapping = $metadatum->getAssociationMapping($fieldName);
|
||||
$mapping->cascade = array_unique(array_merge($mapping->cascade, ['persist']));
|
||||
$mapping->fetch = ClassMetadata::FETCH_EAGER; //Avoid lazy loading issues during migration
|
||||
|
||||
$metadatum->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_NONE);
|
||||
$metadatum->setIdGenerator(new AssignedGenerator());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$io->progressStart(count($metadata));
|
||||
|
||||
//First we migrate users to avoid foreign key constraint issues
|
||||
$io->info('Migrating users first to avoid foreign key constraint issues...');
|
||||
$this->fixUsers($sourceEM);
|
||||
|
||||
//Afterward we migrate all entities
|
||||
foreach ($metadata as $metadatum) {
|
||||
//skip all superclasses
|
||||
if ($metadatum->isMappedSuperclass) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entityClass = $metadatum->getName();
|
||||
|
||||
$io->note('Migrating entity: ' . $entityClass);
|
||||
|
||||
$repo = $sourceEM->getRepository($entityClass);
|
||||
$items = $repo->findAll();
|
||||
foreach ($items as $index => $item) {
|
||||
$this->targetEM->persist($item);
|
||||
}
|
||||
$this->targetEM->flush();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
|
||||
|
||||
//Fix sequences / auto increment values on target database
|
||||
$io->info('Fixing sequences / auto increment values on target database...');
|
||||
$this->fixAutoIncrements($this->targetEM);
|
||||
|
||||
$io->success('Database migration completed successfully.');
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$io->info('Process took peak memory: ' . round(memory_get_peak_usage(true) / 1024 / 1024, 2) . ' MB');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a source EntityManager based on the given connection URL
|
||||
* @param string $url
|
||||
* @return EntityManagerInterface
|
||||
*/
|
||||
private function getSourceEm(string $url): EntityManagerInterface
|
||||
{
|
||||
//Replace any %kernel.project_dir% placeholders
|
||||
$url = str_replace('%kernel.project_dir%', $this->kernelProjectDir, $url);
|
||||
|
||||
$connectionFactory = new ConnectionFactory();
|
||||
$connection = $connectionFactory->createConnection(['url' => $url]);
|
||||
return new EntityManager($connection, $this->targetEM->getConfiguration());
|
||||
}
|
||||
|
||||
private function ensureVersionUpToDate(EntityManagerInterface $sourceEM): void
|
||||
{
|
||||
//Ensure that target database is up to date
|
||||
$migrationStatusCalculator = $this->dependencyFactory->getMigrationStatusCalculator();
|
||||
$newMigrations = $migrationStatusCalculator->getNewMigrations();
|
||||
if (count($newMigrations->getItems()) > 0) {
|
||||
throw new \RuntimeException("Target database is not up to date. Please run all migrations (with doctrine:migrations:migrate) before starting the migration process.");
|
||||
}
|
||||
|
||||
$sourceDependencyLoader = DependencyFactory::fromEntityManager(new ExistingConfiguration($this->dependencyFactory->getConfiguration()), new ExistingEntityManager($sourceEM));
|
||||
$sourceMigrationStatusCalculator = $sourceDependencyLoader->getMigrationStatusCalculator();
|
||||
$sourceNewMigrations = $sourceMigrationStatusCalculator->getNewMigrations();
|
||||
if (count($sourceNewMigrations->getItems()) > 0) {
|
||||
throw new \RuntimeException("Source database is not up to date. Please run all migrations (with doctrine:migrations:migrate) on the source database before starting the migration process.");
|
||||
}
|
||||
}
|
||||
|
||||
private function fixUsers(EntityManagerInterface $sourceEM): void
|
||||
{
|
||||
//To avoid a problem with (Column 'settings' cannot be null) in MySQL we need to migrate the user entities first
|
||||
//and fix the settings and backupCodes fields
|
||||
|
||||
$reflClass = new \ReflectionClass(User::class);
|
||||
foreach ($sourceEM->getRepository(User::class)->findAll() as $user) {
|
||||
foreach (['settings', 'backupCodes'] as $field) {
|
||||
$property = $reflClass->getProperty($field);
|
||||
if (!$property->isInitialized($user) || $property->getValue($user) === null) {
|
||||
$property->setValue($user, []);
|
||||
}
|
||||
}
|
||||
$this->targetEM->persist($user);
|
||||
}
|
||||
}
|
||||
|
||||
private function fixAutoIncrements(EntityManagerInterface $em): void
|
||||
{
|
||||
$connection = $em->getConnection();
|
||||
$platform = $connection->getDatabasePlatform();
|
||||
|
||||
if ($platform instanceof PostgreSQLPlatform) {
|
||||
$connection->executeStatement(
|
||||
//From: https://wiki.postgresql.org/wiki/Fixing_Sequences
|
||||
<<<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;
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
}
|
||||
445
src/Command/UpdateCommand.php
Normal file
445
src/Command/UpdateCommand.php
Normal file
@@ -0,0 +1,445 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'partdb:update', description: 'Check for and install Part-DB updates', aliases: ['app:update'])]
|
||||
class UpdateCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly UpdateChecker $updateChecker,
|
||||
private readonly UpdateExecutor $updateExecutor)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp(<<<'HELP'
|
||||
The <info>%command.name%</info> command checks for Part-DB updates and can install them.
|
||||
|
||||
<comment>Check for updates:</comment>
|
||||
<info>php %command.full_name% --check</info>
|
||||
|
||||
<comment>List available versions:</comment>
|
||||
<info>php %command.full_name% --list</info>
|
||||
|
||||
<comment>Update to the latest version:</comment>
|
||||
<info>php %command.full_name%</info>
|
||||
|
||||
<comment>Update to a specific version:</comment>
|
||||
<info>php %command.full_name% v2.6.0</info>
|
||||
|
||||
<comment>Update without creating a backup (faster but riskier):</comment>
|
||||
<info>php %command.full_name% --no-backup</info>
|
||||
|
||||
<comment>Non-interactive update for scripts:</comment>
|
||||
<info>php %command.full_name% --force</info>
|
||||
|
||||
<comment>View update logs:</comment>
|
||||
<info>php %command.full_name% --logs</info>
|
||||
HELP
|
||||
)
|
||||
->addArgument(
|
||||
'version',
|
||||
InputArgument::OPTIONAL,
|
||||
'Target version to update to (e.g., v2.6.0). If not specified, updates to the latest stable version.'
|
||||
)
|
||||
->addOption(
|
||||
'check',
|
||||
'c',
|
||||
InputOption::VALUE_NONE,
|
||||
'Only check for updates without installing'
|
||||
)
|
||||
->addOption(
|
||||
'list',
|
||||
'l',
|
||||
InputOption::VALUE_NONE,
|
||||
'List all available versions'
|
||||
)
|
||||
->addOption(
|
||||
'no-backup',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip creating a backup before updating (not recommended)'
|
||||
)
|
||||
->addOption(
|
||||
'force',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip confirmation prompts'
|
||||
)
|
||||
->addOption(
|
||||
'include-prerelease',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Include pre-release versions'
|
||||
)
|
||||
->addOption(
|
||||
'logs',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Show recent update logs'
|
||||
)
|
||||
->addOption(
|
||||
'refresh',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Force refresh of cached version information'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Handle --logs option
|
||||
if ($input->getOption('logs')) {
|
||||
return $this->showLogs($io);
|
||||
}
|
||||
|
||||
// Handle --refresh option
|
||||
if ($input->getOption('refresh')) {
|
||||
$io->text('Refreshing version information...');
|
||||
$this->updateChecker->refreshVersionInfo();
|
||||
$io->success('Version cache cleared.');
|
||||
}
|
||||
|
||||
// Handle --list option
|
||||
if ($input->getOption('list')) {
|
||||
return $this->listVersions($io, $input->getOption('include-prerelease'));
|
||||
}
|
||||
|
||||
// Get update status
|
||||
$status = $this->updateChecker->getUpdateStatus();
|
||||
|
||||
// Display current status
|
||||
$io->title('Part-DB Update Manager');
|
||||
|
||||
$this->displayStatus($io, $status);
|
||||
|
||||
// Handle --check option
|
||||
if ($input->getOption('check')) {
|
||||
return $this->checkOnly($io, $status);
|
||||
}
|
||||
|
||||
// Validate we can update
|
||||
$validationResult = $this->validateUpdate($io, $status);
|
||||
if ($validationResult !== null) {
|
||||
return $validationResult;
|
||||
}
|
||||
|
||||
// Determine target version
|
||||
$targetVersion = $input->getArgument('version');
|
||||
$includePrerelease = $input->getOption('include-prerelease');
|
||||
|
||||
if (!$targetVersion) {
|
||||
$latest = $this->updateChecker->getLatestVersion($includePrerelease);
|
||||
if (!$latest) {
|
||||
$io->error('Could not determine the latest version. Please specify a version manually.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$targetVersion = $latest['tag'];
|
||||
}
|
||||
|
||||
// Validate target version
|
||||
if (!$this->updateChecker->isNewerVersionThanCurrent($targetVersion)) {
|
||||
$io->warning(sprintf(
|
||||
'Version %s is not newer than the current version %s.',
|
||||
$targetVersion,
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if (!$input->getOption('force')) {
|
||||
if (!$io->confirm('Do you want to proceed anyway?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm update
|
||||
if (!$input->getOption('force')) {
|
||||
$io->section('Update Plan');
|
||||
|
||||
$io->listing([
|
||||
sprintf('Target version: <info>%s</info>', $targetVersion),
|
||||
$input->getOption('no-backup')
|
||||
? '<fg=yellow>Backup will be SKIPPED</>'
|
||||
: 'A full backup will be created before updating',
|
||||
'Maintenance mode will be enabled during update',
|
||||
'Database migrations will be run automatically',
|
||||
'Cache will be cleared and rebuilt',
|
||||
]);
|
||||
|
||||
$io->warning('The update process may take several minutes. Do not interrupt it.');
|
||||
|
||||
if (!$io->confirm('Do you want to proceed with the update?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute update
|
||||
return $this->executeUpdate($io, $targetVersion, !$input->getOption('no-backup'));
|
||||
}
|
||||
|
||||
private function displayStatus(SymfonyStyle $io, array $status): void
|
||||
{
|
||||
$io->definitionList(
|
||||
['Current Version' => sprintf('<info>%s</info>', $status['current_version'])],
|
||||
['Latest Version' => $status['latest_version']
|
||||
? sprintf('<info>%s</info>', $status['latest_version'])
|
||||
: '<fg=yellow>Unknown</>'],
|
||||
['Installation Type' => $status['installation']['type_name']],
|
||||
['Git Branch' => $status['git']['branch'] ?? '<fg=gray>N/A</>'],
|
||||
['Git Commit' => $status['git']['commit'] ?? '<fg=gray>N/A</>'],
|
||||
['Local Changes' => $status['git']['has_local_changes']
|
||||
? '<fg=yellow>Yes (update blocked)</>'
|
||||
: '<fg=green>No</>'],
|
||||
['Commits Behind' => $status['git']['commits_behind'] > 0
|
||||
? sprintf('<fg=yellow>%d</>', $status['git']['commits_behind'])
|
||||
: '<fg=green>0</>'],
|
||||
['Update Available' => $status['update_available']
|
||||
? '<fg=green>Yes</>'
|
||||
: 'No'],
|
||||
['Can Auto-Update' => $status['can_auto_update']
|
||||
? '<fg=green>Yes</>'
|
||||
: '<fg=yellow>No</>'],
|
||||
);
|
||||
|
||||
if (!empty($status['update_blockers'])) {
|
||||
$io->warning('Update blockers: ' . implode(', ', $status['update_blockers']));
|
||||
}
|
||||
}
|
||||
|
||||
private function checkOnly(SymfonyStyle $io, array $status): int
|
||||
{
|
||||
if (!$status['check_enabled']) {
|
||||
$io->warning('Update checking is disabled in privacy settings.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($status['update_available']) {
|
||||
$io->success(sprintf(
|
||||
'A new version is available: %s (current: %s)',
|
||||
$status['latest_version'],
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if ($status['release_url']) {
|
||||
$io->text(sprintf('Release notes: <href=%s>%s</>', $status['release_url'], $status['release_url']));
|
||||
}
|
||||
|
||||
if ($status['can_auto_update']) {
|
||||
$io->text('');
|
||||
$io->text('Run <info>php bin/console partdb:update</info> to update.');
|
||||
} else {
|
||||
$io->text('');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->success('You are running the latest version.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function validateUpdate(SymfonyStyle $io, array $status): ?int
|
||||
{
|
||||
// Check if update checking is enabled
|
||||
if (!$status['check_enabled']) {
|
||||
$io->error('Update checking is disabled in privacy settings. Enable it to use automatic updates.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Check installation type
|
||||
if (!$status['can_auto_update']) {
|
||||
$io->error('Automatic updates are not supported for this installation type.');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Validate preconditions
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$io->error('Cannot proceed with update:');
|
||||
$io->listing($validation['errors']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executeUpdate(SymfonyStyle $io, string $targetVersion, bool $createBackup): int
|
||||
{
|
||||
$io->section('Executing Update');
|
||||
$io->text(sprintf('Updating to version: <info>%s</info>', $targetVersion));
|
||||
$io->text('');
|
||||
|
||||
$progressCallback = function (array $step) use ($io): void {
|
||||
$icon = $step['success'] ? '<fg=green>✓</>' : '<fg=red>✗</>';
|
||||
$duration = $step['duration'] ? sprintf(' <fg=gray>(%.1fs)</>', $step['duration']) : '';
|
||||
$io->text(sprintf(' %s <info>%s</info>: %s%s', $icon, $step['step'], $step['message'], $duration));
|
||||
};
|
||||
|
||||
// Use executeUpdateWithProgress to update the progress file for web UI
|
||||
$result = $this->updateExecutor->executeUpdateWithProgress($targetVersion, $createBackup, $progressCallback);
|
||||
|
||||
$io->text('');
|
||||
|
||||
if ($result['success']) {
|
||||
$io->success(sprintf(
|
||||
'Successfully updated to %s in %.1f seconds!',
|
||||
$targetVersion,
|
||||
$result['duration']
|
||||
));
|
||||
|
||||
$io->text([
|
||||
sprintf('Rollback tag: <info>%s</info>', $result['rollback_tag']),
|
||||
sprintf('Log file: <info>%s</info>', $result['log_file']),
|
||||
]);
|
||||
|
||||
$io->note('If you encounter any issues, you can rollback using: git checkout ' . $result['rollback_tag']);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->error('Update failed: ' . $result['error']);
|
||||
|
||||
if ($result['rollback_tag']) {
|
||||
$io->warning(sprintf('System was rolled back to: %s', $result['rollback_tag']));
|
||||
}
|
||||
|
||||
if ($result['log_file']) {
|
||||
$io->text(sprintf('See log file for details: %s', $result['log_file']));
|
||||
}
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
private function listVersions(SymfonyStyle $io, bool $includePrerelease): int
|
||||
{
|
||||
$releases = $this->updateChecker->getAvailableReleases(15);
|
||||
$currentVersion = $this->updateChecker->getCurrentVersionString();
|
||||
|
||||
if (empty($releases)) {
|
||||
$io->warning('Could not fetch available versions. Check your internet connection.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title('Available Part-DB Versions');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Tag', 'Version', 'Released', 'Status']);
|
||||
|
||||
foreach ($releases as $release) {
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $release['version'];
|
||||
$status = [];
|
||||
|
||||
if (version_compare($version, $currentVersion, '=')) {
|
||||
$status[] = '<fg=cyan>current</>';
|
||||
} elseif (version_compare($version, $currentVersion, '>')) {
|
||||
$status[] = '<fg=green>newer</>';
|
||||
}
|
||||
|
||||
if ($release['prerelease']) {
|
||||
$status[] = '<fg=yellow>pre-release</>';
|
||||
}
|
||||
|
||||
$table->addRow([
|
||||
$release['tag'],
|
||||
$version,
|
||||
(new \DateTime($release['published_at']))->format('Y-m-d'),
|
||||
implode(' ', $status) ?: '-',
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Use <info>php bin/console partdb:update [tag]</info> to update to a specific version.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function showLogs(SymfonyStyle $io): int
|
||||
{
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
|
||||
if (empty($logs)) {
|
||||
$io->info('No update logs found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->title('Recent Update Logs');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Date', 'File', 'Size']);
|
||||
|
||||
foreach (array_slice($logs, 0, 10) as $log) {
|
||||
$table->addRow([
|
||||
date('Y-m-d H:i:s', $log['date']),
|
||||
$log['file'],
|
||||
$this->formatBytes($log['size']),
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Log files are stored in: <info>var/log/updates/</info>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$unitIndex = 0;
|
||||
|
||||
while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
$unitIndex++;
|
||||
}
|
||||
|
||||
return sprintf('%.1f %s', $bytes, $units[$unitIndex]);
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,9 @@ declare(strict_types=1);
|
||||
*/
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -33,7 +33,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
#[AsCommand('partdb:version|app:version', 'Shows the currently installed version of Part-DB.')]
|
||||
class VersionCommand extends Command
|
||||
{
|
||||
public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfo $gitVersionInfo)
|
||||
public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfoProvider $gitVersionInfo)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -48,9 +48,9 @@ class VersionCommand extends Command
|
||||
|
||||
$message = 'Part-DB version: '. $this->versionManager->getVersion()->toString();
|
||||
|
||||
if ($this->gitVersionInfo->getGitBranchName() !== null) {
|
||||
$message .= ' Git branch: '. $this->gitVersionInfo->getGitBranchName();
|
||||
$message .= ', Git commit: '. $this->gitVersionInfo->getGitCommitHash();
|
||||
if ($this->gitVersionInfo->getBranchName() !== null) {
|
||||
$message .= ' Git branch: '. $this->gitVersionInfo->getBranchName();
|
||||
$message .= ', Git commit: '. $this->gitVersionInfo->getCommitHash();
|
||||
}
|
||||
|
||||
$io->success($message);
|
||||
|
||||
@@ -24,9 +24,9 @@ namespace App\Controller;
|
||||
|
||||
use App\DataTables\LogDataTable;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\BannerHelper;
|
||||
use App\Services\System\UpdateAvailableManager;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use App\Services\System\UpdateAvailableFacade;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -43,8 +43,8 @@ class HomepageController extends AbstractController
|
||||
|
||||
|
||||
#[Route(path: '/', name: 'homepage')]
|
||||
public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager,
|
||||
UpdateAvailableManager $updateAvailableManager): Response
|
||||
public function homepage(Request $request, GitVersionInfoProvider $versionInfo, EntityManagerInterface $entityManager,
|
||||
UpdateAvailableFacade $updateAvailableManager): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
|
||||
|
||||
@@ -77,8 +77,8 @@ class HomepageController extends AbstractController
|
||||
|
||||
return $this->render('homepage.html.twig', [
|
||||
'banner' => $this->bannerHelper->getBanner(),
|
||||
'git_branch' => $versionInfo->getGitBranchName(),
|
||||
'git_commit' => $versionInfo->getGitCommitHash(),
|
||||
'git_branch' => $versionInfo->getBranchName(),
|
||||
'git_commit' => $versionInfo->getCommitHash(),
|
||||
'show_first_steps' => $show_first_steps,
|
||||
'datatable' => $table,
|
||||
'new_version_available' => $updateAvailableManager->isUpdateAvailable(),
|
||||
|
||||
@@ -30,6 +30,7 @@ use App\Form\InfoProviderSystem\PartSearchType;
|
||||
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Services\InfoProviderSystem\Providers\GenericWebProvider;
|
||||
use App\Settings\AppSettings;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -39,11 +40,15 @@ use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\HttpClient\Exception\ClientException;
|
||||
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
#[Route('/tools/info_providers')]
|
||||
@@ -178,6 +183,13 @@ class InfoProviderController extends AbstractController
|
||||
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
} catch (OAuthReconnectRequiredException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.oauth_reconnect', ['%provider%' => $e->getProviderName()]));
|
||||
} catch (TransportException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.transport_exception'));
|
||||
$exceptionLogger->error('Transport error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.general_exception', ['%type%' => (new \ReflectionClass($e))->getShortName()]));
|
||||
//Log the exception
|
||||
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
|
||||
|
||||
@@ -198,4 +210,58 @@ class InfoProviderController extends AbstractController
|
||||
'update_target' => $update_target
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/from_url', name: 'info_providers_from_url')]
|
||||
public function fromURL(Request $request, GenericWebProvider $provider): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
if (!$provider->isActive()) {
|
||||
$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->handleRequest($request);
|
||||
|
||||
$partDetail = null;
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
//Try to retrieve the part detail from the given URL
|
||||
$url = $form->get('url')->getData();
|
||||
try {
|
||||
$searchResult = $this->infoRetriever->searchByKeyword(
|
||||
keyword: $url,
|
||||
providers: [$provider]
|
||||
);
|
||||
|
||||
if (count($searchResult) === 0) {
|
||||
$this->addFlash('warning', t('info_providers.from_url.no_part_found'));
|
||||
} else {
|
||||
$searchResult = $searchResult[0];
|
||||
//Redirect to the part creation page with the found part detail
|
||||
return $this->redirectToRoute('info_providers_create_part', [
|
||||
'providerKey' => $searchResult->provider_key,
|
||||
'providerId' => $searchResult->provider_id,
|
||||
]);
|
||||
}
|
||||
} catch (ExceptionInterface $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.general_exception', ['%type%' => (new \ReflectionClass($e))->getShortName()]));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('info_providers/from_url/from_url.html.twig', [
|
||||
'form' => $form,
|
||||
'partDetail' => $partDetail,
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ final class PartController extends AbstractController
|
||||
'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
|
||||
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
|
||||
'withdraw_add_helper' => $withdrawAddHelper,
|
||||
'highlightLotId' => $request->query->getInt('highlightLot', 0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,6 +319,7 @@ class PartListsController extends AbstractController
|
||||
|
||||
//As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)!
|
||||
$filter->setName($request->query->getBoolean('name'));
|
||||
$filter->setDbId($request->query->getBoolean('dbid'));
|
||||
$filter->setCategory($request->query->getBoolean('category'));
|
||||
$filter->setDescription($request->query->getBoolean('description'));
|
||||
$filter->setMpn($request->query->getBoolean('mpn'));
|
||||
|
||||
@@ -27,8 +27,8 @@ use App\Services\Attachments\AttachmentURLGenerator;
|
||||
use App\Services\Attachments\BuiltinAttachmentsFinder;
|
||||
use App\Services\Doctrine\DBInfoHelper;
|
||||
use App\Services\Doctrine\NatsortDebugHelper;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\UpdateAvailableManager;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use App\Services\System\UpdateAvailableFacade;
|
||||
use App\Settings\AppSettings;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -47,16 +47,16 @@ class ToolsController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/server_infos', name: 'tools_server_infos')]
|
||||
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager,
|
||||
public function systemInfos(GitVersionInfoProvider $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableFacade $updateAvailableManager,
|
||||
AppSettings $settings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.server_infos');
|
||||
|
||||
return $this->render('tools/server_infos/server_infos.html.twig', [
|
||||
//Part-DB section
|
||||
'git_branch' => $versionInfo->getGitBranchName(),
|
||||
'git_commit' => $versionInfo->getGitCommitHash(),
|
||||
'git_branch' => $versionInfo->getBranchName(),
|
||||
'git_commit' => $versionInfo->getCommitHash(),
|
||||
'default_locale' => $settings->system->localization->locale,
|
||||
'default_timezone' => $settings->system->localization->timezone,
|
||||
'default_currency' => $settings->system->localization->baseCurrency,
|
||||
|
||||
371
src/Controller/UpdateManagerController.php
Normal file
371
src/Controller/UpdateManagerController.php
Normal file
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Services\System\BackupManager;
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Controller for the Update Manager web interface.
|
||||
*
|
||||
* This provides a read-only view of update status and instructions.
|
||||
* Actual updates should be performed via the CLI command for safety.
|
||||
*/
|
||||
#[Route('/system/update-manager')]
|
||||
class UpdateManagerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UpdateChecker $updateChecker,
|
||||
private readonly UpdateExecutor $updateExecutor,
|
||||
private readonly VersionManagerInterface $versionManager,
|
||||
private readonly BackupManager $backupManager,
|
||||
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
||||
private readonly bool $webUpdatesDisabled = false,
|
||||
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
||||
private readonly bool $backupRestoreDisabled = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if web updates are disabled and throw exception if so.
|
||||
*/
|
||||
private function denyIfWebUpdatesDisabled(): void
|
||||
{
|
||||
if ($this->webUpdatesDisabled) {
|
||||
throw new AccessDeniedHttpException('Web-based updates are disabled by server configuration. Please use the CLI command instead.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if backup restore is disabled and throw exception if so.
|
||||
*/
|
||||
private function denyIfBackupRestoreDisabled(): void
|
||||
{
|
||||
if ($this->backupRestoreDisabled) {
|
||||
throw new AccessDeniedHttpException('Backup restore is disabled by server configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main update manager page.
|
||||
*/
|
||||
#[Route('', name: 'admin_update_manager', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$status = $this->updateChecker->getUpdateStatus();
|
||||
$availableUpdates = $this->updateChecker->getAvailableUpdates();
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
|
||||
return $this->render('admin/update_manager/index.html.twig', [
|
||||
'status' => $status,
|
||||
'available_updates' => $availableUpdates,
|
||||
'all_releases' => $this->updateChecker->getAvailableReleases(10),
|
||||
'validation' => $validation,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'lock_info' => $this->updateExecutor->getLockInfo(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(),
|
||||
'update_logs' => $this->updateExecutor->getUpdateLogs(),
|
||||
'backups' => $this->backupManager->getBackups(),
|
||||
'web_updates_disabled' => $this->webUpdatesDisabled,
|
||||
'backup_restore_disabled' => $this->backupRestoreDisabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to check update status.
|
||||
*/
|
||||
#[Route('/status', name: 'admin_update_manager_status', methods: ['GET'])]
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
return $this->json([
|
||||
'status' => $this->updateChecker->getUpdateStatus(),
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'lock_info' => $this->updateExecutor->getLockInfo(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to refresh version information.
|
||||
*/
|
||||
#[Route('/refresh', name: 'admin_update_manager_refresh', methods: ['POST'])]
|
||||
public function refresh(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_refresh', $request->request->get('_token'))) {
|
||||
return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$this->updateChecker->refreshVersionInfo();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'status' => $this->updateChecker->getUpdateStatus(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View release notes for a specific version.
|
||||
*/
|
||||
#[Route('/release/{tag}', name: 'admin_update_manager_release', methods: ['GET'])]
|
||||
public function releaseNotes(string $tag): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$releases = $this->updateChecker->getAvailableReleases(20);
|
||||
$release = null;
|
||||
|
||||
foreach ($releases as $r) {
|
||||
if ($r['tag'] === $tag) {
|
||||
$release = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$release) {
|
||||
throw $this->createNotFoundException('Release not found');
|
||||
}
|
||||
|
||||
return $this->render('admin/update_manager/release_notes.html.twig', [
|
||||
'release' => $release,
|
||||
'current_version' => $this->updateChecker->getCurrentVersionString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View an update log file.
|
||||
*/
|
||||
#[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])]
|
||||
public function viewLog(string $filename): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
// Security: Only allow viewing files from the update logs directory
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
$logPath = null;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if ($log['file'] === $filename) {
|
||||
$logPath = $log['path'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$logPath || !file_exists($logPath)) {
|
||||
throw $this->createNotFoundException('Log file not found');
|
||||
}
|
||||
|
||||
$content = file_get_contents($logPath);
|
||||
|
||||
return $this->render('admin/update_manager/log_viewer.html.twig', [
|
||||
'filename' => $filename,
|
||||
'content' => $content,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an update process.
|
||||
*/
|
||||
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
|
||||
public function startUpdate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfWebUpdatesDisabled();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if update is already running
|
||||
if ($this->updateExecutor->isLocked() || $this->updateExecutor->isUpdateRunning()) {
|
||||
$this->addFlash('error', 'An update is already in progress.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$targetVersion = $request->request->get('version');
|
||||
$createBackup = $request->request->getBoolean('backup', true);
|
||||
|
||||
if (!$targetVersion) {
|
||||
// Get latest version if not specified
|
||||
$latest = $this->updateChecker->getLatestVersion();
|
||||
if (!$latest) {
|
||||
$this->addFlash('error', 'Could not determine target version.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
$targetVersion = $latest['tag'];
|
||||
}
|
||||
|
||||
// Validate preconditions
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$this->addFlash('error', implode(' ', $validation['errors']));
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Start the background update
|
||||
$pid = $this->updateExecutor->startBackgroundUpdate($targetVersion, $createBackup);
|
||||
|
||||
if (!$pid) {
|
||||
$this->addFlash('error', 'Failed to start update process.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Redirect to progress page
|
||||
return $this->redirectToRoute('admin_update_manager_progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress page.
|
||||
*/
|
||||
#[Route('/progress', name: 'admin_update_manager_progress', methods: ['GET'])]
|
||||
public function progress(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
$progress = $this->updateExecutor->getProgress();
|
||||
$currentVersion = $this->versionManager->getVersion()->toString();
|
||||
|
||||
// Determine if this is a downgrade
|
||||
$isDowngrade = false;
|
||||
if ($progress && isset($progress['target_version'])) {
|
||||
$targetVersion = ltrim($progress['target_version'], 'v');
|
||||
$isDowngrade = version_compare($targetVersion, $currentVersion, '<');
|
||||
}
|
||||
|
||||
return $this->render('admin/update_manager/progress.html.twig', [
|
||||
'progress' => $progress,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
'is_downgrade' => $isDowngrade,
|
||||
'current_version' => $currentVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint to get update progress.
|
||||
*/
|
||||
#[Route('/progress/status', name: 'admin_update_manager_progress_status', methods: ['GET'])]
|
||||
public function progressStatus(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_updates');
|
||||
|
||||
$progress = $this->updateExecutor->getProgress();
|
||||
|
||||
return $this->json([
|
||||
'progress' => $progress,
|
||||
'is_locked' => $this->updateExecutor->isLocked(),
|
||||
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup details for restore confirmation.
|
||||
*/
|
||||
#[Route('/backup/{filename}', name: 'admin_update_manager_backup_details', methods: ['GET'])]
|
||||
public function backupDetails(string $filename): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
|
||||
$details = $this->backupManager->getBackupDetails($filename);
|
||||
|
||||
if (!$details) {
|
||||
return $this->json(['error' => 'Backup not found'], 404);
|
||||
}
|
||||
|
||||
return $this->json($details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a backup.
|
||||
*/
|
||||
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
|
||||
public function restore(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||
$this->denyIfBackupRestoreDisabled();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) {
|
||||
$this->addFlash('error', 'Invalid CSRF token.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Check if already locked
|
||||
if ($this->updateExecutor->isLocked()) {
|
||||
$this->addFlash('error', 'An update or restore is already in progress.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
$filename = $request->request->get('filename');
|
||||
$restoreDatabase = $request->request->getBoolean('restore_database', true);
|
||||
$restoreConfig = $request->request->getBoolean('restore_config', false);
|
||||
$restoreAttachments = $request->request->getBoolean('restore_attachments', false);
|
||||
|
||||
if (!$filename) {
|
||||
$this->addFlash('error', 'No backup file specified.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Verify the backup exists
|
||||
$backupDetails = $this->backupManager->getBackupDetails($filename);
|
||||
if (!$backupDetails) {
|
||||
$this->addFlash('error', 'Backup file not found.');
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
|
||||
// Execute restore (this is a synchronous operation for now - could be made async later)
|
||||
$result = $this->updateExecutor->restoreBackup(
|
||||
$filename,
|
||||
$restoreDatabase,
|
||||
$restoreConfig,
|
||||
$restoreAttachments
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->addFlash('success', 'Backup restored successfully.');
|
||||
} else {
|
||||
$this->addFlash('error', 'Restore failed: ' . ($result['error'] ?? 'Unknown error'));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_update_manager');
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||
namespace App\DataTables\Filters;
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
|
||||
class PartSearchFilter implements FilterInterface
|
||||
{
|
||||
@@ -33,6 +34,9 @@ class PartSearchFilter implements FilterInterface
|
||||
/** @var bool Use name field for searching */
|
||||
protected bool $name = true;
|
||||
|
||||
/** @var bool Use id field for searching */
|
||||
protected bool $dbId = false;
|
||||
|
||||
/** @var bool Use category name for searching */
|
||||
protected bool $category = true;
|
||||
|
||||
@@ -120,33 +124,51 @@ class PartSearchFilter implements FilterInterface
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
$fields_to_search = $this->getFieldsToSearch();
|
||||
$is_numeric = preg_match('/^\d+$/', $this->keyword) === 1;
|
||||
|
||||
// Add exact ID match only when the keyword is numeric
|
||||
$search_dbId = $is_numeric && (bool)$this->dbId;
|
||||
|
||||
//If we have nothing to search for, do nothing
|
||||
if ($fields_to_search === [] || $this->keyword === '') {
|
||||
if (($fields_to_search === [] && !$search_dbId) || $this->keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
//Convert the fields to search to a list of expressions
|
||||
$expressions = array_map(function (string $field): string {
|
||||
$expressions = [];
|
||||
|
||||
if($fields_to_search !== []) {
|
||||
//Convert the fields to search to a list of expressions
|
||||
$expressions = array_map(function (string $field): string {
|
||||
if ($this->regex) {
|
||||
return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
|
||||
}
|
||||
|
||||
return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
|
||||
}, $fields_to_search);
|
||||
|
||||
//For regex, we pass the query as is, for like we add % to the start and end as wildcards
|
||||
if ($this->regex) {
|
||||
return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
|
||||
$queryBuilder->setParameter('search_query', $this->keyword);
|
||||
} else {
|
||||
//Escape % and _ characters in the keyword
|
||||
$this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
|
||||
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
|
||||
}, $fields_to_search);
|
||||
//Use equal expression to just search for exact numeric matches
|
||||
if ($search_dbId) {
|
||||
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
|
||||
$queryBuilder->setParameter('id_exact', (int) $this->keyword,
|
||||
\Doctrine\DBAL\ParameterType::INTEGER);
|
||||
}
|
||||
|
||||
//Add Or concatenation of the expressions to our query
|
||||
$queryBuilder->andWhere(
|
||||
$queryBuilder->expr()->orX(...$expressions)
|
||||
);
|
||||
|
||||
//For regex, we pass the query as is, for like we add % to the start and end as wildcards
|
||||
if ($this->regex) {
|
||||
$queryBuilder->setParameter('search_query', $this->keyword);
|
||||
} else {
|
||||
//Escape % and _ characters in the keyword
|
||||
$this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
|
||||
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
|
||||
//Guard condition
|
||||
if (!empty($expressions)) {
|
||||
//Add Or concatenation of the expressions to our query
|
||||
$queryBuilder->andWhere(
|
||||
$queryBuilder->expr()->orX(...$expressions)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +205,17 @@ class PartSearchFilter implements FilterInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDbId(): bool
|
||||
{
|
||||
return $this->dbId;
|
||||
}
|
||||
|
||||
public function setDbId(bool $dbId): PartSearchFilter
|
||||
{
|
||||
$this->dbId = $dbId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isCategory(): bool
|
||||
{
|
||||
return $this->category;
|
||||
|
||||
@@ -208,6 +208,7 @@ class LogDataTable implements DataTableTypeInterface
|
||||
|
||||
$dataTable->add('extra', LogEntryExtraColumn::class, [
|
||||
'label' => 'log.extra',
|
||||
'orderable' => false, //Sorting the JSON column makes no sense: MySQL/Sqlite does it via the string representation, PostgreSQL errors out
|
||||
]);
|
||||
|
||||
$dataTable->add('timeTravel', IconLinkColumn::class, [
|
||||
|
||||
@@ -29,6 +29,7 @@ use App\DataTables\Helpers\PartDataTableHelper;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
@@ -41,7 +42,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
{
|
||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper,
|
||||
protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -79,7 +81,14 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
|
||||
},
|
||||
])
|
||||
|
||||
->add('partId', TextColumn::class, [
|
||||
'label' => $this->translator->trans('project.bom.part_id'),
|
||||
'visible' => true,
|
||||
'orderField' => 'part.id',
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
return $context->getPart() instanceof Part ? (string) $context->getPart()->getId() : '';
|
||||
},
|
||||
])
|
||||
->add('name', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.name'),
|
||||
'orderField' => 'NATSORT(part.name)',
|
||||
|
||||
@@ -104,7 +104,7 @@ final class FieldHelper
|
||||
{
|
||||
$db_platform = $qb->getEntityManager()->getConnection()->getDatabasePlatform();
|
||||
|
||||
$key = 'field2_' . md5($field_expr);
|
||||
$key = 'field2_' . hash('xxh3', $field_expr);
|
||||
|
||||
//If we are on MySQL, we can just use the FIELD function
|
||||
if ($db_platform instanceof AbstractMySQLPlatform) {
|
||||
@@ -121,4 +121,4 @@ final class FieldHelper
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class EDACategoryInfo
|
||||
/** @var bool|null If this is set to true, then this part will be excluded in the simulation */
|
||||
#[Column(type: Types::BOOLEAN, nullable: true)]
|
||||
#[Groups(['full', 'category:read', 'category:write', 'import'])]
|
||||
private ?bool $exclude_from_sim = true;
|
||||
private ?bool $exclude_from_sim = null;
|
||||
|
||||
/** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */
|
||||
#[Column(type: Types::STRING, nullable: true)]
|
||||
|
||||
230
src/EventSubscriber/MaintenanceModeSubscriber.php
Normal file
230
src/EventSubscriber/MaintenanceModeSubscriber.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Blocks all web requests when maintenance mode is enabled during updates.
|
||||
*/
|
||||
readonly class MaintenanceModeSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private UpdateExecutor $updateExecutor)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// High priority to run before other listeners
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 512], //High priority to run before other listeners
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// Only handle main requests
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not in maintenance mode
|
||||
if (!$this->updateExecutor->isMaintenanceMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Allow to view the progress page
|
||||
if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow CLI requests
|
||||
if (PHP_SAPI === 'cli') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get maintenance info
|
||||
$maintenanceInfo = $this->updateExecutor->getMaintenanceInfo();
|
||||
|
||||
// Calculate how long the update has been running
|
||||
$duration = null;
|
||||
if ($maintenanceInfo && isset($maintenanceInfo['enabled_at'])) {
|
||||
try {
|
||||
$startedAt = new \DateTime($maintenanceInfo['enabled_at']);
|
||||
$now = new \DateTime();
|
||||
$duration = $now->getTimestamp() - $startedAt->getTimestamp();
|
||||
} catch (\Exception) {
|
||||
// Ignore date parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
$content = $this->getSimpleMaintenanceHtml($maintenanceInfo, $duration);
|
||||
|
||||
$response = new Response($content, Response::HTTP_SERVICE_UNAVAILABLE);
|
||||
$response->headers->set('Retry-After', '30');
|
||||
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
|
||||
$event->setResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple maintenance page HTML without Twig.
|
||||
*/
|
||||
private function getSimpleMaintenanceHtml(?array $maintenanceInfo, ?int $duration): string
|
||||
{
|
||||
$reason = htmlspecialchars($maintenanceInfo['reason'] ?? 'Update in progress');
|
||||
$durationText = $duration !== null ? sprintf('%d seconds', $duration) : 'a moment';
|
||||
|
||||
$startDateStr = $maintenanceInfo['enabled_at'] ?? 'unknown time';
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="15">
|
||||
<title>Part-DB - Maintenance</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 30px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #00d4ff;
|
||||
}
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
color: #b8c5d6;
|
||||
}
|
||||
.reason {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 15px 25px;
|
||||
border-radius: 10px;
|
||||
margin: 20px 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.progress-bar-inner {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||
border-radius: 3px;
|
||||
animation: progress 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes progress {
|
||||
0% { width: 0%; margin-left: 0%; }
|
||||
50% { width: 50%; margin-left: 25%; }
|
||||
100% { width: 0%; margin-left: 100%; }
|
||||
}
|
||||
.info {
|
||||
font-size: 0.9rem;
|
||||
color: #8899aa;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.duration {
|
||||
font-family: monospace;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<span class="spinner">⚙️</span>
|
||||
</div>
|
||||
<h1>Part-DB is under maintenance</h1>
|
||||
<p>We're making things better. This should only take a moment.</p>
|
||||
|
||||
<div class="reason">
|
||||
<strong>{$reason}</strong>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-inner"></div>
|
||||
</div>
|
||||
|
||||
<p class="info">
|
||||
Maintenance mode active since <span class="duration">{$startDateStr}</span><br>
|
||||
<br>
|
||||
Started <span class="duration">{$durationText}</span> ago<br>
|
||||
<small>This page will automatically refresh every 15 seconds.</small>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
32
src/Exceptions/ProviderIDNotSupportedException.php
Normal file
32
src/Exceptions/ProviderIDNotSupportedException.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
class ProviderIDNotSupportedException extends \RuntimeException
|
||||
{
|
||||
public function fromProvider(string $providerKey, string $id): self
|
||||
{
|
||||
return new self(sprintf('The given ID %s is not supported by the provider %s.', $id, $providerKey,));
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,13 @@ class DBElementRepository extends EntityRepository
|
||||
return [];
|
||||
}
|
||||
|
||||
//Ensure that all IDs are integers and none is null
|
||||
foreach ($ids as $id) {
|
||||
if (!is_int($id)) {
|
||||
throw new \InvalidArgumentException('Non-integer ID given to findByIDInMatchingOrder: ' . var_export($id, true));
|
||||
}
|
||||
}
|
||||
|
||||
$cache_key = implode(',', $ids);
|
||||
|
||||
//Check if the result is already cached
|
||||
|
||||
@@ -139,7 +139,7 @@ class FileTypeFilterTools
|
||||
{
|
||||
$filter = trim($filter);
|
||||
|
||||
return $this->cache->get('filter_exts_'.md5($filter), function (ItemInterface $item) use ($filter) {
|
||||
return $this->cache->get('filter_exts_'.hash('xxh3', $filter), function (ItemInterface $item) use ($filter) {
|
||||
$elements = explode(',', $filter);
|
||||
$extensions = [];
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class UserCacheKeyGenerator
|
||||
//If the user is null, then treat it as anonymous user.
|
||||
//When the anonymous user is passed as user then use this path too.
|
||||
if (!($user instanceof User) || User::ID_ANONYMOUS === $user->getID()) {
|
||||
return 'user$_'.User::ID_ANONYMOUS;
|
||||
return 'user$_'.User::ID_ANONYMOUS . '_'.$locale;
|
||||
}
|
||||
|
||||
//Use the unique user id and the locale to generate the key
|
||||
|
||||
@@ -189,7 +189,7 @@ class KiCadHelper
|
||||
"symbolIdStr" => $part->getEdaInfo()->getKicadSymbol() ?? $part->getCategory()?->getEdaInfo()->getKicadSymbol() ?? "",
|
||||
"exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
|
||||
"exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
|
||||
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? true),
|
||||
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
|
||||
"fields" => []
|
||||
];
|
||||
|
||||
|
||||
@@ -274,6 +274,16 @@ class BOMImporter
|
||||
$entries_by_key = []; // Track entries by name+part combination
|
||||
$mapped_entries = []; // Collect all mapped entries for validation
|
||||
|
||||
// Fetch suppliers once for efficiency
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
$supplierSPNKeys = [];
|
||||
$suppliersByName = []; // Map supplier names to supplier objects
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierName = $supplier->getName();
|
||||
$supplierSPNKeys[] = $supplierName . ' SPN';
|
||||
$suppliersByName[$supplierName] = $supplier;
|
||||
}
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
// Apply field mapping to translate column names
|
||||
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
|
||||
@@ -349,6 +359,41 @@ class BOMImporter
|
||||
}
|
||||
}
|
||||
|
||||
// Try to link existing part based on supplier part number if no Part-DB ID is given
|
||||
if ($part === null) {
|
||||
// Check all available supplier SPN fields
|
||||
foreach ($suppliersByName as $supplierName => $supplier) {
|
||||
$supplier_spn = null;
|
||||
|
||||
if (isset($mapped_entry[$supplierName . ' SPN']) && !empty(trim($mapped_entry[$supplierName . ' SPN']))) {
|
||||
$supplier_spn = trim($mapped_entry[$supplierName . ' SPN']);
|
||||
}
|
||||
|
||||
if ($supplier_spn !== null) {
|
||||
// Query for orderdetails with matching supplier and SPN
|
||||
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
|
||||
->findOneBy([
|
||||
'supplier' => $supplier,
|
||||
'supplierpartnr' => $supplier_spn,
|
||||
]);
|
||||
|
||||
if ($orderdetail !== null && $orderdetail->getPart() !== null) {
|
||||
$part = $orderdetail->getPart();
|
||||
$name = $part->getName(); // Update name with actual part name
|
||||
|
||||
$this->logger->info('Linked BOM entry to existing part via supplier SPN', [
|
||||
'supplier' => $supplierName,
|
||||
'supplier_spn' => $supplier_spn,
|
||||
'part_id' => $part->getID(),
|
||||
'part_name' => $part->getName(),
|
||||
]);
|
||||
|
||||
break; // Stop searching once a match is found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create unique key for this entry (name + part ID)
|
||||
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
|
||||
|
||||
@@ -400,9 +445,14 @@ class BOMImporter
|
||||
if (isset($mapped_entry['Manufacturer'])) {
|
||||
$comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
|
||||
}
|
||||
if (isset($mapped_entry['LCSC'])) {
|
||||
$comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
|
||||
|
||||
// Add supplier part numbers dynamically
|
||||
foreach ($supplierSPNKeys as $spnKey) {
|
||||
if (isset($mapped_entry[$spnKey]) && !empty($mapped_entry[$spnKey])) {
|
||||
$comment_parts[] = $spnKey . ': ' . $mapped_entry[$spnKey];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($mapped_entry['Supplier and ref'])) {
|
||||
$comment_parts[] = $mapped_entry['Supplier and ref'];
|
||||
}
|
||||
|
||||
@@ -39,10 +39,10 @@ class PKImportHelper
|
||||
* Existing users and groups are not purged.
|
||||
* This is needed to avoid ID collisions.
|
||||
*/
|
||||
public function purgeDatabaseForImport(): void
|
||||
public function purgeDatabaseForImport(?EntityManagerInterface $entityManager = null, array $excluded_tables = ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']): void
|
||||
{
|
||||
//We use the ResetAutoIncrementORMPurger to reset the auto increment values of the tables. Also it normalizes table names before checking for exclusion.
|
||||
$purger = new ResetAutoIncrementORMPurger($this->em, ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']);
|
||||
$purger = new ResetAutoIncrementORMPurger($entityManager ?? $this->em, $excluded_tables);
|
||||
$purger->purge();
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ declare(strict_types=1);
|
||||
namespace App\Services\InfoProviderSystem;
|
||||
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\InfoProviderSystem\Providers\URLHandlerInfoProviderInterface;
|
||||
|
||||
/**
|
||||
* This class keeps track of all registered info providers and allows to find them by their key
|
||||
@@ -47,6 +48,8 @@ final class ProviderRegistry
|
||||
*/
|
||||
private array $providers_disabled = [];
|
||||
|
||||
private array $providers_by_domain = [];
|
||||
|
||||
/**
|
||||
* @var bool Whether the registry has been initialized
|
||||
*/
|
||||
@@ -78,6 +81,14 @@ final class ProviderRegistry
|
||||
$this->providers_by_name[$key] = $provider;
|
||||
if ($provider->isActive()) {
|
||||
$this->providers_active[$key] = $provider;
|
||||
if ($provider instanceof URLHandlerInfoProviderInterface) {
|
||||
foreach ($provider->getHandledDomains() as $domain) {
|
||||
if (isset($this->providers_by_domain[$domain])) {
|
||||
throw new \LogicException("Domain $domain is already handled by another provider");
|
||||
}
|
||||
$this->providers_by_domain[$domain] = $provider;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->providers_disabled[$key] = $provider;
|
||||
}
|
||||
@@ -139,4 +150,29 @@ final class ProviderRegistry
|
||||
|
||||
return $this->providers_disabled;
|
||||
}
|
||||
}
|
||||
|
||||
public function getProviderHandlingDomain(string $domain): (InfoProviderInterface&URLHandlerInfoProviderInterface)|null
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initStructures();
|
||||
}
|
||||
|
||||
//Check if the domain is directly existing:
|
||||
if (isset($this->providers_by_domain[$domain])) {
|
||||
return $this->providers_by_domain[$domain];
|
||||
}
|
||||
|
||||
//Otherwise check for subdomains:
|
||||
$parts = explode('.', $domain);
|
||||
while (count($parts) > 2) {
|
||||
array_shift($parts);
|
||||
$check_domain = implode('.', $parts);
|
||||
if (isset($this->providers_by_domain[$check_domain])) {
|
||||
return $this->providers_by_domain[$check_domain];
|
||||
}
|
||||
}
|
||||
|
||||
//If we found nothing, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
639
src/Services/InfoProviderSystem/Providers/BuerklinProvider.php
Normal file
639
src/Services/InfoProviderSystem/Providers/BuerklinProvider.php
Normal file
@@ -0,0 +1,639 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
* Copyright (C) 2025 Marc Kreidler (https://github.com/mkne)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\BuerklinSettings;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class BuerklinProvider implements BatchInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
|
||||
|
||||
public const DISTRIBUTOR_NAME = 'Buerklin';
|
||||
|
||||
private const CACHE_TTL = 600;
|
||||
/**
|
||||
* Local in-request cache to avoid hitting the PSR cache repeatedly for the same product.
|
||||
* @var array<string, array>
|
||||
*/
|
||||
private array $productCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly CacheItemPoolInterface $partInfoCache,
|
||||
private readonly BuerklinSettings $settings,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest OAuth token for the Buerklin API, or creates a new one if none is available
|
||||
* TODO: Rework this to use the OAuth token manager system in the database...
|
||||
* @return string
|
||||
*/
|
||||
private function getToken(): string
|
||||
{
|
||||
// Cache token to avoid hammering the auth server on every request
|
||||
$cacheKey = 'buerklin.oauth.token';
|
||||
$item = $this->partInfoCache->getItem($cacheKey);
|
||||
|
||||
if ($item->isHit()) {
|
||||
$token = $item->get();
|
||||
if (is_string($token) && $token !== '') {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
// Buerklin OAuth2 password grant (ROPC)
|
||||
$resp = $this->client->request('POST', 'https://www.buerklin.com/authorizationserver/oauth/token/', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
'body' => [
|
||||
'grant_type' => 'password',
|
||||
'client_id' => $this->settings->clientId,
|
||||
'client_secret' => $this->settings->secret,
|
||||
'username' => $this->settings->username,
|
||||
'password' => $this->settings->password,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = $resp->toArray(false);
|
||||
|
||||
if (!isset($data['access_token'])) {
|
||||
throw new \RuntimeException(
|
||||
'Invalid token response from Buerklin: HTTP ' . $resp->getStatusCode() . ' body=' . $resp->getContent(false)
|
||||
);
|
||||
}
|
||||
|
||||
$token = (string) $data['access_token'];
|
||||
|
||||
// Cache for (expires_in - 30s) if available
|
||||
$ttl = 300;
|
||||
if (isset($data['expires_in']) && is_numeric($data['expires_in'])) {
|
||||
$ttl = max(60, (int) $data['expires_in'] - 30);
|
||||
}
|
||||
|
||||
$item->set($token);
|
||||
$item->expiresAfter($ttl);
|
||||
$this->partInfoCache->save($item);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function getDefaultQueryParams(): array
|
||||
{
|
||||
return [
|
||||
'curr' => $this->settings->currency ?: 'EUR',
|
||||
'language' => $this->settings->language ?: 'en',
|
||||
];
|
||||
}
|
||||
|
||||
private function getProduct(string $code): array
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code === '') {
|
||||
throw new \InvalidArgumentException('Product code must not be empty.');
|
||||
}
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'buerklin.product.%s',
|
||||
md5($code . '|' . $this->settings->language . '|' . $this->settings->currency)
|
||||
);
|
||||
|
||||
if (isset($this->productCache[$cacheKey])) {
|
||||
return $this->productCache[$cacheKey];
|
||||
}
|
||||
|
||||
$item = $this->partInfoCache->getItem($cacheKey);
|
||||
if ($item->isHit() && is_array($cached = $item->get())) {
|
||||
return $this->productCache[$cacheKey] = $cached;
|
||||
}
|
||||
|
||||
$product = $this->makeAPICall('/products/' . rawurlencode($code) . '/');
|
||||
|
||||
$item->set($product);
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
$this->partInfoCache->save($item);
|
||||
|
||||
return $this->productCache[$cacheKey] = $product;
|
||||
}
|
||||
|
||||
private function makeAPICall(string $endpoint, array $queryParams = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [
|
||||
'auth_bearer' => $this->getToken(),
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
'query' => array_merge($this->getDefaultQueryParams(), $queryParams),
|
||||
]);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException("Buerklin API request failed: " .
|
||||
"Endpoint: " . $endpoint .
|
||||
"Token: [redacted] " .
|
||||
"QueryParams: " . json_encode($queryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " .
|
||||
"Exception message: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Buerklin',
|
||||
'description' => 'This provider uses the Buerklin API to search for parts.',
|
||||
'url' => 'https://www.buerklin.com/',
|
||||
'disabled_help' => 'Configure the API Client ID, Secret, Username and Password provided by Buerklin in the provider settings to enable.',
|
||||
'settings_class' => BuerklinSettings::class
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'buerklin';
|
||||
}
|
||||
|
||||
// This provider is considered active if settings are present
|
||||
public function isActive(): bool
|
||||
{
|
||||
// The client credentials and user credentials must be set
|
||||
return $this->settings->clientId !== null && $this->settings->clientId !== ''
|
||||
&& $this->settings->secret !== null && $this->settings->secret !== ''
|
||||
&& $this->settings->username !== null && $this->settings->username !== ''
|
||||
&& $this->settings->password !== null && $this->settings->password !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a field by removing any HTML tags and other unwanted characters
|
||||
* @param string|null $field
|
||||
* @return string|null
|
||||
*/
|
||||
private function sanitizeField(?string $field): ?string
|
||||
{
|
||||
if ($field === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strip_tags($field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a deserialized JSON object of the product and returns a PartDetailDTO
|
||||
* @param array $product
|
||||
* @return PartDetailDTO
|
||||
*/
|
||||
private function getPartDetail(array $product): PartDetailDTO
|
||||
{
|
||||
// If this is a search-result object, it may not contain prices/features/images -> reload full details.
|
||||
if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) {
|
||||
try {
|
||||
$product = $this->getProduct((string) $product['code']);
|
||||
} catch (\Throwable $e) {
|
||||
// If reload fails, keep the partial product data and continue.
|
||||
}
|
||||
}
|
||||
|
||||
// Extract images from API response
|
||||
$productImages = $this->getProductImages($product['images'] ?? null);
|
||||
|
||||
// Set preview image
|
||||
$preview = $productImages[0]->url ?? null;
|
||||
|
||||
// Extract features (parameters) from classifications[0].features of Buerklin JSON response
|
||||
$features = $product['classifications'][0]['features'] ?? [];
|
||||
|
||||
// Feature parameters (from classifications->features)
|
||||
$featureParams = $this->attributesToParameters($features, ''); // leave group empty for normal parameters
|
||||
|
||||
// Compliance parameters (from top-level fields like RoHS/SVHC/…)
|
||||
$complianceParams = $this->complianceToParameters($product, 'Compliance');
|
||||
|
||||
// Merge all parameters
|
||||
$allParams = array_merge($featureParams, $complianceParams);
|
||||
|
||||
// Assign footprint: "Design" (en) / "Bauform" (de) / "Enclosure" (en) / "Gehäuse" (de)
|
||||
$footprint = null;
|
||||
if (is_array($features)) {
|
||||
foreach ($features as $feature) {
|
||||
$name = $feature['name'] ?? null;
|
||||
if ($name === 'Design' || $name === 'Bauform' || $name === 'Enclosure' || $name === 'Gehäuse') {
|
||||
$footprint = $feature['featureValues'][0]['value'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prices: prefer volumePrices, fallback to single price
|
||||
$code = (string) ($product['orderNumber'] ?? $product['code'] ?? '');
|
||||
$prices = $product['volumePrices'] ?? null;
|
||||
|
||||
if (!is_array($prices) || count($prices) === 0) {
|
||||
$pVal = $product['price']['value'] ?? null;
|
||||
$pCur = $product['price']['currencyIso'] ?? ($this->settings->currency ?: 'EUR');
|
||||
|
||||
if (is_numeric($pVal)) {
|
||||
$prices = [
|
||||
[
|
||||
'minQuantity' => 1,
|
||||
'value' => (float) $pVal,
|
||||
'currencyIso' => (string) $pCur,
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$prices = [];
|
||||
}
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: (string) ($product['code'] ?? $code),
|
||||
|
||||
name: (string) ($product['manufacturerProductId'] ?? $code),
|
||||
description: $this->sanitizeField($product['description'] ?? null),
|
||||
|
||||
category: $this->sanitizeField($product['classifications'][0]['name'] ?? ($product['categories'][0]['name'] ?? null)),
|
||||
manufacturer: $this->sanitizeField($product['manufacturer'] ?? null),
|
||||
mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null),
|
||||
|
||||
preview_image_url: $preview,
|
||||
manufacturing_status: null,
|
||||
|
||||
provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)),
|
||||
footprint: $footprint,
|
||||
|
||||
datasheets: null, // not found in JSON response, the Buerklin website however has links to datasheets
|
||||
images: $productImages,
|
||||
|
||||
parameters: $allParams,
|
||||
|
||||
vendor_infos: $this->pricesToVendorInfo(
|
||||
sku: $code,
|
||||
url: $this->getProductShortURL($code),
|
||||
prices: $prices
|
||||
),
|
||||
|
||||
mass: $product['weight'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO
|
||||
* @param string $sku
|
||||
* @param string $url
|
||||
* @param array $prices
|
||||
* @return array
|
||||
*/
|
||||
private function pricesToVendorInfo(string $sku, string $url, array $prices): array
|
||||
{
|
||||
$priceDTOs = array_map(function ($price) {
|
||||
$val = $price['value'] ?? null;
|
||||
$valStr = is_numeric($val)
|
||||
? number_format((float) $val, 6, '.', '') // 6 decimal places, trailing zeros are fine
|
||||
: (string) $val;
|
||||
|
||||
// Optional: softly trim unnecessary trailing zeros (e.g. 75.550000 -> 75.55)
|
||||
$valStr = rtrim(rtrim($valStr, '0'), '.');
|
||||
|
||||
return new PriceDTO(
|
||||
minimum_discount_amount: (float) ($price['minQuantity'] ?? 1),
|
||||
price: $valStr,
|
||||
currency_iso_code: (string) ($price['currencyIso'] ?? $this->settings->currency ?? 'EUR'),
|
||||
includes_tax: false
|
||||
);
|
||||
}, $prices);
|
||||
|
||||
return [
|
||||
new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $sku,
|
||||
prices: $priceDTOs,
|
||||
product_url: $url,
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a valid Buerklin product short URL from product code
|
||||
* @param string $product_code
|
||||
* @return string
|
||||
*/
|
||||
private function getProductShortURL(string $product_code): string
|
||||
{
|
||||
return 'https://www.buerklin.com/' . $this->settings->language . '/p/' . $product_code . '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deduplicated list of product images as FileDTOs.
|
||||
*
|
||||
* - takes only real image arrays (with 'url' field)
|
||||
* - makes relative URLs absolute
|
||||
* - deduplicates using URL
|
||||
* - prefers 'zoom' format, then 'product' format, then all others
|
||||
*
|
||||
* @param array|null $images
|
||||
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
|
||||
*/
|
||||
private function getProductImages(?array $images): array
|
||||
{
|
||||
if (!is_array($images)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1) Only real image entries with URL
|
||||
$imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url'])));
|
||||
|
||||
// 2) Prefer zoom images
|
||||
$zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom'));
|
||||
$chosen = count($zoom) > 0
|
||||
? $zoom
|
||||
: array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product'));
|
||||
|
||||
// 3) If still none, take all
|
||||
if (count($chosen) === 0) {
|
||||
$chosen = $imgs;
|
||||
}
|
||||
|
||||
// 4) Deduplicate by URL (after making absolute)
|
||||
$byUrl = [];
|
||||
foreach ($chosen as $img) {
|
||||
$url = (string) $img['url'];
|
||||
|
||||
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
||||
$url = 'https://www.buerklin.com' . $url;
|
||||
}
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byUrl[$url] = $url;
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn($url) => new FileDTO($url),
|
||||
array_values($byUrl)
|
||||
);
|
||||
}
|
||||
|
||||
private function attributesToParameters(array $features, ?string $group = null): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($features as $f) {
|
||||
if (!is_array($f)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $f['name'] ?? null;
|
||||
if (!is_string($name) || trim($name) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vals = [];
|
||||
foreach (($f['featureValues'] ?? []) as $fv) {
|
||||
if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') {
|
||||
$vals[] = trim($fv['value']);
|
||||
}
|
||||
}
|
||||
if (empty($vals)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multiple values: join with comma
|
||||
$value = implode(', ', array_values(array_unique($vals)));
|
||||
|
||||
// Unit/symbol from Buerklin feature
|
||||
$unit = $f['featureUnit']['symbol'] ?? null;
|
||||
if (!is_string($unit) || trim($unit) === '') {
|
||||
$unit = null;
|
||||
}
|
||||
|
||||
// ParameterDTO parses value field (handles value + unit)
|
||||
$out[] = ParameterDTO::parseValueField(
|
||||
name: $name,
|
||||
value: $value,
|
||||
unit: $unit,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
}
|
||||
|
||||
// Deduplicate by name
|
||||
$byName = [];
|
||||
foreach ($out as $p) {
|
||||
$byName[$p->name] ??= $p;
|
||||
}
|
||||
|
||||
return array_values($byName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PartDetailDTO[]
|
||||
*/
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$keyword = strtoupper(trim($keyword));
|
||||
if ($keyword === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$response = $this->makeAPICall('/products/search/', [
|
||||
'pageSize' => 50,
|
||||
'currentPage' => 0,
|
||||
'query' => $keyword,
|
||||
'sort' => 'relevance',
|
||||
]);
|
||||
|
||||
$products = $response['products'] ?? [];
|
||||
|
||||
// Normal case: products found in search results
|
||||
if (is_array($products) && !empty($products)) {
|
||||
return array_map(fn($p) => $this->getPartDetail($p), $products);
|
||||
}
|
||||
|
||||
// Fallback: try direct lookup by code
|
||||
try {
|
||||
$product = $this->getProduct($keyword);
|
||||
return [$this->getPartDetail($product)];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
// Detail endpoint is /products/{code}/
|
||||
$response = $this->getProduct($id);
|
||||
|
||||
return $this->getPartDetail($response);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
//ProviderCapabilities::DATASHEET, // currently not implemented
|
||||
ProviderCapabilities::PRICE,
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
private function complianceToParameters(array $product, ?string $group = 'Compliance'): array
|
||||
{
|
||||
$params = [];
|
||||
|
||||
$add = function (string $name, $value) use (&$params, $group) {
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
$value = $value ? 'Yes' : 'No';
|
||||
} elseif (is_array($value) || is_object($value)) {
|
||||
// Avoid dumping large or complex structures
|
||||
return;
|
||||
} else {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$params[] = ParameterDTO::parseValueField(
|
||||
name: $name,
|
||||
value: (string) $value,
|
||||
unit: null,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
};
|
||||
|
||||
$add('RoHS conform', $product['labelRoHS'] ?? null); // "yes"/"no"
|
||||
|
||||
$rawRoHsDate = $product['dateRoHS'] ?? null;
|
||||
// Try to parse and reformat date to Y-m-d (do not use language-dependent formats)
|
||||
if (is_string($rawRoHsDate) && $rawRoHsDate !== '') {
|
||||
try {
|
||||
$dt = new \DateTimeImmutable($rawRoHsDate);
|
||||
$formatted = $dt->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
$formatted = $rawRoHsDate;
|
||||
}
|
||||
// Always use the same parameter name (do not use language-dependent names)
|
||||
$add('RoHS date', $formatted);
|
||||
}
|
||||
$add('SVHC free', $product['SVHC'] ?? null); // bool
|
||||
$add('Hazardous good', $product['hazardousGood'] ?? null); // bool
|
||||
$add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool
|
||||
|
||||
$add('Country of origin', $product['countryOfOrigin'] ?? null);
|
||||
// Customs tariff code must always be stored as string, otherwise "85411000" may be stored as "8.5411e+7"
|
||||
if (isset($product['articleCustomsCode'])) {
|
||||
// Raw value as string
|
||||
$codeRaw = (string) $product['articleCustomsCode'];
|
||||
|
||||
// Optionally keep only digits (in case of spaces or other characters)
|
||||
$code = preg_replace('/\D/', '', $codeRaw) ?? $codeRaw;
|
||||
$code = trim($code);
|
||||
|
||||
if ($code !== '') {
|
||||
$params[] = new ParameterDTO(
|
||||
name: 'Customs code',
|
||||
value_text: $code,
|
||||
value_typ: null,
|
||||
value_min: null,
|
||||
value_max: null,
|
||||
unit: null,
|
||||
symbol: null,
|
||||
group: $group
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $keywords
|
||||
* @return array<string, SearchResultDTO[]>
|
||||
*/
|
||||
public function searchByKeywordsBatch(array $keywords): array
|
||||
{
|
||||
/** @var array<string, SearchResultDTO[]> $results */
|
||||
$results = [];
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$keyword = strtoupper(trim((string) $keyword));
|
||||
if ($keyword === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reuse existing single search -> returns PartDetailDTO[]
|
||||
/** @var PartDetailDTO[] $partDetails */
|
||||
$partDetails = $this->searchByKeyword($keyword);
|
||||
|
||||
// Convert to SearchResultDTO[]
|
||||
$results[$keyword] = array_map(
|
||||
fn(PartDetailDTO $detail) => $this->convertPartDetailToSearchResult($detail),
|
||||
$partDetails
|
||||
);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a PartDetailDTO into a SearchResultDTO for bulk search.
|
||||
*/
|
||||
private function convertPartDetailToSearchResult(PartDetailDTO $detail): SearchResultDTO
|
||||
{
|
||||
return new SearchResultDTO(
|
||||
provider_key: $detail->provider_key,
|
||||
provider_id: $detail->provider_id,
|
||||
name: $detail->name,
|
||||
description: $detail->description ?? '',
|
||||
category: $detail->category ?? null,
|
||||
manufacturer: $detail->manufacturer ?? null,
|
||||
mpn: $detail->mpn ?? null,
|
||||
preview_image_url: $detail->preview_image_url ?? null,
|
||||
manufacturing_status: $detail->manufacturing_status ?? null,
|
||||
provider_url: $detail->provider_url ?? null,
|
||||
footprint: $detail->footprint ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
343
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
343
src/Services/InfoProviderSystem/Providers/ConradProvider.php
Normal file
@@ -0,0 +1,343 @@
|
||||
<?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\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;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\ConradSettings;
|
||||
use App\Settings\InfoProviderSystem\ConradShopIDs;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch';
|
||||
public const DISTRIBUTOR_NAME = 'Conrad';
|
||||
|
||||
private HttpClientInterface $httpClient;
|
||||
|
||||
public function __construct( HttpClientInterface $httpClient, private ConradSettings $settings)
|
||||
{
|
||||
//We want everything in JSON
|
||||
$this->httpClient = $httpClient->withOptions([
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Conrad',
|
||||
'description' => 'Retrieves part information from conrad.de',
|
||||
'url' => 'https://www.conrad.de/',
|
||||
'disabled_help' => 'Set API key in settings',
|
||||
'settings_class' => ConradSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'conrad';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return !empty($this->settings->apiKey);
|
||||
}
|
||||
|
||||
private function getProductUrl(string $productId): string
|
||||
{
|
||||
return 'https://' . $this->settings->shopID->getDomain() . '/' . $this->settings->shopID->getLanguage() . '/p/' . $productId;
|
||||
}
|
||||
|
||||
private function getFootprintFromTechnicalDetails(array $technicalDetails): ?string
|
||||
{
|
||||
foreach ($technicalDetails as $detail) {
|
||||
if ($detail['name'] === 'ATT_LOV_HOUSING_SEMICONDUCTORS') {
|
||||
return $detail['values'][0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/'
|
||||
. $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage()
|
||||
. '/' . $this->settings->shopID->getCustomerType();
|
||||
|
||||
$response = $this->httpClient->request('POST', $url, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
],
|
||||
'json' => [
|
||||
'query' => $keyword,
|
||||
'size' => 50,
|
||||
'sort' => [["field"=>"_score","order"=>"desc"]],
|
||||
],
|
||||
]);
|
||||
|
||||
$out = [];
|
||||
$results = $response->toArray();
|
||||
|
||||
foreach($results['hits'] as $result) {
|
||||
|
||||
$out[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $result['productId'],
|
||||
name: $result['manufacturerId'] ?? $result['productId'],
|
||||
description: $result['title'] ?? '',
|
||||
manufacturer: $result['brand']['name'] ?? null,
|
||||
mpn: $result['manufacturerId'] ?? null,
|
||||
preview_image_url: $result['image'] ?? null,
|
||||
provider_url: $this->getProductUrl($result['productId']),
|
||||
footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function getFootprintFromTechnicalAttributes(array $technicalDetails): ?string
|
||||
{
|
||||
foreach ($technicalDetails as $detail) {
|
||||
if ($detail['attributeID'] === 'ATT.LOV.HOUSING_SEMICONDUCTORS') {
|
||||
return $detail['values'][0]['value'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $technicalAttributes
|
||||
* @return array<ParameterDTO>
|
||||
*/
|
||||
private function technicalAttributesToParameters(array $technicalAttributes): array
|
||||
{
|
||||
return array_map(static function (array $p) {
|
||||
if (count($p['values']) === 1) { //Single value attribute
|
||||
if (array_key_exists('unit', $p['values'][0])) {
|
||||
return ParameterDTO::parseValueField( //With unit
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
unit: $p['values'][0]['unit']['name'],
|
||||
);
|
||||
}
|
||||
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $p['values'][0]['value'],
|
||||
);
|
||||
}
|
||||
|
||||
if (count($p['values']) === 2) { //Multi value attribute (e.g. min/max)
|
||||
$value = $p['values'][0]['value'] ?? null;
|
||||
$value2 = $p['values'][1]['value'] ?? null;
|
||||
$unit = $p['values'][0]['unit']['name'] ?? '';
|
||||
$unit2 = $p['values'][1]['unit']['name'] ?? '';
|
||||
if ($unit === $unit2 && is_numeric($value) && is_numeric($value2)) {
|
||||
if (array_key_exists('unit', $p['values'][0])) { //With unit
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: (float)$value,
|
||||
value_max: (float)$value2,
|
||||
unit: $unit,
|
||||
);
|
||||
}
|
||||
|
||||
return new ParameterDTO(
|
||||
name: $p['attributeName'],
|
||||
value_min: (float)$value,
|
||||
value_max: (float)$value2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// fallback implementation
|
||||
$values = implode(", ", array_map(fn($q) =>
|
||||
array_key_exists('unit', $q) ? $q['value']." ". ($q['unit']['name'] ?? $q['unit']) : $q['value']
|
||||
, $p['values']));
|
||||
return ParameterDTO::parseValueIncludingUnit(
|
||||
name: $p['attributeName'],
|
||||
value: $values,
|
||||
);
|
||||
}, $technicalAttributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $productMedia
|
||||
* @return array<FileDTO>
|
||||
*/
|
||||
public function productMediaToDatasheets(array $productMedia): array
|
||||
{
|
||||
$files = [];
|
||||
foreach ($productMedia['manuals'] as $manual) {
|
||||
//Filter out unwanted languages
|
||||
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = new FileDTO($manual['fullUrl'], $manual['title'] . ' (' . $manual['language'] . ')');
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queries prices for a given product ID. It makes a POST request to the Conrad API
|
||||
* @param string $productId
|
||||
* @return PurchaseInfoDTO
|
||||
*/
|
||||
private function queryPrices(string $productId): PurchaseInfoDTO
|
||||
{
|
||||
$priceQueryURL = $this->settings->shopID->getAPIRoot() . '/price-availability/4/'
|
||||
. $this->settings->shopID->getShopID() . '/facade';
|
||||
|
||||
$response = $this->httpClient->request('POST', $priceQueryURL, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
'overrideCalculationSchema' => $this->settings->includeVAT ? 'GROSS' : 'NET'
|
||||
],
|
||||
'json' => [
|
||||
'ns:inputArticleItemList' => [
|
||||
"#namespaces" => [
|
||||
"ns" => "http://www.conrad.de/ccp/basit/service/article/priceandavailabilityservice/api"
|
||||
],
|
||||
'articles' => [
|
||||
[
|
||||
"articleID" => $productId,
|
||||
"calculatePrice" => true,
|
||||
"checkAvailability" => true,
|
||||
],
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$result = $response->toArray();
|
||||
|
||||
$priceInfo = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['price'] ?? [];
|
||||
$price = $priceInfo['price'] ?? "0.0";
|
||||
$currency = $priceInfo['currency'] ?? "EUR";
|
||||
$includesVat = !$priceInfo['isGrossAmount'] || $priceInfo['isGrossAmount'] === "true";
|
||||
$minOrderAmount = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['availabilityStatus']['minimumOrderQuantity'] ?? 1;
|
||||
|
||||
$prices = [];
|
||||
foreach ($priceInfo['priceScale'] ?? [] as $priceScale) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: max($priceScale['scaleFrom'], $minOrderAmount),
|
||||
price: (string)$priceScale['pricePerUnit'],
|
||||
currency_iso_code: $currency,
|
||||
includes_tax: $includesVat
|
||||
);
|
||||
}
|
||||
if (empty($prices)) { //Fallback if no price scales are defined
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: $minOrderAmount,
|
||||
price: (string)$price,
|
||||
currency_iso_code: $currency,
|
||||
includes_tax: $includesVat
|
||||
);
|
||||
}
|
||||
|
||||
return new PurchaseInfoDTO(
|
||||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $productId,
|
||||
prices: $prices,
|
||||
product_url: $this->getProductUrl($productId)
|
||||
);
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID()
|
||||
. '/product/' . $id;
|
||||
|
||||
$response = $this->httpClient->request('GET', $productInfoURL, [
|
||||
'query' => [
|
||||
'apikey' => $this->settings->apiKey,
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $data['shortProductNumber'],
|
||||
name: $data['productFullInformation']['manufacturer']['name'] ?? $data['productFullInformation']['manufacturer']['id'] ?? $data['shortProductNumber'],
|
||||
description: $data['productShortInformation']['title'] ?? '',
|
||||
category: $data['productShortInformation']['articleGroupName'] ?? null,
|
||||
manufacturer: $data['brand']['displayName'] !== null ? preg_replace("/[\u{2122}\u{00ae}]/", "", $data['brand']['displayName']) : null, //Replace ™ and ® symbols
|
||||
mpn: $data['productFullInformation']['manufacturer']['id'] ?? null,
|
||||
preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null,
|
||||
provider_url: $this->getProductUrl($data['shortProductNumber']),
|
||||
footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
notes: $data['productFullInformation']['description'] ?? null,
|
||||
datasheets: $this->productMediaToDatasheets($data['productMedia'] ?? []),
|
||||
parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []),
|
||||
vendor_infos: [$this->queryPrices($data['shortProductNumber'])]
|
||||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::DATASHEET,
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
$domains = [];
|
||||
foreach (ConradShopIDs::cases() as $shopID) {
|
||||
$domains[] = $shopID->getDomain();
|
||||
}
|
||||
return array_unique($domains);
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input: https://www.conrad.de/de/p/apple-iphone-air-wolkenweiss-256-gb-eek-a-a-g-16-5-cm-6-5-zoll-3475299.html
|
||||
//The numbers before the optional .html are the product ID
|
||||
|
||||
$matches = [];
|
||||
if (preg_match('/-(\d+)(\.html)?$/', $url, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,14 @@ class DigikeyProvider implements InfoProviderInterface
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() === 404) {
|
||||
//No media found
|
||||
return [
|
||||
'datasheets' => [],
|
||||
'images' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$media_array = $response->toArray();
|
||||
|
||||
foreach ($media_array['MediaLinks'] as $media_link) {
|
||||
|
||||
@@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\Element14Settings;
|
||||
use Composer\CaBundle\CaBundle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class Element14Provider implements InfoProviderInterface
|
||||
class Element14Provider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://api.element14.com/catalog/products';
|
||||
@@ -309,4 +309,21 @@ class Element14Provider implements InfoProviderInterface
|
||||
ProviderCapabilities::DATASHEET,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['element14.com', 'farnell.com', 'newark.com'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input URL example: https://de.farnell.com/on-semiconductor/bc547b/transistor-npn-to-92/dp/1017673
|
||||
//The digits after the /dp/ are the part ID
|
||||
$matches = [];
|
||||
if (preg_match('#/dp/(\d+)#', $url, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
435
src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
Normal file
435
src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
Normal file
@@ -0,0 +1,435 @@
|
||||
<?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;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Settings\InfoProviderSystem\GenericWebProviderSettings;
|
||||
use Brick\Schema\Interfaces\BreadcrumbList;
|
||||
use Brick\Schema\Interfaces\ImageObject;
|
||||
use Brick\Schema\Interfaces\Product;
|
||||
use Brick\Schema\Interfaces\PropertyValue;
|
||||
use Brick\Schema\Interfaces\QuantitativeValue;
|
||||
use Brick\Schema\Interfaces\Thing;
|
||||
use Brick\Schema\SchemaReader;
|
||||
use Brick\Schema\SchemaTypeList;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class GenericWebProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
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,
|
||||
)
|
||||
{
|
||||
$this->httpClient = $httpClient->withOptions(
|
||||
[
|
||||
'headers' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
|
||||
],
|
||||
'timeout' => 15,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Generic Web URL',
|
||||
'description' => 'Tries to extract a part from a given product webpage URL using common metadata standards like JSON-LD and OpenGraph.',
|
||||
//'url' => 'https://example.com',
|
||||
'disabled_help' => 'Enable in settings to use this provider',
|
||||
'settings_class' => GenericWebProviderSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getProviderKey(): string
|
||||
{
|
||||
return 'generic_web';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$url = $this->fixAndValidateURL($keyword);
|
||||
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->delegateToOtherProvider($url);
|
||||
if ($delegatedPart !== null) {
|
||||
return [$delegatedPart];
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
$this->getDetails($keyword, false) //We already tried delegation
|
||||
]; } catch (ProviderIDNotSupportedException $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function extractShopName(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if ($host === false || $host === null) {
|
||||
return self::DISTRIBUTOR_NAME;
|
||||
}
|
||||
return $host;
|
||||
}
|
||||
|
||||
private function breadcrumbToCategory(?BreadcrumbList $breadcrumbList): ?string
|
||||
{
|
||||
if ($breadcrumbList === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$items = $breadcrumbList->itemListElement->getValues();
|
||||
if (count($items) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
//Build our category from the breadcrumb items
|
||||
$categories = [];
|
||||
foreach ($items as $item) {
|
||||
if (isset($item->name)) {
|
||||
$categories[] = trim($item->name->toString());
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(' -> ', $categories);
|
||||
}
|
||||
|
||||
private function productToPart(Product $product, string $url, Crawler $dom, ?BreadcrumbList $categoryBreadcrumb): PartDetailDTO
|
||||
{
|
||||
$notes = $product->description->toString() ?? "";
|
||||
if ($product->disambiguatingDescription !== null) {
|
||||
if (!empty($notes)) {
|
||||
$notes .= "\n\n";
|
||||
}
|
||||
$notes .= $product->disambiguatingDescription->toString();
|
||||
}
|
||||
|
||||
|
||||
//Extract vendor infos
|
||||
$vendor_infos = null;
|
||||
$offer = $product->offers->getFirstValue();
|
||||
if ($offer !== null) {
|
||||
$prices = [];
|
||||
if ($offer->price->toString() !== null) {
|
||||
$prices = [new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $offer->price->toString(),
|
||||
currency_iso_code: $offer->priceCurrency?->toString()
|
||||
)];
|
||||
} else { //Check for nested offers (like IKEA does it)
|
||||
$offer2 = $offer->offers->getFirstValue();
|
||||
if ($offer2 !== null && $offer2->price->toString() !== null) {
|
||||
$prices = [
|
||||
new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $offer2->price->toString(),
|
||||
currency_iso_code: $offer2->priceCurrency?->toString()
|
||||
)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$vendor_infos = [new PurchaseInfoDTO(
|
||||
distributor_name: $this->extractShopName($url),
|
||||
order_number: $product->sku?->toString() ?? $product->identifier?->toString() ?? 'Unknown',
|
||||
prices: $prices,
|
||||
product_url: $offer->url?->toString() ?? $url,
|
||||
)];
|
||||
}
|
||||
|
||||
//Extract image:
|
||||
$image = null;
|
||||
if ($product->image !== null) {
|
||||
$imageObj = $product->image->getFirstValue();
|
||||
if (is_string($imageObj)) {
|
||||
$image = $imageObj;
|
||||
} else if ($imageObj instanceof ImageObject) {
|
||||
$image = $imageObj->contentUrl?->toString() ?? $imageObj->url?->toString();
|
||||
}
|
||||
}
|
||||
|
||||
//Extract parameters from additionalProperty
|
||||
$parameters = [];
|
||||
foreach ($product->additionalProperty->getValues() as $property) {
|
||||
if ($property instanceof PropertyValue) { //TODO: Handle minValue and maxValue
|
||||
if ($property->unitText->toString() !== null) {
|
||||
$parameters[] = ParameterDTO::parseValueField(
|
||||
name: $property->name->toString() ?? 'Unknown',
|
||||
value: $property->value->toString() ?? '',
|
||||
unit: $property->unitText->toString()
|
||||
);
|
||||
} else {
|
||||
$parameters[] = ParameterDTO::parseValueIncludingUnit(
|
||||
name: $property->name->toString() ?? 'Unknown',
|
||||
value: $property->value->toString() ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Try to extract weight
|
||||
$mass = null;
|
||||
if (($weight = $product->weight?->getFirstValue()) instanceof QuantitativeValue) {
|
||||
$mass = $weight->value->toString();
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $url,
|
||||
name: $product->name?->toString() ?? $product->alternateName?->toString() ?? $product->mpn?->toString() ?? 'Unknown Name',
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
category: $this->breadcrumbToCategory($categoryBreadcrumb) ?? $product->category?->toString(),
|
||||
manufacturer: self::propertyOrString($product->manufacturer) ?? self::propertyOrString($product->brand),
|
||||
mpn: $product->mpn?->toString(),
|
||||
preview_image_url: $image,
|
||||
provider_url: $url,
|
||||
notes: $notes,
|
||||
parameters: $parameters,
|
||||
vendor_infos: $vendor_infos,
|
||||
mass: $mass
|
||||
);
|
||||
}
|
||||
|
||||
private static function propertyOrString(SchemaTypeList|Thing|string|null $value, string $property = "name"): ?string
|
||||
{
|
||||
if ($value instanceof SchemaTypeList) {
|
||||
$value = $value->getFirstValue();
|
||||
}
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $value->$property?->toString();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the content of a meta tag by its name or property attribute, or null if not found
|
||||
* @param Crawler $dom
|
||||
* @param string $name
|
||||
* @return string|null
|
||||
*/
|
||||
private function getMetaContent(Crawler $dom, string $name): ?string
|
||||
{
|
||||
$meta = $dom->filter('meta[property="'.$name.'"]');
|
||||
if ($meta->count() > 0) {
|
||||
return $meta->attr('content');
|
||||
}
|
||||
|
||||
//Try name attribute
|
||||
$meta = $dom->filter('meta[name="'.$name.'"]');
|
||||
if ($meta->count() > 0) {
|
||||
return $meta->attr('content');
|
||||
}
|
||||
|
||||
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, '/');
|
||||
|
||||
$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
|
||||
{
|
||||
$url = $this->fixAndValidateURL($id);
|
||||
|
||||
if ($check_for_delegation) {
|
||||
//Before loading the page, try to delegate to another provider
|
||||
$delegatedPart = $this->delegateToOtherProvider($url);
|
||||
if ($delegatedPart !== null) {
|
||||
return $this->infoRetriever->getDetailsForSearchResult($delegatedPart);
|
||||
}
|
||||
}
|
||||
|
||||
//Try to get the webpage content
|
||||
$response = $this->httpClient->request('GET', $url);
|
||||
$content = $response->getContent();
|
||||
|
||||
$dom = new Crawler($content);
|
||||
|
||||
//Try to determine a canonical URL
|
||||
$canonicalURL = $url;
|
||||
if ($dom->filter('link[rel="canonical"]')->count() > 0) {
|
||||
$canonicalURL = $dom->filter('link[rel="canonical"]')->attr('href');
|
||||
} else if ($dom->filter('meta[property="og:url"]')->count() > 0) {
|
||||
$canonicalURL = $dom->filter('meta[property="og:url"]')->attr('content');
|
||||
}
|
||||
|
||||
//If the canonical URL is relative, make it absolute
|
||||
if (parse_url($canonicalURL, PHP_URL_SCHEME) === null) {
|
||||
$parsedUrl = parse_url($url);
|
||||
$scheme = $parsedUrl['scheme'] ?? 'https';
|
||||
$host = $parsedUrl['host'] ?? '';
|
||||
$canonicalURL = $scheme.'://'.$host.$canonicalURL;
|
||||
}
|
||||
|
||||
|
||||
$schemaReader = SchemaReader::forAllFormats();
|
||||
$things = $schemaReader->readHtml($content, $canonicalURL);
|
||||
|
||||
//Try to find a breadcrumb schema to extract the category
|
||||
$categoryBreadCrumbs = null;
|
||||
foreach ($things as $thing) {
|
||||
if ($thing instanceof BreadcrumbList) {
|
||||
$categoryBreadCrumbs = $thing;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//Try to find a Product schema
|
||||
foreach ($things as $thing) {
|
||||
if ($thing instanceof Product) {
|
||||
return $this->productToPart($thing, $canonicalURL, $dom, $categoryBreadCrumbs);
|
||||
}
|
||||
}
|
||||
|
||||
//If no JSON-LD data is found, try to extract basic data from meta tags
|
||||
$pageTitle = $dom->filter('title')->count() > 0 ? $dom->filter('title')->text() : 'Unknown';
|
||||
|
||||
$prices = [];
|
||||
if ($price = $this->getMetaContent($dom, 'product:price:amount')) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $price,
|
||||
currency_iso_code: $this->getMetaContent($dom, 'product:price:currency'),
|
||||
);
|
||||
} else {
|
||||
//Amazon fallback
|
||||
$amazonAmount = $dom->filter('input[type="hidden"][name*="amount"]');
|
||||
if ($amazonAmount->count() > 0) {
|
||||
$prices[] = new PriceDTO(
|
||||
minimum_discount_amount: 1,
|
||||
price: $amazonAmount->first()->attr('value'),
|
||||
currency_iso_code: $dom->filter('input[type="hidden"][name*="currencyCode"]')->first()->attr('value'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$vendor_infos = [new PurchaseInfoDTO(
|
||||
distributor_name: $this->extractShopName($canonicalURL),
|
||||
order_number: 'Unknown',
|
||||
prices: $prices,
|
||||
product_url: $canonicalURL,
|
||||
)];
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $canonicalURL,
|
||||
name: $this->getMetaContent($dom, 'og:title') ?? $pageTitle,
|
||||
description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '',
|
||||
manufacturer: $this->getMetaContent($dom, 'product:brand'),
|
||||
preview_image_url: $this->getMetaContent($dom, 'og:image'),
|
||||
provider_url: $canonicalURL,
|
||||
vendor_infos: $vendor_infos,
|
||||
);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [
|
||||
ProviderCapabilities::BASIC,
|
||||
ProviderCapabilities::PICTURE,
|
||||
ProviderCapabilities::PRICE
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\LCSCSettings;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class LCSCProvider implements BatchInfoProviderInterface
|
||||
class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
|
||||
@@ -452,4 +452,21 @@ class LCSCProvider implements BatchInfoProviderInterface
|
||||
ProviderCapabilities::FOOTPRINT,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['lcsc.com'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input example: https://www.lcsc.com/product-detail/C258144.html?s_z=n_BC547
|
||||
//The part between the "C" and the ".html" is the unique ID
|
||||
|
||||
$matches = [];
|
||||
if (preg_match("#/product-detail/(\w+)\.html#", $url, $matches) > 0) {
|
||||
return $matches[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,13 +397,13 @@ class OEMSecretsProvider implements InfoProviderInterface
|
||||
* Generates a cache key for storing part details based on the provided provider ID.
|
||||
*
|
||||
* This method creates a unique cache key by prefixing the provider ID with 'part_details_'
|
||||
* and hashing the provider ID using MD5 to ensure a consistent and compact key format.
|
||||
* and hashing the provider ID using XXH3 to ensure a consistent and compact key format.
|
||||
*
|
||||
* @param string $provider_id The unique identifier of the provider or part.
|
||||
* @return string The generated cache key.
|
||||
*/
|
||||
private function getCacheKey(string $provider_id): string {
|
||||
return 'oemsecrets_part_' . md5($provider_id);
|
||||
return 'oemsecrets_part_' . hash('xxh3', $provider_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -680,7 +680,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
||||
if (is_array($prices)) {
|
||||
// Step 1: Check if prices exist in the preferred currency
|
||||
if (isset($prices[$this->settings->currency]) && is_array($prices[$this->settings->currency])) {
|
||||
$priceDetails = $prices[$this->$this->settings->currency];
|
||||
$priceDetails = $prices[$this->settings->currency];
|
||||
foreach ($priceDetails as $priceDetail) {
|
||||
if (
|
||||
is_array($priceDetail) &&
|
||||
|
||||
@@ -36,7 +36,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class PollinProvider implements InfoProviderInterface
|
||||
class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
@@ -141,11 +141,16 @@ class PollinProvider implements InfoProviderInterface
|
||||
$orderId = trim($dom->filter('span[itemprop="sku"]')->text()); //Text is important here
|
||||
|
||||
//Calculate the mass
|
||||
$massStr = $dom->filter('meta[itemprop="weight"]')->attr('content');
|
||||
//Remove the unit
|
||||
$massStr = str_replace('kg', '', $massStr);
|
||||
//Convert to float and convert to grams
|
||||
$mass = (float) $massStr * 1000;
|
||||
$massDom = $dom->filter('meta[itemprop="weight"]');
|
||||
if ($massDom->count() > 0) {
|
||||
$massStr = $massDom->attr('content');
|
||||
$massStr = str_replace('kg', '', $massStr);
|
||||
//Convert to float and convert to grams
|
||||
$mass = (float) $massStr * 1000;
|
||||
} else {
|
||||
$mass = null;
|
||||
}
|
||||
|
||||
|
||||
//Parse purchase info
|
||||
$purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl);
|
||||
@@ -248,4 +253,22 @@ class PollinProvider implements InfoProviderInterface
|
||||
ProviderCapabilities::DATASHEET
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['pollin.de'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//URL like: https://www.pollin.de/p/shelly-bluetooth-schalter-und-dimmer-blu-zb-button-plug-play-mocha-592325
|
||||
|
||||
//Extract the 6-digit number at the end of the URL
|
||||
$matches = [];
|
||||
if (preg_match('/-(\d{6})(?:\/|$)/', $url, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ enum ProviderCapabilities
|
||||
/** Basic information about a part, like the name, description, part number, manufacturer etc */
|
||||
case BASIC;
|
||||
|
||||
/** Information about the footprint of a part */
|
||||
case FOOTPRINT;
|
||||
|
||||
/** Provider can provide a picture for a part */
|
||||
case PICTURE;
|
||||
|
||||
@@ -43,6 +40,24 @@ enum ProviderCapabilities
|
||||
/** Provider can provide prices for a part */
|
||||
case PRICE;
|
||||
|
||||
/** Information about the footprint of a part */
|
||||
case FOOTPRINT;
|
||||
|
||||
/**
|
||||
* Get the order index for displaying capabilities in a stable order.
|
||||
* @return int
|
||||
*/
|
||||
public function getOrderIndex(): int
|
||||
{
|
||||
return match($this) {
|
||||
self::BASIC => 1,
|
||||
self::PICTURE => 2,
|
||||
self::DATASHEET => 3,
|
||||
self::PRICE => 4,
|
||||
self::FOOTPRINT => 5,
|
||||
};
|
||||
}
|
||||
|
||||
public function getTranslationKey(): string
|
||||
{
|
||||
return 'info_providers.capabilities.' . match($this) {
|
||||
|
||||
@@ -32,7 +32,7 @@ use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\TMESettings;
|
||||
|
||||
class TMEProvider implements InfoProviderInterface
|
||||
class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface
|
||||
{
|
||||
|
||||
private const VENDOR_NAME = 'TME';
|
||||
@@ -296,4 +296,22 @@ class TMEProvider implements InfoProviderInterface
|
||||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHandledDomains(): array
|
||||
{
|
||||
return ['tme.eu'];
|
||||
}
|
||||
|
||||
public function getIDFromURL(string $url): ?string
|
||||
{
|
||||
//Input: https://www.tme.eu/de/details/fi321_se/kuhler/alutronic/
|
||||
//The ID is the part after the details segment and before the next slash
|
||||
|
||||
$matches = [];
|
||||
if (preg_match('#/details/([^/]+)/#', $url, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\Services\InfoProviderSystem\Providers;
|
||||
|
||||
/**
|
||||
* If an interface
|
||||
*/
|
||||
interface URLHandlerInfoProviderInterface
|
||||
{
|
||||
/**
|
||||
* Returns a list of supported domains (e.g. ["digikey.com"])
|
||||
* @return array An array of supported domains
|
||||
*/
|
||||
public function getHandledDomains(): array;
|
||||
|
||||
/**
|
||||
* Extracts the unique ID of a part from a given URL. It is okay if this is not a canonical ID, as long as it can be used to uniquely identify the part within this provider.
|
||||
* @param string $url The URL to extract the ID from
|
||||
* @return string|null The extracted ID, or null if the URL is not valid for this provider
|
||||
*/
|
||||
public function getIDFromURL(string $url): ?string;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ final class BarcodeRedirector
|
||||
throw new EntityNotFoundException();
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID()]);
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID(), 'highlightLot' => $lot->getID()]);
|
||||
|
||||
case LabelSupportedElement::STORELOCATION:
|
||||
return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Misc;
|
||||
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
class GitVersionInfo
|
||||
{
|
||||
protected string $project_dir;
|
||||
|
||||
public function __construct(KernelInterface $kernel)
|
||||
{
|
||||
$this->project_dir = $kernel->getProjectDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Git branch name of the installed system.
|
||||
*
|
||||
* @return string|null The current git branch name. Null, if this is no Git installation
|
||||
*/
|
||||
public function getGitBranchName(): ?string
|
||||
{
|
||||
if (is_file($this->project_dir.'/.git/HEAD')) {
|
||||
$git = file($this->project_dir.'/.git/HEAD');
|
||||
$head = explode('/', $git[0], 3);
|
||||
|
||||
if (!isset($head[2])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($head[2]);
|
||||
}
|
||||
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash of the last git commit (on remote "origin"!).
|
||||
*
|
||||
* If this method does not work, try to make a "git pull" first!
|
||||
*
|
||||
* @param int $length if this is smaller than 40, only the first $length characters will be returned
|
||||
*
|
||||
* @return string|null The hash of the last commit, null If this is no Git installation
|
||||
*/
|
||||
public function getGitCommitHash(int $length = 7): ?string
|
||||
{
|
||||
$filename = $this->project_dir.'/.git/refs/remotes/origin/'.$this->getGitBranchName();
|
||||
if (is_file($filename)) {
|
||||
$head = file($filename);
|
||||
|
||||
if (!isset($head[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = $head[0];
|
||||
|
||||
return substr($hash, 0, $length);
|
||||
}
|
||||
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
}
|
||||
453
src/Services/System/BackupManager.php
Normal file
453
src/Services/System/BackupManager.php
Normal file
@@ -0,0 +1,453 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Manages Part-DB backups: creation, restoration, and listing.
|
||||
*
|
||||
* This service handles all backup-related operations and can be used
|
||||
* by the Update Manager, CLI commands, or other services.
|
||||
*/
|
||||
readonly class BackupManager
|
||||
{
|
||||
private const BACKUP_DIR = 'var/backups';
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(param: 'kernel.project_dir')]
|
||||
private string $projectDir,
|
||||
private LoggerInterface $logger,
|
||||
private Filesystem $filesystem,
|
||||
private VersionManagerInterface $versionManager,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private CommandRunHelper $commandRunHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backup directory path.
|
||||
*/
|
||||
public function getBackupDir(): string
|
||||
{
|
||||
return $this->projectDir . '/' . self::BACKUP_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version string for use in filenames.
|
||||
*/
|
||||
private function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->versionManager->getVersion()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup before updating.
|
||||
*
|
||||
* @param string|null $targetVersion Optional target version for naming
|
||||
* @param string|null $prefix Optional prefix for the backup filename
|
||||
* @return string The path to the created backup file
|
||||
*/
|
||||
public function createBackup(?string $targetVersion = null, ?string $prefix = 'backup'): string
|
||||
{
|
||||
$backupDir = $this->getBackupDir();
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
$this->filesystem->mkdir($backupDir, 0755);
|
||||
}
|
||||
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
|
||||
// Build filename
|
||||
if ($targetVersion) {
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip';
|
||||
} else {
|
||||
$backupFile = $backupDir . '/' . $prefix . '-v' . $currentVersion . '-' . date('Y-m-d-His') . '.zip';
|
||||
}
|
||||
|
||||
$this->commandRunHelper->runCommand([
|
||||
'php', 'bin/console', 'partdb:backup',
|
||||
'--full',
|
||||
'--overwrite',
|
||||
$backupFile,
|
||||
], 'Create backup', 600);
|
||||
|
||||
$this->logger->info('Created backup', ['file' => $backupFile]);
|
||||
|
||||
return $backupFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of backups, that are available, sorted by date descending.
|
||||
*
|
||||
* @return array<array{file: string, path: string, date: int, size: int}>
|
||||
*/
|
||||
public function getBackups(): array
|
||||
{
|
||||
$backupDir = $this->getBackupDir();
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$backups = [];
|
||||
foreach (glob($backupDir . '/*.zip') as $backupFile) {
|
||||
$backups[] = [
|
||||
'file' => basename($backupFile),
|
||||
'path' => $backupFile,
|
||||
'date' => filemtime($backupFile),
|
||||
'size' => filesize($backupFile),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
usort($backups, static fn($a, $b) => $b['date'] <=> $a['date']);
|
||||
|
||||
return $backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details about a specific backup file.
|
||||
*
|
||||
* @param string $filename The backup filename
|
||||
* @return null|array{file: string, path: string, date: int, size: int, from_version: ?string, to_version: ?string, contains_database?: bool, contains_config?: bool, contains_attachments?: bool} Backup details or null if not found
|
||||
*/
|
||||
public function getBackupDetails(string $filename): ?array
|
||||
{
|
||||
$backupDir = $this->getBackupDir();
|
||||
$backupPath = $backupDir . '/' . basename($filename);
|
||||
|
||||
if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse version info from filename: pre-update-v2.5.1-to-v2.5.0-2024-01-30-185400.zip
|
||||
$info = [
|
||||
'file' => basename($backupPath),
|
||||
'path' => $backupPath,
|
||||
'date' => filemtime($backupPath),
|
||||
'size' => filesize($backupPath),
|
||||
'from_version' => null,
|
||||
'to_version' => null,
|
||||
];
|
||||
|
||||
if (preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename, $matches)) {
|
||||
$info['from_version'] = $matches[1];
|
||||
$info['to_version'] = $matches[2];
|
||||
}
|
||||
|
||||
// Check what the backup contains by reading the ZIP
|
||||
try {
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($backupPath) === true) {
|
||||
$info['contains_database'] = $zip->locateName('database.sql') !== false || $zip->locateName('var/app.db') !== false;
|
||||
$info['contains_config'] = $zip->locateName('.env.local') !== false || $zip->locateName('config/parameters.yaml') !== false;
|
||||
$info['contains_attachments'] = $zip->locateName('public/media/') !== false || $zip->locateName('uploads/') !== false;
|
||||
$zip->close();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Could not read backup ZIP contents', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup file.
|
||||
*
|
||||
* @param string $filename The backup filename to delete
|
||||
* @return bool True if deleted successfully
|
||||
*/
|
||||
public function deleteBackup(string $filename): bool
|
||||
{
|
||||
$backupDir = $this->getBackupDir();
|
||||
$backupPath = $backupDir . '/' . basename($filename);
|
||||
|
||||
if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->filesystem->remove($backupPath);
|
||||
$this->logger->info('Deleted backup', ['file' => $filename]);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to delete backup', ['file' => $filename, 'error' => $e->getMessage()]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a backup file.
|
||||
*
|
||||
* @param string $filename The backup filename to restore
|
||||
* @param bool $restoreDatabase Whether to restore the database
|
||||
* @param bool $restoreConfig Whether to restore config files
|
||||
* @param bool $restoreAttachments Whether to restore attachments
|
||||
* @param callable|null $onProgress Callback for progress updates
|
||||
* @return array{success: bool, steps: array, error: ?string}
|
||||
*/
|
||||
public function restoreBackup(
|
||||
string $filename,
|
||||
bool $restoreDatabase = true,
|
||||
bool $restoreConfig = false,
|
||||
bool $restoreAttachments = false,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$steps = [];
|
||||
$startTime = microtime(true);
|
||||
|
||||
$log = function (string $step, string $message, bool $success, ?float $duration = null) use (&$steps, $onProgress): void {
|
||||
$entry = [
|
||||
'step' => $step,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
'duration' => $duration,
|
||||
];
|
||||
$steps[] = $entry;
|
||||
$this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Validate backup file
|
||||
$backupDir = $this->getBackupDir();
|
||||
$backupPath = $backupDir . '/' . basename($filename);
|
||||
|
||||
if (!file_exists($backupPath)) {
|
||||
throw new \RuntimeException('Backup file not found: ' . $filename);
|
||||
}
|
||||
|
||||
$stepStart = microtime(true);
|
||||
|
||||
// Step 1: Extract backup to temp directory
|
||||
$tempDir = sys_get_temp_dir() . '/partdb_restore_' . uniqid();
|
||||
$this->filesystem->mkdir($tempDir);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($backupPath) !== true) {
|
||||
throw new \RuntimeException('Could not open backup ZIP file');
|
||||
}
|
||||
$zip->extractTo($tempDir);
|
||||
$zip->close();
|
||||
$log('extract', 'Extracted backup to temporary directory', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 2: Restore database if requested and present
|
||||
if ($restoreDatabase) {
|
||||
$stepStart = microtime(true);
|
||||
$this->restoreDatabaseFromBackup($tempDir);
|
||||
$log('database', 'Restored database', true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 3: Restore config files if requested and present
|
||||
if ($restoreConfig) {
|
||||
$stepStart = microtime(true);
|
||||
$this->restoreConfigFromBackup($tempDir);
|
||||
$log('config', 'Restored configuration files', true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 4: Restore attachments if requested and present
|
||||
if ($restoreAttachments) {
|
||||
$stepStart = microtime(true);
|
||||
$this->restoreAttachmentsFromBackup($tempDir);
|
||||
$log('attachments', 'Restored attachments', true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 5: Clean up temp directory
|
||||
$stepStart = microtime(true);
|
||||
$this->filesystem->remove($tempDir);
|
||||
$log('cleanup', 'Cleaned up temporary files', true, microtime(true) - $stepStart);
|
||||
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
$log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'steps' => $steps,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Restore failed: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'file' => $filename,
|
||||
]);
|
||||
|
||||
// Try to clean up
|
||||
try {
|
||||
if (isset($tempDir) && is_dir($tempDir)) {
|
||||
$this->filesystem->remove($tempDir);
|
||||
}
|
||||
} catch (\Throwable $cleanupError) {
|
||||
$this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'steps' => $steps,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore database from backup.
|
||||
*/
|
||||
private function restoreDatabaseFromBackup(string $tempDir): void
|
||||
{
|
||||
// Check for SQL dump (MySQL/PostgreSQL)
|
||||
$sqlFile = $tempDir . '/database.sql';
|
||||
if (file_exists($sqlFile)) {
|
||||
// Import SQL using mysql/psql command directly
|
||||
// First, get database connection params from Doctrine
|
||||
$connection = $this->entityManager->getConnection();
|
||||
$params = $connection->getParams();
|
||||
$platform = $connection->getDatabasePlatform();
|
||||
|
||||
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
|
||||
// Use mysql command to import - need to use shell to handle input redirection
|
||||
$mysqlCmd = 'mysql';
|
||||
if (isset($params['host'])) {
|
||||
$mysqlCmd .= ' -h ' . escapeshellarg($params['host']);
|
||||
}
|
||||
if (isset($params['port'])) {
|
||||
$mysqlCmd .= ' -P ' . escapeshellarg((string)$params['port']);
|
||||
}
|
||||
if (isset($params['user'])) {
|
||||
$mysqlCmd .= ' -u ' . escapeshellarg($params['user']);
|
||||
}
|
||||
if (isset($params['password']) && $params['password']) {
|
||||
$mysqlCmd .= ' -p' . escapeshellarg($params['password']);
|
||||
}
|
||||
if (isset($params['dbname'])) {
|
||||
$mysqlCmd .= ' ' . escapeshellarg($params['dbname']);
|
||||
}
|
||||
$mysqlCmd .= ' < ' . escapeshellarg($sqlFile);
|
||||
|
||||
// Execute using shell
|
||||
$process = Process::fromShellCommandline($mysqlCmd, $this->projectDir, null, null, 300);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput());
|
||||
}
|
||||
} elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
// Use psql command to import
|
||||
$psqlCmd = 'psql';
|
||||
if (isset($params['host'])) {
|
||||
$psqlCmd .= ' -h ' . escapeshellarg($params['host']);
|
||||
}
|
||||
if (isset($params['port'])) {
|
||||
$psqlCmd .= ' -p ' . escapeshellarg((string)$params['port']);
|
||||
}
|
||||
if (isset($params['user'])) {
|
||||
$psqlCmd .= ' -U ' . escapeshellarg($params['user']);
|
||||
}
|
||||
if (isset($params['dbname'])) {
|
||||
$psqlCmd .= ' -d ' . escapeshellarg($params['dbname']);
|
||||
}
|
||||
$psqlCmd .= ' -f ' . escapeshellarg($sqlFile);
|
||||
|
||||
// Set PGPASSWORD environment variable if password is provided
|
||||
$env = null;
|
||||
if (isset($params['password']) && $params['password']) {
|
||||
$env = ['PGPASSWORD' => $params['password']];
|
||||
}
|
||||
|
||||
// Execute using shell
|
||||
$process = Process::fromShellCommandline($psqlCmd, $this->projectDir, $env, null, 300);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new \RuntimeException('PostgreSQL import failed: ' . $process->getErrorOutput());
|
||||
}
|
||||
} else {
|
||||
throw new \RuntimeException('Unsupported database platform for restore');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for SQLite database file
|
||||
$sqliteFile = $tempDir . '/var/app.db';
|
||||
if (file_exists($sqliteFile)) {
|
||||
$targetDb = $this->projectDir . '/var/app.db';
|
||||
$this->filesystem->copy($sqliteFile, $targetDb, true);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->warning('No database found in backup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore config files from backup.
|
||||
*/
|
||||
private function restoreConfigFromBackup(string $tempDir): void
|
||||
{
|
||||
// Restore .env.local
|
||||
$envLocal = $tempDir . '/.env.local';
|
||||
if (file_exists($envLocal)) {
|
||||
$this->filesystem->copy($envLocal, $this->projectDir . '/.env.local', true);
|
||||
}
|
||||
|
||||
// Restore config/parameters.yaml
|
||||
$parametersYaml = $tempDir . '/config/parameters.yaml';
|
||||
if (file_exists($parametersYaml)) {
|
||||
$this->filesystem->copy($parametersYaml, $this->projectDir . '/config/parameters.yaml', true);
|
||||
}
|
||||
|
||||
// Restore config/banner.md
|
||||
$bannerMd = $tempDir . '/config/banner.md';
|
||||
if (file_exists($bannerMd)) {
|
||||
$this->filesystem->copy($bannerMd, $this->projectDir . '/config/banner.md', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore attachments from backup.
|
||||
*/
|
||||
private function restoreAttachmentsFromBackup(string $tempDir): void
|
||||
{
|
||||
// Restore public/media
|
||||
$publicMedia = $tempDir . '/public/media';
|
||||
if (is_dir($publicMedia)) {
|
||||
$this->filesystem->mirror($publicMedia, $this->projectDir . '/public/media', null, ['override' => true]);
|
||||
}
|
||||
|
||||
// Restore uploads
|
||||
$uploads = $tempDir . '/uploads';
|
||||
if (is_dir($uploads)) {
|
||||
$this->filesystem->mirror($uploads, $this->projectDir . '/uploads', null, ['override' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/Services/System/CommandRunHelper.php
Normal file
73
src/Services/System/CommandRunHelper.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?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 Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class CommandRunHelper
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a shell command with proper error handling.
|
||||
*/
|
||||
public function runCommand(array $command, string $description, int $timeout = 120): string
|
||||
{
|
||||
$process = new Process($command, $this->project_dir);
|
||||
$process->setTimeout($timeout);
|
||||
|
||||
// Set environment variables needed for Composer and other tools
|
||||
// This is especially important when running as www-data which may not have HOME set
|
||||
// We inherit from current environment and override/add specific variables
|
||||
$currentEnv = getenv();
|
||||
if (!is_array($currentEnv)) {
|
||||
$currentEnv = [];
|
||||
}
|
||||
$env = array_merge($currentEnv, [
|
||||
'HOME' => $this->project_dir.'/var/www-data-home',
|
||||
'COMPOSER_HOME' => $this->project_dir.'/var/composer',
|
||||
'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin',
|
||||
]);
|
||||
$process->setEnv($env);
|
||||
|
||||
$output = '';
|
||||
$process->run(function ($type, $buffer) use (&$output) {
|
||||
$output .= $buffer;
|
||||
});
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$errorOutput = $process->getErrorOutput() ?: $process->getOutput();
|
||||
throw new \RuntimeException(
|
||||
sprintf('%s failed: %s', $description, trim($errorOutput))
|
||||
);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
141
src/Services/System/GitVersionInfoProvider.php
Normal file
141
src/Services/System/GitVersionInfoProvider.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* This service provides information about the current Git installation (if any).
|
||||
*/
|
||||
final readonly class GitVersionInfoProvider
|
||||
{
|
||||
public function __construct(#[Autowire(param: 'kernel.project_dir')] private string $project_dir)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the project directory is a Git repository.
|
||||
* @return bool
|
||||
*/
|
||||
public function isGitRepo(): bool
|
||||
{
|
||||
return is_dir($this->getGitDirectory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the Git directory of the installed system without a trailing slash.
|
||||
* Even if this is no Git installation, the path is returned.
|
||||
* @return string The path to the Git directory of the installed system
|
||||
*/
|
||||
public function getGitDirectory(): string
|
||||
{
|
||||
return $this->project_dir . '/.git';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Git branch name of the installed system.
|
||||
*
|
||||
* @return string|null The current git branch name. Null, if this is no Git installation
|
||||
*/
|
||||
public function getBranchName(): ?string
|
||||
{
|
||||
if (is_file($this->getGitDirectory() . '/HEAD')) {
|
||||
$git = file($this->getGitDirectory() . '/HEAD');
|
||||
$head = explode('/', $git[0], 3);
|
||||
|
||||
if (!isset($head[2])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($head[2]);
|
||||
}
|
||||
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash of the last git commit (on remote "origin"!).
|
||||
*
|
||||
* If this method does not work, try to make a "git pull" first!
|
||||
*
|
||||
* @param int $length if this is smaller than 40, only the first $length characters will be returned
|
||||
*
|
||||
* @return string|null The hash of the last commit, null If this is no Git installation
|
||||
*/
|
||||
public function getCommitHash(int $length = 8): ?string
|
||||
{
|
||||
$path = $this->getGitDirectory() . '/HEAD';
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$head = trim(file_get_contents($path));
|
||||
|
||||
// If it's a symbolic ref (e.g., "ref: refs/heads/main")
|
||||
if (str_starts_with($head, 'ref:')) {
|
||||
$refPath = $this->getGitDirectory() . '/' . trim(substr($head, 5));
|
||||
if (file_exists($refPath)) {
|
||||
$hash = trim(file_get_contents($refPath));
|
||||
}
|
||||
} else {
|
||||
// Otherwise, it's a detached HEAD (the hash is right there)
|
||||
$hash = $head;
|
||||
}
|
||||
|
||||
return isset($hash) ? substr($hash, 0, $length) : null;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Git remote URL of the installed system.
|
||||
*/
|
||||
public function getRemoteURL(): ?string
|
||||
{
|
||||
// Get remote URL
|
||||
$configFile = $this->getGitDirectory() . '/config';
|
||||
if (file_exists($configFile)) {
|
||||
$config = file_get_contents($configFile);
|
||||
if (preg_match('#url = (.+)#', $config, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are local changes in the Git repository.
|
||||
* Attention: This runs a git command, which might be slow!
|
||||
* @return bool|null True if there are local changes, false if not, null if this is not a Git installation
|
||||
*/
|
||||
public function hasLocalChanges(): ?bool
|
||||
{
|
||||
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
return null; // this is not a Git installation
|
||||
}
|
||||
return !empty(trim($process->getOutput()));
|
||||
}
|
||||
}
|
||||
65
src/Services/System/InstallationType.php
Normal file
65
src/Services/System/InstallationType.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\Services\System;
|
||||
|
||||
/**
|
||||
* Detects the installation type of Part-DB to determine the appropriate update strategy.
|
||||
*/
|
||||
enum InstallationType: string
|
||||
{
|
||||
case GIT = 'git';
|
||||
case DOCKER = 'docker';
|
||||
case ZIP_RELEASE = 'zip_release';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::GIT => 'Git Clone',
|
||||
self::DOCKER => 'Docker',
|
||||
self::ZIP_RELEASE => 'Release Archive (ZIP File)',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
public function supportsAutoUpdate(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::GIT => true,
|
||||
self::DOCKER => false,
|
||||
// ZIP_RELEASE auto-update not yet implemented
|
||||
self::ZIP_RELEASE => false,
|
||||
self::UNKNOWN => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function getUpdateInstructions(): 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::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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
153
src/Services/System/InstallationTypeDetector.php
Normal file
153
src/Services/System/InstallationTypeDetector.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
readonly class InstallationTypeDetector
|
||||
{
|
||||
public function __construct(#[Autowire(param: 'kernel.project_dir')] private string $project_dir, private GitVersionInfoProvider $gitVersionInfoProvider)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the installation type based on filesystem markers.
|
||||
*/
|
||||
public function detect(): InstallationType
|
||||
{
|
||||
// Check for Docker environment first
|
||||
if ($this->isDocker()) {
|
||||
return InstallationType::DOCKER;
|
||||
}
|
||||
|
||||
// Check for Git installation
|
||||
if ($this->isGitInstall()) {
|
||||
return InstallationType::GIT;
|
||||
}
|
||||
|
||||
// Check for ZIP release (has VERSION file but no .git)
|
||||
if ($this->isZipRelease()) {
|
||||
return InstallationType::ZIP_RELEASE;
|
||||
}
|
||||
|
||||
return InstallationType::UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running inside a Docker container.
|
||||
*/
|
||||
public function isDocker(): bool
|
||||
{
|
||||
// Check for /.dockerenv file
|
||||
if (file_exists('/.dockerenv')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for DOCKER environment variable
|
||||
if (getenv('DOCKER') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for container runtime in cgroup
|
||||
if (file_exists('/proc/1/cgroup')) {
|
||||
$cgroup = @file_get_contents('/proc/1/cgroup');
|
||||
if ($cgroup !== false && (str_contains($cgroup, 'docker') || str_contains($cgroup, 'containerd'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a Git-based installation.
|
||||
*/
|
||||
public function isGitInstall(): bool
|
||||
{
|
||||
return $this->gitVersionInfoProvider->isGitRepo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this appears to be a ZIP release installation.
|
||||
*/
|
||||
public function isZipRelease(): bool
|
||||
{
|
||||
// Has VERSION file but no .git directory
|
||||
return file_exists($this->project_dir . '/VERSION') && !$this->isGitInstall();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about the installation.
|
||||
*/
|
||||
public function getInstallationInfo(): array
|
||||
{
|
||||
$type = $this->detect();
|
||||
|
||||
$info = [
|
||||
'type' => $type,
|
||||
'type_name' => $type->getLabel(),
|
||||
'supports_auto_update' => $type->supportsAutoUpdate(),
|
||||
'update_instructions' => $type->getUpdateInstructions(),
|
||||
'project_dir' => $this->project_dir,
|
||||
];
|
||||
|
||||
if ($type === InstallationType::GIT) {
|
||||
$info['git'] = $this->getGitInfo();
|
||||
}
|
||||
|
||||
if ($type === InstallationType::DOCKER) {
|
||||
$info['docker'] = $this->getDockerInfo();
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git-specific information.
|
||||
* @return array{branch: string|null, commit: string|null, remote_url: string|null, has_local_changes: bool}
|
||||
*/
|
||||
private function getGitInfo(): array
|
||||
{
|
||||
return [
|
||||
'branch' => $this->gitVersionInfoProvider->getBranchName(),
|
||||
'commit' => $this->gitVersionInfoProvider->getCommitHash(8),
|
||||
'remote_url' => $this->gitVersionInfoProvider->getRemoteURL(),
|
||||
'has_local_changes' => $this->gitVersionInfoProvider->hasLocalChanges() ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker-specific information.
|
||||
* @return array{container_id: string|null, image: string|null}
|
||||
*/
|
||||
private function getDockerInfo(): array
|
||||
{
|
||||
return [
|
||||
'container_id' => @file_get_contents('/proc/1/cpuset') ?: null,
|
||||
'image' => getenv('DOCKER_IMAGE') ?: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -24,28 +24,23 @@ declare(strict_types=1);
|
||||
namespace App\Services\System;
|
||||
|
||||
use App\Settings\SystemSettings\PrivacySettings;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Version\Version;
|
||||
|
||||
/**
|
||||
* This class checks if a new version of Part-DB is available.
|
||||
*/
|
||||
class UpdateAvailableManager
|
||||
class UpdateAvailableFacade
|
||||
{
|
||||
|
||||
private const API_URL = 'https://api.github.com/repos/Part-DB/Part-DB-server/releases/latest';
|
||||
private const CACHE_KEY = 'uam_latest_version';
|
||||
private const CACHE_TTL = 60 * 60 * 24 * 2; // 2 day
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $httpClient,
|
||||
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
|
||||
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
|
||||
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode)
|
||||
public function __construct(
|
||||
private readonly CacheInterface $updateCache,
|
||||
private readonly PrivacySettings $privacySettings,
|
||||
private readonly UpdateChecker $updateChecker,
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
@@ -89,9 +84,7 @@ class UpdateAvailableManager
|
||||
}
|
||||
|
||||
$latestVersion = $this->getLatestVersion();
|
||||
$currentVersion = $this->versionManager->getVersion();
|
||||
|
||||
return $latestVersion->isGreaterThan($currentVersion);
|
||||
return $this->updateChecker->isNewerVersionThanCurrent($latestVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,34 +104,7 @@ class UpdateAvailableManager
|
||||
|
||||
return $this->updateCache->get(self::CACHE_KEY, function (ItemInterface $item) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', self::API_URL);
|
||||
$result = $response->toArray();
|
||||
$tag_name = $result['tag_name'];
|
||||
|
||||
// Remove the leading 'v' from the tag name
|
||||
$version = substr($tag_name, 1);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'url' => $result['html_url'],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
//When we are in dev mode, throw the exception, otherwise just silently log it
|
||||
if ($this->is_dev_mode) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
//In the case of an error, try it again after half of the cache time
|
||||
$item->expiresAfter(self::CACHE_TTL / 2);
|
||||
|
||||
$this->logger->error('Checking for updates failed: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'version' => '0.0.1',
|
||||
'url' => 'update-checking-error'
|
||||
];
|
||||
}
|
||||
return $this->updateChecker->getLatestVersion();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
338
src/Services/System/UpdateChecker.php
Normal file
338
src/Services/System/UpdateChecker.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use App\Settings\SystemSettings\PrivacySettings;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Version\Version;
|
||||
|
||||
/**
|
||||
* Enhanced update checker that fetches release information including changelogs.
|
||||
*/
|
||||
class UpdateChecker
|
||||
{
|
||||
private const GITHUB_API_BASE = 'https://api.github.com/repos/Part-DB/Part-DB-server';
|
||||
private const CACHE_KEY_RELEASES = 'update_checker_releases';
|
||||
private const CACHE_KEY_COMMITS = 'update_checker_commits_behind';
|
||||
private const CACHE_TTL = 60 * 60 * 6; // 6 hours
|
||||
private const CACHE_TTL_ERROR = 60 * 60; // 1 hour on error
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $httpClient,
|
||||
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
|
||||
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
|
||||
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)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current installed version.
|
||||
*/
|
||||
public function getCurrentVersion(): Version
|
||||
{
|
||||
return $this->versionManager->getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version as string.
|
||||
*/
|
||||
public function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->getCurrentVersion()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git repository information.
|
||||
* @return array{branch: ?string, commit: ?string, has_local_changes: bool, commits_behind: int, is_git_install: bool}
|
||||
*/
|
||||
private function getGitInfo(): array
|
||||
{
|
||||
$info = [
|
||||
'branch' => null,
|
||||
'commit' => null,
|
||||
'has_local_changes' => false,
|
||||
'commits_behind' => 0,
|
||||
'is_git_install' => false,
|
||||
];
|
||||
|
||||
if (!$this->gitVersionInfoProvider->isGitRepo()) {
|
||||
return $info;
|
||||
}
|
||||
|
||||
$info['is_git_install'] = true;
|
||||
|
||||
$info['branch'] = $this->gitVersionInfoProvider->getBranchName();
|
||||
$info['commit'] = $this->gitVersionInfoProvider->getCommitHash(8);
|
||||
$info['has_local_changes'] = $this->gitVersionInfoProvider->hasLocalChanges();
|
||||
|
||||
// Get commits behind (fetch first)
|
||||
if ($info['branch']) {
|
||||
// Try to get cached commits behind count
|
||||
$info['commits_behind'] = $this->getCommitsBehind($info['branch']);
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of commits behind the remote branch (cached).
|
||||
*/
|
||||
private function getCommitsBehind(string $branch): int
|
||||
{
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cacheKey = self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $branch);
|
||||
|
||||
return $this->updateCache->get($cacheKey, function (ItemInterface $item) use ($branch) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
|
||||
// Fetch from remote first
|
||||
$process = new Process(['git', 'fetch', '--tags', 'origin'], $this->project_dir);
|
||||
$process->run();
|
||||
|
||||
// Count commits behind
|
||||
$process = new Process(['git', 'rev-list', 'HEAD..origin/' . $branch, '--count'], $this->project_dir);
|
||||
$process->run();
|
||||
|
||||
return $process->isSuccessful() ? (int) trim($process->getOutput()) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh git information by invalidating cache.
|
||||
*/
|
||||
public function refreshVersionInfo(): void
|
||||
{
|
||||
$gitBranch = $this->gitVersionInfoProvider->getBranchName();
|
||||
if ($gitBranch) {
|
||||
$this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $gitBranch));
|
||||
}
|
||||
$this->updateCache->delete(self::CACHE_KEY_RELEASES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available releases from GitHub (cached).
|
||||
*
|
||||
* @return array<array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, draft:bool, assets: array, tarball_url: ?string, zipball_url: ?string}>
|
||||
*/
|
||||
public function getAvailableReleases(int $limit = 10): array
|
||||
{
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', self::GITHUB_API_BASE . '/releases', [
|
||||
'query' => ['per_page' => $limit],
|
||||
'headers' => [
|
||||
'Accept' => 'application/vnd.github.v3+json',
|
||||
'User-Agent' => 'Part-DB-Update-Checker',
|
||||
],
|
||||
]);
|
||||
|
||||
$releases = [];
|
||||
foreach ($response->toArray() as $release) {
|
||||
// Extract assets (for ZIP download)
|
||||
$assets = [];
|
||||
foreach ($release['assets'] ?? [] as $asset) {
|
||||
if (str_ends_with($asset['name'], '.zip') || str_ends_with($asset['name'], '.tar.gz')) {
|
||||
$assets[] = [
|
||||
'name' => $asset['name'],
|
||||
'url' => $asset['browser_download_url'],
|
||||
'size' => $asset['size'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$releases[] = [
|
||||
'version' => ltrim($release['tag_name'], 'v'),
|
||||
'tag' => $release['tag_name'],
|
||||
'name' => $release['name'] ?? $release['tag_name'],
|
||||
'url' => $release['html_url'],
|
||||
'published_at' => $release['published_at'],
|
||||
'body' => $release['body'] ?? '',
|
||||
'prerelease' => $release['prerelease'] ?? false,
|
||||
'draft' => $release['draft'] ?? false,
|
||||
'assets' => $assets,
|
||||
'tarball_url' => $release['tarball_url'] ?? null,
|
||||
'zipball_url' => $release['zipball_url'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $releases;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to fetch releases from GitHub: ' . $e->getMessage());
|
||||
$item->expiresAfter(self::CACHE_TTL_ERROR);
|
||||
|
||||
if ($this->is_dev_mode) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest stable release.
|
||||
* @return array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, assets: array}|null
|
||||
*/
|
||||
public function getLatestVersion(bool $includePrerelease = false): ?array
|
||||
{
|
||||
$releases = $this->getAvailableReleases();
|
||||
|
||||
foreach ($releases as $release) {
|
||||
// Skip drafts always
|
||||
if ($release['draft']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip prereleases unless explicitly included
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $release;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific version is newer than current.
|
||||
*/
|
||||
public function isNewerVersionThanCurrent(Version|string $version): bool
|
||||
{
|
||||
if ($version instanceof Version) {
|
||||
return $version->isGreaterThan($this->getCurrentVersion());
|
||||
}
|
||||
try {
|
||||
return Version::fromString(ltrim($version, 'v'))->isGreaterThan($this->getCurrentVersion());
|
||||
} catch (\Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive update status.
|
||||
* @return array{current_version: string, latest_version: ?string, latest_tag: ?string, update_available: bool, release_notes: ?string, release_url: ?string,
|
||||
* published_at: ?string, git: array, installation: array, can_auto_update: bool, update_blockers: array, check_enabled: bool}
|
||||
*/
|
||||
public function getUpdateStatus(): array
|
||||
{
|
||||
$current = $this->getCurrentVersion();
|
||||
$latest = $this->getLatestVersion();
|
||||
$gitInfo = $this->getGitInfo();
|
||||
$installInfo = $this->installationTypeDetector->getInstallationInfo();
|
||||
|
||||
$updateAvailable = false;
|
||||
$latestVersion = null;
|
||||
$latestTag = null;
|
||||
|
||||
if ($latest) {
|
||||
try {
|
||||
$latestVersionObj = Version::fromString($latest['version']);
|
||||
$updateAvailable = $latestVersionObj->isGreaterThan($current);
|
||||
$latestVersion = $latest['version'];
|
||||
$latestTag = $latest['tag'];
|
||||
} catch (\Exception) {
|
||||
// Invalid version string
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we can auto-update
|
||||
$canAutoUpdate = $installInfo['supports_auto_update'];
|
||||
$updateBlockers = [];
|
||||
|
||||
if ($gitInfo['has_local_changes']) {
|
||||
$canAutoUpdate = false;
|
||||
$updateBlockers[] = 'local_changes';
|
||||
}
|
||||
|
||||
if ($installInfo['type'] === InstallationType::DOCKER) {
|
||||
$updateBlockers[] = 'docker_installation';
|
||||
}
|
||||
|
||||
return [
|
||||
'current_version' => $current->toString(),
|
||||
'latest_version' => $latestVersion,
|
||||
'latest_tag' => $latestTag,
|
||||
'update_available' => $updateAvailable,
|
||||
'release_notes' => $latest['body'] ?? null,
|
||||
'release_url' => $latest['url'] ?? null,
|
||||
'published_at' => $latest['published_at'] ?? null,
|
||||
'git' => $gitInfo,
|
||||
'installation' => $installInfo,
|
||||
'can_auto_update' => $canAutoUpdate,
|
||||
'update_blockers' => $updateBlockers,
|
||||
'check_enabled' => $this->privacySettings->checkForUpdates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get releases newer than the current version.
|
||||
* @return array<array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, assets: array}>
|
||||
*/
|
||||
public function getAvailableUpdates(bool $includePrerelease = false): array
|
||||
{
|
||||
$releases = $this->getAvailableReleases();
|
||||
$current = $this->getCurrentVersion();
|
||||
$updates = [];
|
||||
|
||||
foreach ($releases as $release) {
|
||||
if ($release['draft']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$releaseVersion = Version::fromString($release['version']);
|
||||
if ($releaseVersion->isGreaterThan($current)) {
|
||||
$updates[] = $release;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $updates;
|
||||
}
|
||||
}
|
||||
940
src/Services/System/UpdateExecutor.php
Normal file
940
src/Services/System/UpdateExecutor.php
Normal file
@@ -0,0 +1,940 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Handles the execution of Part-DB updates with safety mechanisms.
|
||||
*
|
||||
* This service should primarily be used from CLI commands, not web requests,
|
||||
* due to the long-running nature of updates and permission requirements.
|
||||
*
|
||||
* For web requests, use startBackgroundUpdate() method.
|
||||
*/
|
||||
class UpdateExecutor
|
||||
{
|
||||
private const LOCK_FILE = 'var/update.lock';
|
||||
private const MAINTENANCE_FILE = 'var/maintenance.flag';
|
||||
private const UPDATE_LOG_DIR = 'var/log/updates';
|
||||
private const PROGRESS_FILE = 'var/update_progress.json';
|
||||
|
||||
/** @var array<array{step: string, message: string, success: bool, timestamp: string, duration: ?float}> */
|
||||
private array $steps = [];
|
||||
|
||||
private ?string $currentLogFile = null;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(param: 'kernel.project_dir')]
|
||||
private readonly string $project_dir,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly Filesystem $filesystem,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
private readonly UpdateChecker $updateChecker,
|
||||
private readonly BackupManager $backupManager,
|
||||
private readonly CommandRunHelper $commandRunHelper,
|
||||
#[Autowire(param: 'app.debug_mode')]
|
||||
private readonly bool $debugMode = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version string for use in filenames.
|
||||
*/
|
||||
private function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->updateChecker->getCurrentVersionString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is currently in progress.
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
// Check if lock is stale (older than 1 hour)
|
||||
$lockData = $this->getLockInfo();
|
||||
if ($lockData === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($lockData && isset($lockData['started_at'])) {
|
||||
$startedAt = new \DateTime($lockData['started_at']);
|
||||
$now = new \DateTime();
|
||||
$diff = $now->getTimestamp() - $startedAt->getTimestamp();
|
||||
|
||||
// If lock is older than 1 hour, consider it stale
|
||||
if ($diff > 3600) {
|
||||
$this->logger->warning('Found stale update lock, removing it');
|
||||
$this->releaseLock();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lock information, or null if not locked.
|
||||
* @return null|array{started_at: string, pid: int, user: string}
|
||||
*/
|
||||
public function getLockInfo(): ?array
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (!file_exists($lockFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(file_get_contents($lockFile), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if maintenance mode is enabled.
|
||||
*/
|
||||
public function isMaintenanceMode(): bool
|
||||
{
|
||||
return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintenance mode information.
|
||||
* @return null|array{enabled_at: string, reason: string}
|
||||
*/
|
||||
public function getMaintenanceInfo(): ?array
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
|
||||
if (!file_exists($maintenanceFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(file_get_contents($maintenanceFile), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an exclusive lock for the update process.
|
||||
*/
|
||||
public function acquireLock(): bool
|
||||
{
|
||||
if ($this->isLocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
$lockDir = dirname($lockFile);
|
||||
|
||||
if (!is_dir($lockDir)) {
|
||||
$this->filesystem->mkdir($lockDir);
|
||||
}
|
||||
|
||||
$lockData = [
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'pid' => getmypid(),
|
||||
'user' => get_current_user(),
|
||||
];
|
||||
|
||||
$this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the update lock.
|
||||
*/
|
||||
public function releaseLock(): void
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (file_exists($lockFile)) {
|
||||
$this->filesystem->remove($lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable maintenance mode to block user access during update.
|
||||
*/
|
||||
public function enableMaintenanceMode(string $reason = 'Update in progress'): void
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
$maintenanceDir = dirname($maintenanceFile);
|
||||
|
||||
if (!is_dir($maintenanceDir)) {
|
||||
$this->filesystem->mkdir($maintenanceDir);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'enabled_at' => (new \DateTime())->format('c'),
|
||||
'reason' => $reason,
|
||||
];
|
||||
|
||||
$this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable maintenance mode.
|
||||
*/
|
||||
public function disableMaintenanceMode(): void
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
|
||||
if (file_exists($maintenanceFile)) {
|
||||
$this->filesystem->remove($maintenanceFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that we can perform an update.
|
||||
*
|
||||
* @return array{valid: bool, errors: array<string>}
|
||||
*/
|
||||
public function validateUpdatePreconditions(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Check installation type
|
||||
$installType = $this->installationTypeDetector->detect();
|
||||
if (!$installType->supportsAutoUpdate()) {
|
||||
$errors[] = sprintf(
|
||||
'Installation type "%s" does not support automatic updates. %s',
|
||||
$installType->getLabel(),
|
||||
$installType->getUpdateInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Git installation
|
||||
if ($installType === InstallationType::GIT) {
|
||||
// Check if git is available
|
||||
$process = new Process(['git', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'Git command not found. Please ensure Git is installed and in PATH.';
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
||||
$process->run();
|
||||
if (!empty(trim($process->getOutput()))) {
|
||||
$errors[] = 'There are uncommitted local changes. Please commit or stash them before updating.';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if composer is available
|
||||
$process = new Process(['composer', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'Composer command not found. Please ensure Composer is installed and in PATH.';
|
||||
}
|
||||
|
||||
// Check if PHP CLI is available
|
||||
$process = new Process(['php', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'PHP CLI not found. Please ensure PHP is installed and in PATH.';
|
||||
}
|
||||
|
||||
// Check if yarn is available (for frontend assets)
|
||||
$process = new Process(['yarn', '--version']);
|
||||
$process->run();
|
||||
if (!$process->isSuccessful()) {
|
||||
$errors[] = 'Yarn command not found. Please ensure Yarn is installed and in PATH for frontend asset compilation.';
|
||||
}
|
||||
|
||||
// Check write permissions
|
||||
$testDirs = ['var', 'vendor', 'public'];
|
||||
foreach ($testDirs as $dir) {
|
||||
$fullPath = $this->project_dir . '/' . $dir;
|
||||
if (is_dir($fullPath) && !is_writable($fullPath)) {
|
||||
$errors[] = sprintf('Directory "%s" is not writable.', $dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the update to a specific version.
|
||||
*
|
||||
* @param string $targetVersion The target version/tag to update to (e.g., "v2.6.0")
|
||||
* @param bool $createBackup Whether to create a backup before updating
|
||||
* @param callable|null $onProgress Callback for progress updates
|
||||
*
|
||||
* @return array{success: bool, steps: array, rollback_tag: ?string, error: ?string, log_file: ?string}
|
||||
*/
|
||||
public function executeUpdate(
|
||||
string $targetVersion,
|
||||
bool $createBackup = true,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$this->steps = [];
|
||||
$rollbackTag = null;
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Initialize log file
|
||||
$this->initializeLogFile($targetVersion);
|
||||
|
||||
$log = function (string $step, string $message, bool $success = true, ?float $duration = null) use ($onProgress): void {
|
||||
$entry = [
|
||||
'step' => $step,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
'duration' => $duration,
|
||||
];
|
||||
|
||||
$this->steps[] = $entry;
|
||||
$this->writeToLogFile($entry);
|
||||
$this->logger->info("Update [{$step}]: {$message}", ['success' => $success]);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Validate preconditions
|
||||
$validation = $this->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
throw new \RuntimeException('Precondition check failed: ' . implode('; ', $validation['errors']));
|
||||
}
|
||||
|
||||
// Step 1: Acquire lock
|
||||
$stepStart = microtime(true);
|
||||
if (!$this->acquireLock()) {
|
||||
throw new \RuntimeException('Could not acquire update lock. Another update may be in progress.');
|
||||
}
|
||||
$log('lock', 'Acquired exclusive update lock', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 2: Enable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->enableMaintenanceMode('Updating to ' . $targetVersion);
|
||||
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 3: Create rollback point with version info
|
||||
$stepStart = microtime(true);
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$rollbackTag = 'pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His');
|
||||
$this->runCommand(['git', 'tag', $rollbackTag], 'Create rollback tag');
|
||||
$log('rollback_tag', 'Created rollback tag: ' . $rollbackTag, true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 4: Create backup (optional)
|
||||
if ($createBackup) {
|
||||
$stepStart = microtime(true);
|
||||
$backupFile = $this->backupManager->createBackup($targetVersion);
|
||||
$log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 5: Fetch from remote
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['git', 'fetch', '--tags', '--force', 'origin'], 'Fetch from origin', 120);
|
||||
$log('fetch', 'Fetched latest changes and tags from origin', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 6: Checkout target version
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version');
|
||||
$log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 7: Install PHP dependencies
|
||||
$stepStart = microtime(true);
|
||||
if ($this->debugMode) {
|
||||
$this->runCommand([ // Install with dev dependencies in debug mode
|
||||
'composer',
|
||||
'install',
|
||||
'--no-interaction',
|
||||
'--no-progress',
|
||||
], 'Install PHP dependencies', 600);
|
||||
} else {
|
||||
$this->runCommand([
|
||||
'composer',
|
||||
'install',
|
||||
'--no-dev',
|
||||
'--optimize-autoloader',
|
||||
'--no-interaction',
|
||||
'--no-progress',
|
||||
], 'Install PHP dependencies', 600);
|
||||
}
|
||||
$log('composer', 'Installed/updated PHP dependencies', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 8: Install frontend dependencies
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'yarn', 'install',
|
||||
'--frozen-lockfile',
|
||||
'--non-interactive',
|
||||
], 'Install frontend dependencies', 600);
|
||||
$log('yarn_install', 'Installed frontend dependencies', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 9: Build frontend assets
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'yarn', 'build',
|
||||
], 'Build frontend assets', 600);
|
||||
$log('yarn_build', 'Built frontend assets', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 10: Run database migrations
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'doctrine:migrations:migrate',
|
||||
'--no-interaction',
|
||||
'--allow-no-migration',
|
||||
], 'Run migrations', 300);
|
||||
$log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 11: Clear cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'--env=prod',
|
||||
'--no-interaction',
|
||||
], 'Clear cache', 120);
|
||||
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 12: Warm up cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:warmup',
|
||||
'--env=prod',
|
||||
], 'Warmup cache', 120);
|
||||
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 13: Disable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->disableMaintenanceMode();
|
||||
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 14: Release lock
|
||||
$stepStart = microtime(true);
|
||||
$this->releaseLock();
|
||||
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
$log('complete', sprintf('Update completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'steps' => $this->steps,
|
||||
'rollback_tag' => $rollbackTag,
|
||||
'error' => null,
|
||||
'log_file' => $this->currentLogFile,
|
||||
'duration' => $totalDuration,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$log('error', 'Update failed: ' . $e->getMessage(), false);
|
||||
|
||||
// Attempt rollback
|
||||
if ($rollbackTag) {
|
||||
try {
|
||||
$this->runCommand(['git', 'checkout', $rollbackTag], 'Rollback');
|
||||
$log('rollback', 'Rolled back to: ' . $rollbackTag, true);
|
||||
|
||||
// Re-run composer install after rollback
|
||||
$this->runCommand([
|
||||
'composer', 'install',
|
||||
'--no-dev',
|
||||
'--optimize-autoloader',
|
||||
'--no-interaction',
|
||||
], 'Reinstall dependencies after rollback', 600);
|
||||
$log('rollback_composer', 'Reinstalled PHP dependencies after rollback', true);
|
||||
|
||||
// Re-run yarn install after rollback
|
||||
$this->runCommand([
|
||||
'yarn', 'install',
|
||||
'--frozen-lockfile',
|
||||
'--non-interactive',
|
||||
], 'Reinstall frontend dependencies after rollback', 600);
|
||||
$log('rollback_yarn_install', 'Reinstalled frontend dependencies after rollback', true);
|
||||
|
||||
// Re-run yarn build after rollback
|
||||
$this->runCommand([
|
||||
'yarn', 'build',
|
||||
], 'Rebuild frontend assets after rollback', 600);
|
||||
$log('rollback_yarn_build', 'Rebuilt frontend assets after rollback', true);
|
||||
|
||||
// Clear cache after rollback
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'--env=prod',
|
||||
], 'Clear cache after rollback', 120);
|
||||
$log('rollback_cache', 'Cleared cache after rollback', true);
|
||||
|
||||
} catch (\Exception $rollbackError) {
|
||||
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
$this->disableMaintenanceMode();
|
||||
$this->releaseLock();
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'steps' => $this->steps,
|
||||
'rollback_tag' => $rollbackTag,
|
||||
'error' => $e->getMessage(),
|
||||
'log_file' => $this->currentLogFile,
|
||||
'duration' => microtime(true) - $startTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a shell command with proper error handling.
|
||||
*/
|
||||
private function runCommand(array $command, string $description, int $timeout = 120): string
|
||||
{
|
||||
return $this->commandRunHelper->runCommand($command, $description, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the log file for this update.
|
||||
*/
|
||||
private function initializeLogFile(string $targetVersion): void
|
||||
{
|
||||
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
$this->filesystem->mkdir($logDir, 0755);
|
||||
}
|
||||
|
||||
// Include version numbers in log filename: update-v2.5.1-to-v2.6.0-2024-01-30-185400.log
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$this->currentLogFile = $logDir . '/update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.log';
|
||||
|
||||
$header = sprintf(
|
||||
"Part-DB Update Log\n" .
|
||||
"==================\n" .
|
||||
"Started: %s\n" .
|
||||
"From Version: %s\n" .
|
||||
"Target Version: %s\n" .
|
||||
"==================\n\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
$currentVersion,
|
||||
$targetVersion
|
||||
);
|
||||
|
||||
file_put_contents($this->currentLogFile, $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry to the log file.
|
||||
*/
|
||||
private function writeToLogFile(array $entry): void
|
||||
{
|
||||
if (!$this->currentLogFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
$line = sprintf(
|
||||
"[%s] %s: %s%s\n",
|
||||
$entry['timestamp'],
|
||||
strtoupper($entry['step']),
|
||||
$entry['message'],
|
||||
$entry['duration'] ? sprintf(' (%.2fs)', $entry['duration']) : ''
|
||||
);
|
||||
|
||||
file_put_contents($this->currentLogFile, $line, FILE_APPEND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of update log files.
|
||||
* @return array{file: string, path: string, date: int, size: int}[]
|
||||
*/
|
||||
public function getUpdateLogs(): array
|
||||
{
|
||||
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$logs = [];
|
||||
foreach (glob($logDir . '/update-*.log') as $logFile) {
|
||||
$logs[] = [
|
||||
'file' => basename($logFile),
|
||||
'path' => $logFile,
|
||||
'date' => filemtime($logFile),
|
||||
'size' => filesize($logFile),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
usort($logs, static fn($a, $b) => $b['date'] <=> $a['date']);
|
||||
|
||||
return $logs;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Restore from a backup file with maintenance mode and cache clearing.
|
||||
*
|
||||
* This wraps BackupManager::restoreBackup with additional safety measures
|
||||
* like lock acquisition, maintenance mode, and cache operations.
|
||||
*
|
||||
* @param string $filename The backup filename to restore
|
||||
* @param bool $restoreDatabase Whether to restore the database
|
||||
* @param bool $restoreConfig Whether to restore config files
|
||||
* @param bool $restoreAttachments Whether to restore attachments
|
||||
* @param callable|null $onProgress Callback for progress updates
|
||||
* @return array{success: bool, steps: array, error: ?string}
|
||||
*/
|
||||
public function restoreBackup(
|
||||
string $filename,
|
||||
bool $restoreDatabase = true,
|
||||
bool $restoreConfig = false,
|
||||
bool $restoreAttachments = false,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$this->steps = [];
|
||||
$startTime = microtime(true);
|
||||
|
||||
$log = function (string $step, string $message, bool $success, ?float $duration = null) use ($onProgress): void {
|
||||
$entry = [
|
||||
'step' => $step,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
'duration' => $duration,
|
||||
];
|
||||
$this->steps[] = $entry;
|
||||
$this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$stepStart = microtime(true);
|
||||
|
||||
// Step 1: Acquire lock
|
||||
if (!$this->acquireLock()) {
|
||||
throw new \RuntimeException('Could not acquire lock. Another operation may be in progress.');
|
||||
}
|
||||
$log('lock', 'Acquired exclusive restore lock', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 2: Enable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->enableMaintenanceMode('Restoring from backup...');
|
||||
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 3: Delegate to BackupManager for core restoration
|
||||
$stepStart = microtime(true);
|
||||
$result = $this->backupManager->restoreBackup(
|
||||
$filename,
|
||||
$restoreDatabase,
|
||||
$restoreConfig,
|
||||
$restoreAttachments,
|
||||
function ($entry) use ($log) {
|
||||
// Forward progress from BackupManager
|
||||
$log($entry['step'], $entry['message'], $entry['success'], $entry['duration'] ?? null);
|
||||
}
|
||||
);
|
||||
|
||||
if (!$result['success']) {
|
||||
throw new \RuntimeException($result['error'] ?? 'Restore failed');
|
||||
}
|
||||
|
||||
// Step 4: Clear cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['php', 'bin/console', 'cache:clear', '--no-warmup'], 'Clear cache');
|
||||
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 5: Warm up cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache');
|
||||
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 6: Disable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->disableMaintenanceMode();
|
||||
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 7: Release lock
|
||||
$this->releaseLock();
|
||||
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
$log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'steps' => $this->steps,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Restore failed: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'file' => $filename,
|
||||
]);
|
||||
|
||||
// Try to clean up
|
||||
try {
|
||||
$this->disableMaintenanceMode();
|
||||
$this->releaseLock();
|
||||
} catch (\Throwable $cleanupError) {
|
||||
$this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'steps' => $this->steps,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the progress file.
|
||||
*/
|
||||
public function getProgressFilePath(): string
|
||||
{
|
||||
return $this->project_dir . '/' . self::PROGRESS_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save progress to file for web UI polling.
|
||||
* @param array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string} $progress
|
||||
*/
|
||||
private function saveProgress(array $progress): void
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
$progressDir = dirname($progressFile);
|
||||
|
||||
if (!is_dir($progressDir)) {
|
||||
$this->filesystem->mkdir($progressDir);
|
||||
}
|
||||
|
||||
$this->filesystem->dumpFile($progressFile, json_encode($progress, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current update progress from file.
|
||||
* @return null|array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string}
|
||||
*/
|
||||
public function getProgress(): ?array
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
|
||||
if (!file_exists($progressFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($progressFile), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// If the progress file is stale (older than 30 minutes), consider it invalid
|
||||
if ($data && isset($data['started_at'])) {
|
||||
$startedAt = strtotime($data['started_at']);
|
||||
if (time() - $startedAt > 1800) {
|
||||
$this->clearProgress();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear progress file.
|
||||
*/
|
||||
public function clearProgress(): void
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
|
||||
if (file_exists($progressFile)) {
|
||||
$this->filesystem->remove($progressFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is currently running (based on progress file).
|
||||
*/
|
||||
public function isUpdateRunning(): bool
|
||||
{
|
||||
$progress = $this->getProgress();
|
||||
|
||||
if (!$progress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($progress['status']) && $progress['status'] === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the update process in the background.
|
||||
* Returns the process ID or null on failure.
|
||||
*/
|
||||
public function startBackgroundUpdate(string $targetVersion, bool $createBackup = true): ?int
|
||||
{
|
||||
// Validate first
|
||||
$validation = $this->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$this->logger->error('Update validation failed', ['errors' => $validation['errors']]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize progress file
|
||||
$this->saveProgress([
|
||||
'status' => 'starting',
|
||||
'target_version' => $targetVersion,
|
||||
'create_backup' => $createBackup,
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'current_step' => 0,
|
||||
'total_steps' => 14,
|
||||
'step_name' => 'initializing',
|
||||
'step_message' => 'Starting update process...',
|
||||
'steps' => [],
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
// Build the command to run in background
|
||||
// Use 'php' from PATH as PHP_BINARY might point to php-fpm
|
||||
$consolePath = $this->project_dir . '/bin/console';
|
||||
$logFile = $this->project_dir . '/var/log/update-background.log';
|
||||
|
||||
// Ensure log directory exists
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
$this->filesystem->mkdir($logDir, 0755);
|
||||
}
|
||||
|
||||
//If we are on Windows, we cannot use nohup
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$command = sprintf(
|
||||
'start /B php %s partdb:update %s %s --force --no-interaction >> %s 2>&1',
|
||||
escapeshellarg($consolePath),
|
||||
escapeshellarg($targetVersion),
|
||||
$createBackup ? '' : '--no-backup',
|
||||
escapeshellarg($logFile)
|
||||
);
|
||||
} else { //Unix like platforms should be able to use nohup
|
||||
// Use nohup to properly detach the process from the web request
|
||||
// The process will continue running even after the PHP request ends
|
||||
$command = sprintf(
|
||||
'nohup php %s partdb:update %s %s --force --no-interaction >> %s 2>&1 &',
|
||||
escapeshellarg($consolePath),
|
||||
escapeshellarg($targetVersion),
|
||||
$createBackup ? '' : '--no-backup',
|
||||
escapeshellarg($logFile)
|
||||
);
|
||||
}
|
||||
|
||||
$this->logger->info('Starting background update', [
|
||||
'command' => $command,
|
||||
'target_version' => $targetVersion,
|
||||
]);
|
||||
|
||||
// Execute in background using shell_exec for proper detachment
|
||||
// shell_exec with & runs the command in background
|
||||
|
||||
//@php-ignore-next-line We really need to use shell_exec here
|
||||
$output = shell_exec($command);
|
||||
|
||||
// Give it a moment to start
|
||||
usleep(500000); // 500ms
|
||||
|
||||
// Check if progress file was updated (indicates process started)
|
||||
$progress = $this->getProgress();
|
||||
if ($progress && isset($progress['status'])) {
|
||||
$this->logger->info('Background update started successfully');
|
||||
return 1; // Return a non-null value to indicate success
|
||||
}
|
||||
|
||||
$this->logger->error('Background update may not have started', ['output' => $output]);
|
||||
return 1; // Still return success as the process might just be slow to start
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute update with progress file updates for web UI.
|
||||
* This is called by the CLI command and updates the progress file.
|
||||
*/
|
||||
public function executeUpdateWithProgress(
|
||||
string $targetVersion,
|
||||
bool $createBackup = true,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$totalSteps = 12;
|
||||
$currentStep = 0;
|
||||
|
||||
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {
|
||||
$currentStep++;
|
||||
$progress = $this->getProgress() ?? [
|
||||
'status' => 'running',
|
||||
'target_version' => $targetVersion,
|
||||
'create_backup' => $createBackup,
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'steps' => [],
|
||||
];
|
||||
|
||||
$progress['current_step'] = $currentStep;
|
||||
$progress['total_steps'] = $totalSteps;
|
||||
$progress['step_name'] = $stepName;
|
||||
$progress['step_message'] = $message;
|
||||
$progress['status'] = 'running';
|
||||
$progress['steps'][] = [
|
||||
'step' => $stepName,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
];
|
||||
|
||||
$this->saveProgress($progress);
|
||||
};
|
||||
|
||||
// Wrap the existing executeUpdate with progress tracking
|
||||
$result = $this->executeUpdate($targetVersion, $createBackup, function ($entry) use ($updateProgress, $onProgress) {
|
||||
$updateProgress($entry['step'], $entry['message'], $entry['success']);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Update final status
|
||||
$finalProgress = $this->getProgress() ?? [];
|
||||
$finalProgress['status'] = $result['success'] ? 'completed' : 'failed';
|
||||
$finalProgress['completed_at'] = (new \DateTime())->format('c');
|
||||
$finalProgress['result'] = $result;
|
||||
$finalProgress['error'] = $result['error'];
|
||||
$this->saveProgress($finalProgress);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ use App\Entity\UserSystem\User;
|
||||
use App\Helpers\Trees\TreeViewNode;
|
||||
use App\Services\Cache\UserCacheKeyGenerator;
|
||||
use App\Services\ElementTypeNameGenerator;
|
||||
use App\Services\InfoProviderSystem\Providers\GenericWebProvider;
|
||||
use App\Settings\InfoProviderSystem\GenericWebProviderSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
@@ -58,6 +60,7 @@ class ToolsTreeBuilder
|
||||
protected UserCacheKeyGenerator $keyGenerator,
|
||||
protected Security $security,
|
||||
private readonly ElementTypeNameGenerator $elementTypeNameGenerator,
|
||||
private readonly GenericWebProviderSettings $genericWebProviderSettings
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -147,6 +150,13 @@ class ToolsTreeBuilder
|
||||
$this->urlGenerator->generate('info_providers_search')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down');
|
||||
|
||||
if ($this->genericWebProviderSettings->enabled) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('info_providers.from_url.title'),
|
||||
$this->urlGenerator->generate('info_providers_from_url')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-book-atlas');
|
||||
}
|
||||
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('info_providers.bulk_import.manage_jobs'),
|
||||
$this->urlGenerator->generate('bulk_info_provider_manage')
|
||||
@@ -315,6 +325,13 @@ class ToolsTreeBuilder
|
||||
))->setIcon('fa fa-fw fa-gears fa-solid');
|
||||
}
|
||||
|
||||
if ($this->security->isGranted('@system.show_updates')) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.system.update_manager'),
|
||||
$this->urlGenerator->generate('admin_update_manager')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-arrow-circle-up');
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +111,9 @@ class PermissionPresetsHelper
|
||||
|
||||
//Allow to manage Oauth tokens
|
||||
$this->permissionResolver->setPermission($perm_holder, 'system', 'manage_oauth_tokens', PermissionData::ALLOW);
|
||||
//Allow to show updates
|
||||
//Allow to show and manage updates
|
||||
$this->permissionResolver->setPermission($perm_holder, 'system', 'show_updates', PermissionData::ALLOW);
|
||||
$this->permissionResolver->setPermission($perm_holder, 'system', 'manage_updates', PermissionData::ALLOW);
|
||||
|
||||
}
|
||||
|
||||
|
||||
84
src/Settings/InfoProviderSystem/BuerklinSettings.php
Normal file
84
src/Settings/InfoProviderSystem/BuerklinSettings.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
* Copyright (C) 2025 Marc Kreidler (https://github.com/mkne)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Form\Type\APIKeyType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(label: new TM("settings.ips.buerklin"), description: new TM("settings.ips.buerklin.help"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class BuerklinSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.ips.digikey.client_id"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_CLIENT_ID", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $clientId = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.ips.digikey.secret"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_SECRET", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $secret = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.ips.buerklin.username"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_USER", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $username = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("user.edit.password"),
|
||||
formType: APIKeyType::class,
|
||||
envVar: "PROVIDER_BUERKLIN_PASSWORD", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public ?string $password = null;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.currency"), formType: CurrencyType::class,
|
||||
formOptions: ["preferred_choices" => ["EUR"]],
|
||||
envVar: "PROVIDER_BUERKLIN_CURRENCY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Currency()]
|
||||
public string $currency = "EUR";
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class,
|
||||
formOptions: ["preferred_choices" => ["en", "de"]],
|
||||
envVar: "PROVIDER_BUERKLIN_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
#[Assert\Language]
|
||||
public string $language = "en";
|
||||
}
|
||||
77
src/Settings/InfoProviderSystem/ConradSettings.php
Normal file
77
src/Settings/InfoProviderSystem/ConradSettings.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Form\Type\APIKeyType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\StringType;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CountryType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(label: new TM("settings.ips.conrad"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class ConradSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.element14.apiKey"),
|
||||
formType: APIKeyType::class,
|
||||
formOptions: ["help_html" => true], envVar: "PROVIDER_CONRAD_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
public ?string $apiKey = null;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.conrad.shopID"),
|
||||
description: new TM("settings.ips.conrad.shopID.description"),
|
||||
formType: EnumType::class,
|
||||
formOptions: ['class' => ConradShopIDs::class],
|
||||
)]
|
||||
public ConradShopIDs $shopID = ConradShopIDs::COM_B2B;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.reichelt.include_vat"))]
|
||||
public bool $includeVAT = true;
|
||||
|
||||
/**
|
||||
* @var array|string[] Only attachments in these languages will be downloaded (ISO 639-1 codes)
|
||||
*/
|
||||
#[Assert\Unique()]
|
||||
#[Assert\All([new Assert\Language()])]
|
||||
#[SettingsParameter(type: ArrayType::class,
|
||||
label: new TM("settings.ips.conrad.attachment_language_filter"), description: new TM("settings.ips.conrad.attachment_language_filter.description"),
|
||||
options: ['type' => StringType::class],
|
||||
formType: LanguageType::class,
|
||||
formOptions: [
|
||||
'multiple' => true,
|
||||
'preferred_choices' => ['en', 'de', 'fr', 'it', 'cs', 'da', 'nl', 'hu', 'hr', 'sk', 'pl']
|
||||
],
|
||||
)]
|
||||
public array $attachmentLanguageFilter = ['en'];
|
||||
}
|
||||
167
src/Settings/InfoProviderSystem/ConradShopIDs.php
Normal file
167
src/Settings/InfoProviderSystem/ConradShopIDs.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?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 Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
enum ConradShopIDs: string implements TranslatableInterface
|
||||
{
|
||||
case COM_B2B = 'HP_COM_B2B';
|
||||
case DE_B2B = 'CQ_DE_B2B';
|
||||
case DE_B2C = 'CQ_DE_B2C';
|
||||
case AT_B2C = 'CQ_AT_B2C';
|
||||
case CH_B2C_DE = 'CQ_CH_B2C_DE';
|
||||
case CH_B2C_FR = 'CQ_CH_B2C_FR';
|
||||
case SE_B2B = 'HP_SE_B2B';
|
||||
case HU_B2C = 'CQ_HU_B2C';
|
||||
case CZ_B2B = 'HP_CZ_B2B';
|
||||
case SI_B2B = 'HP_SI_B2B';
|
||||
case SK_B2B = 'HP_SK_B2B';
|
||||
case BE_B2B = 'HP_BE_B2B';
|
||||
case PL_B2B = 'HP_PL_B2B';
|
||||
case NL_B2B = 'CQ_NL_B2B';
|
||||
case NL_B2C = 'CQ_NL_B2C';
|
||||
case DK_B2B = 'HP_DK_B2B';
|
||||
case IT_B2B = 'HP_IT_B2B';
|
||||
|
||||
case FR_B2B = 'HP_FR_B2B';
|
||||
case AT_B2B = 'CQ_AT_B2B';
|
||||
case HR_B2B = 'HP_HR_B2B';
|
||||
|
||||
|
||||
public function trans(TranslatorInterface $translator, ?string $locale = null): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DE_B2B => "conrad.de (B2B)",
|
||||
self::AT_B2C => "conrad.at (B2C)",
|
||||
self::CH_B2C_DE => "conrad.ch DE (B2C)",
|
||||
self::CH_B2C_FR => "conrad.ch FR (B2C)",
|
||||
self::SE_B2B => "conrad.se (B2B)",
|
||||
self::HU_B2C => "conrad.hu (B2C)",
|
||||
self::CZ_B2B => "conrad.cz (B2B)",
|
||||
self::SI_B2B => "conrad.si (B2B)",
|
||||
self::SK_B2B => "conrad.sk (B2B)",
|
||||
self::BE_B2B => "conrad.be (B2B)",
|
||||
self::DE_B2C => "conrad.de (B2C)",
|
||||
self::PL_B2B => "conrad.pl (B2B)",
|
||||
self::NL_B2B => "conrad.nl (B2B)",
|
||||
self::DK_B2B => "conradelektronik.dk (B2B)",
|
||||
self::IT_B2B => "conrad.it (B2B)",
|
||||
self::NL_B2C => "conrad.nl (B2C)",
|
||||
self::FR_B2B => "conrad.fr (B2B)",
|
||||
self::COM_B2B => "conrad.com (B2B)",
|
||||
self::AT_B2B => "conrad.at (B2B)",
|
||||
self::HR_B2B => "conrad.hr (B2B)",
|
||||
};
|
||||
}
|
||||
|
||||
public function getDomain(): string
|
||||
{
|
||||
if ($this === self::DK_B2B) {
|
||||
return 'conradelektronik.dk';
|
||||
}
|
||||
|
||||
return 'conrad.' . $this->getDomainEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the API root URL for this shop ID. e.g. https://api.conrad.de
|
||||
* @return string
|
||||
*/
|
||||
public function getAPIRoot(): string
|
||||
{
|
||||
return 'https://api.' . $this->getDomain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shop ID value used in the API requests. e.g. 'CQ_DE_B2B'
|
||||
* @return string
|
||||
*/
|
||||
public function getShopID(): string
|
||||
{
|
||||
if ($this === self::CH_B2C_FR || $this === self::CH_B2C_DE) {
|
||||
return 'CQ_CH_B2C';
|
||||
}
|
||||
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function getDomainEnd(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DE_B2B, self::DE_B2C => 'de',
|
||||
self::AT_B2B, self::AT_B2C => 'at',
|
||||
self::CH_B2C_DE => 'ch', self::CH_B2C_FR => 'ch',
|
||||
self::SE_B2B => 'se',
|
||||
self::HU_B2C => 'hu',
|
||||
self::CZ_B2B => 'cz',
|
||||
self::SI_B2B => 'si',
|
||||
self::SK_B2B => 'sk',
|
||||
self::BE_B2B => 'be',
|
||||
self::PL_B2B => 'pl',
|
||||
self::NL_B2B, self::NL_B2C => 'nl',
|
||||
self::DK_B2B => 'dk',
|
||||
self::IT_B2B => 'it',
|
||||
self::FR_B2B => 'fr',
|
||||
self::COM_B2B => 'com',
|
||||
self::HR_B2B => 'hr',
|
||||
};
|
||||
}
|
||||
|
||||
public function getLanguage(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DE_B2B, self::DE_B2C, self::AT_B2B, self::AT_B2C => 'de',
|
||||
self::CH_B2C_DE => 'de', self::CH_B2C_FR => 'fr',
|
||||
self::SE_B2B => 'sv',
|
||||
self::HU_B2C => 'hu',
|
||||
self::CZ_B2B => 'cs',
|
||||
self::SI_B2B => 'sl',
|
||||
self::SK_B2B => 'sk',
|
||||
self::BE_B2B => 'nl',
|
||||
self::PL_B2B => 'pl',
|
||||
self::NL_B2B, self::NL_B2C => 'nl',
|
||||
self::DK_B2B => 'da',
|
||||
self::IT_B2B => 'it',
|
||||
self::FR_B2B => 'fr',
|
||||
self::COM_B2B => 'en',
|
||||
self::HR_B2B => 'hr',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the customer type for this shop ID. e.g. 'b2b' or 'b2c'
|
||||
* @return string 'b2b' or 'b2c'
|
||||
*/
|
||||
public function getCustomerType(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DE_B2B, self::AT_B2B, self::SE_B2B, self::CZ_B2B, self::SI_B2B,
|
||||
self::SK_B2B, self::BE_B2B, self::PL_B2B, self::NL_B2B, self::DK_B2B,
|
||||
self::IT_B2B, self::FR_B2B, self::COM_B2B, self::HR_B2B => 'b2b',
|
||||
self::DE_B2C, self::AT_B2C, self::CH_B2C_DE, self::CH_B2C_FR, self::HU_B2C, self::NL_B2C => 'b2c',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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\InfoProviderSystem;
|
||||
|
||||
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: "generic_web_provider", label: new TM("settings.ips.generic_web_provider"), description: new TM("settings.ips.generic_web_provider.description"))]
|
||||
#[SettingsIcon("fa-plug")]
|
||||
class GenericWebProviderSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.ips.lcsc.enabled"), description: new TM("settings.ips.generic_web_provider.enabled.help"),
|
||||
envVar: "bool:PROVIDER_GENERIC_WEB_ENABLED", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
public bool $enabled = false;
|
||||
}
|
||||
@@ -37,6 +37,9 @@ class InfoProviderSettings
|
||||
#[EmbeddedSettings]
|
||||
public ?InfoProviderGeneralSettings $general = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?GenericWebProviderSettings $genericWebProvider = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?DigikeySettings $digikey = null;
|
||||
|
||||
@@ -63,4 +66,10 @@ class InfoProviderSettings
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?PollinSettings $pollin = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?BuerklinSettings $buerklin = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?ConradSettings $conrad = null;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace App\State;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\PartDBInfo;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\BannerHelper;
|
||||
use App\Services\System\GitVersionInfoProvider;
|
||||
use App\Settings\SystemSettings\CustomizationSettings;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
@@ -17,7 +17,7 @@ class PartDBInfoProvider implements ProviderInterface
|
||||
{
|
||||
|
||||
public function __construct(private readonly VersionManagerInterface $versionManager,
|
||||
private readonly GitVersionInfo $gitVersionInfo,
|
||||
private readonly GitVersionInfoProvider $gitVersionInfo,
|
||||
private readonly BannerHelper $bannerHelper,
|
||||
private readonly string $default_uri,
|
||||
private readonly LocalizationSettings $localizationSettings,
|
||||
@@ -31,8 +31,8 @@ class PartDBInfoProvider implements ProviderInterface
|
||||
{
|
||||
return new PartDBInfo(
|
||||
version: $this->versionManager->getVersion()->toString(),
|
||||
git_branch: $this->gitVersionInfo->getGitBranchName(),
|
||||
git_commit: $this->gitVersionInfo->getGitCommitHash(),
|
||||
git_branch: $this->gitVersionInfo->getBranchName(),
|
||||
git_commit: $this->gitVersionInfo->getCommitHash(),
|
||||
title: $this->customizationSettings->instanceName,
|
||||
banner: $this->bannerHelper->getBanner(),
|
||||
default_uri: $this->default_uri,
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Translation\Fixes;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\Translation\Dumper\FileDumper;
|
||||
use Symfony\Component\Translation\MessageCatalogue;
|
||||
use Symfony\Component\Translation\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Backport of the XliffFile dumper from Symfony 7.2, which supports segment attributes and notes, this keeps the
|
||||
* metadata when editing the translations from inside Symfony.
|
||||
*/
|
||||
#[AsDecorator("translation.dumper.xliff")]
|
||||
class SegmentAwareXliffFileDumper extends FileDumper
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private string $extension = 'xlf',
|
||||
) {
|
||||
}
|
||||
|
||||
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
|
||||
{
|
||||
$xliffVersion = '1.2';
|
||||
if (\array_key_exists('xliff_version', $options)) {
|
||||
$xliffVersion = $options['xliff_version'];
|
||||
}
|
||||
|
||||
if (\array_key_exists('default_locale', $options)) {
|
||||
$defaultLocale = $options['default_locale'];
|
||||
} else {
|
||||
$defaultLocale = \Locale::getDefault();
|
||||
}
|
||||
|
||||
if ('1.2' === $xliffVersion) {
|
||||
return $this->dumpXliff1($defaultLocale, $messages, $domain, $options);
|
||||
}
|
||||
if ('2.0' === $xliffVersion) {
|
||||
return $this->dumpXliff2($defaultLocale, $messages, $domain);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException(\sprintf('No support implemented for dumping XLIFF version "%s".', $xliffVersion));
|
||||
}
|
||||
|
||||
protected function getExtension(): string
|
||||
{
|
||||
return $this->extension;
|
||||
}
|
||||
|
||||
private function dumpXliff1(string $defaultLocale, MessageCatalogue $messages, ?string $domain, array $options = []): string
|
||||
{
|
||||
$toolInfo = ['tool-id' => 'symfony', 'tool-name' => 'Symfony'];
|
||||
if (\array_key_exists('tool_info', $options)) {
|
||||
$toolInfo = array_merge($toolInfo, $options['tool_info']);
|
||||
}
|
||||
|
||||
$dom = new \DOMDocument('1.0', 'utf-8');
|
||||
$dom->formatOutput = true;
|
||||
|
||||
$xliff = $dom->appendChild($dom->createElement('xliff'));
|
||||
$xliff->setAttribute('version', '1.2');
|
||||
$xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:1.2');
|
||||
|
||||
$xliffFile = $xliff->appendChild($dom->createElement('file'));
|
||||
$xliffFile->setAttribute('source-language', str_replace('_', '-', $defaultLocale));
|
||||
$xliffFile->setAttribute('target-language', str_replace('_', '-', $messages->getLocale()));
|
||||
$xliffFile->setAttribute('datatype', 'plaintext');
|
||||
$xliffFile->setAttribute('original', 'file.ext');
|
||||
|
||||
$xliffHead = $xliffFile->appendChild($dom->createElement('header'));
|
||||
$xliffTool = $xliffHead->appendChild($dom->createElement('tool'));
|
||||
foreach ($toolInfo as $id => $value) {
|
||||
$xliffTool->setAttribute($id, $value);
|
||||
}
|
||||
|
||||
if ($catalogueMetadata = $messages->getCatalogueMetadata('', $domain) ?? []) {
|
||||
$xliffPropGroup = $xliffHead->appendChild($dom->createElement('prop-group'));
|
||||
foreach ($catalogueMetadata as $key => $value) {
|
||||
$xliffProp = $xliffPropGroup->appendChild($dom->createElement('prop'));
|
||||
$xliffProp->setAttribute('prop-type', $key);
|
||||
$xliffProp->appendChild($dom->createTextNode($value));
|
||||
}
|
||||
}
|
||||
|
||||
$xliffBody = $xliffFile->appendChild($dom->createElement('body'));
|
||||
foreach ($messages->all($domain) as $source => $target) {
|
||||
$translation = $dom->createElement('trans-unit');
|
||||
|
||||
$translation->setAttribute('id', strtr(substr(base64_encode(hash('xxh128', $source, true)), 0, 7), '/+', '._'));
|
||||
$translation->setAttribute('resname', $source);
|
||||
|
||||
$s = $translation->appendChild($dom->createElement('source'));
|
||||
$s->appendChild($dom->createTextNode($source));
|
||||
|
||||
// Does the target contain characters requiring a CDATA section?
|
||||
$text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target);
|
||||
|
||||
$targetElement = $dom->createElement('target');
|
||||
$metadata = $messages->getMetadata($source, $domain);
|
||||
if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) {
|
||||
foreach ($metadata['target-attributes'] as $name => $value) {
|
||||
$targetElement->setAttribute($name, $value);
|
||||
}
|
||||
}
|
||||
$t = $translation->appendChild($targetElement);
|
||||
$t->appendChild($text);
|
||||
|
||||
if ($this->hasMetadataArrayInfo('notes', $metadata)) {
|
||||
foreach ($metadata['notes'] as $note) {
|
||||
if (!isset($note['content'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$n = $translation->appendChild($dom->createElement('note'));
|
||||
$n->appendChild($dom->createTextNode($note['content']));
|
||||
|
||||
if (isset($note['priority'])) {
|
||||
$n->setAttribute('priority', $note['priority']);
|
||||
}
|
||||
|
||||
if (isset($note['from'])) {
|
||||
$n->setAttribute('from', $note['from']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$xliffBody->appendChild($translation);
|
||||
}
|
||||
|
||||
return $dom->saveXML();
|
||||
}
|
||||
|
||||
private function dumpXliff2(string $defaultLocale, MessageCatalogue $messages, ?string $domain): string
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'utf-8');
|
||||
$dom->formatOutput = true;
|
||||
|
||||
$xliff = $dom->appendChild($dom->createElement('xliff'));
|
||||
$xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:2.0');
|
||||
$xliff->setAttribute('version', '2.0');
|
||||
$xliff->setAttribute('srcLang', str_replace('_', '-', $defaultLocale));
|
||||
$xliff->setAttribute('trgLang', str_replace('_', '-', $messages->getLocale()));
|
||||
|
||||
$xliffFile = $xliff->appendChild($dom->createElement('file'));
|
||||
if (str_ends_with($domain, MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
|
||||
$xliffFile->setAttribute('id', substr($domain, 0, -\strlen(MessageCatalogue::INTL_DOMAIN_SUFFIX)).'.'.$messages->getLocale());
|
||||
} else {
|
||||
$xliffFile->setAttribute('id', $domain.'.'.$messages->getLocale());
|
||||
}
|
||||
|
||||
if ($catalogueMetadata = $messages->getCatalogueMetadata('', $domain) ?? []) {
|
||||
$xliff->setAttribute('xmlns:m', 'urn:oasis:names:tc:xliff:metadata:2.0');
|
||||
$xliffMetadata = $xliffFile->appendChild($dom->createElement('m:metadata'));
|
||||
foreach ($catalogueMetadata as $key => $value) {
|
||||
$xliffMeta = $xliffMetadata->appendChild($dom->createElement('prop'));
|
||||
$xliffMeta->setAttribute('type', $key);
|
||||
$xliffMeta->appendChild($dom->createTextNode($value));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($messages->all($domain) as $source => $target) {
|
||||
$translation = $dom->createElement('unit');
|
||||
$translation->setAttribute('id', strtr(substr(base64_encode(hash('xxh128', $source, true)), 0, 7), '/+', '._'));
|
||||
|
||||
if (\strlen($source) <= 80) {
|
||||
$translation->setAttribute('name', $source);
|
||||
}
|
||||
|
||||
$metadata = $messages->getMetadata($source, $domain);
|
||||
|
||||
// Add notes section
|
||||
if ($this->hasMetadataArrayInfo('notes', $metadata)) {
|
||||
$notesElement = $dom->createElement('notes');
|
||||
foreach ($metadata['notes'] as $note) {
|
||||
$n = $dom->createElement('note');
|
||||
$n->appendChild($dom->createTextNode($note['content'] ?? ''));
|
||||
unset($note['content']);
|
||||
|
||||
foreach ($note as $name => $value) {
|
||||
$n->setAttribute($name, $value);
|
||||
}
|
||||
$notesElement->appendChild($n);
|
||||
}
|
||||
$translation->appendChild($notesElement);
|
||||
}
|
||||
|
||||
$segment = $translation->appendChild($dom->createElement('segment'));
|
||||
|
||||
if ($this->hasMetadataArrayInfo('segment-attributes', $metadata)) {
|
||||
foreach ($metadata['segment-attributes'] as $name => $value) {
|
||||
$segment->setAttribute($name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
$s = $segment->appendChild($dom->createElement('source'));
|
||||
$s->appendChild($dom->createTextNode($source));
|
||||
|
||||
// Does the target contain characters requiring a CDATA section?
|
||||
$text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target);
|
||||
|
||||
$targetElement = $dom->createElement('target');
|
||||
if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) {
|
||||
foreach ($metadata['target-attributes'] as $name => $value) {
|
||||
$targetElement->setAttribute($name, $value);
|
||||
}
|
||||
}
|
||||
$t = $segment->appendChild($targetElement);
|
||||
$t->appendChild($text);
|
||||
|
||||
$xliffFile->appendChild($translation);
|
||||
}
|
||||
|
||||
return $dom->saveXML();
|
||||
}
|
||||
|
||||
private function hasMetadataArrayInfo(string $key, ?array $metadata = null): bool
|
||||
{
|
||||
return is_iterable($metadata[$key] ?? null);
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Translation\Fixes;
|
||||
|
||||
use Symfony\Component\Config\Resource\FileResource;
|
||||
use Symfony\Component\Config\Util\Exception\InvalidXmlException;
|
||||
use Symfony\Component\Config\Util\Exception\XmlParsingException;
|
||||
use Symfony\Component\Config\Util\XmlUtils;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\Translation\Exception\InvalidResourceException;
|
||||
use Symfony\Component\Translation\Exception\NotFoundResourceException;
|
||||
use Symfony\Component\Translation\Exception\RuntimeException;
|
||||
use Symfony\Component\Translation\Loader\LoaderInterface;
|
||||
use Symfony\Component\Translation\MessageCatalogue;
|
||||
use Symfony\Component\Translation\Util\XliffUtils;
|
||||
|
||||
/**
|
||||
* Backport of the XliffFile dumper from Symfony 7.2, which supports segment attributes and notes, this keeps the
|
||||
* metadata when editing the translations from inside Symfony.
|
||||
*/
|
||||
#[AsDecorator("translation.loader.xliff")]
|
||||
class SegmentAwareXliffFileLoader implements LoaderInterface
|
||||
{
|
||||
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
|
||||
{
|
||||
if (!class_exists(XmlUtils::class)) {
|
||||
throw new RuntimeException('Loading translations from the Xliff format requires the Symfony Config component.');
|
||||
}
|
||||
|
||||
if (!$this->isXmlString($resource)) {
|
||||
if (!stream_is_local($resource)) {
|
||||
throw new InvalidResourceException(\sprintf('This is not a local file "%s".', $resource));
|
||||
}
|
||||
|
||||
if (!file_exists($resource)) {
|
||||
throw new NotFoundResourceException(\sprintf('File "%s" not found.', $resource));
|
||||
}
|
||||
|
||||
if (!is_file($resource)) {
|
||||
throw new InvalidResourceException(\sprintf('This is neither a file nor an XLIFF string "%s".', $resource));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->isXmlString($resource)) {
|
||||
$dom = XmlUtils::parse($resource);
|
||||
} else {
|
||||
$dom = XmlUtils::loadFile($resource);
|
||||
}
|
||||
} catch (\InvalidArgumentException|XmlParsingException|InvalidXmlException $e) {
|
||||
throw new InvalidResourceException(\sprintf('Unable to load "%s": ', $resource).$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
if ($errors = XliffUtils::validateSchema($dom)) {
|
||||
throw new InvalidResourceException(\sprintf('Invalid resource provided: "%s"; Errors: ', $resource).XliffUtils::getErrorsAsString($errors));
|
||||
}
|
||||
|
||||
$catalogue = new MessageCatalogue($locale);
|
||||
$this->extract($dom, $catalogue, $domain);
|
||||
|
||||
if (is_file($resource) && class_exists(FileResource::class)) {
|
||||
$catalogue->addResource(new FileResource($resource));
|
||||
}
|
||||
|
||||
return $catalogue;
|
||||
}
|
||||
|
||||
private function extract(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain): void
|
||||
{
|
||||
$xliffVersion = XliffUtils::getVersionNumber($dom);
|
||||
|
||||
if ('1.2' === $xliffVersion) {
|
||||
$this->extractXliff1($dom, $catalogue, $domain);
|
||||
}
|
||||
|
||||
if ('2.0' === $xliffVersion) {
|
||||
$this->extractXliff2($dom, $catalogue, $domain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract messages and metadata from DOMDocument into a MessageCatalogue.
|
||||
*/
|
||||
private function extractXliff1(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain): void
|
||||
{
|
||||
$xml = simplexml_import_dom($dom);
|
||||
$encoding = $dom->encoding ? strtoupper($dom->encoding) : null;
|
||||
|
||||
$namespace = 'urn:oasis:names:tc:xliff:document:1.2';
|
||||
$xml->registerXPathNamespace('xliff', $namespace);
|
||||
|
||||
foreach ($xml->xpath('//xliff:file') as $file) {
|
||||
$fileAttributes = $file->attributes();
|
||||
|
||||
$file->registerXPathNamespace('xliff', $namespace);
|
||||
|
||||
foreach ($file->xpath('.//xliff:prop') as $prop) {
|
||||
$catalogue->setCatalogueMetadata($prop->attributes()['prop-type'], (string) $prop, $domain);
|
||||
}
|
||||
|
||||
foreach ($file->xpath('.//xliff:trans-unit') as $translation) {
|
||||
$attributes = $translation->attributes();
|
||||
|
||||
if (!(isset($attributes['resname']) || isset($translation->source))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = (string) (isset($attributes['resname']) && $attributes['resname'] ? $attributes['resname'] : $translation->source);
|
||||
|
||||
if (isset($translation->target)
|
||||
&& 'needs-translation' === (string) $translation->target->attributes()['state']
|
||||
&& \in_array((string) $translation->target, [$source, (string) $translation->source], true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the xlf file has another encoding specified, try to convert it because
|
||||
// simple_xml will always return utf-8 encoded values
|
||||
$target = $this->utf8ToCharset((string) ($translation->target ?? $translation->source), $encoding);
|
||||
|
||||
$catalogue->set($source, $target, $domain);
|
||||
|
||||
$metadata = [
|
||||
'source' => (string) $translation->source,
|
||||
'file' => [
|
||||
'original' => (string) $fileAttributes['original'],
|
||||
],
|
||||
];
|
||||
if ($notes = $this->parseNotesMetadata($translation->note, $encoding)) {
|
||||
$metadata['notes'] = $notes;
|
||||
}
|
||||
|
||||
if (isset($translation->target) && $translation->target->attributes()) {
|
||||
$metadata['target-attributes'] = [];
|
||||
foreach ($translation->target->attributes() as $key => $value) {
|
||||
$metadata['target-attributes'][$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($attributes['id'])) {
|
||||
$metadata['id'] = (string) $attributes['id'];
|
||||
}
|
||||
|
||||
$catalogue->setMetadata($source, $metadata, $domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extractXliff2(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain): void
|
||||
{
|
||||
$xml = simplexml_import_dom($dom);
|
||||
$encoding = $dom->encoding ? strtoupper($dom->encoding) : null;
|
||||
|
||||
$xml->registerXPathNamespace('xliff', 'urn:oasis:names:tc:xliff:document:2.0');
|
||||
|
||||
foreach ($xml->xpath('//xliff:unit') as $unit) {
|
||||
foreach ($unit->segment as $segment) {
|
||||
$attributes = $unit->attributes();
|
||||
$source = $attributes['name'] ?? $segment->source;
|
||||
|
||||
// If the xlf file has another encoding specified, try to convert it because
|
||||
// simple_xml will always return utf-8 encoded values
|
||||
$target = $this->utf8ToCharset((string) ($segment->target ?? $segment->source), $encoding);
|
||||
|
||||
$catalogue->set((string) $source, $target, $domain);
|
||||
|
||||
$metadata = [];
|
||||
if ($segment->attributes()) {
|
||||
$metadata['segment-attributes'] = [];
|
||||
foreach ($segment->attributes() as $key => $value) {
|
||||
$metadata['segment-attributes'][$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($segment->target) && $segment->target->attributes()) {
|
||||
$metadata['target-attributes'] = [];
|
||||
foreach ($segment->target->attributes() as $key => $value) {
|
||||
$metadata['target-attributes'][$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($unit->notes)) {
|
||||
$metadata['notes'] = [];
|
||||
foreach ($unit->notes->note as $noteNode) {
|
||||
$note = [];
|
||||
foreach ($noteNode->attributes() as $key => $value) {
|
||||
$note[$key] = (string) $value;
|
||||
}
|
||||
$note['content'] = (string) $noteNode;
|
||||
$metadata['notes'][] = $note;
|
||||
}
|
||||
}
|
||||
|
||||
$catalogue->setMetadata((string) $source, $metadata, $domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a UTF8 string to the specified encoding.
|
||||
*/
|
||||
private function utf8ToCharset(string $content, ?string $encoding = null): string
|
||||
{
|
||||
if ('UTF-8' !== $encoding && $encoding) {
|
||||
return mb_convert_encoding($content, $encoding, 'UTF-8');
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function parseNotesMetadata(?\SimpleXMLElement $noteElement = null, ?string $encoding = null): array
|
||||
{
|
||||
$notes = [];
|
||||
|
||||
if (null === $noteElement) {
|
||||
return $notes;
|
||||
}
|
||||
|
||||
/** @var \SimpleXMLElement $xmlNote */
|
||||
foreach ($noteElement as $xmlNote) {
|
||||
$noteAttributes = $xmlNote->attributes();
|
||||
$note = ['content' => $this->utf8ToCharset((string) $xmlNote, $encoding)];
|
||||
if (isset($noteAttributes['priority'])) {
|
||||
$note['priority'] = (int) $noteAttributes['priority'];
|
||||
}
|
||||
|
||||
if (isset($noteAttributes['from'])) {
|
||||
$note['from'] = (string) $noteAttributes['from'];
|
||||
}
|
||||
|
||||
$notes[] = $note;
|
||||
}
|
||||
|
||||
return $notes;
|
||||
}
|
||||
|
||||
private function isXmlString(string $resource): bool
|
||||
{
|
||||
return str_starts_with($resource, '<?xml');
|
||||
}
|
||||
}
|
||||
88
src/Translation/NoCDATAXliffFileDumper.php
Normal file
88
src/Translation/NoCDATAXliffFileDumper.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 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\Translation;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\Translation\Dumper\FileDumper;
|
||||
use Symfony\Component\Translation\MessageCatalogue;
|
||||
|
||||
/**
|
||||
* The goal of this class, is to ensure that the XLIFF dumper does not output CDATA, but instead outputs the text
|
||||
* using the normal XML escaping. Crowdin outputs the translations without CDATA, we want to be consistent with that, to
|
||||
* prevent unnecessary diffs in the translation files when we update them with translations from Crowdin.
|
||||
*/
|
||||
#[AsDecorator("translation.dumper.xliff")]
|
||||
class NoCDATAXliffFileDumper extends FileDumper
|
||||
{
|
||||
|
||||
public function __construct(private readonly FileDumper $decorated)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private function convertCDataToEscapedText(string $xmlContent): string
|
||||
{
|
||||
$dom = new DOMDocument();
|
||||
// Preserve whitespace to keep Symfony's formatting intact
|
||||
$dom->preserveWhiteSpace = true;
|
||||
$dom->formatOutput = true;
|
||||
|
||||
// Load the XML (handle internal errors if necessary)
|
||||
$dom->loadXML($xmlContent);
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
// Find all CDATA sections
|
||||
$cdataNodes = $xpath->query('//node()/comment()|//node()/text()|//node()') ;
|
||||
|
||||
// We specifically want CDATA sections. XPath 1.0 doesn't have a direct
|
||||
// "cdata-section()" selector easily, so we iterate through all nodes
|
||||
// and check their type.
|
||||
|
||||
$nodesToRemove = [];
|
||||
foreach ($xpath->query('//text() | //*') as $node) {
|
||||
foreach ($node->childNodes as $child) {
|
||||
if ($child->nodeType === XML_CDATA_SECTION_NODE) {
|
||||
// Create a new text node with the content of the CDATA
|
||||
// DOMDocument will automatically escape special chars on save
|
||||
$newTextNode = $dom->createTextNode($child->textContent);
|
||||
$node->replaceChild($newTextNode, $child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $dom->saveXML();
|
||||
}
|
||||
|
||||
public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
|
||||
{
|
||||
return $this->convertCDataToEscapedText($this->decorated->formatCatalogue($messages, $domain, $options));
|
||||
}
|
||||
|
||||
protected function getExtension(): string
|
||||
{
|
||||
return $this->decorated->getExtension();
|
||||
}
|
||||
}
|
||||
79
src/Twig/UpdateExtension.php
Normal file
79
src/Twig/UpdateExtension.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Services\System\UpdateAvailableFacade;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Twig extension for update-related functions.
|
||||
*/
|
||||
final class UpdateExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private readonly UpdateAvailableFacade $updateAvailableManager,
|
||||
private readonly Security $security)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('is_update_available', $this->isUpdateAvailable(...)),
|
||||
new TwigFunction('get_latest_version', $this->getLatestVersion(...)),
|
||||
new TwigFunction('get_latest_version_url', $this->getLatestVersionUrl(...)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is available and the user has permission to see it.
|
||||
*/
|
||||
public function isUpdateAvailable(): bool
|
||||
{
|
||||
// Only show to users with the show_updates permission
|
||||
if (!$this->security->isGranted('@system.show_updates')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->updateAvailableManager->isUpdateAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest available version string.
|
||||
*/
|
||||
public function getLatestVersion(): string
|
||||
{
|
||||
return $this->updateAvailableManager->getLatestVersionString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to the latest version release page.
|
||||
*/
|
||||
public function getLatestVersionUrl(): string
|
||||
{
|
||||
return $this->updateAvailableManager->getLatestVersionUrl();
|
||||
}
|
||||
}
|
||||
13
symfony.lock
13
symfony.lock
@@ -718,18 +718,17 @@
|
||||
"files": []
|
||||
},
|
||||
"symfony/ux-translator": {
|
||||
"version": "2.9",
|
||||
"version": "2.32",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.9",
|
||||
"ref": "bc396565cc4cab95692dd6df810553dc22e352e1"
|
||||
"version": "2.32",
|
||||
"ref": "20e2abac415da4c3a9a6bafa059a6419beb74593"
|
||||
},
|
||||
"files": [
|
||||
"./assets/translator.js",
|
||||
"./config/packages/ux_translator.yaml",
|
||||
"./var/translations/configuration.js",
|
||||
"./var/translations/index.js"
|
||||
"assets/translator.js",
|
||||
"config/packages/ux_translator.yaml",
|
||||
"var/translations/index.js"
|
||||
]
|
||||
},
|
||||
"symfony/ux-turbo": {
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<!-- <span class="navbar-toggler-icon"></span> -->
|
||||
<i class="fas fa-folder-open fa-lg fa-fw"></i>
|
||||
</button>
|
||||
{% if is_granted("@tools.label_scanner") %}
|
||||
{% if is_granted("@tools.label_scanner") %}
|
||||
<a href="{{ path('scan_dialog') }}" class="navbar-toggler nav-link ms-3">
|
||||
<i class="fas fa-camera-retro fa-fw"></i>
|
||||
<i class="fas fa-camera-retro fa-fw"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -52,6 +52,14 @@
|
||||
{% trans %}info_providers.search.title{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% if settings_instance('generic_web_provider').enabled %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ path('info_providers_from_url') }}">
|
||||
<i class="fa-fw fa-solid fa-book-atlas"></i>
|
||||
{% trans %}info_providers.from_url.title{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('@parts.import') %}
|
||||
@@ -69,11 +77,24 @@
|
||||
{% if is_granted('@parts.read') %}
|
||||
{{ search.search_form("navbar") }}
|
||||
|
||||
{# {% include "_navbar_search.html.twig" %} #}
|
||||
{# {% include "_navbar_search.html.twig" %} #}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<ul class="navbar-nav ms-3" id="login-content">
|
||||
{# Update notification badge #}
|
||||
{% if is_update_available() %}
|
||||
<li class="nav-item me-2">
|
||||
<a href="{{ path('admin_update_manager') }}" class="nav-link position-relative"
|
||||
title="{% trans %}update_manager.new_version_available.title{% endtrans %}: {{ get_latest_version() }}">
|
||||
<i class="fas fa-arrow-circle-up text-success"></i>
|
||||
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-success" style="font-size: 0.6rem;">
|
||||
{% trans %}update_manager.new{% endtrans %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a href="#" class="dropdown-toggle link-anchor nav-link" data-bs-toggle="dropdown" role="button"
|
||||
aria-haspopup="true" aria-expanded="false" id="navbar-user-dropdown-btn" data-bs-reference="window">
|
||||
@@ -145,4 +166,4 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
|
||||
423
templates/admin/update_manager/index.html.twig
Normal file
423
templates/admin/update_manager/index.html.twig
Normal file
@@ -0,0 +1,423 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-arrow-circle-up"></i> Part-DB {% trans %}update_manager.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<div>
|
||||
|
||||
{# Maintenance Mode Warning #}
|
||||
{% if is_maintenance %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-tools me-2"></i>
|
||||
<strong>{% trans %}update_manager.maintenance_mode_active{% endtrans %}</strong>
|
||||
{% if maintenance_info.reason is defined %}
|
||||
- {{ maintenance_info.reason }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Lock Warning #}
|
||||
{% if is_locked %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fas fa-lock me-2"></i>
|
||||
<strong>{% trans %}update_manager.update_in_progress{% endtrans %}</strong>
|
||||
{% if lock_info.started_at is defined %}
|
||||
({% trans %}update_manager.started_at{% endtrans %}: {{ lock_info.started_at }})
|
||||
{% endif %}
|
||||
<a href="{{ path('admin_update_manager_progress') }}" class="alert-link ms-2">
|
||||
{% trans %}update_manager.view_progress{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Web Updates Disabled Warning #}
|
||||
{% if web_updates_disabled %}
|
||||
<div class="alert alert-secondary" role="alert">
|
||||
<i class="fas fa-ban me-2"></i>
|
||||
<strong>{% trans %}update_manager.web_updates_disabled{% endtrans %}</strong>
|
||||
<p class="mb-0 mt-2">{% trans %}update_manager.web_updates_disabled_hint{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Backup Restore Disabled Warning #}
|
||||
{% if backup_restore_disabled %}
|
||||
<div class="alert alert-secondary" role="alert">
|
||||
<i class="fas fa-ban me-2"></i>
|
||||
<strong>{% trans %}update_manager.backup_restore_disabled{% endtrans %}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
{# Current Version Card #}
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-info-circle me-2"></i>{% trans %}update_manager.current_installation{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row" style="width: 40%">{% trans %}update_manager.version{% endtrans %}</th>
|
||||
<td>
|
||||
<span class="badge bg-primary fs-6">{{ status.current_version }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.installation_type{% endtrans %}</th>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ status.installation.type_name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% if status.git.is_git_install %}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.git_branch{% endtrans %}</th>
|
||||
<td><code>{{ status.git.branch ?? 'N/A' }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.git_commit{% endtrans %}</th>
|
||||
<td><code>{{ status.git.commit ?? 'N/A' }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.local_changes{% endtrans %}</th>
|
||||
<td>
|
||||
{% if status.git.has_local_changes %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>{% trans %}Yes{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check me-1"></i>{% trans %}No{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
||||
<td>
|
||||
{% if status.can_auto_update %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check me-1"></i>{% trans %}Yes{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-times me-1"></i>{% trans %}No{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<form action="{{ path('admin_update_manager_refresh') }}" method="post" class="d-inline">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_refresh') }}">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.refresh{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Latest Version / Update Card #}
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100 {{ status.update_available ? 'border-success' : '' }}">
|
||||
<div class="card-header {{ status.update_available ? 'bg-success text-white' : '' }}">
|
||||
{% if status.update_available %}
|
||||
<i class="fas fa-gift me-2"></i>{% trans %}update_manager.new_version_available.title{% endtrans %}
|
||||
{% else %}
|
||||
<i class="fas fa-check-circle me-2"></i>{% trans %}update_manager.latest_release{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if status.latest_version %}
|
||||
<div class="text-center mb-3">
|
||||
<span class="badge bg-{{ status.update_available ? 'success' : 'primary' }} fs-4 px-4 py-2">
|
||||
{{ status.latest_tag }}
|
||||
</span>
|
||||
{% if not status.update_available %}
|
||||
<p class="text-success mt-2 mb-0">
|
||||
<i class="fas fa-check-circle me-1"></i>
|
||||
{% trans %}update_manager.already_up_to_date{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</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 }}">
|
||||
|
||||
<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 %}
|
||||
|
||||
{% if status.published_at %}
|
||||
<p class="text-muted small mt-3 mb-0">
|
||||
<i class="fas fa-calendar me-1"></i>
|
||||
{% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fas fa-question-circle fa-3x mb-3"></i>
|
||||
<p>{% trans %}update_manager.could_not_fetch_releases{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if status.latest_tag %}
|
||||
<div class="card-footer">
|
||||
<a href="{{ path('admin_update_manager_release', {tag: status.latest_tag}) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-file-alt me-1"></i> {% trans %}update_manager.view_release_notes{% endtrans %}
|
||||
</a>
|
||||
{% if status.release_url %}
|
||||
<a href="{{ status.release_url }}" class="btn btn-outline-secondary btn-sm" target="_blank">
|
||||
<i class="fab fa-github me-1"></i> GitHub
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Validation Issues #}
|
||||
{% if not validation.valid %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h6 class="alert-heading">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.validation_issues{% endtrans %}
|
||||
</h6>
|
||||
<ul class="mb-0">
|
||||
{% for error in validation.errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# 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>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
{# Available Versions #}
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-tags me-2"></i>{% trans %}update_manager.available_versions{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.version{% endtrans %}</th>
|
||||
<th>{% trans %}update_manager.released{% endtrans %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for release in all_releases %}
|
||||
<tr{% if release.version == status.current_version %} class="table-active"{% endif %}>
|
||||
<td>
|
||||
<code>{{ release.tag }}</code>
|
||||
{% if release.prerelease %}
|
||||
<span class="badge bg-warning text-dark ms-1">pre</span>
|
||||
{% endif %}
|
||||
{% if release.version == status.current_version %}
|
||||
<span class="badge bg-primary ms-1">{% trans %}update_manager.current{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
{{ release.published_at|date('Y-m-d') }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ path('admin_update_manager_release', {tag: release.tag}) }}"
|
||||
class="btn btn-outline-secondary"
|
||||
title="{% trans %}update_manager.view_release_notes{% endtrans %}">
|
||||
<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 %}
|
||||
<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' }}"
|
||||
data-update-confirm-target-version-value="{{ release.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="{{ release.tag }}">
|
||||
<input type="hidden" name="backup" value="1">
|
||||
<button type="submit"
|
||||
class="btn btn-{{ release.version > status.current_version ? 'outline-success' : 'outline-warning' }}"
|
||||
title="{% trans %}update_manager.switch_to{% endtrans %} {{ release.tag }}">
|
||||
{% if release.version > status.current_version %}
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted py-3">
|
||||
{% trans %}update_manager.no_releases_found{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Update History & Backups #}
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#logs-tab" type="button">
|
||||
<i class="fas fa-history me-1"></i>{% trans %}update_manager.update_logs{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#backups-tab" type="button">
|
||||
<i class="fas fa-archive me-1"></i>{% trans %}update_manager.backups{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="logs-tab">
|
||||
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.date{% endtrans %}</th>
|
||||
<th>{% trans %}update_manager.log_file{% endtrans %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in update_logs %}
|
||||
<tr>
|
||||
<td class="text-muted small">
|
||||
{{ log.date|date('Y-m-d H:i') }}
|
||||
</td>
|
||||
<td><code class="small">{{ log.file }}</code></td>
|
||||
<td>
|
||||
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted py-3">
|
||||
{% trans %}update_manager.no_logs_found{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="backups-tab">
|
||||
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.date{% endtrans %}</th>
|
||||
<th>{% trans %}update_manager.file{% endtrans %}</th>
|
||||
<th>{% trans %}update_manager.size{% endtrans %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td class="text-muted small">
|
||||
{{ backup.date|date('Y-m-d H:i') }}
|
||||
</td>
|
||||
<td><code class="small">{{ backup.file }}</code></td>
|
||||
<td class="text-muted small">
|
||||
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if status.can_auto_update and validation.valid and not backup_restore_disabled %}
|
||||
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
|
||||
data-controller="backup-restore"
|
||||
data-backup-restore-filename-value="{{ backup.file }}"
|
||||
data-backup-restore-date-value="{{ backup.date|date('Y-m-d H:i') }}"
|
||||
data-backup-restore-confirm-title-value="{{ 'update_manager.restore_confirm_title'|trans }}"
|
||||
data-backup-restore-confirm-message-value="{{ 'update_manager.restore_confirm_message'|trans }}"
|
||||
data-backup-restore-confirm-warning-value="{{ 'update_manager.restore_confirm_warning'|trans }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_restore') }}">
|
||||
<input type="hidden" name="filename" value="{{ backup.file }}">
|
||||
<input type="hidden" name="restore_database" value="1">
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-warning"
|
||||
title="{% trans %}update_manager.restore_backup{% endtrans %}">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-3">
|
||||
{% trans %}update_manager.no_backups_found{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
40
templates/admin/update_manager/log_viewer.html.twig
Normal file
40
templates/admin/update_manager/log_viewer.html.twig
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}{{ filename }} - {% trans %}update_manager.log_viewer{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-file-code"></i> {{ filename }}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<div class="mb-4">
|
||||
<a href="{{ path('admin_update_manager') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.back_to_update_manager{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="fas fa-terminal me-2"></i>{% trans %}update_manager.update_log{% endtrans %}
|
||||
</span>
|
||||
<span class="badge bg-secondary">{{ content|length }} {% trans %}update_manager.bytes{% endtrans %}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<pre class="bg-dark text-light p-3 mb-0" style="max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;"><code>{{ content }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
pre code {
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Highlight different log levels */
|
||||
pre code {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user