mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-23 10:12:33 +01:00
Compare commits
145 Commits
db_convert
...
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 | ||
|
|
64efca4786 | ||
|
|
3e071f2b74 | ||
|
|
89322d329c |
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
|
||||
|
||||
@@ -46,13 +46,11 @@ RUN apt-get update && apt-get -y install \
|
||||
&& rm -rvf /var/www/html/*
|
||||
|
||||
# Install node and yarn
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
|
||||
curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
RUN curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get update && apt-get install -y \
|
||||
nodejs \
|
||||
yarn \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
|
||||
&& 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
|
||||
|
||||
@@ -14,31 +14,21 @@ RUN apt-get update && apt-get -y install \
|
||||
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
RUN set -eux; \
|
||||
# Prepare keyrings directory
|
||||
mkdir -p /etc/apt/keyrings; \
|
||||
\
|
||||
# Import Yarn GPG key
|
||||
curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg \
|
||||
| tee /etc/apt/keyrings/yarn.gpg >/dev/null; \
|
||||
chmod 644 /etc/apt/keyrings/yarn.gpg; \
|
||||
\
|
||||
# Add Yarn repo with signed-by
|
||||
echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian stable main" \
|
||||
| tee /etc/apt/sources.list.d/yarn.list; \
|
||||
\
|
||||
# Run NodeSource setup script (unchanged)
|
||||
# Run NodeSource setup script
|
||||
curl -sL https://deb.nodesource.com/setup_22.x | bash -; \
|
||||
\
|
||||
# Install Node.js + Yarn
|
||||
# Install Node.js
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
yarn; \
|
||||
nodejs; \
|
||||
\
|
||||
# Cleanup
|
||||
apt-get -y autoremove; \
|
||||
apt-get clean autoclean; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
\
|
||||
# Install Yarn via npm
|
||||
npm install -g yarn
|
||||
|
||||
|
||||
# Install PHP
|
||||
|
||||
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,8 +81,8 @@
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/type-info": "7.4.0",
|
||||
"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.*",
|
||||
|
||||
1678
composer.lock
generated
1678
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
|
||||
|
||||
@@ -3,6 +3,7 @@ jbtronics_settings:
|
||||
|
||||
cache:
|
||||
default_cacheable: true
|
||||
service: 'cache.settings'
|
||||
|
||||
orm_storage:
|
||||
default_entity_class: App\Entity\SettingsEntry
|
||||
|
||||
@@ -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:
|
||||
|
||||
2235
config/reference.php
2235
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
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -278,6 +293,16 @@ The following env configuration options are available:
|
||||
* `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('');
|
||||
}
|
||||
|
||||
}
|
||||
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, [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
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,7 +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 %}
|
||||
196
templates/admin/update_manager/progress.html.twig
Normal file
196
templates/admin/update_manager/progress.html.twig
Normal file
@@ -0,0 +1,196 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_title{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.title{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
{% if progress and progress.status == 'running' %}
|
||||
<i class="fas fa-sync-alt fa-spin"></i>
|
||||
{% elseif progress and progress.status == 'completed' %}
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
{% elseif progress and progress.status == 'failed' %}
|
||||
<i class="fas fa-times-circle text-danger"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-hourglass-start"></i>
|
||||
{% endif %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_title{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.title{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ parent() }}
|
||||
{# Auto-refresh while update is running - also refresh when 'starting' status #}
|
||||
{% if not progress or progress.status == 'running' or progress.status == 'starting' %}
|
||||
<meta http-equiv="refresh" content="5">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<div id="update-progress">
|
||||
|
||||
{# Progress Header #}
|
||||
<div class="text-center mb-4">
|
||||
<div class="mb-3">
|
||||
{% if progress and progress.status == 'completed' %}
|
||||
<i class="fas fa-check-circle fa-3x text-success"></i>
|
||||
{% elseif progress and progress.status == 'failed' %}
|
||||
<i class="fas fa-times-circle fa-3x text-danger"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-cog fa-spin fa-3x text-primary"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h4>
|
||||
{% if progress and progress.status == 'running' %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrading{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.updating{% endtrans %}
|
||||
{% endif %}
|
||||
{% elseif progress and progress.status == 'completed' %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_completed{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.completed{% endtrans %}
|
||||
{% endif %}
|
||||
{% elseif progress and progress.status == 'failed' %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_failed{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.failed{% endtrans %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.initializing{% endtrans %}
|
||||
{% endif %}
|
||||
</h4>
|
||||
<p class="text-muted">
|
||||
{% if progress %}
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans with {'%version%': progress.target_version|default('unknown')} %}update_manager.progress.downgrading_to{% endtrans %}
|
||||
{% else %}
|
||||
{% trans with {'%version%': progress.target_version|default('unknown')} %}update_manager.progress.updating_to{% endtrans %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Progress Bar #}
|
||||
{% set percent = progress ? ((progress.current_step|default(0) / progress.total_steps|default(12)) * 100)|round : 0 %}
|
||||
{% if progress and progress.status == 'completed' %}
|
||||
{% set percent = 100 %}
|
||||
{% endif %}
|
||||
<div class="progress mb-4" style="height: 25px;">
|
||||
<div class="progress-bar {% if progress and progress.status == 'completed' %}bg-success{% elseif progress and progress.status == 'failed' %}bg-danger{% else %}progress-bar-striped progress-bar-animated{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ percent }}%"
|
||||
aria-valuenow="{{ progress.current_step|default(0) }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{ progress.total_steps|default(12) }}">
|
||||
{{ percent }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Current Step - shows what's currently being worked on #}
|
||||
{% if progress and (progress.status == 'running' or progress.status == 'starting') %}
|
||||
<div class="alert alert-info mb-4">
|
||||
<strong>{{ progress.step_name|default('initializing')|replace({'_': ' '})|capitalize }}</strong>:
|
||||
{{ progress.step_message|default('Processing...') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Error Message #}
|
||||
{% if progress and progress.status == 'failed' %}
|
||||
<div class="alert alert-danger mb-4">
|
||||
<strong>{% trans %}update_manager.progress.error{% endtrans %}:</strong>
|
||||
{{ progress.error|default('An unknown error occurred') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Success Message #}
|
||||
{% if progress and progress.status == 'completed' %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_success_message{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.success_message{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Steps Timeline #}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-list-ol me-2"></i>
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_steps{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.steps{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% if progress and progress.steps %}
|
||||
{% for step in progress.steps %}
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
{% if step.success %}
|
||||
<i class="fas fa-check-circle text-success me-3"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-times-circle text-danger me-3"></i>
|
||||
{% endif %}
|
||||
<div class="flex-grow-1">
|
||||
<strong>{{ step.step|replace({'_': ' '})|capitalize }}</strong>
|
||||
<br><small class="text-muted">{{ step.message }}</small>
|
||||
</div>
|
||||
<small class="text-muted">{{ step.timestamp|date('H:i:s') }}</small>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="list-group-item text-center text-muted py-3">
|
||||
<i class="fas fa-clock me-2"></i>{% trans %}update_manager.progress.waiting{% endtrans %}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="text-center">
|
||||
{% if progress and (progress.status == 'completed' or progress.status == 'failed') %}
|
||||
<a href="{{ path('admin_update_manager') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.progress.back{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ path('admin_update_manager_progress') }}" class="btn btn-primary">
|
||||
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.progress.refresh_page{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Warning Notice #}
|
||||
{% if not progress or progress.status == 'running' or progress.status == 'starting' %}
|
||||
<div class="alert alert-warning mt-4">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{% trans %}update_manager.progress.warning{% endtrans %}:</strong>
|
||||
{% if is_downgrade|default(false) %}
|
||||
{% trans %}update_manager.progress.downgrade_do_not_close{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}update_manager.progress.do_not_close{% endtrans %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# JavaScript refresh - more reliable than meta refresh #}
|
||||
<script nonce="{{ csp_nonce('script') }}">
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
110
templates/admin/update_manager/release_notes.html.twig
Normal file
110
templates/admin/update_manager/release_notes.html.twig
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}{{ release.name }} - {% trans %}update_manager.release_notes{% endtrans %}{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-file-alt"></i> {{ release.name }}
|
||||
{% 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="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<th style="width: 30%">{% trans %}update_manager.version{% endtrans %}</th>
|
||||
<td>
|
||||
<span class="badge bg-primary fs-6">{{ release.version }}</span>
|
||||
{% if release.prerelease %}
|
||||
<span class="badge bg-warning text-dark ms-1">{% trans %}update_manager.prerelease{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.tag{% endtrans %}</th>
|
||||
<td><code>{{ release.tag }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.released{% endtrans %}</th>
|
||||
<td>{{ release.published_at|date('Y-m-d H:i') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans %}update_manager.status{% endtrans %}</th>
|
||||
<td>
|
||||
{% if release.version == current_version %}
|
||||
<span class="badge bg-primary">{% trans %}update_manager.current{% endtrans %}</span>
|
||||
{% elseif release.version > current_version %}
|
||||
<span class="badge bg-success">{% trans %}update_manager.newer{% endtrans %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{% trans %}update_manager.older{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<a href="{{ release.url }}" class="btn btn-primary" target="_blank">
|
||||
<i class="fab fa-github me-1"></i> {% trans %}update_manager.view_on_github{% endtrans %}
|
||||
</a>
|
||||
{% if release.zipball_url %}
|
||||
<a href="{{ release.zipball_url }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-download me-1"></i> ZIP
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if release.assets is not empty %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-paperclip me-2"></i>{% trans %}update_manager.download_assets{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for asset in release.assets %}
|
||||
<li class="mb-2">
|
||||
<a href="{{ asset.url }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-download me-1"></i> {{ asset.name }}
|
||||
</a>
|
||||
<span class="text-muted ms-2">({{ (asset.size / 1024 / 1024)|number_format(1) }} MB)</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-list-ul me-2"></i>{% trans %}update_manager.changelog{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if release.body %}
|
||||
<div class="markdown-body">
|
||||
{{ release.body|markdown_to_html }}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">{% trans %}update_manager.no_release_notes{% endtrans %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if release.version > current_version %}
|
||||
<div class="card mt-4 border-success">
|
||||
<div class="card-header bg-success text-white">
|
||||
<i class="fas fa-arrow-up me-2"></i>{% trans %}update_manager.update_to_this_version{% endtrans %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>{% trans %}update_manager.run_command_to_update{% endtrans %}</p>
|
||||
<div class="bg-dark text-light p-3 rounded">
|
||||
<code class="text-info">php bin/console partdb:update {{ release.tag }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -11,6 +11,10 @@
|
||||
<input type="checkbox" class="form-check-input" id="search_name" name="name" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_name" class="form-check-label justify-content-start">{% trans %}name.label{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_dbid" name="dbid" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_dbid" class="form-check-label justify-content-start">{% trans %}id.label{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_category" name="category" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_category" class="form-check-label justify-content-start">{% trans %}category.label{% endtrans %}</label>
|
||||
@@ -102,4 +106,4 @@
|
||||
{{ _self.settings_drodown(is_navbar) }}
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endmacro %}
|
||||
{% endmacro %}
|
||||
|
||||
21
templates/info_providers/from_url/from_url.html.twig
Normal file
21
templates/info_providers/from_url/from_url.html.twig
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% import "info_providers/providers.macro.html.twig" as providers_macro %}
|
||||
{% import "helper.twig" as helper %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}info_providers.from_url.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-book-atlas"></i> {% trans %}info_providers.from_url.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<p class="text-muted offset-3">{% trans %}info_providers.from_url.help{% endtrans %}</p>
|
||||
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.url) }}
|
||||
{{ form_row(form.submit) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -10,7 +10,7 @@
|
||||
{% block card_content %}
|
||||
<div class="offset-sm-3">
|
||||
<h3>
|
||||
{% if info_provider_info.url %}
|
||||
{% if info_provider_info.url is defined %}
|
||||
<a href="{{ info_provider_info.url }}" class="link-external" target="_blank" rel="nofollow">{{ info_provider_info.name }}</a>
|
||||
{% else %}
|
||||
{{ info_provider_info.name }}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<tbody>
|
||||
{% for lot in part.partLots %}
|
||||
<tr>
|
||||
<tr {% if lot.id == highlightLotId %}class="table-primary row-highlight row-pulse"{% endif %}>
|
||||
<td>{{ lot.description }}</td>
|
||||
<td>
|
||||
{% if lot.storageLocation %}
|
||||
@@ -117,4 +117,4 @@
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,6 +353,16 @@ class BOMImporterTest extends WebTestCase
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematic(): void
|
||||
{
|
||||
// Create test suppliers for this test
|
||||
$lcscSupplier = new Supplier();
|
||||
$lcscSupplier->setName('LCSC');
|
||||
$mouserSupplier = new Supplier();
|
||||
$mouserSupplier->setName('Mouser');
|
||||
|
||||
$this->entityManager->persist($lcscSupplier);
|
||||
$this->entityManager->persist($mouserSupplier);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$input = <<<CSV
|
||||
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
|
||||
"R1,R2","10k","R_0805_2012Metric",2,"CRCW080510K0FKEA","Vishay","C123456","123-M10K"
|
||||
@@ -386,10 +396,20 @@ class BOMImporterTest extends WebTestCase
|
||||
$this->assertStringContainsString('Value: 10k', $bom_entries[0]->getComment());
|
||||
$this->assertStringContainsString('MPN: CRCW080510K0FKEA', $bom_entries[0]->getComment());
|
||||
$this->assertStringContainsString('Manf: Vishay', $bom_entries[0]->getComment());
|
||||
$this->assertStringContainsString('LCSC SPN: C123456', $bom_entries[0]->getComment());
|
||||
$this->assertStringContainsString('Mouser SPN: 123-M10K', $bom_entries[0]->getComment());
|
||||
|
||||
|
||||
// Check second entry
|
||||
$this->assertEquals('C1', $bom_entries[1]->getMountnames());
|
||||
$this->assertEquals(1.0, $bom_entries[1]->getQuantity());
|
||||
$this->assertStringContainsString('LCSC SPN: C789012', $bom_entries[1]->getComment());
|
||||
$this->assertStringContainsString('Mouser SPN: 80-CL21A104KOCLRNC', $bom_entries[1]->getComment());
|
||||
|
||||
// Clean up
|
||||
$this->entityManager->remove($lcscSupplier);
|
||||
$this->entityManager->remove($mouserSupplier);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicWithPriority(): void
|
||||
@@ -596,6 +616,181 @@ class BOMImporterTest extends WebTestCase
|
||||
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicWithSupplierSPN(): void
|
||||
{
|
||||
// Create test supplier
|
||||
$lcscSupplier = new Supplier();
|
||||
$lcscSupplier->setName('LCSC');
|
||||
$this->entityManager->persist($lcscSupplier);
|
||||
|
||||
// Create a test part with required fields
|
||||
$part = new Part();
|
||||
$part->setName('Test Resistor 10k 0805');
|
||||
$part->setCategory($this->getDefaultCategory($this->entityManager));
|
||||
$this->entityManager->persist($part);
|
||||
|
||||
// Create orderdetail linking the part to a supplier SPN
|
||||
$orderdetail = new \App\Entity\PriceInformations\Orderdetail();
|
||||
$orderdetail->setPart($part);
|
||||
$orderdetail->setSupplier($lcscSupplier);
|
||||
$orderdetail->setSupplierpartnr('C123456');
|
||||
$this->entityManager->persist($orderdetail);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Import CSV with LCSC SPN matching the orderdetail
|
||||
$input = <<<CSV
|
||||
"Reference","Value","LCSC SPN","Quantity"
|
||||
"R1,R2","10k","C123456","2"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'LCSC SPN' => 'LCSC SPN',
|
||||
'Quantity' => 'Quantity'
|
||||
];
|
||||
|
||||
$bom_entries = $this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
|
||||
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
|
||||
$this->assertCount(1, $bom_entries);
|
||||
|
||||
// Verify that the BOM entry is linked to the correct part via supplier SPN
|
||||
$this->assertSame($part, $bom_entries[0]->getPart());
|
||||
$this->assertEquals('Test Resistor 10k 0805', $bom_entries[0]->getName());
|
||||
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
|
||||
$this->assertEquals(2.0, $bom_entries[0]->getQuantity());
|
||||
$this->assertStringContainsString('LCSC SPN: C123456', $bom_entries[0]->getComment());
|
||||
$this->assertStringContainsString('Part-DB ID: ' . $part->getID(), $bom_entries[0]->getComment());
|
||||
|
||||
// Clean up
|
||||
$this->entityManager->remove($orderdetail);
|
||||
$this->entityManager->remove($part);
|
||||
$this->entityManager->remove($lcscSupplier);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicWithMultipleSupplierSPNs(): void
|
||||
{
|
||||
// Create test suppliers
|
||||
$lcscSupplier = new Supplier();
|
||||
$lcscSupplier->setName('LCSC');
|
||||
$mouserSupplier = new Supplier();
|
||||
$mouserSupplier->setName('Mouser');
|
||||
$this->entityManager->persist($lcscSupplier);
|
||||
$this->entityManager->persist($mouserSupplier);
|
||||
|
||||
// Create first part linked via LCSC SPN
|
||||
$part1 = new Part();
|
||||
$part1->setName('Resistor 10k');
|
||||
$part1->setCategory($this->getDefaultCategory($this->entityManager));
|
||||
$this->entityManager->persist($part1);
|
||||
|
||||
$orderdetail1 = new \App\Entity\PriceInformations\Orderdetail();
|
||||
$orderdetail1->setPart($part1);
|
||||
$orderdetail1->setSupplier($lcscSupplier);
|
||||
$orderdetail1->setSupplierpartnr('C123456');
|
||||
$this->entityManager->persist($orderdetail1);
|
||||
|
||||
// Create second part linked via Mouser SPN
|
||||
$part2 = new Part();
|
||||
$part2->setName('Capacitor 100nF');
|
||||
$part2->setCategory($this->getDefaultCategory($this->entityManager));
|
||||
$this->entityManager->persist($part2);
|
||||
|
||||
$orderdetail2 = new \App\Entity\PriceInformations\Orderdetail();
|
||||
$orderdetail2->setPart($part2);
|
||||
$orderdetail2->setSupplier($mouserSupplier);
|
||||
$orderdetail2->setSupplierpartnr('789-CAP100NF');
|
||||
$this->entityManager->persist($orderdetail2);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Import CSV with both LCSC and Mouser SPNs
|
||||
$input = <<<CSV
|
||||
"Reference","Value","LCSC SPN","Mouser SPN","Quantity"
|
||||
"R1","10k","C123456","","1"
|
||||
"C1","100nF","","789-CAP100NF","1"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'LCSC SPN' => 'LCSC SPN',
|
||||
'Mouser SPN' => 'Mouser SPN',
|
||||
'Quantity' => 'Quantity'
|
||||
];
|
||||
|
||||
$bom_entries = $this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
|
||||
$this->assertCount(2, $bom_entries);
|
||||
|
||||
// Verify first entry linked via LCSC SPN
|
||||
$this->assertSame($part1, $bom_entries[0]->getPart());
|
||||
$this->assertEquals('Resistor 10k', $bom_entries[0]->getName());
|
||||
|
||||
// Verify second entry linked via Mouser SPN
|
||||
$this->assertSame($part2, $bom_entries[1]->getPart());
|
||||
$this->assertEquals('Capacitor 100nF', $bom_entries[1]->getName());
|
||||
|
||||
// Clean up
|
||||
$this->entityManager->remove($orderdetail1);
|
||||
$this->entityManager->remove($orderdetail2);
|
||||
$this->entityManager->remove($part1);
|
||||
$this->entityManager->remove($part2);
|
||||
$this->entityManager->remove($lcscSupplier);
|
||||
$this->entityManager->remove($mouserSupplier);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicWithNonMatchingSPN(): void
|
||||
{
|
||||
// Create test supplier
|
||||
$lcscSupplier = new Supplier();
|
||||
$lcscSupplier->setName('LCSC');
|
||||
$this->entityManager->persist($lcscSupplier);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Import CSV with LCSC SPN that doesn't match any orderdetail
|
||||
$input = <<<CSV
|
||||
"Reference","Value","LCSC SPN","Quantity"
|
||||
"R1","10k","C999999","1"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'LCSC SPN' => 'LCSC SPN',
|
||||
'Quantity' => 'Quantity'
|
||||
];
|
||||
|
||||
$bom_entries = $this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
|
||||
$this->assertCount(1, $bom_entries);
|
||||
|
||||
// Verify that no part is linked (SPN not found)
|
||||
$this->assertNull($bom_entries[0]->getPart());
|
||||
$this->assertEquals('10k', $bom_entries[0]->getName()); // Should use Value as name
|
||||
$this->assertStringContainsString('LCSC SPN: C999999', $bom_entries[0]->getComment());
|
||||
|
||||
// Clean up
|
||||
$this->entityManager->remove($lcscSupplier);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function getDefaultCategory(EntityManagerInterface $entityManager)
|
||||
{
|
||||
// Get the first available category or create a default one
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace App\Tests\Services\InfoProviderSystem;
|
||||
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use App\Services\InfoProviderSystem\Providers\URLHandlerInfoProviderInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ProviderRegistryTest extends TestCase
|
||||
@@ -44,9 +45,10 @@ class ProviderRegistryTest extends TestCase
|
||||
|
||||
public function getMockProvider(string $key, bool $active = true): InfoProviderInterface
|
||||
{
|
||||
$mock = $this->createMock(InfoProviderInterface::class);
|
||||
$mock = $this->createMockForIntersectionOfInterfaces([InfoProviderInterface::class, URLHandlerInfoProviderInterface::class]);
|
||||
$mock->method('getProviderKey')->willReturn($key);
|
||||
$mock->method('isActive')->willReturn($active);
|
||||
$mock->method('getHandledDomains')->willReturn(["$key.com", "test.$key.de"]);
|
||||
|
||||
return $mock;
|
||||
}
|
||||
@@ -109,4 +111,18 @@ class ProviderRegistryTest extends TestCase
|
||||
|
||||
$registry->getProviders();
|
||||
}
|
||||
|
||||
public function testGetProviderHandlingDomain(): void
|
||||
{
|
||||
$registry = new ProviderRegistry($this->providers);
|
||||
|
||||
$this->assertEquals($this->providers[0], $registry->getProviderHandlingDomain('test1.com'));
|
||||
$this->assertEquals($this->providers[0], $registry->getProviderHandlingDomain('www.test1.com')); //Subdomain should also work
|
||||
|
||||
$this->assertEquals(
|
||||
$this->providers[1],
|
||||
$registry->getProviderHandlingDomain('test.test2.de')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ final class BarcodeRedirectorTest extends KernelTestCase
|
||||
{
|
||||
yield [new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL), '/en/part/1'];
|
||||
//Part lot redirects to Part info page (Part lot 1 is associated with part 3)
|
||||
yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL), '/en/part/3'];
|
||||
yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL), '/en/part/3?highlightLot=1'];
|
||||
yield [new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL), '/en/store_location/1/parts'];
|
||||
}
|
||||
|
||||
|
||||
102
tests/Services/System/BackupManagerTest.php
Normal file
102
tests/Services/System/BackupManagerTest.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?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\Tests\Services\System;
|
||||
|
||||
use App\Services\System\BackupManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
class BackupManagerTest extends KernelTestCase
|
||||
{
|
||||
private ?BackupManager $backupManager = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->backupManager = self::getContainer()->get(BackupManager::class);
|
||||
}
|
||||
|
||||
public function testGetBackupDir(): void
|
||||
{
|
||||
$backupDir = $this->backupManager->getBackupDir();
|
||||
|
||||
// Should end with var/backups
|
||||
$this->assertStringEndsWith('var/backups', $backupDir);
|
||||
}
|
||||
|
||||
public function testGetBackupsReturnsEmptyArrayWhenNoBackups(): void
|
||||
{
|
||||
// If there are no backups (or the directory doesn't exist), should return empty array
|
||||
$backups = $this->backupManager->getBackups();
|
||||
|
||||
$this->assertIsArray($backups);
|
||||
}
|
||||
|
||||
public function testGetBackupDetailsReturnsNullForNonExistentFile(): void
|
||||
{
|
||||
$details = $this->backupManager->getBackupDetails('non-existent-backup.zip');
|
||||
|
||||
$this->assertNull($details);
|
||||
}
|
||||
|
||||
public function testGetBackupDetailsReturnsNullForNonZipFile(): void
|
||||
{
|
||||
$details = $this->backupManager->getBackupDetails('not-a-zip.txt');
|
||||
|
||||
$this->assertNull($details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that version parsing from filename works correctly.
|
||||
* This tests the regex pattern used in getBackupDetails.
|
||||
*/
|
||||
public function testVersionParsingFromFilename(): void
|
||||
{
|
||||
// Test the regex pattern directly
|
||||
$filename = 'pre-update-v2.5.1-to-v2.6.0-2024-01-30-185400.zip';
|
||||
$matches = [];
|
||||
|
||||
$result = preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename, $matches);
|
||||
|
||||
$this->assertEquals(1, $result);
|
||||
$this->assertEquals('2.5.1', $matches[1]);
|
||||
$this->assertEquals('2.6.0', $matches[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test version parsing with different filename formats.
|
||||
*/
|
||||
public function testVersionParsingVariants(): void
|
||||
{
|
||||
// Without 'v' prefix on target version
|
||||
$filename1 = 'pre-update-v1.0.0-to-2.0.0-2024-01-30-185400.zip';
|
||||
preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename1, $matches1);
|
||||
$this->assertEquals('1.0.0', $matches1[1]);
|
||||
$this->assertEquals('2.0.0', $matches1[2]);
|
||||
|
||||
// With 'v' prefix on target version
|
||||
$filename2 = 'pre-update-v1.0.0-to-v2.0.0-2024-01-30-185400.zip';
|
||||
preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename2, $matches2);
|
||||
$this->assertEquals('1.0.0', $matches2[1]);
|
||||
$this->assertEquals('2.0.0', $matches2[2]);
|
||||
}
|
||||
}
|
||||
167
tests/Services/System/UpdateExecutorTest.php
Normal file
167
tests/Services/System/UpdateExecutorTest.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 - 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\Tests\Services\System;
|
||||
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
class UpdateExecutorTest extends KernelTestCase
|
||||
{
|
||||
private ?UpdateExecutor $updateExecutor = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->updateExecutor = self::getContainer()->get(UpdateExecutor::class);
|
||||
}
|
||||
|
||||
public function testIsLockedReturnsFalseWhenNoLockFile(): void
|
||||
{
|
||||
// Initially there should be no lock
|
||||
// Note: This test assumes no concurrent update is running
|
||||
$isLocked = $this->updateExecutor->isLocked();
|
||||
|
||||
$this->assertIsBool($isLocked);
|
||||
}
|
||||
|
||||
public function testIsMaintenanceModeReturnsBool(): void
|
||||
{
|
||||
$isMaintenanceMode = $this->updateExecutor->isMaintenanceMode();
|
||||
|
||||
$this->assertIsBool($isMaintenanceMode);
|
||||
}
|
||||
|
||||
public function testGetLockInfoReturnsNullOrArray(): void
|
||||
{
|
||||
$lockInfo = $this->updateExecutor->getLockInfo();
|
||||
|
||||
// Should be null when not locked, or array when locked
|
||||
$this->assertTrue($lockInfo === null || is_array($lockInfo));
|
||||
}
|
||||
|
||||
public function testGetMaintenanceInfoReturnsNullOrArray(): void
|
||||
{
|
||||
$maintenanceInfo = $this->updateExecutor->getMaintenanceInfo();
|
||||
|
||||
// Should be null when not in maintenance, or array when in maintenance
|
||||
$this->assertTrue($maintenanceInfo === null || is_array($maintenanceInfo));
|
||||
}
|
||||
|
||||
public function testGetUpdateLogsReturnsArray(): void
|
||||
{
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
|
||||
$this->assertIsArray($logs);
|
||||
}
|
||||
|
||||
|
||||
public function testValidateUpdatePreconditionsReturnsProperStructure(): void
|
||||
{
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
|
||||
$this->assertIsArray($validation);
|
||||
$this->assertArrayHasKey('valid', $validation);
|
||||
$this->assertArrayHasKey('errors', $validation);
|
||||
$this->assertIsBool($validation['valid']);
|
||||
$this->assertIsArray($validation['errors']);
|
||||
}
|
||||
|
||||
public function testGetProgressFilePath(): void
|
||||
{
|
||||
$progressPath = $this->updateExecutor->getProgressFilePath();
|
||||
|
||||
$this->assertIsString($progressPath);
|
||||
$this->assertStringEndsWith('var/update_progress.json', $progressPath);
|
||||
}
|
||||
|
||||
public function testGetProgressReturnsNullOrArray(): void
|
||||
{
|
||||
$progress = $this->updateExecutor->getProgress();
|
||||
|
||||
// Should be null when no progress file, or array when exists
|
||||
$this->assertTrue($progress === null || is_array($progress));
|
||||
}
|
||||
|
||||
public function testIsUpdateRunningReturnsBool(): void
|
||||
{
|
||||
$isRunning = $this->updateExecutor->isUpdateRunning();
|
||||
|
||||
$this->assertIsBool($isRunning);
|
||||
}
|
||||
|
||||
public function testAcquireAndReleaseLock(): void
|
||||
{
|
||||
// First, ensure no lock exists
|
||||
if ($this->updateExecutor->isLocked()) {
|
||||
$this->updateExecutor->releaseLock();
|
||||
}
|
||||
|
||||
// Acquire lock
|
||||
$acquired = $this->updateExecutor->acquireLock();
|
||||
$this->assertTrue($acquired);
|
||||
|
||||
// Should be locked now
|
||||
$this->assertTrue($this->updateExecutor->isLocked());
|
||||
|
||||
// Lock info should exist
|
||||
$lockInfo = $this->updateExecutor->getLockInfo();
|
||||
$this->assertIsArray($lockInfo);
|
||||
$this->assertArrayHasKey('started_at', $lockInfo);
|
||||
|
||||
// Trying to acquire again should fail
|
||||
$acquiredAgain = $this->updateExecutor->acquireLock();
|
||||
$this->assertFalse($acquiredAgain);
|
||||
|
||||
// Release lock
|
||||
$this->updateExecutor->releaseLock();
|
||||
|
||||
// Should no longer be locked
|
||||
$this->assertFalse($this->updateExecutor->isLocked());
|
||||
}
|
||||
|
||||
public function testEnableAndDisableMaintenanceMode(): void
|
||||
{
|
||||
// First, ensure maintenance mode is off
|
||||
if ($this->updateExecutor->isMaintenanceMode()) {
|
||||
$this->updateExecutor->disableMaintenanceMode();
|
||||
}
|
||||
|
||||
// Enable maintenance mode
|
||||
$this->updateExecutor->enableMaintenanceMode('Test maintenance');
|
||||
|
||||
// Should be in maintenance mode now
|
||||
$this->assertTrue($this->updateExecutor->isMaintenanceMode());
|
||||
|
||||
// Maintenance info should exist
|
||||
$maintenanceInfo = $this->updateExecutor->getMaintenanceInfo();
|
||||
$this->assertIsArray($maintenanceInfo);
|
||||
$this->assertArrayHasKey('reason', $maintenanceInfo);
|
||||
$this->assertEquals('Test maintenance', $maintenanceInfo['reason']);
|
||||
|
||||
// Disable maintenance mode
|
||||
$this->updateExecutor->disableMaintenanceMode();
|
||||
|
||||
// Should no longer be in maintenance mode
|
||||
$this->assertFalse($this->updateExecutor->isMaintenanceMode());
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,4 @@
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
@@ -8,4 +8,4 @@
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
@@ -2,13 +2,10 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de">
|
||||
<file id="SchebTwoFactorBundle.de">
|
||||
<unit id="QoghNQ6" name="login">
|
||||
<notes>
|
||||
<note category="file-source" priority="1">Part-DB1\templates\security\2fa_base_form.html.twig:52</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<source>login</source>
|
||||
<target>Login</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
@@ -2,13 +2,10 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="en">
|
||||
<file id="SchebTwoFactorBundle.en">
|
||||
<unit id="QoghNQ6" name="login">
|
||||
<notes>
|
||||
<note category="file-source" priority="1">Part-DB1\templates\security\2fa_base_form.html.twig:52</note>
|
||||
</notes>
|
||||
<segment>
|
||||
<source>login</source>
|
||||
<target>Login</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user