Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions config/custom-fields.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,25 @@
'currencies' => null,
],

/*
|--------------------------------------------------------------------------
| Imports
|--------------------------------------------------------------------------
|
| How CSV cells are read during import. Dates and numbers are parsed against a
| declared convention rather than guessed, so an ambiguous cell like 3/4/2024 has
| exactly one meaning. The defaults match how this package has always behaved.
|
*/
'imports' => [
// 'iso' (Y-m-d only), 'european' (day first), or 'american' (month first).
// Every convention also accepts ISO, so picking one only widens what is read.
'date_format' => env('CUSTOM_FIELDS_IMPORT_DATE_FORMAT', 'iso'),

// 'point' (1,234.56) or 'comma' (1.234,56).
'number_format' => env('CUSTOM_FIELDS_IMPORT_NUMBER_FORMAT', 'point'),
],

'database' => [
'migrations_path' => database_path('custom-fields'),
'table_names' => [
Expand Down
22 changes: 22 additions & 0 deletions src/CustomFields.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Relaticle\CustomFields;

use Closure;
use Relaticle\CustomFields\Enums\ImportDateFormat;
use Relaticle\CustomFields\Enums\ImportNumberFormat;
use Relaticle\CustomFields\Models\CustomField;
use Relaticle\CustomFields\Models\CustomFieldOption;
use Relaticle\CustomFields\Models\CustomFieldSection;
Expand Down Expand Up @@ -226,6 +228,26 @@ public static function dateTimeDisplayFormat(): ?string
return self::$dateTimeDisplayFormat;
}

/**
* Date convention used when reading CSV cells during import.
*/
public static function importDateFormat(): ImportDateFormat
{
return ImportDateFormat::tryFrom(
(string) config('custom-fields.imports.date_format', 'iso')
) ?? ImportDateFormat::ISO;
}

/**
* Decimal separator convention used when reading CSV cells during import.
*/
public static function importNumberFormat(): ImportNumberFormat
{
return ImportNumberFormat::tryFrom(
(string) config('custom-fields.imports.number_format', 'point')
) ?? ImportNumberFormat::POINT;
}

/**
* Register a custom tenant resolver callback.
*
Expand Down
137 changes: 137 additions & 0 deletions src/Enums/ImportDateFormat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

declare(strict_types=1);

namespace Relaticle\CustomFields\Enums;

use Carbon\CarbonImmutable;
use DateTimeImmutable;
use Filament\Support\Contracts\HasLabel;

/**
* Declared date convention for CSV import parsing.
*
* `3/4/2024` is 3 April in Europe and 4 March in the US. Guessing it, as
* `Carbon::parse` does, silently transposes day and month whenever both are 12 or under.
* Declaring the convention gives the cell exactly one meaning.
*/
enum ImportDateFormat: string implements HasLabel
{
case ISO = 'iso';
case EUROPEAN = 'european';
case AMERICAN = 'american';

/**
* `Y` matches a two-digit year, so without a floor `1/1/24` parses to 1 January 24 AD.
*/
private const int MINIMUM_YEAR = 1000;

/**
* A written-out month is unambiguous in either word order, so both named
* conventions accept all of these. Only `ISO` excludes them, because a column
* declared ISO should mean the `Y-m-d` family and nothing else.
*
* @var list<string>
*/
private const array TEXTUAL_FORMATS = ['j F Y', 'j M Y', 'F j, Y', 'F jS Y', 'M j, Y', 'M jS Y'];

public function getLabel(): string
{
return match ($this) {
self::ISO => 'ISO standard',
self::EUROPEAN => 'European (day first)',
self::AMERICAN => 'American (month first)',
};
}

/**
* @return array<int, string>
*/
public function getExamples(bool $withTime = false): array
{
if ($withTime) {
return match ($this) {
self::ISO => ['2024-05-15 16:00:00'],
self::EUROPEAN => ['2024-05-15 16:00:00', '15/05/2024 16:00:00'],
self::AMERICAN => ['2024-05-15 16:00:00', '05/15/2024 16:00:00'],
};
}

return match ($this) {
self::ISO => ['2024-05-15'],
self::EUROPEAN => ['2024-05-15', '15/05/2024', '15 May 2024'],
self::AMERICAN => ['2024-05-15', '05/15/2024', 'May 15, 2024'],
};
}

public function parse(string $value, bool $withTime = false): ?CarbonImmutable
{
$value = trim($value);

if ($value === '') {
return null;
}

foreach ($this->getParseFormats($withTime) as $format) {
$parsed = $this->parseStrictly($format, $value);

if ($parsed instanceof CarbonImmutable) {
return $parsed;
}
}

return null;
}

/**
* `createFromFormat` overflows silently: `d/m/Y` turns 31/02/2024 into 2 March and
* `Y-m-d` turns 2024-02-31 into the same. The `!` prefix does not prevent it and
* `Carbon::hasFormat()` does not detect it. `getLastErrors()` reports a warning for
* every overflow, so that is the gate.
*/
private function parseStrictly(string $format, string $value): ?CarbonImmutable
{
$parsed = DateTimeImmutable::createFromFormat('!'.$format, $value);
$errors = DateTimeImmutable::getLastErrors();

if ($parsed === false) {
return null;
}

if ($errors !== false && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0)) {
return null;
}

if ((int) $parsed->format('Y') < self::MINIMUM_YEAR) {
return null;
}

return CarbonImmutable::instance($parsed);
}

/**
* ISO forms come first in every list so a `Y-m-d` cell is never re-read as something
* else, and every list carries the textual and datetime forms the previous
* `Carbon::parse` fallback accepted, so nothing that imports today stops importing.
*
* @return array<int, string>
*/
private function getParseFormats(bool $withTime): array
{
$iso = $withTime
? ['Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i', 'Y-m-d H:i']
: ['Y-m-d', 'Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'Y-m-d H:i'];

$localised = match ($this) {
self::ISO => [],
self::EUROPEAN => $withTime
? ['d/m/Y H:i:s', 'd-m-Y H:i:s', 'd.m.Y H:i:s', 'j/n/Y H:i:s', 'd/m/Y H:i', 'j/n/Y H:i']
: ['d/m/Y', 'd-m-Y', 'd.m.Y', 'j/n/Y', 'j-n-Y', 'j.n.Y', ...self::TEXTUAL_FORMATS],
self::AMERICAN => $withTime
? ['m/d/Y H:i:s', 'm-d-Y H:i:s', 'n/j/Y H:i:s', 'm/d/Y H:i', 'n/j/Y H:i']
: ['m/d/Y', 'm-d-Y', 'n/j/Y', 'n-j-Y', ...self::TEXTUAL_FORMATS],
};

return [...$iso, ...$localised];
}
}
77 changes: 77 additions & 0 deletions src/Enums/ImportNumberFormat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Relaticle\CustomFields\Enums;

use Filament\Support\Contracts\HasLabel;

/**
* Decimal separator convention for CSV import parsing.
*
* Only the decimal separator is configurable; thousands separators are stripped.
* Declaring it means `1.234,56` has one meaning instead of two.
*/
enum ImportNumberFormat: string implements HasLabel
{
case POINT = 'point';
case COMMA = 'comma';

public function getLabel(): string
{
return match ($this) {
self::POINT => 'Point',
self::COMMA => 'Comma',
};
}

public function getExample(): string
{
return match ($this) {
self::POINT => '1,234.56',
self::COMMA => '1.234,56',
};
}

/**
* `$stripCurrencySymbol` keeps currency columns accepting `$1,234.56` and
* `1234.56 EUR`, which they accept today. It is not applied to plain number
* columns, which reject anything with non-numeric characters.
*/
public function parse(string $value, bool $stripCurrencySymbol = false): ?float
{
$value = trim($value);

if ($value === '') {
return null;
}

if ($stripCurrencySymbol) {
// Keep the sign, drop a symbol or code on either side: -$5.50, $1,234.56, 1234.56 EUR.
$value = preg_replace('/^([+-]?)[^\d]*/u', '$1', $value) ?? '';
$value = preg_replace('/[^\d]*$/u', '', $value) ?? '';
}

$decimalSeparator = match ($this) {
self::POINT => '.',
self::COMMA => ',',
};

$otherSeparator = match ($this) {
self::POINT => ',',
self::COMMA => '.',
};

$value = str_replace([' ', "\u{00A0}", $otherSeparator], '', $value);

if ($decimalSeparator === ',') {
$value = str_replace(',', '.', $value);
}

if (! is_numeric($value)) {
return null;
}

return (float) $value;
}
}
17 changes: 13 additions & 4 deletions src/FieldTypeSystem/Definitions/CurrencyFieldType.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Utilities\Get;
use NumberFormatter;
use Relaticle\CustomFields\CustomFields;
use Relaticle\CustomFields\Data\Settings\CurrencyFieldSettingsData;
use Relaticle\CustomFields\FieldTypeSystem\BaseFieldType;
use Relaticle\CustomFields\FieldTypeSystem\FieldSchema;
use Relaticle\CustomFields\Filament\Integration\Components\Forms\CurrencyComponent;
use Relaticle\CustomFields\Filament\Integration\Components\Infolists\CurrencyEntry;
use Relaticle\CustomFields\Filament\Integration\Components\Tables\Columns\CurrencyColumn;
use Relaticle\CustomFields\Imports\UnresolvedValue;
use Relaticle\CustomFields\Support\CurrencyProvider;
use Relaticle\CustomFields\Validation\Capabilities\MaxValueCapability;
use Relaticle\CustomFields\Validation\Capabilities\MinValueCapability;
Expand All @@ -40,16 +42,23 @@ public function configure(): FieldSchema
fn (): array => $this->settingsSchema(),
)
->importExample('99.99')
->importTransformer(function (mixed $state): ?float {
->importTransformer(function (mixed $state): float|UnresolvedValue|null {
if (blank($state)) {
return null;
}

if (is_string($state)) {
$state = preg_replace('/[^0-9.-]/', '', $state);
$format = CustomFields::importNumberFormat();
$parsed = $format->parse((string) $state, stripCurrencySymbol: true);

if ($parsed === null) {
return UnresolvedValue::make($state, sprintf(
"'%s' is not a valid amount. Expected format: %s.",
$state,
$format->getExample(),
));
}

return (float) $state;
return $parsed;
})
->exportTransformer(function (mixed $value): ?string {
if ($value === null) {
Expand Down
Loading