Compare commits

..

6 commits

Author SHA1 Message Date
Jan Böhmer
956ece60af Added documentation for attachments download command
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / docker (push) Waiting to run
Docker Image Build (FrankenPHP) / docker (push) Waiting to run
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.1, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.1, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.1, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
2025-03-29 13:35:29 +01:00
Jan Böhmer
53da45d7d7 Added command to download all external-only attachments to the local file system 2025-03-29 13:33:35 +01:00
Jan Böhmer
57f0432a87 Fixed typo in attachmentrepository 2025-03-29 12:52:43 +01:00
Jan Böhmer
fb535ec6f7 Added tests for latex formatted units 2025-03-29 12:37:17 +01:00
Jan Böhmer
4e1b1a4ffa Render units of parameters in upstanding latex
Fixes issue #856
2025-03-29 12:33:18 +01:00
Jan Böhmer
5b111d80f1 Invalidate kicad category cache, when parts get changed, as this might affect the visibility of categories too
Related to #885
2025-03-29 12:01:26 +01:00
11 changed files with 226 additions and 21 deletions

View file

@ -25,9 +25,20 @@ import "katex/dist/katex.css";
export default class extends Controller {
static targets = ["input", "preview"];
static values = {
unit: {type: Boolean, default: false} //Render as upstanding (non-italic) text, useful for units
}
updatePreview()
{
katex.render(this.inputTarget.value, this.previewTarget, {
let value = "";
if (this.unitValue) {
value = "\\mathrm{" + this.inputTarget.value + "}";
} else {
value = this.inputTarget.value;
}
katex.render(value, this.previewTarget, {
throwOnError: false,
});
}

View file

@ -111,4 +111,11 @@ ul.structural_link li a:hover {
.permission-checkbox:checked {
background-color: var(--bs-success);
border-color: var(--bs-success);
}
/***********************************************
* Katex rendering with same height as text
***********************************************/
.katex-same-height-as-text .katex {
font-size: 1.0em;
}

View file

@ -71,3 +71,9 @@ docker exec --user=www-data partdb php bin/console cache:clear
* `php bin/console doctrine:migrations:migrate`: Migrate the database to the latest version
* `php bin/console doctrine:migrations:up-to-date`: Check if the database is up-to-date
## Attachment commands
* `php bin/console partdb:attachments:download`: Download all attachments, which are not already downloaded, to the
local filesystem. This is useful to create local backups of the attachments, no matter what happens on the remote and
also makes pictures thumbnails available for the frontend for them

View file

@ -0,0 +1,136 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* 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\Attachments;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentUpload;
use App\Exceptions\AttachmentDownloadException;
use App\Services\Attachments\AttachmentManager;
use App\Services\Attachments\AttachmentSubmitHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:attachments:download', "Downloads all attachments which have only an external URL to the local filesystem.")]
class DownloadAttachmentsCommand extends Command
{
public function __construct(private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
private EntityManagerInterface $entityManager)
{
parent::__construct();
}
public function configure(): void
{
$this->setHelp('This command downloads all attachments, which only have an external URL, to the local filesystem, so that you have an offline copy of the attachments.');
$this->addOption('--private', null, null, 'If set, the attachments will be downloaded to the private storage.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$qb = $this->entityManager->createQueryBuilder();
$qb->select('attachment')
->from(Attachment::class, 'attachment')
->where('attachment.external_path IS NOT NULL')
->andWhere('attachment.external_path != \'\'')
->andWhere('attachment.internal_path IS NULL');
$query = $qb->getQuery();
$attachments = $query->getResult();
if (count($attachments) === 0) {
$io->success('No attachments with external URL found.');
return Command::SUCCESS;
}
$io->note('Found ' . count($attachments) . ' attachments with external URL, that will be downloaded.');
//If the option --private is set, the attachments will be downloaded to the private storage.
$private = $input->getOption('private');
if ($private) {
if (!$io->confirm('Attachments will be downloaded to the private storage. Continue?')) {
return Command::SUCCESS;
}
} else {
if (!$io->confirm('Attachments will be downloaded to the public storage, where everybody knowing the correct URL can access it. Continue?')){
return Command::SUCCESS;
}
}
$progressBar = $io->createProgressBar(count($attachments));
$progressBar->setFormat("%current%/%max% [%bar%] %percent:3s%% %elapsed:16s%/%estimated:-16s% \n%message%");
$progressBar->setMessage('Starting download...');
$progressBar->start();
$errors = [];
foreach ($attachments as $attachment) {
/** @var Attachment $attachment */
$progressBar->setMessage(sprintf('%s (ID: %s) from %s', $attachment->getName(), $attachment->getID(), $attachment->getHost()));
$progressBar->advance();
try {
$attachmentUpload = new AttachmentUpload(file: null, downloadUrl: true, private: $private);
$this->attachmentSubmitHandler->handleUpload($attachment, $attachmentUpload);
//Write changes to the database
$this->entityManager->flush();
} catch (AttachmentDownloadException $e) {
$errors[] = [
'attachment' => $attachment,
'error' => $e->getMessage()
];
}
}
$progressBar->finish();
//Fix the line break after the progress bar
$io->newLine();
$io->newLine();
if (count($errors) > 0) {
$io->warning('Some attachments could not be downloaded:');
foreach ($errors as $error) {
$io->warning(sprintf("Attachment %s (ID %s) could not be downloaded from %s:\n%s",
$error['attachment']->getName(),
$error['attachment']->getID(),
$error['attachment']->getExternalPath(),
$error['error'])
);
}
} else {
$io->success('All attachments downloaded successfully.');
}
return Command::SUCCESS;
}
}

View file

@ -208,7 +208,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
*/
#[Groups(['parameter:read', 'full'])]
#[SerializedName('formatted')]
public function getFormattedValue(): string
public function getFormattedValue(bool $latex_formatted = false): string
{
//If we just only have text value, return early
if (null === $this->value_typical && null === $this->value_min && null === $this->value_max) {
@ -218,7 +218,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
$str = '';
$bracket_opened = false;
if ($this->value_typical) {
$str .= $this->getValueTypicalWithUnit();
$str .= $this->getValueTypicalWithUnit($latex_formatted);
if ($this->value_min || $this->value_max) {
$bracket_opened = true;
$str .= ' (';
@ -226,11 +226,11 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
}
if ($this->value_max && $this->value_min) {
$str .= $this->getValueMinWithUnit().' ... '.$this->getValueMaxWithUnit();
$str .= $this->getValueMinWithUnit($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_max) {
$str .= 'max. '.$this->getValueMaxWithUnit();
$str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_min) {
$str .= 'min. '.$this->getValueMinWithUnit();
$str .= 'min. '.$this->getValueMinWithUnit($latex_formatted);
}
//Add closing bracket
@ -344,25 +344,25 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
/**
* Return a formatted version with the minimum value with the unit of this parameter.
*/
public function getValueTypicalWithUnit(): string
public function getValueTypicalWithUnit(bool $with_latex = false): string
{
return $this->formatWithUnit($this->value_typical);
return $this->formatWithUnit($this->value_typical, with_latex: $with_latex);
}
/**
* Return a formatted version with the maximum value with the unit of this parameter.
*/
public function getValueMaxWithUnit(): string
public function getValueMaxWithUnit(bool $with_latex = false): string
{
return $this->formatWithUnit($this->value_max);
return $this->formatWithUnit($this->value_max, with_latex: $with_latex);
}
/**
* Return a formatted version with the typical value with the unit of this parameter.
*/
public function getValueMinWithUnit(): string
public function getValueMinWithUnit(bool $with_latex = false): string
{
return $this->formatWithUnit($this->value_min);
return $this->formatWithUnit($this->value_min, with_latex: $with_latex);
}
/**
@ -441,11 +441,18 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
/**
* Return a string representation and (if possible) with its unit.
*/
protected function formatWithUnit(float $value, string $format = '%g'): string
protected function formatWithUnit(float $value, string $format = '%g', bool $with_latex = false): string
{
$str = sprintf($format, $value);
if ($this->unit !== '') {
return $str.' '.$this->unit;
if (!$with_latex) {
$unit = $this->unit;
} else {
$unit = '$\mathrm{'.$this->unit.'}$';
}
return $str.' '.$unit;
}
return $str;

View file

@ -66,7 +66,7 @@ class AttachmentRepository extends DBElementRepository
}
/**
* Gets the count of all external attachments (attachments containing an external path).
* Gets the count of all external attachments (attachments containing only an external path).
*
* @throws NoResultException
* @throws NonUniqueResultException
@ -75,8 +75,9 @@ class AttachmentRepository extends DBElementRepository
{
$qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)')
->andWhere('attaachment.internal_path IS NULL')
->where('attachment.external_path IS NOT NULL');
->where('attachment.external_path IS NOT NULL')
->andWhere('attachment.internal_path IS NULL');
$query = $qb->getQuery();
return (int) $query->getSingleScalarResult();

View file

@ -66,6 +66,10 @@ class KiCadHelper
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class);
$item->tag($secure_class_name);
//Invalidate the cache on part changes (as the visibility depends on parts, and the parts can change)
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Part::class);
$item->tag($secure_class_name);
//If the category depth is smaller than 0, create only one dummy category
if ($this->category_depth < 0) {
return [

View file

@ -54,7 +54,7 @@
<td class="col-sm-2">{{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }}</td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol", "data-pages--latex-preview-target": "input"}}) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value) }}</td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td {{ stimulus_controller('pages/latex_preview', {"unit": true}) }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value_text) }}</td>
<td>
<button type="button" class="btn btn-danger btn-sm" {{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">

View file

@ -218,14 +218,16 @@
<thead>
<tr>
<th>{% trans %}specifications.property{% endtrans %}</th>
<th>{% trans %}specifications.symbol{% endtrans %}</th>
<th>{% trans %}specifications.value{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for param in parameters %}
<tr>
<td>{{ param.name }} {% if param.symbol is not empty %}<span class="latex" data-controller="common--latex">${{ param.symbol }}$</span>{% endif %}</td>
<td>{{ param.formattedValue }}</td>
<td>{{ param.name }}</td>
<td>{% if param.symbol is not empty %}<span class="latex" {{ stimulus_controller('common/latex') }}>${{ param.symbol }}$</span>{% endif %}</td>
<td {{ stimulus_controller('common/latex') }} class="katex-same-height-as-text">{{ param.formattedValue(true) }}</td>
</tr>
{% endfor %}
</tbody>

View file

@ -75,7 +75,7 @@
<td>{{ form_widget(form.value_min) }}{{ form_errors(form.value_min) }}</td>
<td>{{ form_widget(form.value_typical) }}{{ form_errors(form.value_typical) }}</td>
<td>{{ form_widget(form.value_max) }}{{ form_errors(form.value_max) }}</td>
<td {{ stimulus_controller('pages/latex_preview') }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.unit) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td {{ stimulus_controller('pages/latex_preview', {"unit": true}) }}>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.unit) }}<span {{ stimulus_target('pages/latex_preview', 'preview') }}></span></td>
<td>{{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }}</td>
<td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</td>
<td>

View file

@ -67,6 +67,19 @@ class PartParameterTest extends TestCase
yield ['10.23 V (9 V ... 11 V) [Test]', 9, 10.23, 11, 'V', 'Test'];
}
public function formattedValueWithLatexDataProvider(): \Iterator
{
yield ['Text Test', null, null, null, 'V', 'Text Test'];
yield ['10.23 $\mathrm{V}$', null, 10.23, null, 'V', ''];
yield ['10.23 $\mathrm{V}$ [Text]', null, 10.23, null, 'V', 'Text'];
yield ['max. 10.23 $\mathrm{V}$', null, null, 10.23, 'V', ''];
yield ['max. 10.23 [Text]', null, null, 10.23, '', 'Text'];
yield ['min. 10.23 $\mathrm{V}$', 10.23, null, null, 'V', ''];
yield ['10.23 $\mathrm{V}$ ... 11 $\mathrm{V}$', 10.23, null, 11, 'V', ''];
yield ['10.23 $\mathrm{V}$ (9 $\mathrm{V}$ ... 11 $\mathrm{V}$)', 9, 10.23, 11, 'V', ''];
yield ['10.23 $\mathrm{V}$ (9 $\mathrm{V}$ ... 11 $\mathrm{V}$) [Test]', 9, 10.23, 11, 'V', 'Test'];
}
/**
* @dataProvider valueWithUnitDataProvider
*/
@ -117,4 +130,22 @@ class PartParameterTest extends TestCase
$param->setValueText($text);
$this->assertSame($expected, $param->getFormattedValue());
}
/**
* @dataProvider formattedValueWithLatexDataProvider
*
* @param float $min
* @param float $typical
* @param float $max
*/
public function testGetFormattedValueWithLatex(string $expected, ?float $min, ?float $typical, ?float $max, string $unit, string $text): void
{
$param = new PartParameter();
$param->setUnit($unit);
$param->setValueMin($min);
$param->setValueTypical($typical);
$param->setValueMax($max);
$param->setValueText($text);
$this->assertSame($expected, $param->getFormattedValue(true));
}
}