OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,236 @@
<?php
namespace Leantime\Core\Support;
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
/**
* Abstract base class for entity formatters.
*
* Provides common functionality for formatting domain entities into markdown,
* including sanitization, field filtering, and utility methods.
*/
abstract class AbstractEntityFormatter implements EntityFormatterInterface
{
/**
* Default fields to exclude from formatting across all entities.
*/
protected array $defaultExcludedFields = [
'password',
'token',
'secret',
'key',
'hash',
'salt',
];
/**
* Fields to exclude from formatting for this specific entity type.
*/
protected array $excludedFields = [];
/**
* Priority order for displaying fields (higher priority = displayed first).
*/
protected array $fieldPriority = [];
/**
* Format the entity using the default formatting logic.
*/
public function format(): string
{
return $this->formatForContext([]);
}
/**
* Format the entity with context-aware formatting.
*/
public function formatForContext(array $context = []): string
{
$data = $this->prepareEntityData($context);
$header = $this->formatHeader($data);
$body = $this->formatBody($data, $context);
return $header."\n".$body;
}
/**
* Get a compact summary of the entity.
*/
public function getSummary(): string
{
$data = $this->prepareEntityData();
return $this->formatSummary($data);
}
/**
* Prepare the entity data for formatting.
* This method should be implemented by concrete classes to extract
* and organize data from their specific entity types.
*/
abstract protected function prepareEntityData(array $context = []): array;
/**
* Format the header section of the entity.
*/
abstract protected function formatHeader(array $data): string;
/**
* Format the body section of the entity.
*/
protected function formatBody(array $data, array $context = []): string
{
$filteredData = $this->filterFields($data, $context);
$sortedData = $this->sortFields($filteredData);
return Str::toMarkdown($sortedData);
}
/**
* Format a compact summary of the entity.
*/
abstract protected function formatSummary(array $data): string;
/**
* Filter out excluded fields and apply context-specific filtering.
*/
protected function filterFields(array $data, array $context = []): array
{
$excludedFields = array_merge($this->defaultExcludedFields, $this->excludedFields);
// Apply context-specific field filtering
if (isset($context['includeFields']) && is_array($context['includeFields'])) {
$data = array_intersect_key($data, array_flip($context['includeFields']));
}
if (isset($context['excludeFields']) && is_array($context['excludeFields'])) {
$excludedFields = array_merge($excludedFields, $context['excludeFields']);
}
// Remove excluded fields
foreach ($excludedFields as $field) {
unset($data[$field]);
}
// Remove empty values unless specifically requested to keep them
if (! isset($context['keepEmpty']) || ! $context['keepEmpty']) {
$data = array_filter($data, function ($value) {
return $value !== null && $value !== '' && $value !== [];
});
}
return $data;
}
/**
* Sort fields according to priority and alphabetically.
*/
protected function sortFields(array $data): array
{
if (empty($this->fieldPriority)) {
return $data;
}
$prioritized = [];
// First add prioritized fields in order
foreach ($this->fieldPriority as $field) {
if (isset($data[$field])) {
$prioritized[$field] = $data[$field];
unset($data[$field]);
}
}
// Then add remaining fields alphabetically
ksort($data);
return array_merge($prioritized, $data);
}
/**
* Sanitize a value for safe LLM consumption.
*
* @param mixed $value
*/
protected function sanitizeValue($value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? 'Yes' : 'No';
}
if (is_array($value)) {
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '[]';
}
$stringValue = (string) $value;
return Str::sanitizeForLLM($stringValue);
}
/**
* Format a date value in a human-readable format.
*
* @param mixed $date
*/
protected function formatDate($date): string
{
if (empty($date) || $date === '0000-00-00' || $date === '0000-00-00 00:00:00') {
return 'Not set';
}
try {
$dateTime = CarbonImmutable::parse($date);
return $dateTime->toIso8601String();
} catch (\Exception $e) {
return $this->sanitizeValue($date);
}
}
/**
* Format a priority value with appropriate visual indicators.
*
* @param mixed $priority
*/
protected function formatPriority($priority): string
{
return match (strtolower((string) $priority)) {
'1', 'low' => '🔵 Low',
'2', 'medium', 'normal' => '🟡 Medium',
'3', 'high' => '🟠 High',
'4', 'urgent', 'critical' => '🔴 Urgent',
default => $this->sanitizeValue($priority) ?: 'Not set'
};
}
/**
* Format a status with appropriate visual indicators.
*
* @param mixed $status
* @param array $statusLabels Optional status label mapping
*/
protected function formatStatus($status, array $statusLabels = []): string
{
if (! empty($statusLabels) && isset($statusLabels[$status])) {
$label = $statusLabels[$status];
$statusType = $label['statusType'] ?? '';
$emoji = match (strtolower($statusType)) {
'new' => '🆕',
'inprogress' => '🔄',
'done' => '✅',
default => '📝'
};
return $emoji.' '.$label['name'];
}
return $this->sanitizeValue($status) ?: 'Not set';
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Leantime\Core\Support;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use LasseRafn\InitialAvatarGenerator\InitialAvatar;
use LasseRafn\Initials\Initials;
use SVG\SVG;
class Avatarcreator
{
protected $filePrefix = 'user';
protected const MAX_FILENAME_LENGTH = 255;
public function __construct(
protected InitialAvatar $avatarGenerator,
protected Initials $initials
) {
$this->initials->allowSpecialCharacters(true);
// Set some default values
$this->avatarGenerator->font(APP_ROOT.'/public/dist/fonts/roboto/Roboto-Medium.ttf');
$this->avatarGenerator->background('#00a887')->color('#fff');
}
public function setBackground(string $color): void
{
$this->avatarGenerator->background($color);
}
public function setFilePrefix($prefix): void
{
$this->filePrefix = Str::sanitizeFilename($prefix);
}
public function getFilePrefix(): string
{
return $this->filePrefix;
}
public function setInitials($name)
{
$cleanString = Str::sanitizeFilename($name);
if (empty($cleanString)) {
$this->initials->name('👻');
} else {
$this->initials->name($cleanString);
}
$this->avatarGenerator->name($cleanString);
}
public function getInitials()
{
return $this->initials->getInitials();
}
public function getAvatar($name): SVG
{
$this->setInitials($name);
$filename = $this->getSafeFilename();
if (file_exists($filename)) {
return SVG::fromFile($filename);
}
return $this->saveAvatar();
}
protected function saveAvatar(): SVG
{
if (is_dir(storage_path('framework/cache/avatars')) === false) {
if (! mkdir($concurrentDirectory = storage_path('framework/cache/avatars')) && ! is_dir(
$concurrentDirectory
)) {
throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
}
// Set proper permissions for security
chmod(storage_path('framework/cache/avatars'), 0755);
}
$filename = $this->getSafeFilename();
if (! file_exists($filename)) {
$image = $this->generateAvatar();
if (! is_writable(storage_path('framework/cache/avatars/'))) {
Log::error("Can't write to avatars folder");
return $image;
}
file_put_contents($filename, $image);
}
return SVG::fromFile($filename);
}
protected function getSafeFilename(): string
{
$baseFilename = $this->filePrefix.'-'.$this->getInitials();
// Ensure filename doesn't exceed maximum length
if (strlen($baseFilename) > self::MAX_FILENAME_LENGTH - 4) { // -4 for .svg
$baseFilename = substr($baseFilename, 0, self::MAX_FILENAME_LENGTH - 4);
}
return storage_path('framework/cache/avatars/'.
Str::sanitizeFilename($baseFilename).'.svg');
}
protected function generateAvatar(): SVG
{
return $this->avatarGenerator->generateSvg();
}
}

215
app/Core/Support/Build.php Normal file
View File

@@ -0,0 +1,215 @@
<?php
namespace Leantime\Core\Support;
class Build
{
public function __construct(private object $object) {}
/**
* @param string $key The property name
* @param mixed $value The property value or a callable to set the nested property value
**/
public function set(string $key, mixed $value): self
{
$keys = explode('.', $key);
$lastKey = array_pop($keys);
$currentElement = &$this->object;
$this->handleDotNotation($currentElement, $keys);
if (is_callable($value)) {
// Apply closure to a new builder for the nested element (object or array)
$nestedElement = is_array($currentElement) ? ($currentElement[$lastKey] ?? []) : ($currentElement->$lastKey ?? new \stdClass);
$value(build($nestedElement));
$this->setValue($currentElement, $lastKey, $nestedElement);
return $this;
}
$this->setValue($currentElement, $lastKey, $value);
return $this;
}
public function tap(string $key, callable $configurator): self
{
$keys = explode('.', $key);
$lastKey = array_pop($keys);
$currentElement = &$this->object;
$this->handleDotNotation($currentElement, $keys);
$configurator($currentElement->$lastKey ?? $currentElement[$lastKey]);
return $this;
}
/**
* @param string $method
* @param array $params
**/
public function __call($method, $params): mixed
{
if (
! str_starts_with($method, 'set')
&& ! str_starts_with($method, 'tap')
&& ! str_starts_with($method, 'get')
) {
throw new \BadMethodCallException("Method $method does not exist");
}
$baseMethod = substr($method, 0, 3); // 'set', 'tap', or 'get'
$property = substr($method, 3);
// Convert camelCase to dot.notation
$properties = explode('.', preg_replace('/(?<!^)[A-Z]/', '.$0', $property));
$currentElement = $this->object;
foreach ($properties as &$property) {
$isset = false;
foreach ([$property, lcfirst($property)] as $propName) {
if (
in_array(true, [
is_object($currentElement) && ! property_exists($currentElement, $propName),
])
) {
continue;
}
$property = $propName;
$isset = true;
break;
}
if (! $isset) {
if ($baseMethod !== 'get') {
throw new \Exception('You must use properties that already exist when using dynamic set methods (E.G. "setPropertyname")');
}
return null;
}
}
$property = implode('.', $properties);
return $this->{$baseMethod}($property, ...$params);
}
private function handleDotNotation(mixed &$currentElement, array $keys): void
{
foreach ($keys as $nestedKey) {
if (! is_array($currentElement) && ! is_object($currentElement)) {
throw new \Exception('Can\'t set value on non array/object');
}
if (! isset($currentElement[$nestedKey]) && ! isset($currentElement->$nestedKey)) {
if (
version_compare(PHP_VERSION, '8.2.0', '>=')
&& is_object($currentElement)
&& empty((new \ReflectionClass($currentElement))->getAttributes('AllowDynamicProperties'))
) {
throw new \Exception('This property doesn\'t support dynamic property setting');
}
$this->setValue($currentElement, $nestedKey, is_array($currentElement) ? [] : new \stdClass);
}
}
}
private function setValue(object|array &$property, string $key, mixed $value): void
{
if (is_array($property)) {
$property[$key] = $value;
return;
}
if (method_exists($property, 'set'.ucfirst($key))) {
$property->{'set'.ucfirst($key)}($value);
} elseif (method_exists($this->object, 'set'.$key)) {
$property->{'set'.$key}($value);
} else {
// Coerce external/API data to the declared property type before
// assigning. Models built from the marketplace API (e.g.
// MarketplacePlugin) declare non-nullable typed properties, but the
// API can return null or a mismatched type (e.g. a string for an
// `array $categories`), which PHP 8 rejects with a TypeError and 500s
// the whole request. Coercing here protects every Build-hydrated
// model, not just one. (#3207, #3342)
if (property_exists($property, $key)) {
$reflection = new \ReflectionProperty($property, $key);
$type = $reflection->getType();
if ($type instanceof \ReflectionNamedType && $type->isBuiltin()) {
$value = $this->coerceToBuiltinType($value, $type);
}
}
$property->$key = $value;
}
}
/**
* Coerces a value to a builtin (scalar/array) property type so external/API
* data can't trip a TypeError on a typed property. Casts compatible scalars;
* when a value can't be safely coerced (e.g. a non-numeric string for an int,
* or a scalar for an array) it falls back to null for nullable types or the
* type's zero-value otherwise — never the raw, mismatched value.
*/
private function coerceToBuiltinType(mixed $value, \ReflectionNamedType $type): mixed
{
$typeName = $type->getName();
$allowsNull = $type->allowsNull();
if ($value === null) {
return $allowsNull ? null : $this->builtinTypeDefault($typeName);
}
// Fallback for a value that can't be coerced to the declared type.
$fallback = fn () => $allowsNull ? null : $this->builtinTypeDefault($typeName);
return match ($typeName) {
'string' => is_string($value) ? $value : (is_scalar($value) ? (string) $value : $fallback()),
'int' => is_int($value) ? $value : (is_numeric($value) ? (int) $value : $fallback()),
'float' => is_float($value) ? $value : (is_numeric($value) ? (float) $value : $fallback()),
'bool' => is_bool($value) ? $value : (bool) $value,
// Don't wrap scalars into a single-element array — array-typed model
// properties are consumed as lists of associative rows downstream, so
// a wrapped scalar would only defer the crash to the template.
'array' => is_array($value) ? $value : $fallback(),
default => $value,
};
}
/**
* The zero-value for a non-nullable builtin type.
*/
private function builtinTypeDefault(string $typeName): mixed
{
return match ($typeName) {
'string' => '',
'int' => 0,
'float' => 0.0,
'bool' => false,
'array' => [],
default => null,
};
}
public function get(string $key = ''): mixed
{
if ($key === '') {
return $this->object;
}
$keys = explode('.', $key);
$lastKey = array_pop($keys);
$currentElement = &$this->object;
$this->handleDotNotation($currentElement, $keys);
return $currentElement->$lastKey ?? $currentElement[$lastKey] ?? null;
}
public function getAndTap(string $key = '', ?callable $callback = null): mixed
{
$result = $this->get($key);
return tap($result, $callback);
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Leantime\Core\Support;
use Carbon\CarbonImmutable;
use Leantime\Core\Language;
/**
* Class CarbonMacros
*
* This class provides macros for formatting date and time using the Carbon library.
* Class is being loaded via mixins and then available to the Carbon object
*
* @property string $userTimezone The user's timezone
* @property string $userLanguage The user's language
* @property string $userDateFormat The user's date format
* @property string $userTimeFormat The user's time format
* @property string $dbTimezone The database timezone
* @property string $dbFormat The database format
*
* @method static CarbonImmutable this()
*/
class CarbonMacros
{
/**
* Constructor method for creating a new instance of the class.
*
* @param string $userTimezone The user's preferred timezone.
* @param string $userLanguage The user's preferred language.
* @param string $userDateFormat The user's preferred date format.
* @param string $userTimeFormat The user's preferred time format.
* @param string $dbFormat The format to be used for database storage.
* @param string $dbTimezone The timezone to be used for database storage.
* @return void
*/
public function __construct(
public string $userTimezone = '',
public string $userLanguage = '',
public string $userDateFormat = '',
public string $userTimeFormat = '',
public string $dbFormat = 'Y-m-d H:i:s',
public string $dbTimezone = 'UTC'
) {
if ($userLanguage == 'nl_NL') {
$language = app()->make(Language::class);
$translator = \Carbon\Translator::get('nl_NL');
$translator->setTranslations([
'weekdays_short' => explode(',', $language->__('language.dayNamesShort')),
'weekdays_min' => explode(',', $language->__('language.dayNamesMin')),
'months_short' => explode(',', $language->__('language.monthNamesShort')),
'mmm_suffix' => '',
]);
$translator = \Carbon\Translator::get('nl');
$translator->setTranslations([
'weekdays_short' => explode(',', $language->__('language.dayNamesShort')),
'weekdays_min' => explode(',', $language->__('language.dayNamesMin')),
'months_short' => explode(',', $language->__('language.monthNamesShort')),
'mmm_suffix' => '',
]);
}
}
/**
* Formats the current date for the user based on the user's timezone,
* language, and date format.
*
* @return \Closure Returns a closure that accepts no arguments and returns
* the formatted date as per the user's settings.
*/
public function formatDateForUser(): \Closure
{
$mixin = $this;
return function () use ($mixin): string {
return self::this()
->locale($mixin->userLanguage)
->setTimezone($mixin->userTimezone)
->translatedFormat($mixin->userDateFormat);
};
}
/**
* Formats the current time for the user based on the user's timezone,
* language, and time format.
*
* @return \Closure Returns a closure that accepts no arguments and returns
* the formatted time as per the user's settings.
*/
public function formatTimeForUser(): \Closure
{
$mixin = $this;
return function () use ($mixin): string {
return self::this()
->setTimezone($mixin->userTimezone)
->locale($mixin->userLanguage)
->translatedFormat($mixin->userTimeFormat);
};
}
/**
* Formats the current time for the user based on the user's timezone,
* language, and time format.
*
* @return \Closure Returns a closure that accepts no arguments and returns
* the formatted time as per the user's settings.
*/
public function format24HTimeForUser(): \Closure
{
$mixin = $this;
return function () use ($mixin): string {
return self::this()
->setTimezone($mixin->userTimezone)
->locale($mixin->userLanguage)
->translatedFormat('H:i');
};
}
/**
* Formats the current date and time for storing in the database based on
* the database timezone, user language, and database format.
*
* @return \Closure Returns a closure that accepts no arguments and returns
* the formatted date and time as per the database settings.
*/
public function formatDateTimeForDb(): \Closure
{
$mixin = $this;
return function () use ($mixin): string {
return self::this()
->setTimezone($mixin->dbTimezone)
->locale($mixin->userLanguage)
->format($mixin->dbFormat);
};
}
/**
* Sets the current timezone and locale to the user's timezone and language.
*
* @return \Closure Returns a closure that accepts no arguments and sets the
* timezone and locale to the user's settings.
*/
public function setToUserTimezone(): \Closure
{
$mixin = $this;
return function () use ($mixin): CarbonImmutable {
return self::this()
->setTimezone($mixin->userTimezone)
->locale($mixin->userLanguage);
};
}
/**
* Sets the timezone of the current datetime object to the database timezone
* and sets the locale to the user's language.
*
* @return \Closure Returns a closure that accepts no arguments and returns the
* current datetime object with the timezone and locale set.
*/
public function setToDbTimezone(): \Closure
{
$mixin = $this;
return function () use ($mixin): CarbonImmutable {
return self::this()
->setTimezone($mixin->dbTimezone)
->locale($mixin->userLanguage);
};
}
}

204
app/Core/Support/Cast.php Normal file
View File

@@ -0,0 +1,204 @@
<?php
namespace Leantime\Core\Support;
use Illuminate\Support\Str;
/**
* @todo the cast to method needs to be refactored to have better support of constructor params
**/
class Cast
{
protected array $mappings;
public function __construct(private array|object $object)
{
if (is_array($this->object)) {
$this->object = (object) $this->object;
}
}
/**
* @throws \InvalidArgumentException
* @throws \RuntimeException
* @throws \ReflectionException
**/
public function castTo(string $classDest, array $constructParams = [], array $mappings = []): object
{
$this->mappings ??= $mappings;
if (! class_exists($classDest)) {
throw new \InvalidArgumentException(sprintf('Class %s does not exist.', $classDest));
}
$sourceObj = $this->object;
$classRef = new \ReflectionClass($classDest);
$properties = $classRef->getProperties();
if (
! empty($reflectedConstructParams = $classRef->getConstructor()?->getParameters() ?? [])
&& empty($constructParams)
) {
foreach ($reflectedConstructParams as $param) {
if (isset($sourceObj->{$param->getName()})) {
$constructParams[] = $sourceObj->{$param->getName()};
continue;
}
if ($param->isOptional()) {
$constructParams[] = $param->getDefaultValue();
continue;
}
throw new \InvalidArgumentException(sprintf('Missing construct parameter %s.', $param->getName()));
}
}
$returnObj = build(new $classDest(...$constructParams));
foreach ($properties as $property) {
$name = $property->getName();
if (! isset($sourceObj->$name)) {
if ($property->hasDefaultValue()) {
$returnObj->set($name, $property->getDefaultValue());
continue;
}
throw new \RuntimeException(sprintf('Property %s does not exist in source object.', $name));
}
try {
$type = collect($mappings)->firstOrFail(fn ($mapping, $key) => in_array($key, [$name, '*']));
} catch (\Illuminate\Support\ItemNotFoundException) {
$type = ($reflectionType = $property->getType()) instanceof \ReflectionNamedType ? $reflectionType->getName() : null;
}
$returnObj->set($name, match (true) {
enum_exists($type) => self::castEnum($sourceObj->$name, $type),
$type !== 'stdClass' && class_exists($type) => (new self($sourceObj->$name))->castTo(
classDest: $type,
mappings: $this->getMatchingMappings($mappings, $name),
),
in_array($type, ['array', 'object', 'stdClass']) => $this->handleIterator(
$sourceObj->$name,
$this->getMatchingMappings($mappings, $name)
),
is_null($type) || $type == 'mixed' => $sourceObj->$name,
default => self::castSimple($sourceObj->$name, $type),
});
}
return $returnObj->get();
}
/**
* @throws \RuntimeException
* @throws \InvalidArgumentException
**/
public static function castSimple(mixed $value, string $simpleType): mixed
{
if (
is_null($castedValue = match ($simpleType) {
'int', 'integer' => filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE),
'float' => filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE),
'string', 'str' => is_array($value) || (is_object($value) && ! method_exists($value, '__toString')) ? null : (string) $value,
'bool', 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE),
'object', 'stdClass' => is_array($value) ? (object) $value : null,
'array' => is_object($value) || is_array($value) ? (array) $value : null,
default => throw new \InvalidArgumentException(sprintf('%s is not a simple type.', $simpleType)),
})
) {
throw new \RuntimeException(sprintf('Could not cast value to type %s.', $simpleType));
}
return $castedValue;
}
/**
* Cast to backed enum
*
**/
public static function castEnum(mixed $value, string $enumClass): mixed
{
// For non-backed enums, iterate and match by name.
if (is_string($value)) {
foreach ($enumClass::cases() as $case) {
if ($case->name === $value) {
return $case;
}
}
}
// For backed enums, try to get the case by value
if (
is_subclass_of($enumClass, \BackedEnum::class)
&& ($enum = $enumClass::tryFrom($value) ?? false)
) {
return $enum;
}
throw new \InvalidArgumentException(sprintf('Value cannot be casted to %s.', $enumClass));
}
/**
* Casts a string value into a datetime object.
*
* @param string $value The value to be casted into a datetime object.
* @return \Carbon\CarbonImmutable The datetime object.
*
* @throws \InvalidArgumentException If the value is not a valid datetime string.
**/
public static function castDateTime(string $value)
{
if (is_string($value)) {
return dtHelper()->parseDbDateTime($value);
}
}
protected function handleIterator(iterable $iterator, array $mappings = []): array|object
{
$result = is_object($iterator) ? new \stdClass : [];
foreach ($iterator as $key => $value) {
if (is_numeric($key)) {
$type = $mappings['*'] ?? false;
} else {
if ($type = preg_match('/\<[a-zA-Z0-9\\\\]+\>/', $key)) {
$key = preg_replace('/\<[a-zA-Z0-9\\\\]+\>/', '', $key);
} else {
$type = $mappings[$key] ?? $mappings['*'] ?? false;
}
}
$value = match (true) {
$type && enum_exists($type) => self::castEnum($value, $type),
$type && class_exists($type) => (new self($value))->castTo($type),
$type && in_array($type, ['string', 'str', 'int', 'integer', 'float', 'bool', 'boolean']) => self::castSimple($value, $type),
$type && in_array($type, ['array', 'object', 'stdClass']),
is_array($value),
is_object($value) => $this->{__FUNCTION__}($value, $this->getMatchingMappings($mappings, (string) $key)),
default => $value,
};
if (is_object($result)) {
$result->$key = $value;
} else {
$result[$key] = $value;
}
}
return $result;
}
protected function getMatchingMappings(array $mappings, string $propName): array
{
return collect($mappings)
->filter(fn ($mapping, $key) => Str::startsWith($key, "$propName.") || Str::startsWith($key, '*.'))
->mapWithKeys(fn ($mapping, $key) => [Str::after($key, '.') => $mapping])
->all();
}
}

View File

@@ -0,0 +1,306 @@
<?php
namespace Leantime\Core\Support;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Carbon\Exceptions\InvalidDateException;
use Carbon\Exceptions\InvalidFormatException;
use DateTime;
use DateTimeInterface;
use DateTimeZone;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Language;
/**
* Class DateTimeHelper
*
* A helper class for working with dates and times.
* This class should NOT contain any formatting methods. Any datetime formatting should be included into the
* CarbonMacros class
*
* @mixin CarbonMacros
*/
class DateTimeHelper extends CarbonImmutable
{
private string $userTimezone;
private string $userLanguage;
private string $userDateFormat;
private string $userTimeFormat;
private readonly string $dbTimezone;
private readonly string $dbFormat;
private ?CarbonImmutable $datetime;
/**
* Constructs a new instance of the class.
*
* @param DateTimeInterface|null|string $time Optional. The datetime object, ISO format string, or null.
* @param DateTimeZone|null|string $tz Optional. The timezone object, timezone identifier, or null.
*
* @throws BindingResolutionException
*/
public function __construct($time = null, $tz = null)
{
parent::__construct($time, $tz);
// Check if our custom macros are already registered
if (! static::hasMacro('formatDateTimeForDb')) {
static::mixin(new CarbonMacros(
session('usersettings.timezone') ?? app()->make(Environment::class)->defaultTimezone,
str_replace('-', '_', session('usersettings.language') ?? app()->make(Environment::class)->language),
session('usersettings.date_format') ?? app()->make(Language::class)->__('language.dateformat'),
session('usersettings.time_format') ?? app()->make(Language::class)->__('language.timeformat')
));
}
// Continue with regular initialization
$language = app()->make(Language::class);
$config = app()->make(Environment::class);
// These are read only for a reason
$this->dbFormat = 'Y-m-d H:i:s';
$this->dbTimezone = 'UTC';
// Session is set in middleware, unlikely to not be set but just in case set defaults.
$this->userTimezone = session('usersettings.timezone') ?? $config->defaultTimezone;
$this->userLanguage = str_replace('-', '_', (session('usersettings.language') ?? $config->language));
$this->userDateFormat = session('usersettings.date_format') ?? $language->__('language.dateformat');
$this->userTimeFormat = session('usersettings.time_format') ?? $language->__('language.timeformat');
}
/**
* Parses a user input date and time and returns a CarbonImmutable object.
*
* @param string $userDate The user input date in the format specified by $this->userDateFormat.
* @param ?string $userTime The user input time in the format specified by $this->userTimeFormat.
* Defaults to an empty string. Can also be one of start|end to denote start or end time of
* day
* @return CarbonImmutable The parsed date and time in user timezone as a CarbonImmutable object.
*
* @throws InvalidDateException
*/
public function parseUserDateTime(string $userDate, ?string $userTime = ''): CarbonImmutable
{
// Initialize result variable to null
$this->datetime = null;
// Validate input string
if (! $this->isValidDateString($userDate)) {
throw new InvalidDateException(
'The string is not a valid date time string to parse as user datetime string', $userDate
);
}
try {
// If no standard format worked, handle user format cases
$locale = substr($this->userLanguage, 0, 2);
$trimmedDate = trim($userDate);
if ($userTime === 'start') {
$this->datetime = CarbonImmutable::createFromLocaleFormat(
'!'.$this->userDateFormat,
$locale,
$trimmedDate,
$this->userTimezone
)
->startOfDay();
} elseif ($userTime === 'end') {
$this->datetime = CarbonImmutable::createFromLocaleFormat(
'!'.$this->userDateFormat,
$locale,
$trimmedDate,
$this->userTimezone
)
->endOfDay();
} elseif ($userTime === '' || $userTime === null) {
$this->datetime = CarbonImmutable::createFromLocaleFormat(
'!'.$this->userDateFormat,
$locale,
$trimmedDate,
$this->userTimezone
);
} else {
$this->datetime = CarbonImmutable::createFromLocaleFormat(
'!'.$this->userDateFormat.' '.$this->userTimeFormat,
$locale,
trim($trimmedDate.' '.$userTime),
$this->userTimezone
);
}
return $this->datetime;
} catch (\Exception $e) {
// Try standard formats first (non timezone formats are assumed user timezone)
$standardFormat = $this->tryParseStandardFormats($userDate, $this->userTimezone);
if ($standardFormat !== false) {
$this->datetime = $standardFormat;
return $this->datetime;
}
throw new InvalidFormatException('The string is not a valid date time string to parse as user datetime string');
}
}
/**
* Parses a database date string and returns a CarbonImmutable instance.
*
* @param string $dbDate The date string in the database format to parse.
* @return CarbonImmutable The parsed CarbonImmutable instance in db timezone (UTC)
*
* @throws InvalidDateException
*/
public function parseDbDateTime(string $dbDate): CarbonImmutable
{
if (! $this->isValidDateString($dbDate)) {
throw new InvalidDateException('The string is not a valid date time string to parse as Database string', $dbDate);
}
try {
$this->datetime = CarbonImmutable::createFromFormat($this->dbFormat, $dbDate, $this->dbTimezone)->locale(
$this->userLanguage
);
return $this->datetime;
} catch (\Exception $e) {
// Try standard formats first (non timezone formats are assumed user timezone)
$standardFormat = $this->tryParseStandardFormats($dbDate, $this->dbTimezone);
if ($standardFormat !== false) {
$this->datetime = $standardFormat;
return $this->datetime;
}
throw new InvalidFormatException('The string is not a valid date time string to parse as Database string');
}
}
protected function tryParseStandardFormats($userDate, $timezone): CarbonImmutable|false
{
// Define standard formats to try first
$standardFormats = [
"Y-m-d\TH:i:sP", // ISO 8601 with timezone offset (e.g., 2025-04-16T00:00:00-04:00)
"Y-m-d\TH:i:s\Z", // ISO 8601 UTC/Zulu time (e.g., 2025-04-16T00:00:00Z)
'Y-m-d H:i:s',
DateTime::ATOM,
DateTime::ISO8601,
DateTime::W3C,
"Ymd\THis\Z", // ISO 8601 UTC/Zulu time (e.g., 20250429T110000Z) (for ical)
"Ymd\THis", // ISO 8601 no timezone (e.g., 20250429T110000) (for ical)
"Y-m-d\TH:i:s", // ISO 8601 without timezone (e.g., 2025-04-16T00:00:00)
"Y-m-d\TH:i:se",
'Y-m-d',
'Y-m-d H:i',
];
// Added in PHP 8.2
if (defined('DateTime::ISO8601_EXPANDED')) {
$standardFormats[] = DateTime::ISO8601_EXPANDED;
}
// Try standard formats first
foreach ($standardFormats as $format) {
try {
// If dates provided don't timezone informaiton, we assume it's in user datetime
if ($format === 'Y-m-d') {
return CarbonImmutable::createFromFormat($format, $userDate, $timezone)->midDay();
}
if ($format === 'Y-m-d H:i:s' || $format === 'Y-m-d H:i' || $format === "Ymd\THis") {
return CarbonImmutable::createFromFormat($format, $userDate, $timezone);
} else {
return CarbonImmutable::createFromFormat($format, $userDate, $timezone);
}
} catch (\Exception $e) {
// Continue to next format
}
}
return false;
}
/**
* Parses a user 24-hour time string and returns a CarbonImmutable instance.
*
* @param string $local24Time The 24-hour time string to parse.
* @return CarbonImmutable The parsed CarbonImmutable instance in the user's timezone
*/
public function parseUser24hTime(string $local24Time): CarbonImmutable
{
$this->datetime = CarbonImmutable::createFromFormat('!H:i', $local24Time, $this->userTimezone);
return $this->datetime;
}
/**
* Parses a database 24-hour time string and returns a CarbonImmutable instance.
*
* @param string $db24Time The 24-hour time string to parse.
* @return CarbonImmutable The parsed CarbonImmutable instance in the database timezone (UTC)
*/
public function parseDb24hTime(string $db24Time): CarbonImmutable
{
$this->datetime = CarbonImmutable::createFromFormat('!H:i', $db24Time, $this->dbTimezone);
return $this->datetime;
}
/**
* Returns the current date and time based on the user's timezone and language.
*
* @return CarbonImmutable The current date and time in the user's timezone and language.
*/
public function userNow(): CarbonImmutable
{
return CarbonImmutable::now($this->userTimezone)->locale($this->userLanguage);
}
/**
* Returns the current date and time in the database timezone as a CarbonImmutable instance.
*
* @return CarbonImmutable The current date and time in the database timezone (UTC) as a CarbonImmutable instance.
*/
public function dbNow(): CarbonImmutable
{
return CarbonImmutable::now($this->dbTimezone)->locale($this->userLanguage);
}
/**
* Sets the CarbonImmutable date for the current instance.
*
* @param CarbonImmutable|Carbon $date The CarbonImmutable or Carbon instance to set the date.
* @return string|CarbonImmutable|false
*/
public function setCarbonDate(CarbonImmutable|Carbon $date): string|CarbonImmutable|bool
{
return $this->datetime = CarbonImmutable::create($date)->locale($this->userLanguage);
}
/**
* isValidDateString - checks if a given string is a valid date and time string
*
* @param ?string $dateTimeString The date and time string to be validated
* @return bool Returns true if the string is a valid string that is worth sending to a parser, false otherwise
*/
public function isValidDateString(?string $dateTimeString): bool
{
return empty($dateTimeString) === false
&& $dateTimeString !== '1969-12-31 00:00:00'
&& $dateTimeString !== '0000-00-00 00:00:00';
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Leantime\Core\Support;
/**
* Enum class FromFormat
*
* An enumeration class representing various formats for date and time values.
*/
enum DateTimeInfoEnum
{
/**
* Displays date with content:
* Written On DATE at TIME
*/
case WrittenOnAt;
/**
* Displays date with content:
* Updated On DATE at TIME
*/
case UpcatedOnAt;
/**
* Displays date with content:
* XXX days/months ago
*/
case HumanReadable;
/**
* Just displays DATE TIME
*/
case Plain;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Leantime\Core\Support;
/**
* Enum representing the available editor types in Leantime.
*
* Each case maps to a specific editor configuration with varying feature sets.
*/
enum EditorTypeEnum: string
{
/**
* Shows a simplified editor used for comments
*/
case Simple = 'tiptapSimple';
/**
* Shows a more complex editor for entity descriptions
*/
case Complex = 'tiptapComplex';
/**
* Shows the full editor with all features
*/
case Notes = 'tiptapNotes';
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Leantime\Core\Support;
/**
* Interface for entity formatters that convert domain models into markdown for AI consumption.
*
* This interface defines the contract for formatting domain entities (tickets, projects, users, etc.)
* into structured markdown documents suitable for AI prompts and embeddings.
*/
interface EntityFormatterInterface
{
/**
* Format the entity into a standard markdown representation.
*
* @return string Markdown formatted string representing the entity
*/
public function format(): string;
/**
* Format the entity with additional context-aware information.
*
* @param array $context Additional context that might affect formatting (e.g., user preferences, specific fields to include/exclude)
* @return string Context-aware markdown formatted string
*/
public function formatForContext(array $context = []): string;
/**
* Get the type of entity this formatter handles.
*
* @return string Entity type (e.g., 'ticket', 'project', 'user')
*/
public function getEntityType(): string;
/**
* Get the unique identifier of the entity being formatted.
*
* @return mixed Entity ID (could be int, string, or other identifier type)
*/
public function getEntityId(): mixed;
/**
* Get a compact, one-line summary of the entity.
*
* @return string Brief summary suitable for lists or references
*/
public function getSummary(): string;
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Leantime\Core\Support;
/**
* Enum class EntityRelationshipEnum
*
* Represents various types of entity relationships.
*/
enum EntityRelationshipEnum: string
{
/**
* Represents a collaborator relationship between ticket and user.
*/
case Collaborator = 'collaborator';
/**
* Represents a "generated from" relationship (entity created from a canvas item).
*/
case GeneratedFrom = 'generated_from';
/**
* Represents a "maps to" relationship (cross-structure element mapping).
*/
case MapsTo = 'maps_to';
/**
* Associates a goal with a milestone for informational display only — the
* milestone(s) are rendered as a list on the goal; they do NOT drive goal
* progress (progress stays metric-defined from the goal's own values).
* Direction convention: entityA = goal (GoalItem), entityB = milestone
* (Ticket). The edge-based successor to the legacy single
* zp_canvas_items.milestoneId column (retained during the transition) so a
* goal can hold many milestones.
*/
case TrackedBy = 'tracked_by';
/**
* Associates a ticket (task/milestone) with a linked resource for
* informational display — BOM / 工艺文件 / 工具清单 / 文件 / wiki.
* Direction convention: entityA = Ticket (task/milestone), entityB = the
* linked resource id; entityBType carries the resource kind
* (bom | process | tooling | file | wiki) so a single polymorphic edge
* covers all five resource families without extra tables.
*/
case LinkedResource = 'linked_resource';
}

334
app/Core/Support/Format.php Normal file
View File

@@ -0,0 +1,334 @@
<?php
namespace Leantime\Core\Support;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Date;
use Leantime\Core\Language;
use PHPUnit\Exception;
/**
* Class Format
*
* This class provides various formatting methods for simple data types (strings, ints, floats)
*/
class Format
{
private mixed $value = '';
private mixed $value2 = '';
private DateTimeHelper $dateTimeHelper;
private Language $language;
/**
* Creates a new instance of the class.
*
* PSA: This class will NOT throw exceptions or error messages since it is a user facing string formatting class.
* If you need to evaluate correct parsing of datetimes use the datetime helper and not this format class.
*
* @param string|int|float $value The value to be assigned. If empty, the constructor will return early.
* @param null|string|int|float $value2 The second value to be assigned. It can be null. Used for certain cases
* as specified by $fromFormat.
* @param FromFormat|null $fromFormat The format of the values. Can be one of the constants defined in the
* FromFormat class.
* @return void
*/
public function __construct(
string|int|float|null|\DateTimeInterface|CarbonInterface $value,
string|int|float|null|\DateTimeInterface|CarbonInterface $value2,
?FromFormat $fromFormat = FromFormat::DbDate
) {
$this->dateTimeHelper = app()->make(DateTimeHelper::class);
$this->language = app()->make(Language::class);
if (empty($value)) {
return;
}
if ($value instanceof \DateTime) {
$value = CarbonImmutable::create($value);
$this->value = CarbonImmutable::create($value);
}
if ($value2 instanceof \DateTime) {
$value2 = CarbonImmutable::create($value2);
$this->value = CarbonImmutable::create($value2);
}
try {
switch ($fromFormat) {
case FromFormat::DbDate:
$this->value = $this->dateTimeHelper->parseDbDateTime($value);
break;
case FromFormat::UserDateTime:
$this->value = $this->dateTimeHelper->parseUserDateTime($value, $value2);
break;
case FromFormat::User24hTime:
$this->value = $this->dateTimeHelper->parseUser24hTime($value);
break;
case FromFormat::Db24hTime:
$this->value = $this->dateTimeHelper->parseDb24hTime($value);
break;
case FromFormat::UserDateStartOfDay:
$this->value = $this->dateTimeHelper->parseUserDateTime($value, 'start');
break;
case FromFormat::UserDateEndOfDay:
$this->value = $this->dateTimeHelper->parseUserDateTime($value, 'end');
break;
default:
$this->value = $value;
break;
}
} catch (\Throwable $e) {
// Several things can throw exceptions in the date parsing scripts.
// Most common is an invalid date format. This could also be an empty string or a 0000-00... date.
// Since this format class is purely for user facing purposes we will not show an error message
// but return an empty string.
$this->value = $value;
return;
}
}
/**
* Returns the user formatted date string based on the 'value' property.
*
* @param string $emptyOutput The output to be returned when the 'value' property is empty.
* Defaults to an empty string.
* @return string The formatted date string or the $emptyOutput if the 'value' property is empty or
* the formatted date string is empty.
*/
public function date(string $emptyOutput = ''): string
{
if (empty($this->value) || ! $this->value instanceof CarbonImmutable) {
return $emptyOutput;
}
$formattedDate = $this->value->formatDateForUser();
return $formattedDate !== '' ? $formattedDate : $emptyOutput;
}
/**
* Retrieve the formatted time string from the ISO value.
* Returns an empty string if 'value' is null.
*
* @return string The formatted time string.
*/
public function time(): string
{
if (empty($this->value) || ! $this->value instanceof CarbonImmutable) {
return '';
}
return $this->value->formatTimeForUser();
}
/**
* Generates an ISO 8601 formatted date and time string in user timezone
*
* @return string The ISO 8601 formatted date and time string. Returns an empty string if the value is null.
*/
public function isoDateTime(): string
{
if (empty($this->value) || ! $this->value instanceof CarbonImmutable) {
return '';
}
return $this->value->setToUserTimezone()->format('Y-m-d H:i:s');
}
/**
* Generates an ISO 8601 formatted date and time string in UTC timezone
*
* @return string The ISO 8601 formatted date and time string. Returns an empty string if the value is null.
*/
public function isoDateTimeUTC(): string
{
if (empty($this->value) || ! $this->value instanceof CarbonImmutable) {
return '';
}
return $this->value->formatDateTimeForDb();
}
/**
* @deprecated
*
* This method is deprecated and only included because of plugin backwards compatibility.
* Once all plugins are updated this will be removed.
*
* @return string The ISO 8601 formatted date string. Returns an empty string if the value is null.
*/
public function isoDate(): string
{
if (empty($this->value)) {
return '';
}
// This method should not be used anymore however we have plugins that are still using it and they will not have
// the new enum values. So they are still calling format($var)->isoDate() without a enum modifier that would
// indicate that this is a user date (which it was historically).
// So now we have to shuffle things around and since the format was probably not correct anyways, let's reparse
if (! $this->value instanceof CarbonImmutable) {
try {
$this->value = $this->dateTimeHelper->parseUserDateTime($this->value, 'start');
} catch (Exception $e) {
report($e);
return '';
}
} else {
// If for some reason Carbon was able to parse the date we'll need to make sure the timezone is set to the
// users timezone.
// Date was falsly parsed as UTC but is actually user date. Shift timezone.
$userTimezone = session('usersettings.timezone');
// Carbon shift timezone will change timezone without actually changing the numbers
$this->value->shiftTimezone($userTimezone);
}
return $this->value->formatDateTimeForDb();
}
/**
* Generate unix timestamp from date.
*/
public function timestamp(): int|bool
{
if (empty($this->value) || ! $this->value instanceof CarbonImmutable) {
return false;
}
return $this->value->getTimestamp();
}
/**
* Generate unix timestamp from date in miliseconds for javascript usage
*/
public function jsTimestamp(): int|bool
{
if (empty($this->value) || ! $this->value instanceof CarbonImmutable) {
return false;
}
return $this->value->getTimestampMs();
}
/**
* Retrieves the 24-hour time string from the ISO formatted value property.
*
* @return string The 24-hour time string. If the value property is null, an empty string is returned.
*/
public function time24(): string
{
if (empty($this->value) || ! $this->value instanceof CarbonImmutable) {
return '';
}
return $this->value->format24HTimeForUser();
}
/**
* Converts a 24-hour formatted time string to a user-friendly time string.
*
* @return string The user-friendly time string in the format "H:i A". Returns an empty string if the value is null.
*/
public function userTime24toUserTime(): string
{
if (empty($this->value) || ! $this->value instanceof CarbonImmutable) {
return '';
}
return $this->value->formatTimeForUser();
}
/**
* Generates a string representation of currency.
*
* @return string The string representation of currency.
*/
public function currency(): string
{
if ($this->value == null) {
return '';
}
return $this->language->__('language.currency').''.number_format($this->value, 2);
}
/**
* Generates a string representation of a percentage.
*
* @return string The percentage string. Returns an empty string if the value is null.
* If the second value is empty, the first value is returned with a "%" sign appended.
* If both values are set, the percentage is calculated and formatted to two decimal places, followed by a "%" sign.
*/
public function percent(): string
{
// First value empty, just return empty string
if ($this->value == null) {
return '';
}
// Second value empty return first value with % sign
if (empty($this->value2)) {
return number_format($this->value, 2).'%';
}
// Both values set. Return percent calculation
$percent = ($this->value / $this->value2) * 100;
return number_format($percent, 2).'%';
}
/**
* Formats a decimal number with two decimal places.
*
* @return string The decimal number formatted with two decimal places.
*/
public function decimal(): string
{
return number_format((float) $this->value, 2);
}
public function diffForHumans(): string
{
if ($this->value->isToday()) {
return $this->language->__('dates.today');
} elseif ($this->value->isYesterday()) {
return $this->language->__('dates.yesterday');
} elseif ($this->value->isTomorrow()) {
return $this->language->__('dates.tomorrow');
} else {
return $this->value->endOfDay()->diffForHumans();
}
}
/**
* Format bytes to human readable format
*
* @return string The formatted size
*/
public function formatBytes(): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($this->value, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
return round($bytes / (1024 ** $pow), 2).' '.$units[$pow];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Leantime\Core\Support;
/**
* Enum class FromFormat
*
* An enumeration class representing various formats for date and time values.
*/
enum FromFormat
{
/**
* For value containing date time string from database in UTC
*/
case DbDate;
/**
* For value containing both date and time in users preferred format and timezone separated by a space
*/
case UserDateTime;
/**
* For values containing the time string in local timezone but formatted as 24 hour time (time html fields)
*/
case User24hTime;
/**
* For values containing UTC date time with 24hour time format
*/
case Db24hTime;
/**
* for values containing only the user formatted date and timezone. Adds start of day time to string
*/
case UserDateStartOfDay;
/**
* For values containing only the user formatted date and timezone. Adds end of day time to string
*/
case UserDateEndOfDay;
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Leantime\Core\Support;
use Illuminate\Support\Str;
/**
* Sanitizes strings for safe LLM/AI consumption, prevents prompt injection attacks.
*
* Delegates to the Str::sanitizeForLLM() macro for the actual sanitization logic.
* This class provides a static API for use in entity formatters and other contexts
* where the Str macro call pattern is less convenient.
*/
class LLMStringSanitizer
{
/**
* Sanitize a string for safe use with LLM APIs.
*
* Removes potential prompt injection patterns and problematic characters
* that could interfere with JSON serialization or system prompts.
*
* @param mixed $input The value to sanitize
* @param bool $removeNewlines Whether to strip newlines from the result
* @return string The sanitized string
*/
public static function sanitizeForLLM(mixed $input, bool $removeNewlines = false): string
{
if ($input === null) {
return '';
}
if (! is_string($input)) {
return (string) $input;
}
return Str::sanitizeForLLM($input, $removeNewlines);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Leantime\Core\Support;
use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Leantime\Core\Support\String\AlphaNumeric;
use Leantime\Core\Support\String\BeautifyFilename;
use Leantime\Core\Support\String\SanitizeFilename;
use Leantime\Core\Support\String\SanitizeForLLM;
use Leantime\Core\Support\String\ToMarkdown;
class LoadMacrosServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Register string macros
Str::mixin(new AlphaNumeric);
Str::mixin(new BeautifyFilename);
Str::mixin(new SanitizeFilename);
Str::mixin(new SanitizeForLLM);
Str::mixin(new ToMarkdown);
Collection::macro('countNested', function ($childrenKey = 'children') {
return $this->reduce(function ($count, $item) use ($childrenKey) {
$itemCount = 1;
if (isset($item[$childrenKey]) && is_array($item[$childrenKey])) {
$itemCount += collect($item[$childrenKey])->countNested($childrenKey);
}
return $count + $itemCount;
}, 0);
});
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Leantime\Core\Support;
use Illuminate\Support\Str;
/**
* Converts PHP arrays into formatted markdown strings for LLM consumption.
*
* Delegates to the Str::toMarkdown() macro for the actual conversion logic.
* This class provides a static API for use in entity formatters and other contexts.
*/
class MarkdownHelper
{
/**
* Converts a PHP array into a formatted markdown string.
*
* @param mixed $data The data to convert to markdown
* @param int $headerLevel Starting header level (1-6)
* @return string Markdown formatted string
*/
public static function encode(mixed $data, int $headerLevel = 2): string
{
if (! is_array($data)) {
return self::sanitizeForMarkdown((string) $data);
}
return Str::toMarkdown($data);
}
/**
* Check if an array is associative (has string keys) or sequential (numeric keys).
*
* @param mixed $array The array to check
* @return bool True if associative, false if sequential
*/
public static function isAssociativeArray(mixed $array): bool
{
if (! is_array($array) || empty($array)) {
return false;
}
return array_keys($array) !== range(0, count($array) - 1);
}
/**
* Sanitize a value for safe markdown formatting.
*
* @param mixed $value The value to sanitize
* @return string The sanitized string
*/
public static function sanitizeForMarkdown(mixed $value): string
{
if ($value === null) {
return '*null*';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
return LLMStringSanitizer::sanitizeForLLM((string) $value);
}
}

118
app/Core/Support/Mix.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
namespace Leantime\Core\Support;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Leantime\Core\Events\DispatchesEvents;
class Mix
{
use DispatchesEvents;
private $manifest = [];
public function __construct()
{
// if (! Cache::store('installation')->has('manifest://' . ($manifestDir = APP_ROOT . '/public/dist'))) {
// Cache::store('installation')->put('manifest://' . $manifestDir, json_decode(file_get_contents("$manifestDir/mix-manifest.json"), true), 60 * 60 * 24 * 7);
// }
// $this->manifest[$manifestDir] = Cache::store('installation')->get('manifest://' . $manifestDir);
$this->manifest[$manifestDir = APP_ROOT.'/public/dist'] = json_decode(file_get_contents("$manifestDir/mix-manifest.json"), true);
/**
* WARNING: All files in the manifest directories will be exposed to public queries!
*
* @var string[] $manifestDirectories
**/
$manifestDirectories = self::dispatchFilter('mix_manifest_directories', []);
if (empty($manifestDirectories) || ! is_array($manifestDirectories)) {
return;
}
foreach ($manifestDirectories as $manifestDirectory) {
$manifestDirectory = rtrim($manifestDirectory, '/');
// if (Cache::store('installation')->has('manifest://' . $manifestDirectory)) {
// $this->manifest[$manifestDirectory] = Cache::get('manifest://' . $manifestDirectory);
// continue;
// }
if (! file_exists($manifestPath = $manifestDirectory.'/mix-manifest.json')) {
continue;
}
// Cache::store('installation')->put('manifest://' . $manifestDirectory, array_map(
// fn ($path) => $this->preparePath($path, $manifestDirectory),
// json_decode(file_get_contents($manifestPath), true)
// ), 60 * 60 * 24 * 7);
//
// $this->manifest[$manifestDirectory] = Cache::store('installation')->get('manifest://' . $manifestDirectory);
$this->manifest[$manifestDirectory] = array_map(
fn ($path) => $this->preparePath($path, $manifestDirectory),
json_decode(file_get_contents($manifestPath), true)
);
}
}
public function __invoke(string $path, string $manifestDirectory = ''): string
{
$manifestDirectory = Str::start('/', $manifestDirectory ?: APP_ROOT.'/public/dist');
$path = Str::start($path, '/');
if (! isset($this->manifest[$manifestDirectory])) {
throw new \Exception("Unable to locate Manifest in: {$manifestDirectory}.");
}
if (! isset($this->manifest[$manifestDirectory][$path])) {
throw new \InvalidArgumentException("Unable to locate Mix file: {$path}.");
}
return $this->manifest[$manifestDirectory][$path];
}
private function preparePath(string $path, string $manifestDirectory): string
{
if (str_starts_with($manifestDirectory, APP_ROOT.'/app')) {
$urlPrefix = Str::of($manifestDirectory)
->replace(APP_ROOT.'/app', '')
->ltrim('/')
->explode('/')
->map(fn ($pathPart) => Str::slug($pathPart))
->join('/');
$urlPrefix = Str::of($urlPrefix)
->prepend('/api/static-asset/')
->rtrim('/')
->toString();
} elseif (str_starts_with($manifestDirectory, 'phar://'.APP_ROOT.'/app')) {
$urlPrefix = Str::of($manifestDirectory)
->replace('phar://'.APP_ROOT.'/app', '')
->ltrim('/')
->explode('/')
->join('/');
$urlPrefix = Str::of($urlPrefix)
->prepend('/api/static-asset/')
->rtrim('/')
->toString();
} else {
$urlPrefix = Str::of($manifestDirectory)
->replace(APP_ROOT.'/public', '')
->start('/public')
->rtrim('/')
->toString();
}
return $urlPrefix.Str::start($path, '/');
}
public function getManifest(): array
{
return $this->manifest;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Leantime\Core\Support;
/**
* Sanitizes person/display names before they are stored or rendered into
* outgoing emails (invite mails, mention notifications, From display names).
*
* Names were previously stored and emailed raw, which let spammers use the
* firstname field as an email payload (URLs, contact numbers, bidi tricks).
* Legitimate names in any script (CJK, Arabic, Cyrillic, ...) must pass —
* this strips abuse vectors, it does not enforce a charset.
*/
class NameSanitizer
{
/**
* Maximum length of a sanitized name.
*/
private const MAX_LENGTH = 50;
/**
* Clean a person name for storage and email use.
*
* Removes HTML, control/format characters (including bidi overrides and
* zero-width characters), URLs, email addresses, and long digit runs
* (contact-number spam), then collapses whitespace and caps the length.
*
* @param mixed $name The raw name value
* @return string The sanitized name (may be an empty string)
*/
public static function clean(mixed $name): string
{
if (! is_string($name)) {
return '';
}
$name = strip_tags($name);
// Control + format characters: bidi overrides, zero-width chars, etc.
$name = preg_replace('/\p{C}+/u', '', $name) ?? '';
// URLs and bare domains used as spam payloads
$name = preg_replace('~(?:https?|ftp)://\S+~iu', '', $name) ?? '';
$name = preg_replace('~www\.\S+~iu', '', $name) ?? '';
// Email addresses embedded in names
$name = preg_replace('/\S+@\S+\.\S+/u', '', $name) ?? '';
// Long digit runs (QQ/WeChat/phone contact spam); real names don't have them
$name = preg_replace('/\d{5,}/u', '', $name) ?? '';
$name = preg_replace('/\s+/u', ' ', $name) ?? '';
$name = trim($name);
// Explicit UTF-8 so the length cap is deterministic regardless of the
// PHP internal-encoding setting (multi-script names count by codepoint).
return mb_substr($name, 0, self::MAX_LENGTH, 'UTF-8');
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace Leantime\Core\Support;
use Illuminate\Support\Facades\Log;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
/**
* Shared SSRF guard for server-initiated outbound HTTP requests (external calendars, webhook
* notifications, and any other feature that fetches a user-supplied URL).
*
* Enforces http/https only, resolves every A/AAAA record for the host, and rejects the request
* when any resolved address is loopback, private, link-local, CGNAT, or otherwise reserved —
* closing the "public hostname, private IP" bypass. {@see redirectOptions()} re-runs the same
* check on every redirect hop so an allowed public URL can't 30x-redirect into an internal target.
*/
final class OutboundUrlGuard
{
/**
* IPv4 ranges that must never be reached by a server-initiated request. Beyond RFC1918 this
* adds CGNAT (100.64.0.0/10 — the range the calendar guard was missing), IETF-reserved,
* benchmarking, multicast, and broadcast ranges.
*
* @var array<int, string>
*/
private const IPV4_DENY_RANGES = [
'0.0.0.0/8', // "this" network
'10.0.0.0/8', // RFC1918 private
'100.64.0.0/10', // CGNAT (RFC6598)
'127.0.0.0/8', // loopback
'169.254.0.0/16', // link-local (incl. cloud metadata 169.254.169.254)
'172.16.0.0/12', // RFC1918 private
'192.0.0.0/24', // IETF protocol assignments
'192.0.2.0/24', // TEST-NET-1
'192.168.0.0/16', // RFC1918 private
'198.18.0.0/15', // benchmarking
'224.0.0.0/4', // multicast
'240.0.0.0/4', // reserved
'255.255.255.255/32', // broadcast
];
/**
* True when $url is safe for a server-initiated outbound request.
*/
public static function isAllowedUrl(string $url): bool
{
$parsed = parse_url($url);
if ($parsed === false || empty($parsed['scheme']) || empty($parsed['host'])) {
return false;
}
if (! in_array(strtolower($parsed['scheme']), ['http', 'https'], true)) {
Log::warning('SSRF guard: blocked disallowed scheme', ['scheme' => $parsed['scheme']]);
return false;
}
$host = $parsed['host'];
// IP literal: validate directly.
if (filter_var($host, FILTER_VALIDATE_IP)) {
return self::isIpAllowed($host);
}
// Resolve every A and AAAA record; block if any single record is disallowed.
$ips = [];
foreach ((@dns_get_record($host, DNS_A) ?: []) as $record) {
$ips[] = $record['ip'] ?? null;
}
foreach ((@dns_get_record($host, DNS_AAAA) ?: []) as $record) {
$ips[] = $record['ipv6'] ?? null;
}
$ips = array_filter($ips);
if ($ips === []) {
Log::warning('SSRF guard: unable to resolve host', ['host' => $host]);
return false;
}
foreach ($ips as $ip) {
if (! self::isIpAllowed($ip)) {
Log::warning('SSRF guard: blocked private/reserved IP', ['host' => $host, 'ip' => $ip]);
return false;
}
}
return true;
}
/**
* True when an IP (v4 or v6) is a public, routable address safe to reach.
*/
public static function isIpAllowed(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
// Decompose an IPv4-mapped IPv6 address (::ffff:a.b.c.d) and apply the IPv4 rules,
// so CGNAT/private ranges can't slip through in v6 form.
$packed = inet_pton($ip);
if ($packed !== false && strlen($packed) === 16 && str_starts_with($packed, "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff")) {
$mappedV4 = inet_ntop(substr($packed, 12));
// Fail closed if the mapped address can't be rendered back to IPv4.
return $mappedV4 !== false && self::isIpv4Allowed($mappedV4);
}
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
return self::isIpv4Allowed($ip);
}
private static function isIpv4Allowed(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
return false;
}
foreach (self::IPV4_DENY_RANGES as $range) {
if (self::ipv4InRange($ip, $range)) {
return false;
}
}
return true;
}
private static function ipv4InRange(string $ip, string $range): bool
{
[$subnet, $bits] = explode('/', $range);
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
if ($ipLong === false || $subnetLong === false) {
return false;
}
$mask = -1 << (32 - (int) $bits);
return ($ipLong & $mask) === ($subnetLong & $mask);
}
/**
* Guzzle `allow_redirects` options that re-validate every redirect hop with the same guard,
* so a permitted public URL can't be used to bounce the request into an internal target.
*
* @return array<string, mixed>
*/
public static function redirectOptions(): array
{
return [
'max' => 5,
'strict' => true,
'referer' => false,
'protocols' => ['http', 'https'],
'on_redirect' => function (RequestInterface $request, ResponseInterface $response, UriInterface $uri): void {
if (! self::isAllowedUrl((string) $uri)) {
throw new \RuntimeException('SSRF guard: blocked redirect to disallowed URL');
}
},
];
}
}

View File

@@ -0,0 +1,246 @@
<?php
namespace Leantime\Core\Support;
/**
* Routing Formatter - Formats conversation history and routing context for AI consumption.
*/
class RoutingFormatter extends AbstractEntityFormatter
{
protected array $conversationHistory;
public function __construct(array $conversationHistory = [])
{
$this->conversationHistory = $conversationHistory;
// Set field priority for conversation history formatting
$this->fieldPriority = $this->getPriorityFields();
}
/**
* Prepare the entity data for formatting.
*/
protected function prepareEntityData(array $context = []): array
{
return [
'conversation_history' => $this->conversationHistory,
'exchange_count' => count($this->conversationHistory),
'latest_date' => $this->getLatestDate(),
'has_feedback' => $this->hasFeedback(),
];
}
/**
* Format the header section of the entity.
*/
protected function formatHeader(array $data): string
{
$count = $data['exchange_count'] ?? 0;
$latestDate = $data['latest_date'] ?? 'Unknown';
if ($count === 0) {
return "## Recent Conversation History\n\nNo recent conversation history available.";
}
return "## Recent Conversation History\n\n".
"**Total Exchanges:** {$count}\n".
"**Latest Exchange:** {$latestDate}\n\n".
'The following shows recent interactions between the user and assistant:';
}
/**
* Format the body section with conversation history.
*/
protected function formatBody(array $data, array $context = []): string
{
if (empty($data['conversation_history'])) {
return '';
}
return $this->formatRecentHistoryAsPrompt($data['conversation_history']);
}
/**
* Format a compact summary of the conversation history.
*/
protected function formatSummary(array $data): string
{
$count = $data['exchange_count'] ?? 0;
if ($count === 0) {
return 'No conversation history';
}
$latestDate = $data['latest_date'] ?? 'Unknown date';
return "Conversation history with {$count} exchanges, latest from {$latestDate}";
}
/**
* Get entity type.
*/
public function getEntityType(): string
{
return 'routing';
}
/**
* Get entity identifier.
*/
public function getEntityId(): string
{
return 'conversation_history_'.count($this->conversationHistory);
}
/**
* Get the date of the latest conversation exchange.
*/
protected function getLatestDate(): string
{
if (empty($this->conversationHistory)) {
return 'No exchanges';
}
$latest = end($this->conversationHistory);
return $latest['date'] ?? 'Unknown date';
}
/**
* Check if any conversation exchange has feedback.
*/
protected function hasFeedback(): bool
{
foreach ($this->conversationHistory as $exchange) {
if (isset($exchange['feedback']) && $exchange['feedback'] !== null) {
return true;
}
}
return false;
}
/**
* Format recent conversation history as a prompt.
*
* @param array $recentHistory An array containing the recent conversation history. Each item should include
* keys for 'date', 'message', 'response', and 'feedback'.
* @return string The formatted conversation history as a string in prompt format.
*/
protected function formatRecentHistoryAsPrompt(array $recentHistory): string
{
$prompt = '';
// Add recent conversation history if available
if (! empty($recentHistory)) {
foreach ($recentHistory as $exchange) {
$date = $this->sanitizeValue($exchange['date'] ?? 'Unknown date');
$message = $this->sanitizeValue($exchange['message'] ?? '');
$response = $this->sanitizeValue($exchange['response'] ?? '');
$prompt .= "**Date (UTC):** {$date}\n";
$prompt .= "**User:** {$message}\n";
$prompt .= "**Assistant:** {$response}\n\n";
$feedback = $this->formatFeedback($exchange['feedback'] ?? null);
$prompt .= "**User Rating:** {$feedback}\n\n";
$prompt .= "---\n\n";
}
}
return $prompt;
}
/**
* Format feedback value into human-readable text.
*/
protected function formatFeedback(mixed $feedback): string
{
switch ($feedback) {
case 1:
return 'Positive';
case -1:
return 'Negative';
default:
return 'Neutral (no feedback)';
}
}
/**
* Get priority fields for this formatter (conversation history specific).
*/
protected function getPriorityFields(): array
{
return [
'date',
'message',
'response',
'feedback',
'id',
];
}
/**
* Format optimized conversation history for token efficiency.
*
* @param array $optimizedHistory History with full_exchanges and key_facts_exchanges
* @return array Formatted history ready for AI consumption
*/
public function formatOptimizedHistory(array $optimizedHistory): array
{
$formattedHistory = [];
// Add recent full exchanges first
if (! empty($optimizedHistory['full_exchanges'])) {
$formattedHistory = array_merge($formattedHistory, $optimizedHistory['full_exchanges']);
}
// Add key facts exchanges as compressed entries
if (! empty($optimizedHistory['key_facts_exchanges'])) {
foreach ($optimizedHistory['key_facts_exchanges'] as $keyFactExchange) {
if (! empty($keyFactExchange['key_facts'])) {
$formattedHistory[] = [
'id' => $keyFactExchange['id'],
'date' => $keyFactExchange['date'],
'message' => '[Key Facts]',
'response' => $keyFactExchange['key_facts'],
'feedback' => $keyFactExchange['feedback'],
];
}
}
}
return $formattedHistory;
}
/**
* Create a routing formatter from optimized history data.
*/
public static function fromOptimizedHistory(array $optimizedHistory): self
{
$formatter = new self;
$formattedHistory = $formatter->formatOptimizedHistory($optimizedHistory);
$formatter->conversationHistory = $formattedHistory;
return $formatter;
}
/**
* Update the conversation history.
*/
public function setConversationHistory(array $conversationHistory): self
{
$this->conversationHistory = $conversationHistory;
return $this;
}
/**
* Get the current conversation history.
*/
public function getConversationHistory(): array
{
return $this->conversationHistory;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Leantime\Core\Support\String;
/**
* @mixin \Illuminate\Support\Stringable
*/
class AlphaNumeric
{
/**
* Cleans a string by removing special characters and optionally spaces.
*
* @param bool $removeSpaces Whether to remove spaces from the string.
* @return callable A function that cleans a string based on the given parameter.
*/
public function alphaNumeric($removeSpaces = false)
{
return function ($value) use ($removeSpaces) {
$cleaned = preg_replace('/[^A-Za-z0-9 ]/', '', (string) $value);
if ($removeSpaces) {
$cleaned = str_replace(' ', '', $cleaned);
} else {
// Step 2: Replace multiple spaces with a single space
$cleaned = preg_replace('/\s+/', ' ', $cleaned);
}
// Step 3: Trim leading and trailing spaces
$cleaned = trim($cleaned);
return $cleaned;
};
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Leantime\Core\Support\String;
/**
* @mixin \Illuminate\Support\Stringable
*/
class BeautifyFilename
{
/**
* Beautifies a filename by normalizing characters and formatting.
*
* @return callable A function that beautifies a filename
*/
public function beautifyFilename()
{
return function ($filename) {
// reduce consecutive characters
$filename = preg_replace([
// "file name.zip" becomes "file-name.zip"
'/ +/',
// "file___name.zip" becomes "file-name.zip"
'/_+/',
// "file---name.zip" becomes "file-name.zip"
'/-+/',
], '-', $filename);
$filename = preg_replace([
// "file--.--.-.--name.zip" becomes "file.name.zip"
'/-*\.-*/',
// "file...name..zip" becomes "file.name.zip"
'/\.{2,}/',
], '.', $filename);
// lowercase for windows/unix interoperability http://support.microsoft.com/kb/100625
$filename = mb_strtolower($filename, mb_detect_encoding($filename));
// ".file-name.-" becomes "file-name"
$filename = trim($filename, '.-');
return $filename;
};
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Leantime\Core\Support\String;
use Illuminate\Support\Str;
/**
* @mixin \Illuminate\Support\Stringable
*/
class SanitizeFilename
{
/**
* Sanitizes a filename by removing or replacing unsafe characters.
*
* @param bool $beautify Whether to beautify the filename
* @return callable A function that sanitizes a filename
*/
public function sanitizeFilename($beautify = true)
{
return function ($filename) use ($beautify) {
// sanitize filename
$filename = preg_replace(
'~
[<>:"/\\\|?*]| # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
[\x00-\x1F]| # control characters http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx
[\x7F\xA0\xAD]| # non-printing characters DEL, NO-BREAK SPACE, SOFT HYPHEN
[#\[\]@!$&\'()+,;=]| # URI reserved https://www.rfc-editor.org/rfc/rfc3986#section-2.2
[{}^\~`] # URL unsafe characters https://www.ietf.org/rfc/rfc1738.txt
~x',
'-',
$filename
);
// avoids ".", ".." or ".hiddenFiles"
$filename = ltrim($filename, '.-');
// optional beautification
if ($beautify) {
$filename = Str::beautifyFilename($filename);
}
// maximize filename length to 255 bytes http://serverfault.com/a/9548/44086
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$filename = mb_strcut(
pathinfo($filename, PATHINFO_FILENAME),
0,
255 - ($ext ? strlen($ext) + 1 : 0),
mb_detect_encoding($filename)
).($ext ? '.'.$ext : '');
return $filename;
};
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Leantime\Core\Support\String;
/**
* @mixin \Illuminate\Support\Stringable
*/
class SanitizeForLLM
{
/**
* Sanitizes string for safe use with LLM APIs by removing potential prompt injection patterns
* and other problematic characters that could interfere with JSON serialization or system prompts.
*
* @return callable A function that sanitizes a string for LLM processing
*/
public function sanitizeForLLM()
{
return function ($value, bool $removeNewlines = false) {
if (! is_string($value)) {
return $value ?? '';
}
// Step 1: Replace line breaks with space
$result = str_replace(["\r\n", "\r"], "\n", $value);
// Step 2: Escape JSON special characters except newlines
$result = str_replace(
['\\', '"', "\t", "\f", "\b"],
['\\\\', '\\"', ' ', ' ', ' '],
$result
);
// Step 3: Replace problematic characters with safe alternatives
$replacements = [
// Replace backslashes with forward slashes (for paths)
'\\' => '/',
// Replace double quotes with single quotes
'"' => "'",
// Replace special JSON characters with similar safe characters
'{' => '(',
'}' => ')',
// Collapse multiple spaces into single space
' ' => ' ',
];
$result = str_replace(array_keys($replacements), array_values($replacements), $result);
// Step 4: Remove any remaining potentially problematic characters
$result = preg_replace('/[\x80-\x9F]/u', '', $result);
// Remove common delimiters that might be used to "break out" of a system prompt
$attackPatterns = [
// System prompt break patterns
'/\<\/?system\>/', '/\<\/?assistant\>/', '/\<\/?user\>/', '/\<\/?human\>/',
// XML-like tags that might be used in exploits
'/\<\/?instructions\>/', '/\<\/?prompt\>/', '/\<\/?context\>/',
// Special command patterns
'/\[\[.*?\]\]/', '/\{\{.*?\}\}/',
// Common attack prefix/suffix patterns
'/ignore previous instructions/', '/ignore all previous commands/',
'/disregard (previous|prior|all|your) instructions?/',
'/forget (previous|prior|all|your) instructions?/',
// Additional boundary markers
'/```system/', '/```instructions/', '/```prompt/',
'/\$\$\$system/', '/\$\$\$instructions/', '/\$\$\$prompt/',
];
$result = preg_replace($attackPatterns, '', $result);
// Step 5: Handle potential JSON serialization issues
// Ensure the string is valid UTF-8
if (! mb_check_encoding($result, 'UTF-8')) {
$result = mb_convert_encoding($result, 'UTF-8', 'UTF-8');
}
// Step 6: Additional sanitization for special patterns
// Remove or replace specific problematic sequences
$result = str_replace(
['{{{', '}}}', '<<<', '>>>'],
['{ { {', '} } }', '< < <', '> > >'],
$result
);
// Step 7: Remove consecutive spaces (which can occur after other replacements)
$result = preg_replace('/ {2,}/', ' ', $result);
if ($removeNewlines) {
$result = str_replace("\n", ' ', $value);
}
return $result;
};
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Leantime\Core\Support\String;
use Illuminate\Support\Str;
/**
* @mixin \Illuminate\Support\Stringable
*/
class ToMarkdown
{
/**
* Converts a PHP array into a formatted markdown string.
*
* @param int $headerLevel Starting header level (1-6)
* @return callable A function that converts data to markdown format
*/
public function toMarkdown($headerLevel = 2)
{
$sanitizeForMarkdown = function ($value) {
if ($value === null) {
return '*null*';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
$string = (string) $value;
// Use the sanitizeForLLM macro for consistent sanitization
$string = Str::sanitizeForLLM($string);
return $string;
};
return function ($data) use ($headerLevel, $sanitizeForMarkdown) {
if (! is_array($data)) {
if (is_bool($data)) {
return $data ? 'true' : 'false';
}
$string = (string) $data;
// Use the sanitizeForLLM macro for consistent sanitization
return Str::sanitizeForLLM($string);
}
$result = '';
$indentLevel = 0;
// Internal function to process array recursively
$processArray = function ($array, $level, $indent) use (&$processArray, &$result, $sanitizeForMarkdown) {
foreach ($array as $key => $value) {
// Skip numeric keys for sequential arrays if they're just indices
$skipKey = is_int($key) && $key === count($array) - count($array);
if (! $skipKey) {
$data = preg_split('/(?=[A-Z])/', $key);
$string = implode(' ', $data);
$string = ucwords($string);
$result .= str_repeat(' ', $indent).'**'.$sanitizeForMarkdown($string).':**';
}
if (is_array($value)) {
// Handle nested arrays
if (empty($value)) {
$result .= str_repeat(' ', $indent)."*Empty*\n\n";
} elseif (array_keys($array) !== range(0, count($array) - 1)) {
// Associative array - process recursively
$processArray($value, $level + 1, $indent + 1);
} else {
// Sequential array - create a list
foreach ($value as $item) {
if (is_array($item)) {
// Nested array item
$result .= str_repeat(' ', $indent).'- ';
$nestedResult = '';
$processArray($item, $level + 2, 0);
// Format the nested result as an indented block
$lines = explode("\n", trim($nestedResult));
$result .= array_shift($lines)."\n";
foreach ($lines as $line) {
$result .= str_repeat(' ', $indent + 1).$line."\n";
}
} else {
// Simple item
$result .= str_repeat(' ', $indent).'- '.$sanitizeForMarkdown($item)."\n";
}
}
$result .= "\n";
}
} elseif (is_bool($value)) {
// Handle boolean values
$result .= str_repeat(' ', $indent).($value ? '✅ Yes' : '❌ No')."\n\n";
} elseif ($value === null) {
// Handle null values
$result .= str_repeat(' ', $indent)."*Not provided*\n\n";
} else {
// Handle scalar values
$formattedValue = $sanitizeForMarkdown($value);
// Check if value is multi-line and format accordingly
if (strpos($formattedValue, "\n") !== false) {
$result .= str_repeat(' ', $indent)."```\n".$formattedValue."\n```\n\n";
} else {
$result .= str_repeat(' ', $indent).$formattedValue."\n\n";
}
}
}
return $result;
};
// Start processing
$processArray($data, $headerLevel, $indentLevel);
// Clean up and return result
return trim($result);
};
}
}