fix(rich-text) : strip HTML pour les contextes plain-text

Avec MalioInputRichText qui émet désormais du HTML par défaut,
plusieurs points d'affichage rendaient les balises brutes au
lieu du texte. Ajoute un helper stripRichText() (frontend) et
descriptionToPlainText() (backend) pour neutraliser ces cas.

- TimeEntryList : strip avant truncate dans la liste des time
  entries.
- ProjectGroupTab : strip dans la cellule description du
  tableau des groupes.
- CalDavService : strip_tags + html_entity_decode avant injection
  dans le DESCRIPTION VEVENT/VTODO iCal (sinon Outlook/Apple
  Calendar affichaient les <p>...</p> à l'utilisateur).

Co-Authored-By: RuFlo <ruv@ruv.net>
This commit is contained in:
2026-05-04 19:55:23 +02:00
parent 2a68d2f9c6
commit b2cc6e96e1
4 changed files with 36 additions and 5 deletions

View File

@@ -36,7 +36,7 @@
/> />
</template> </template>
<template #cell-description="{ item }"> <template #cell-description="{ item }">
{{ item.description ?? '—' }} {{ stripRichText(item.description) || '—' }}
</template> </template>
<template #actions="{ item }"> <template #actions="{ item }">
<MalioButton <MalioButton
@@ -71,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { Task } from '~/services/dto/task' import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/services/tasks'
import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
projectId: number projectId: number

View File

@@ -33,8 +33,8 @@
</div> </div>
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500"> <div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
<span v-if="entry.project">{{ entry.project.name }}</span> <span v-if="entry.project">{{ entry.project.name }}</span>
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span> <span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
<span v-if="entry.description" class="truncate">{{ entry.description }}</span> <span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
</div> </div>
</div> </div>
@@ -68,6 +68,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
entries: TimeEntry[] entries: TimeEntry[]

View File

@@ -3,3 +3,17 @@ export function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo` return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
} }
export function stripRichText(value: string | null | undefined): string {
if (!value) return ''
return value
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, '\'')
.replace(/\s+/g, ' ')
.trim()
}

View File

@@ -14,6 +14,9 @@ use Sabre\VObject\Component\VCalendar;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable; use Throwable;
use const ENT_HTML5;
use const ENT_QUOTES;
final class CalDavService final class CalDavService
{ {
public function __construct( public function __construct(
@@ -199,7 +202,7 @@ final class CalDavService
$project = $task->getProject(); $project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : ''; $projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle()); $summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
$description = ($task->getDescription() ?? '')."\n\nLesstime task"; $description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
$vcalendar = new VCalendar(); $vcalendar = new VCalendar();
$vcalendar->add('VEVENT', [ $vcalendar->add('VEVENT', [
@@ -225,7 +228,7 @@ final class CalDavService
$project = $task->getProject(); $project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : ''; $projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle()); $summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
$description = ($task->getDescription() ?? '')."\n\nLesstime task"; $description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
$vcalendar = new VCalendar(); $vcalendar = new VCalendar();
$vcalendar->add('VTODO', [ $vcalendar->add('VTODO', [
@@ -337,6 +340,18 @@ final class CalDavService
return sprintf('%s@lesstime', bin2hex(random_bytes(16))); return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
} }
private function descriptionToPlainText(?string $value): string
{
if (null === $value || '' === $value) {
return '';
}
$stripped = strip_tags($value);
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/[ \t]+/', ' ', $decoded));
}
/** @return array<string, string> */ /** @return array<string, string> */
private function getDayMap(): array private function getDayMap(): array
{ {