OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
236
app/Core/Support/AbstractEntityFormatter.php
Normal file
236
app/Core/Support/AbstractEntityFormatter.php
Normal 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';
|
||||
}
|
||||
}
|
||||
127
app/Core/Support/Avatarcreator.php
Normal file
127
app/Core/Support/Avatarcreator.php
Normal 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
215
app/Core/Support/Build.php
Normal 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);
|
||||
}
|
||||
}
|
||||
175
app/Core/Support/CarbonMacros.php
Normal file
175
app/Core/Support/CarbonMacros.php
Normal 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
204
app/Core/Support/Cast.php
Normal 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();
|
||||
}
|
||||
}
|
||||
306
app/Core/Support/DateTimeHelper.php
Normal file
306
app/Core/Support/DateTimeHelper.php
Normal 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';
|
||||
}
|
||||
}
|
||||
35
app/Core/Support/DateTimeInfoEnum.php
Normal file
35
app/Core/Support/DateTimeInfoEnum.php
Normal 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;
|
||||
|
||||
}
|
||||
29
app/Core/Support/EditorTypeEnum.php
Normal file
29
app/Core/Support/EditorTypeEnum.php
Normal 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';
|
||||
|
||||
}
|
||||
48
app/Core/Support/EntityFormatterInterface.php
Normal file
48
app/Core/Support/EntityFormatterInterface.php
Normal 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;
|
||||
}
|
||||
47
app/Core/Support/EntityRelationshipEnum.php
Normal file
47
app/Core/Support/EntityRelationshipEnum.php
Normal 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
334
app/Core/Support/Format.php
Normal 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];
|
||||
}
|
||||
}
|
||||
42
app/Core/Support/FromFormat.php
Normal file
42
app/Core/Support/FromFormat.php
Normal 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;
|
||||
|
||||
}
|
||||
38
app/Core/Support/LLMStringSanitizer.php
Normal file
38
app/Core/Support/LLMStringSanitizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
48
app/Core/Support/LoadMacrosServiceProvider.php
Normal file
48
app/Core/Support/LoadMacrosServiceProvider.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
64
app/Core/Support/MarkdownHelper.php
Normal file
64
app/Core/Support/MarkdownHelper.php
Normal 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
118
app/Core/Support/Mix.php
Normal 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;
|
||||
}
|
||||
}
|
||||
59
app/Core/Support/NameSanitizer.php
Normal file
59
app/Core/Support/NameSanitizer.php
Normal 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');
|
||||
}
|
||||
}
|
||||
168
app/Core/Support/OutboundUrlGuard.php
Normal file
168
app/Core/Support/OutboundUrlGuard.php
Normal 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');
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
246
app/Core/Support/RoutingFormatter.php
Normal file
246
app/Core/Support/RoutingFormatter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/Core/Support/String/AlphaNumeric.php
Normal file
34
app/Core/Support/String/AlphaNumeric.php
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
41
app/Core/Support/String/BeautifyFilename.php
Normal file
41
app/Core/Support/String/BeautifyFilename.php
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
51
app/Core/Support/String/SanitizeFilename.php
Normal file
51
app/Core/Support/String/SanitizeFilename.php
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
99
app/Core/Support/String/SanitizeForLLM.php
Normal file
99
app/Core/Support/String/SanitizeForLLM.php
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
126
app/Core/Support/String/ToMarkdown.php
Normal file
126
app/Core/Support/String/ToMarkdown.php
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user