Compare commits

...

12 commits
v1.0.1 ... main

Author SHA1 Message Date
Michael
a00d7ef1c9 chore(repo): slim .gitignore and remove dev artifacts from tracking
Cleaning up the public-facing repo:
- /build.sh, /moduldoku.md, /package-lock.json (root) untracked in
  prior commit and now blocked via .gitignore
- .gitignore itself: dropped GSD baseline noise, deduped overlapping
  patterns, added the three newly-untracked dev files

The schneespur/ subdirectory (Laravel app) is what users consume;
root-level dev tooling has no business in the distributed source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:06:15 +00:00
Michael
382ee67868 test: Bootstrapped PHPUnit test infrastructure and wrote 9 FilterRegist…
- schneespur/phpunit.xml
- schneespur/tests/TestCase.php
- schneespur/tests/CreatesApplication.php
- schneespur/tests/Feature/FilterRegistryTest.php

GSD context:
- Milestone: M013 - Hook-Fundament — FilterRegistry, Events, NotificationChannelRegistry, Modul-Plumbing
- Slice: S01
- Task: T04 - Bootstrapped PHPUnit test infrastructure and wrote 9 FilterRegistry Feature tests covering priority ordering, stable sort, error isolation, context passing, value chaining, and two integration tests proving navigation and dashboard hooks are wired

GSD-Task: S01/T04
2026-05-21 13:04:10 +00:00
Michael
182f1b4c98 Release v1.0.5: Bugfix-Welle (Manual-Job-Form, :app_name, Logging, DSGVO-EN, Icons)
Sammelrelease nach Feedback aus der Test-Installation. Sieben unabhängige
Probleme — vom Form-Breaker über Übersetzungs-Lücken bis Log-Hygiene.

- fix(jobs): Einsatz manuell erfassen brach das HTML-Attribut, sobald
  Customer-Namen ein " enthielten (@json in x-data=""). Auf json_encode
  via {{ }} umgestellt (HTML-escaped). Alpine initialisiert wieder,
  Anlegen-Button funktioniert.
- feat(i18n): BrandedTranslator injiziert :app_name automatisch in alle
  __()-Aufrufe. Hilfetexte, Mails, Update-Strings rendern jetzt überall
  korrekt 'Schneespur' bzw. 'Wintertrace' statt rohes :app_name.
- feat(dsgvo): EN-Default-Vorlage default-template-en.blade.php mit
  UK/EU GDPR-Wording (Art. 6(1)(f), ICO-Hinweis, Subject Rights).
  Controller laden locale-aware mit Fallback auf DE. Placeholder-Helper
  kennt DE + EN Tokens.
- ui(settings): Alle 8 Settings-Karten haben jetzt Icons, nicht nur
  Module. Markup auf array-driven Loop entrümpelt.
- chore(modules): Example-Modul boot()-gated via EXAMPLE_MODULE_ENABLED
  env-Var (default false). Aus Release-ZIP komplett entfernt. Bestehende
  Installs mit altem example/-Ordner laden es nicht mehr automatisch.
- chore(logging): ModuleManager Discovery/Boot-Logs auf debug
  runtergesetzt (waren info → fluteten laravel.log bei jedem Request).
  Defaults auf daily-Rotation mit 14d Retention + LOG_LEVEL=warning für
  Production.
- fix(install): Hardcoded deutsche Fehlermeldung im InstallerController
  durch __('install.preflight_has_failures') ersetzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:55:19 +00:00
Michael
ed375efb22 chore: auto-commit after execute-task
GSD-Unit: M013/S01/T03
2026-05-20 14:46:51 +00:00
Michael
ea727985ea chore: auto-commit after execute-task
GSD-Unit: M013/S01/T02
2026-05-20 14:44:33 +00:00
Michael
41878e92ef chore: auto-commit after execute-task
GSD-Unit: M013/S01/T01
2026-05-20 14:42:18 +00:00
Michael
87a84eb0ac Release v1.0.4: self-updater hotfixes (wrapper stripping + counter check)
- Self-updater extractZip: detect and strip a common top-level prefix
  folder so that release ZIPs built with a versioned wrapper (the
  build.sh convention) overlay the live install correctly instead of
  creating a schneespur-X.Y.Z/ subdirectory at the install root.
  The v1.0.3 update on the test environment surfaced this — files were
  copied, state.json was committed to 1.0.3, but the wrapper folder
  meant the live config/app.php and VERSION were never actually
  overwritten. Same defensive prefix-stripping pattern as the module
  installer.

- Counter check ordering: the same-version short-circuit now runs
  BEFORE the strict counter check. Previously, after a successful
  install, the next "prüfen" fetched the same manifest (same counter)
  and the counter check would throw "Rollback-Versuch", masking the
  intended "you're up to date" response. The counter check is still
  enforced for genuinely older manifests — it just doesn't misfire
  when the server keeps serving the same release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:03:12 +00:00
Michael
84c68ee35c Move moduldoku.md to project root
This is an internal authoring doc, not part of the shipped Laravel
application. Lifting it out of the schneespur/ subdirectory keeps it
out of the release build (build.sh copies only the Laravel app
directories) and clarifies the separation between code-that-ships
and meta documentation about the module system.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:20:33 +00:00
Michael
7e1222b022 Release v1.0.3: post-1.0.2 hotfixes (logging, customer objects, version display, module catalog & installer)
- bootstrap/app.php: restore default Laravel exception logging. The
  diagnostic reportable() callback no longer returns false unconditionally
  it only suppresses default reporting when a reporter actually handled the
  exception, so storage/logs/laravel.log shows errors again on fresh installs.

- Customer object creation: fix 500 when notify_recipients is empty (NOT NULL
  violation). Reconcile drift across migration/validation/form/lang: the field
  is now treated consistently as an enum (customer|object|both) matching the
  notification consumers; form uses a <select> instead of free-text input;
  validation tightened via in: rule; coercion in prepareForValidation keeps
  the DB invariant intact when the field is empty or missing.

- config/app.php: version is now read from the VERSION file at runtime.
  The previously hardcoded '1.0.0' caused footer, settings, and dashboard to
  show a stale version after every release. VERSION is now the single source
  of truth for display.

- Module catalog UI: fix render crash (htmlspecialchars on i18n category dict)
  and disappearing modules on 304 Not Modified responses. SchneespurModuleClient
  now has a normalizeModule() adapter that bridges server-side field naming
  (current_version, image_url, i18n category dict) to the internal shape used
  by controller and views. The catalog body is cached in state, so 304
  responses replay the cached catalog instead of falling back to the
  semantically wrong "installed" list.

- Module installer: strip common top-level prefix from module ZIPs to prevent
  modules/<slug>/<slug>/ double-nesting. The installer now detects whether all
  ZIP entries share one wrapper folder and strips it during extraction; flat
  ZIPs continue to work unchanged. Path-traversal validation runs on the
  original entry names before the strip, so the security guarantee is intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:53:20 +00:00
Michael
7e27270626 Restructure: move source into schneespur/ subdirectory, remove vendor/release from tracking
- Root: README.md, LICENSE, INSTALL.de.md, INSTALL.en.md only
- schneespur/: all application source code
- Added .gitignore for vendor/, node_modules/, release/, .env, build artifacts
- Removed vendor/ and release/ from git tracking (15,699 files)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 17:02:37 +00:00
Michael
7288b93500 Release v1.0.2: diagnostic infrastructure core
Add neutral diagnostic framework for future reporting modules:
- DiagnosticReporterInterface, Registry, Manager, PayloadSanitizer
- Laravel exception hook in bootstrap/app.php
- Module permission declarations (requires_permissions in module.json)
- Core diagnostic report points (module boot/install/update failures)
- Module documentation update (moduldoku.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 16:54:11 +00:00
Michael
2c63440ed8 Revert: move code back to project root from schneespur/ subdirectory
- Reverts the schneespur/ subdirectory restructure (b8e426b)
- Restores package.json and vite.config.js (needed for npm build, were
  removed in an earlier cleanup before the restructure)
- Updates public/build/ assets with current Vite output (new content hashes)
2026-05-17 18:24:26 +00:00
7668 changed files with 8802 additions and 1042526 deletions

44
.gitignore vendored Normal file
View file

@ -0,0 +1,44 @@
# ── Dependencies ──
/node_modules/
/vendor/
/schneespur/node_modules/
/schneespur/vendor/
# ── Local env (keep .env.example tracked) ──
.env
.env.*
!.env.example
# ── Laravel runtime ──
/schneespur/storage/logs/*.log
/schneespur/storage/framework/cache/*
/schneespur/storage/framework/sessions/*
/schneespur/storage/framework/views/*
/schneespur/storage/testing/
# ── Build artifacts ──
/schneespur/public/build/
/schneespur/public/hot
/release/
# ── Internal dev files (not part of distributable app) ──
/build.sh
/moduldoku.md
/package-lock.json
# ── Test caches ──
.phpunit.result.cache
.phpunit.cache/
# ── IDE / OS ──
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# ── Local tooling ──
.gsd
.gsd-id
.mcp.json
.bg-shell/

View file

@ -18,9 +18,10 @@ APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_STACK=daily
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_LEVEL=warning
LOG_DAILY_DAYS=14
DB_CONNECTION=mysql
DB_HOST=127.0.0.1

View file

@ -1 +1 @@
1.0.1
1.0.5

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Module;
use App\Services\ModuleManager;
use App\Services\SchneespurModuleClient;
use App\Services\SchneespurModuleInstaller;
use Illuminate\Http\RedirectResponse;
@ -22,12 +23,7 @@ class AdminModuleController extends Controller
try {
$catalog = $client->fetchCatalog();
if ($catalog !== null) {
$catalogModules = $catalog['modules'] ?? [];
} else {
$state = $client->loadState();
$catalogModules = $state['installed'] ?? [];
}
$catalogModules = $catalog['modules'] ?? [];
} catch (\Throwable $e) {
Log::warning('schneespur-modules: catalog fetch failed in admin UI', [
'error' => $e->getMessage(),
@ -58,6 +54,7 @@ class AdminModuleController extends Controller
'download_url' => $catModule['download_url'] ?? null,
'sha256' => $catModule['sha256'] ?? null,
'size_bytes' => $catModule['size_bytes'] ?? null,
'requires_permissions' => $catModule['requires_permissions'] ?? [],
];
}
@ -80,6 +77,7 @@ class AdminModuleController extends Controller
'download_url' => null,
'sha256' => null,
'size_bytes' => null,
'requires_permissions' => $this->resolveLocalPermissions($slug),
];
}
@ -230,6 +228,16 @@ class AdminModuleController extends Controller
->with('success', __('modules.disabled', ['slug' => $slug]));
}
private function resolveLocalPermissions(string $slug): array
{
try {
$manager = app(ModuleManager::class);
return $manager->getPermissions($slug);
} catch (\Throwable) {
return [];
}
}
public function remove(string $slug, SchneespurModuleInstaller $installer): RedirectResponse
{
$module = Module::where('slug', $slug)->first();

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\Extension\DashboardWidgetRegistry;
use App\Services\Extension\FilterRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
@ -17,9 +18,10 @@ class DashboardController extends Controller
return redirect()->route('admin.dashboard');
}
public function index(DashboardWidgetRegistry $widgetRegistry): View
public function index(DashboardWidgetRegistry $widgetRegistry, FilterRegistry $filterRegistry): View
{
$widgets = $widgetRegistry->getWidgets();
$widgets = $filterRegistry->apply('schneespur.dashboard.kpis', $widgets);
return view('admin.dashboard', compact('widgets'));
}

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Controllers\DsgvoOnboardingController;
use App\Models\DsgvoConfirmation;
use App\Models\Setting;
use Illuminate\Http\Request;
@ -18,7 +19,7 @@ class DsgvoAdminController extends Controller
$version = (int) Setting::get('dsgvo_template_version', 1);
if ($markdown === null) {
$markdown = view('dsgvo.default-template')->render();
$markdown = view(DsgvoOnboardingController::resolveDefaultTemplateView())->render();
}
$previewHtml = Str::markdown($this->replacePlaceholders($markdown), ['html_input' => 'strip']);
@ -82,25 +83,7 @@ class DsgvoAdminController extends Controller
private function replacePlaceholders(string $text): string
{
$companyName = Setting::get('company_name', '');
$street = Setting::get('company_street', '');
$zip = Setting::get('company_zip', '');
$city = Setting::get('company_city', '');
$email = Setting::get('company_email', '');
$dpo = Setting::get('dpo_contact', '');
$dpoEmail = Setting::get('dpo_email', '');
$address = trim("$street, $zip $city", ', ');
$replacements = [
'[Firmenname eintragen]' => $companyName ?: '[Firmenname eintragen]',
'[Adresse eintragen]' => $address ?: '[Adresse eintragen]',
'[E-Mail-Adresse eintragen]' => $email ?: '[E-Mail-Adresse eintragen]',
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail ?: '[DPO-E-Mail-Adresse eintragen]',
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo ?: '[Datenschutzbeauftragter / Ansprechpartner eintragen]',
];
return str_replace(array_keys($replacements), array_values($replacements), $text);
return dsgvo_apply_company_placeholders($text);
}
public function showConfirmation(int $id): View

View file

@ -66,32 +66,22 @@ class DsgvoOnboardingController extends Controller
$version = (int) Setting::get('dsgvo_template_version', 1);
if ($text === null) {
$text = view('dsgvo.default-template')->render();
$text = view(self::resolveDefaultTemplateView())->render();
}
return [$text, $version];
}
public static function resolveDefaultTemplateView(): string
{
$locale = app()->getLocale();
$localized = "dsgvo.default-template-{$locale}";
return view()->exists($localized) ? $localized : 'dsgvo.default-template';
}
private function replacePlaceholders(string $text): string
{
$companyName = Setting::get('company_name', '');
$street = Setting::get('company_street', '');
$zip = Setting::get('company_zip', '');
$city = Setting::get('company_city', '');
$email = Setting::get('company_email', '');
$dpo = Setting::get('dpo_contact', '');
$dpoEmail = Setting::get('dpo_email', '');
$address = trim("$street, $zip $city", ', ');
$replacements = [
'[Firmenname eintragen]' => $companyName ?: '[Firmenname eintragen]',
'[Adresse eintragen]' => $address ?: '[Adresse eintragen]',
'[E-Mail-Adresse eintragen]' => $email ?: '[E-Mail-Adresse eintragen]',
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail ?: '[DPO-E-Mail-Adresse eintragen]',
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo ?: '[Datenschutzbeauftragter / Ansprechpartner eintragen]',
];
return str_replace(array_keys($replacements), array_values($replacements), $text);
return dsgvo_apply_company_placeholders($text);
}
}

View file

@ -129,7 +129,7 @@ class InstallerController extends Controller
{
if ($this->preflightChecker->hasCriticalFailures()) {
return redirect()->route('install.preflight')
->withErrors(['preflight' => 'Kritische Voraussetzungen nicht erfüllt.']);
->withErrors(['preflight' => __('install.preflight_has_failures')]);
}
return redirect()->route('install.database');

View file

@ -19,6 +19,14 @@ class StoreCustomerObjectRequest extends FormRequest
'price_amount' => str_replace(',', '.', $this->price_amount),
]);
}
// notify_recipients is a mode enum consumed as customer|object|both
// (see SendJobCompletedNotification / SendCustomerReportEmail).
// Fall back to the DB default when the field is missing or empty
// so the NOT NULL column never receives a null value.
if (! $this->filled('notify_recipients')) {
$this->merge(['notify_recipients' => 'customer']);
}
}
/**
@ -44,7 +52,7 @@ class StoreCustomerObjectRequest extends FormRequest
'lon' => ['nullable', 'numeric', 'between:-180,180'],
'auto_notify_email' => ['boolean'],
'notification_email' => ['nullable', 'required_if:auto_notify_email,1', 'email', 'max:200'],
'notify_recipients' => ['nullable', 'string', 'max:1000'],
'notify_recipients' => ['required', 'in:customer,object,both'],
];
}

View file

@ -4,6 +4,7 @@ namespace App\Listeners;
use App\Events\JobCompleted;
use App\Mail\JobCompletedMail;
use App\Services\Extension\FilterRegistry;
use App\Services\NotificationLogService;
use App\Services\PdfReportService;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -15,6 +16,7 @@ class SendJobCompletedNotification implements ShouldQueue
public function __construct(
private NotificationLogService $notificationLogService,
private PdfReportService $pdfReportService,
private FilterRegistry $filterRegistry,
) {}
public function handle(JobCompleted $event): void
@ -35,6 +37,7 @@ class SendJobCompletedNotification implements ShouldQueue
}
$recipients = $this->resolveRecipients($object, $customer);
$recipients = $this->filterRegistry->apply('schneespur.job.notification.recipients', $recipients, $job);
if (empty($recipients)) {
$this->notificationLogService->logSkipped($job, $notificationType, 'skipped_no_email');

View file

@ -11,13 +11,18 @@ use App\Models\User;
use App\Models\Vehicle;
use App\Policies\JobPolicy;
use App\Services\AlertService;
use App\Services\Diagnostic\DiagnosticManager;
use App\Services\Diagnostic\DiagnosticPayloadSanitizer;
use App\Services\Diagnostic\DiagnosticReporterRegistry;
use App\Services\Extension\DashboardWidgetRegistry;
use App\Services\Extension\FilterRegistry;
use App\Services\Extension\NavigationRegistry;
use App\Services\ForecastService;
use App\Services\ModuleManager;
use App\Services\RetentionService;
use App\Services\SchneespurUpdater;
use App\Services\SeasonService;
use App\Services\Translation\BrandedTranslator;
use App\Services\Weather\BrightSkyProvider;
use App\Services\Weather\MetNorwayProvider;
use App\Services\Weather\OpenMeteoApiProvider;
@ -43,7 +48,11 @@ class AppServiceProvider extends ServiceProvider
{
$this->app->singleton(AlertService::class);
$this->app->singleton(DashboardWidgetRegistry::class);
$this->app->singleton(FilterRegistry::class);
$this->app->singleton(NavigationRegistry::class);
$this->app->singleton(DiagnosticPayloadSanitizer::class);
$this->app->singleton(DiagnosticReporterRegistry::class, fn ($app) => new DiagnosticReporterRegistry($app));
$this->app->singleton(DiagnosticManager::class);
$this->app->singleton(ModuleManager::class, fn ($app) => new ModuleManager($app));
$this->app->singleton(WeatherProviderRegistry::class, function ($app) {
$registry = new WeatherProviderRegistry($app);
@ -58,6 +67,17 @@ class AppServiceProvider extends ServiceProvider
if (empty(config('app.key'))) {
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
}
$this->app->extend('translator', function ($translator, $app) {
if ($translator instanceof BrandedTranslator) {
return $translator;
}
$branded = new BrandedTranslator($translator->getLoader(), $translator->getLocale());
if ($fallback = $translator->getFallback()) {
$branded->setFallback($fallback);
}
return $branded;
});
}
/**

View file

@ -0,0 +1,83 @@
<?php
namespace App\Services\Diagnostic;
use Illuminate\Support\Facades\Log;
class DiagnosticManager
{
private bool $dispatching = false;
public function __construct(
private readonly DiagnosticReporterRegistry $registry,
private readonly DiagnosticPayloadSanitizer $sanitizer,
) {}
public function report(string $type, array $payload = [], array $context = []): void
{
if ($this->dispatching) {
return;
}
$this->dispatching = true;
try {
$sanitizedPayload = $this->sanitizer->sanitize($payload);
$baseContext = $this->sanitizer->buildContext();
$mergedContext = array_merge($baseContext, $this->sanitizer->sanitize($context));
foreach ($this->registry->enabledReporters() as $slug => $reporter) {
try {
$reporter->report($type, $sanitizedPayload, $mergedContext);
} catch (\Throwable $e) {
Log::warning('DiagnosticManager: reporter failed', [
'reporter' => $slug,
'type' => $type,
'error' => $e->getMessage(),
]);
}
}
} finally {
$this->dispatching = false;
}
}
public function reportException(\Throwable $e, array $context = [], bool $includeTrace = true): void
{
$payload = $this->sanitizer->sanitizeException($e, $includeTrace);
$this->report('exception', $payload, $context);
}
public function hasReporters(): bool
{
return count($this->registry->all()) > 0;
}
public function hasEnabledReporters(): bool
{
return count($this->registry->enabledReporters()) > 0;
}
/**
* @return array<string, array{ok: bool, message: string, latency_ms: int}>
*/
public function testAllConnections(): array
{
$results = [];
foreach ($this->registry->enabledReporters() as $slug => $reporter) {
try {
$results[$slug] = $reporter->testConnection();
} catch (\Throwable $e) {
$results[$slug] = [
'ok' => false,
'message' => $e->getMessage(),
'latency_ms' => 0,
];
}
}
return $results;
}
}

View file

@ -0,0 +1,210 @@
<?php
namespace App\Services\Diagnostic;
class DiagnosticPayloadSanitizer
{
private const REDACTED = '[REDACTED]';
private const SENSITIVE_KEYS = [
'password',
'password_confirmation',
'passwort',
'secret',
'token',
'api_key',
'apikey',
'api-key',
'authorization',
'auth',
'cookie',
'cookies',
'session',
'session_id',
'csrf',
'_token',
'credit_card',
'card_number',
'cvv',
'ssn',
'dsn',
];
private const EMAIL_PATTERN = '/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/';
private const IPV4_PATTERN = '/\b(?:\d{1,3}\.){3}\d{1,3}\b/';
public function sanitize(array $payload): array
{
return $this->walkArray($payload);
}
public function sanitizeException(\Throwable $e, bool $includeTrace = false): array
{
$sanitized = [
'class' => get_class($e),
'message' => $this->truncateMessage($e->getMessage()),
'code' => $e->getCode(),
'file' => $this->stripBasePath($e->getFile()),
'line' => $e->getLine(),
];
if ($includeTrace) {
$sanitized['trace'] = $this->sanitizeTrace($e);
}
return $sanitized;
}
public function buildContext(): array
{
$context = [
'schneespur_version' => $this->readVersion(),
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
'environment' => app()->environment(),
'active_modules' => $this->activeModuleSlugs(),
];
if (app()->runningInConsole()) {
$context['channel'] = 'cli';
} else {
$context['channel'] = 'http';
$context['route'] = $this->currentRouteWithoutQuery();
$context['method'] = request()->method();
}
return $context;
}
private function walkArray(array $data, int $depth = 0): array
{
if ($depth > 10) {
return [self::REDACTED];
}
$result = [];
foreach ($data as $key => $value) {
$lowerKey = strtolower((string) $key);
if ($this->isSensitiveKey($lowerKey)) {
$result[$key] = self::REDACTED;
continue;
}
if (is_array($value)) {
$result[$key] = $this->walkArray($value, $depth + 1);
} elseif (is_string($value)) {
$result[$key] = $this->sanitizeString($value, $lowerKey);
} else {
$result[$key] = $value;
}
}
return $result;
}
private function isSensitiveKey(string $key): bool
{
foreach (self::SENSITIVE_KEYS as $sensitive) {
if ($key === $sensitive || str_contains($key, $sensitive)) {
return true;
}
}
return false;
}
private function sanitizeString(string $value, string $key): string
{
if (in_array($key, ['email', 'e-mail', 'mail', 'e_mail'], true)) {
return self::REDACTED;
}
$value = preg_replace(self::EMAIL_PATTERN, self::REDACTED, $value);
$value = preg_replace(self::IPV4_PATTERN, self::REDACTED, $value);
return $value;
}
private function truncateMessage(string $message, int $maxLength = 500): string
{
$message = preg_replace(self::EMAIL_PATTERN, self::REDACTED, $message);
$message = preg_replace(self::IPV4_PATTERN, self::REDACTED, $message);
if (mb_strlen($message) > $maxLength) {
return mb_substr($message, 0, $maxLength) . '...';
}
return $message;
}
private function sanitizeTrace(\Throwable $e): array
{
$frames = [];
foreach (array_slice($e->getTrace(), 0, 30) as $frame) {
$frames[] = [
'file' => isset($frame['file']) ? $this->stripBasePath($frame['file']) : null,
'line' => $frame['line'] ?? null,
'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''),
];
}
return $frames;
}
private function stripBasePath(string $path): string
{
$base = base_path() . '/';
return str_starts_with($path, $base)
? substr($path, strlen($base))
: $path;
}
private function currentRouteWithoutQuery(): ?string
{
try {
$route = request()->route();
if ($route) {
return $route->uri();
}
return parse_url(request()->url(), PHP_URL_PATH);
} catch (\Throwable) {
return null;
}
}
private function readVersion(): string
{
try {
$path = base_path('VERSION');
if (file_exists($path)) {
return trim(file_get_contents($path));
}
} catch (\Throwable) {
}
return 'unknown';
}
private function activeModuleSlugs(): array
{
try {
$manager = app(\App\Services\ModuleManager::class);
$slugs = [];
foreach ($manager->getAll() as $slug => $manifest) {
if ($manager->isEnabled($slug)) {
$slugs[] = $slug;
}
}
return $slugs;
} catch (\Throwable) {
return [];
}
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Services\Diagnostic;
interface DiagnosticReporterInterface
{
/**
* Report a diagnostic event to this reporter.
*
* @param string $type Event type, e.g. 'exception', 'cron_failed', 'module_boot_failed'
* @param array $payload Sanitized event data
* @param array $context Additional context (route, user role, schneespur version, etc.)
*/
public function report(string $type, array $payload = [], array $context = []): void;
public function isEnabled(): bool;
/**
* @return array{ok: bool, message: string, latency_ms: int}
*/
public function testConnection(): array;
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Services\Diagnostic;
use App\Services\Extension\ExtensionRegistry;
use Illuminate\Contracts\Container\Container;
class DiagnosticReporterRegistry extends ExtensionRegistry
{
public function __construct(
private readonly Container $container,
) {}
/**
* @param class-string<DiagnosticReporterInterface> $class
*/
public function register(string $slug, mixed $class): void
{
parent::register($slug, $class);
}
public function resolve(?string $slug = null): ?DiagnosticReporterInterface
{
if ($slug === null || ! $this->has($slug)) {
return null;
}
return $this->container->make($this->items[$slug]);
}
/**
* @return array<string, DiagnosticReporterInterface>
*/
public function enabledReporters(): array
{
$enabled = [];
foreach ($this->all() as $slug => $class) {
try {
$reporter = $this->container->make($class);
if ($reporter->isEnabled()) {
$enabled[$slug] = $reporter;
}
} catch (\Throwable) {
// Skip reporters that fail to instantiate
}
}
return $enabled;
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Services\Extension;
use Illuminate\Support\Facades\Log;
class FilterRegistry
{
private array $hooks = [];
private int $insertionCounter = 0;
public function register(string $hook, callable $callback, int $priority = 100): void
{
$this->hooks[$hook][] = [$priority, $this->insertionCounter++, $callback];
}
public function apply(string $hook, mixed $value, mixed ...$context): mixed
{
if (empty($this->hooks[$hook])) {
return $value;
}
$callbacks = $this->hooks[$hook];
usort($callbacks, fn (array $a, array $b) => $a[0] <=> $b[0] ?: $a[1] <=> $b[1]);
foreach ($callbacks as $entry) {
$previousValue = $value;
try {
$value = $entry[2]($value, ...$context);
} catch (\Throwable $e) {
Log::warning('FilterRegistry: callback failed', [
'hook' => $hook,
'index' => $entry[1],
'error' => $e->getMessage(),
]);
$value = $previousValue;
}
}
return $value;
}
}

View file

@ -8,6 +8,10 @@ class NavigationRegistry extends ExtensionRegistry
{
protected array $groups = [];
public function __construct(
private readonly FilterRegistry $filterRegistry,
) {}
public function addGroup(string $key, string $label, int $order = 100): void
{
$this->groups[$key] = ['key' => $key, 'label' => $label, 'order' => $order];
@ -73,6 +77,6 @@ class NavigationRegistry extends ExtensionRegistry
usort($groupItems, fn (array $a, array $b) => $a['order'] <=> $b['order']);
}
return $grouped;
return $this->filterRegistry->apply('schneespur.navigation.items', $grouped);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services;
use App\Services\Diagnostic\DiagnosticManager;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
@ -55,7 +56,7 @@ class ModuleManager
$this->modules[$slug] = $manifest;
Log::info('ModuleManager: module discovered', [
Log::debug('ModuleManager: module discovered', [
'slug' => $slug,
'version' => $manifest['version'] ?? 'unknown',
]);
@ -92,6 +93,13 @@ class ModuleManager
continue;
}
// Reference example module — opt-in only via env var. Ensures the
// bundled dev demo never auto-loads on customer installs even if the
// old folder is still present after upgrading from older releases.
if ($slug === 'example' && ! env('EXAMPLE_MODULE_ENABLED', false)) {
continue;
}
$namespace = $manifest['namespace'] ?? null;
$srcPath = ($manifest['path'] ?? '') . '/src';
@ -127,7 +135,7 @@ class ModuleManager
$provider->register();
$provider->boot();
Log::info('ModuleManager: module booted', [
Log::debug('ModuleManager: module booted', [
'slug' => $slug,
'version' => $manifest['version'] ?? 'unknown',
]);
@ -138,6 +146,7 @@ class ModuleManager
'trace' => $e->getTraceAsString(),
]);
$this->autoDisable($slug, $e->getMessage());
$this->reportDiagnostic('module_boot_failed', $slug, $e);
}
}
}
@ -163,7 +172,7 @@ class ModuleManager
$this->disabledModules[] = $slug;
}
Log::info('ModuleManager: module disabled', ['slug' => $slug]);
Log::debug('ModuleManager: module disabled', ['slug' => $slug]);
return true;
}
@ -188,6 +197,13 @@ class ModuleManager
return $this->disabledModules;
}
public function getPermissions(string $slug): array
{
$manifest = $this->getManifest($slug);
return $manifest['requires_permissions'] ?? [];
}
protected function autoDisable(string $slug, string $reason): void
{
if (! in_array($slug, $this->disabledModules, true)) {
@ -199,4 +215,20 @@ class ModuleManager
'reason' => $reason,
]);
}
private function reportDiagnostic(string $type, string $slug, \Throwable $e): void
{
try {
$manager = app(DiagnosticManager::class);
$manager->report($type, [
'module_slug' => $slug,
'class' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
} catch (\Throwable) {
// Never let diagnostic reporting interfere with module management
}
}
}

View file

@ -28,19 +28,24 @@ class SchneespurModuleClient
/**
* Fetch the module catalog from the server.
*
* Returns parsed catalog array on 200, null on 304 (not modified).
* Throws on HTTP error.
* Returns normalized catalog array. On 304 returns the cached normalized
* catalog from state (or re-fetches without If-None-Match if no cache).
* Returns null only when there is no cache and the server-side state
* cannot be reconstructed. Throws on HTTP error.
*/
public function fetchCatalog(): ?array
{
$state = $this->loadState();
$etag = $state['catalog_etag'] ?? null;
$state = $this->loadState();
$etag = $state['catalog_etag'] ?? null;
$cached = $state['catalog_cache'] ?? null;
$url = $this->serverUrl . str_replace('{slug}', $this->collectionSlug, $this->catalogEndpoint);
$request = Http::acceptJson()->timeout($this->timeout);
if ($etag) {
// Only send If-None-Match when we actually have a cached body to fall
// back on — otherwise a 304 leaves us with nothing to display.
if ($etag && $cached !== null) {
$request = $request->withHeaders(['If-None-Match' => $etag]);
}
@ -51,7 +56,7 @@ class SchneespurModuleClient
$state['synced_at'] = now()->toIso8601String();
$this->writeState($state);
return null;
return $cached;
}
if ($response->status() === 404) {
@ -69,14 +74,20 @@ class SchneespurModuleClient
throw new RuntimeException("Katalog-Fetch fehlgeschlagen: HTTP {$response->status()}");
}
$catalog = $response->json();
if (! is_array($catalog) || ! array_key_exists('modules', $catalog)) {
$raw = $response->json();
if (! is_array($raw) || ! array_key_exists('modules', $raw)) {
throw new RuntimeException('Katalog-Response hat unerwartete Form');
}
$catalog = [
'collection' => $raw['collection'] ?? null,
'modules' => array_map(fn ($m) => $this->normalizeModule($m), $raw['modules']),
];
$newEtag = $response->header('ETag');
$state['catalog_etag'] = $newEtag ?: ($state['catalog_etag'] ?? null);
$state['synced_at'] = now()->toIso8601String();
$state['catalog_etag'] = $newEtag ?: ($state['catalog_etag'] ?? null);
$state['catalog_cache'] = $catalog;
$state['synced_at'] = now()->toIso8601String();
$this->writeState($state);
$moduleCount = count($catalog['modules']);
@ -85,6 +96,36 @@ class SchneespurModuleClient
return $catalog;
}
/**
* Map a server-side module entry to the internal shape expected by the
* admin controller and views. The server may evolve its field names
* independently this is the single place where that drift is bridged.
*/
private function normalizeModule(array $raw): array
{
$appLocale = app()->getLocale();
$primary = $raw['primary_locale'] ?? $appLocale;
$category = $raw['category'] ?? null;
if (is_array($category)) {
$category = self::i18nPick($category, $primary);
}
return [
'slug' => $raw['slug'] ?? null,
'name' => $raw['name'] ?? [],
'description' => $raw['description'] ?? [],
'version' => $raw['current_version'] ?? $raw['version'] ?? null,
'category' => $category,
'image' => $raw['image_url'] ?? $raw['image'] ?? null,
'download_url' => $raw['download_url'] ?? null,
'sha256' => $raw['sha256'] ?? null,
'size_bytes' => $raw['size_bytes'] ?? null,
'requires_permissions' => $raw['requires_permissions'] ?? [],
'primary_locale' => $primary,
];
}
/**
* Download a module ZIP, verify size and SHA256.
*
@ -175,10 +216,11 @@ class SchneespurModuleClient
{
if (! is_file($this->stateFilePath)) {
return [
'catalog_etag' => null,
'synced_at' => null,
'installed' => [],
'orphans' => [],
'catalog_etag' => null,
'catalog_cache' => null,
'synced_at' => null,
'installed' => [],
'orphans' => [],
];
}
@ -193,10 +235,11 @@ class SchneespurModuleClient
}
return [
'catalog_etag' => $parsed['catalog_etag'] ?? null,
'synced_at' => $parsed['synced_at'] ?? null,
'installed' => $parsed['installed'] ?? [],
'orphans' => $parsed['orphans'] ?? [],
'catalog_etag' => $parsed['catalog_etag'] ?? null,
'catalog_cache' => $parsed['catalog_cache'] ?? null,
'synced_at' => $parsed['synced_at'] ?? null,
'installed' => $parsed['installed'] ?? [],
'orphans' => $parsed['orphans'] ?? [],
];
}

View file

@ -2,6 +2,7 @@
namespace App\Services;
use App\Services\Diagnostic\DiagnosticManager;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RuntimeException;
@ -28,7 +29,13 @@ class SchneespurModuleInstaller
return false;
}
return $this->extractZip($zipPath, $targetDir, $slug);
$result = $this->extractZip($zipPath, $targetDir, $slug);
if (! $result) {
$this->reportDiagnostic('module_install_failed', $slug, 'ZIP extraction failed');
}
return $result;
}
public function update(string $zipPath, string $slug): bool
@ -56,6 +63,7 @@ class SchneespurModuleInstaller
}
Log::error('schneespur-modules: update failed, triggering rollback', ['slug' => $slug]);
$this->reportDiagnostic('module_update_failed', $slug, 'ZIP extraction failed, rollback triggered');
$this->rollback($slug);
return false;
}
@ -116,7 +124,52 @@ class SchneespurModuleInstaller
}
File::ensureDirectoryExists($targetDir, 0755);
$zip->extractTo($targetDir);
$prefix = $this->detectCommonPrefix($zip);
if ($prefix === null) {
$zip->extractTo($targetDir);
} else {
Log::info('schneespur-modules: stripping common prefix', [
'slug' => $slug,
'prefix' => $prefix,
]);
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if ($entry === false || $entry === '' || $entry === $prefix) {
continue;
}
if (str_starts_with($entry, '__MACOSX/')) {
continue;
}
$relative = substr($entry, strlen($prefix));
if ($relative === '') {
continue;
}
$dest = $targetDir . '/' . $relative;
if (str_ends_with($entry, '/')) {
File::ensureDirectoryExists($dest, 0755);
continue;
}
File::ensureDirectoryExists(dirname($dest), 0755);
$contents = $zip->getFromIndex($i);
if ($contents === false || file_put_contents($dest, $contents) === false) {
Log::error('schneespur-modules: extract failed', [
'slug' => $slug,
'entry' => $entry,
]);
$zip->close();
return false;
}
}
}
$zip->close();
Log::info('schneespur-modules: unpack complete', ['slug' => $slug]);
@ -124,6 +177,41 @@ class SchneespurModuleInstaller
return true;
}
/**
* Detect whether all (non-metadata) ZIP entries share one common
* top-level folder. Returns the prefix (incl. trailing slash) when so,
* null when the ZIP is flat or has mixed top-level entries.
*/
private function detectCommonPrefix(ZipArchive $zip): ?string
{
$prefix = null;
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if ($entry === false || $entry === '') {
continue;
}
if (str_starts_with($entry, '__MACOSX/')) {
continue;
}
$slash = strpos($entry, '/');
if ($slash === false) {
return null;
}
$top = substr($entry, 0, $slash + 1);
if ($prefix === null) {
$prefix = $top;
} elseif ($prefix !== $top) {
return null;
}
}
return $prefix;
}
private function validateZipEntries(ZipArchive $zip, string $slug): bool
{
for ($i = 0; $i < $zip->numFiles; $i++) {
@ -161,4 +249,15 @@ class SchneespurModuleInstaller
{
return $this->modulesPath . '/' . $slug . '.bak';
}
private function reportDiagnostic(string $type, string $slug, string $reason): void
{
try {
app(DiagnosticManager::class)->report($type, [
'module_slug' => $slug,
'reason' => $reason,
]);
} catch (\Throwable) {
}
}
}

View file

@ -170,6 +170,18 @@ class SchneespurUpdater
throw new RuntimeException('Signatur ungültig — MITM oder beschädigt');
}
// Same-version short-circuit first: if the server still serves the
// currently installed release (same counter, same version), this is
// not a rollback — just "you're up to date". The counter check below
// would otherwise misfire after every successful install, because
// the next manifest fetch carries the exact same counter that was
// committed during install.
if ($manifest['version'] === ($state['current_version'] ?? '')) {
$this->writeLastCheck($state, false);
return null;
}
$newCounter = (int) $manifest['counter'];
if ($newCounter <= (int) ($state['last_counter'] ?? 0)) {
throw new RuntimeException(
@ -177,12 +189,6 @@ class SchneespurUpdater
);
}
if ($manifest['version'] === ($state['current_version'] ?? '')) {
$this->writeLastCheck($state, false);
return null;
}
$this->writeLastCheck($state, true, $manifest);
return $manifest;
@ -279,7 +285,44 @@ class SchneespurUpdater
$this->validateZipEntries($zip);
$zip->extractTo($this->stagingDir);
$prefix = $this->detectCommonPrefix($zip);
if ($prefix === null) {
$zip->extractTo($this->stagingDir);
} else {
$this->logPhase('extract', 'stripping_prefix', ['prefix' => $prefix]);
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if ($entry === false || $entry === '' || $entry === $prefix) {
continue;
}
if (str_starts_with($entry, '__MACOSX/')) {
continue;
}
$relative = substr($entry, strlen($prefix));
if ($relative === '') {
continue;
}
$dest = $this->stagingDir . '/' . $relative;
if (str_ends_with($entry, '/')) {
$this->ensureDirectory($dest);
continue;
}
$this->ensureDirectory(dirname($dest));
$contents = $zip->getFromIndex($i);
if ($contents === false || file_put_contents($dest, $contents) === false) {
$zip->close();
throw new RuntimeException("ZIP-Extraktion fehlgeschlagen: {$entry}");
}
}
}
$zip->close();
$this->logPhase('extract', 'complete', ['files' => $this->countFiles($this->stagingDir)]);
@ -287,6 +330,46 @@ class SchneespurUpdater
return $this->stagingDir;
}
/**
* Detect whether all (non-metadata) ZIP entries share one common
* top-level folder. Returns the prefix (incl. trailing slash) when so,
* null when the ZIP is flat or has mixed top-level entries.
*
* Mirrors the logic in SchneespurModuleInstaller so update ZIPs that
* wrap their content in a versioned folder (the build.sh convention)
* are unwrapped during extraction instead of leaving a stray
* schneespur-X.Y.Z/ subdirectory in the live install.
*/
private function detectCommonPrefix(ZipArchive $zip): ?string
{
$prefix = null;
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
if ($entry === false || $entry === '') {
continue;
}
if (str_starts_with($entry, '__MACOSX/')) {
continue;
}
$slash = strpos($entry, '/');
if ($slash === false) {
return null;
}
$top = substr($entry, 0, $slash + 1);
if ($prefix === null) {
$prefix = $top;
} elseif ($prefix !== $top) {
return null;
}
}
return $prefix;
}
private function validateZipEntries(ZipArchive $zip): void
{
$resolvedStaging = realpath($this->stagingDir) ?: $this->stagingDir;

View file

@ -0,0 +1,26 @@
<?php
namespace App\Services\Translation;
use Illuminate\Translation\Translator;
class BrandedTranslator extends Translator
{
public function get($key, array $replace = [], $locale = null, $fallback = true)
{
if (! array_key_exists('app_name', $replace)) {
$replace['app_name'] = $this->resolveAppName();
}
return parent::get($key, $replace, $locale, $fallback);
}
private function resolveAppName(): string
{
try {
return brand();
} catch (\Throwable) {
return (string) config('app.name', 'Schneespur');
}
}
}

View file

@ -23,3 +23,46 @@ function brand(): string
default => 'Schneespur',
};
}
/**
* Replace company/DPO placeholders in DSGVO/GDPR template markdown.
*
* Knows both German and English placeholder strings so the same Settings
* values feed into either template language. Missing settings leave the
* placeholder visible so admins notice gaps.
*/
function dsgvo_apply_company_placeholders(string $text): string
{
$companyName = Setting::get('company_name', '');
$street = Setting::get('company_street', '');
$zip = Setting::get('company_zip', '');
$city = Setting::get('company_city', '');
$email = Setting::get('company_email', '');
$dpo = Setting::get('dpo_contact', '');
$dpoEmail = Setting::get('dpo_email', '');
$address = trim("$street, $zip $city", ', ');
$map = [
// German placeholders (default-template.blade.php)
'[Firmenname eintragen]' => $companyName,
'[Adresse eintragen]' => $address,
'[E-Mail-Adresse eintragen]' => $email,
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail,
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo,
// English placeholders (default-template-en.blade.php)
'[Company name]' => $companyName,
'[Address]' => $address,
'[Email]' => $email,
'[DPO email]' => $dpoEmail,
'[Data Protection Officer / Contact]' => $dpo,
];
foreach ($map as $token => $value) {
if ($value !== '') {
$text = str_replace($token, $value, $text);
}
}
return $text;
}

View file

@ -8,6 +8,7 @@ use App\Http\Middleware\EnsureDsgvoInformed;
use App\Http\Middleware\InstallerGuard;
use App\Http\Middleware\RedirectToInstaller;
use App\Http\Middleware\SetInstallerLocale;
use App\Services\Diagnostic\DiagnosticManager;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Console\Scheduling\Schedule;
@ -71,5 +72,18 @@ return Application::configure(basePath: dirname(__DIR__))
$schedule->call(fn () => cache()->put('cron.last_run', now()))->everyMinute();
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->reportable(function (\Throwable $e) {
$reported = false;
try {
$manager = app(DiagnosticManager::class);
if ($manager->hasEnabledReporters()) {
$manager->reportException($e);
$reported = true;
}
} catch (\Throwable) {
// Never let diagnostic reporting break the application
}
return $reported ? false : null;
});
})->create();

View file

@ -15,7 +15,7 @@ return [
'name' => env('APP_NAME', 'Schneespur'),
'version' => '1.0.0',
'version' => trim((string) @file_get_contents(dirname(__DIR__).'/VERSION')) ?: '1.0.0',
/*
|--------------------------------------------------------------------------

View file

@ -54,21 +54,21 @@ return [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'channels' => explode(',', (string) env('LOG_STACK', 'daily')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'level' => env('LOG_LEVEL', 'warning'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'level' => env('LOG_LEVEL', 'warning'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],

View file

@ -31,7 +31,10 @@ return [
'field_access_notes' => 'Zufahrt / Zugang',
'field_auto_notify' => 'Automatische Benachrichtigung per E-Mail',
'field_notification_email' => 'Benachrichtigungs-E-Mail',
'field_notify_recipients' => 'Weitere Empfänger (kommagetrennt)',
'field_notify_recipients' => 'Benachrichtigungsempfänger',
'notify_recipients_customer' => 'Nur Kunde',
'notify_recipients_object' => 'Nur Objekt-Kontakt',
'notify_recipients_both' => 'Kunde und Objekt-Kontakt',
'price_unit_per_job' => 'Pro Einsatz',
'price_unit_monthly' => 'Monatlich',
'price_unit_seasonal' => 'Saisonal',

View file

@ -45,4 +45,6 @@ return [
'update_failed' => 'Aktualisierung von ":slug" fehlgeschlagen: :error',
'directory_exists' => 'Modulverzeichnis existiert bereits.',
'extraction_failed' => 'ZIP-Entpacken fehlgeschlagen.',
'permission_tooltip' => 'Dieses Modul benötigt diese Berechtigung.',
];

View file

@ -31,7 +31,10 @@ return [
'field_access_notes' => 'Access / Entrance',
'field_auto_notify' => 'Automatic email notification',
'field_notification_email' => 'Notification Email',
'field_notify_recipients' => 'Additional recipients (comma-separated)',
'field_notify_recipients' => 'Notification recipients',
'notify_recipients_customer' => 'Customer only',
'notify_recipients_object' => 'Object contact only',
'notify_recipients_both' => 'Customer and object contact',
'price_unit_per_job' => 'Per Job',
'price_unit_monthly' => 'Monthly',
'price_unit_seasonal' => 'Seasonal',

View file

@ -45,4 +45,6 @@ return [
'update_failed' => 'Update of ":slug" failed: :error',
'directory_exists' => 'Module directory already exists.',
'extraction_failed' => 'ZIP extraction failed.',
'permission_tooltip' => 'This module requires this permission.',
];

View file

@ -3,6 +3,8 @@
"version": "1.0.0",
"namespace": "Schneespur\\Module\\Example",
"service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
"description": "Reference module demonstrating all extension points (nav, widget, event, settings, route).",
"min_schneespur_version": "1.0.0"
"description": "Reference module demonstrating all extension points (nav, widget, event, settings, route). Dev-only — opt in via EXAMPLE_MODULE_ENABLED=true in .env.",
"min_schneespur_version": "1.0.0",
"requires_permissions": [],
"default_enabled": false
}

View file

@ -4,6 +4,7 @@ namespace Schneespur\Module\Example;
use App\Events\JobCompleted;
use App\Services\Extension\DashboardWidgetRegistry;
use App\Services\Extension\FilterRegistry;
use App\Services\Extension\NavigationRegistry;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
@ -18,14 +19,28 @@ class ExampleServiceProvider extends ServiceProvider
public function boot(): void
{
if (! $this->shouldBoot()) {
return;
}
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'example-module');
$this->registerNavigation();
$this->registerWidget();
$this->registerFilters();
$this->registerEventListeners();
$this->registerRoutes();
}
/**
* Reference module only loads when explicitly enabled.
* Devs can enable for local exploration via .env: EXAMPLE_MODULE_ENABLED=true
*/
protected function shouldBoot(): bool
{
return (bool) env('EXAMPLE_MODULE_ENABLED', false);
}
protected function registerNavigation(): void
{
$nav = $this->app->make(NavigationRegistry::class);
@ -52,6 +67,49 @@ class ExampleServiceProvider extends ServiceProvider
]);
}
protected function registerFilters(): void
{
$filters = $this->app->make(FilterRegistry::class);
$filters->register('schneespur.navigation.items', function (array $grouped): array {
$grouped['modules'][] = [
'group' => 'modules',
'slug' => 'example-filter',
'label' => 'Example Filter',
'route' => 'admin.example.settings',
'icon' => 'heroicon-o-funnel',
'order' => 250,
'permission' => null,
'route_check' => null,
'active_pattern' => 'admin.example.settings',
'badge' => null,
];
return $grouped;
}, 150);
$filters->register('schneespur.dashboard.kpis', function (array $widgets): array {
$widgets[] = [
'key' => 'example-filter-widget',
'label' => 'Filter Demo',
'view' => 'example-module::widgets.example-card',
'order' => 250,
'size' => 'half',
];
return $widgets;
}, 150);
$filters->register('schneespur.job.notification.recipients', function (array $recipients, $job): array {
Log::info('ExampleModule: notification recipients filter', [
'job_id' => $job->id ?? null,
'recipient_count' => count($recipients),
]);
return $recipients;
}, 150);
}
protected function registerEventListeners(): void
{
$this->app['events']->listen(JobCompleted::class, function (JobCompleted $event) {

7283
schneespur/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
schneespur/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"alpinejs": "^3.4.2",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.2.4",
"vite": "^7.0.7",
"vite-plugin-pwa": "^1.2.0"
},
"dependencies": {
"idb": "^8.0.3",
"leaflet": "^1.9.4",
"qrcode": "^1.5.4"
}
}

27
schneespur/phpunit.xml Normal file
View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:dGVzdGluZ2tleXRlc3RpbmdrZXl0ZXN0aW5na2V5cw=="/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
<env name="CACHE_STORE" value="array"/>
</php>
</phpunit>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -12,7 +12,7 @@
"src": "node_modules/leaflet/dist/images/marker-icon.png"
},
"resources/css/app.css": {
"file": "assets/app-CPqZi6LM.css",
"file": "assets/app-Gkl9XGUK.css",
"src": "resources/css/app.css",
"isEntry": true,
"name": "app",
@ -21,7 +21,7 @@
]
},
"resources/js/app.js": {
"file": "assets/app-Bwe1Adxb.js",
"file": "assets/app-GNqTWY09.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true

View file

@ -0,0 +1 @@
{"name":"Schneespur","short_name":"Schneespur","start_url":"/driver","display":"standalone","background_color":"#0f172a","theme_color":"#1e293b","lang":"en","scope":"/","icons":[{"src":"/pwa-icon-192x192.png","sizes":"192x192","type":"image/png","purpose":"any"},{"src":"/pwa-icon-192x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},{"src":"/pwa-icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"any"},{"src":"/pwa-icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}]}

View file

@ -1 +1 @@
if(!self.define){let e,s={};const r=(r,n)=>(r=new URL(r+".js",n).href,s[r]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=r,e.onload=s,document.head.appendChild(e)}else e=r,importScripts(r),s()}).then(()=>{let e=s[r];if(!e)throw new Error(`Module ${r} didnt register its module`);return e}));self.define=(n,i)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let t={};const l=e=>r(e,o),c={module:{uri:o},exports:t,require:l};s[o]=Promise.all(n.map(e=>c[e]||l(e))).then(e=>(i(...e),t))}}define(["./workbox-466e78f2"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"2d094791c49e920331981a2d203b8cdb"},{url:"assets/marker-icon-hN30_KVU.png",revision:null},{url:"assets/layers-BWBAp2CZ.png",revision:null},{url:"assets/layers-2x-Bpkbi35X.png",revision:null},{url:"assets/app-CPqZi6LM.css",revision:null},{url:"assets/app-Bwe1Adxb.js",revision:null}],{}),e.cleanupOutdatedCaches(),e.registerRoute(/\/(login|register|forgot-password|reset-password|verify-email|confirm-password)/,new e.NetworkOnly,"GET"),e.registerRoute(/\.(?:woff2?|ttf|eot|otf)$/,new e.CacheFirst({cacheName:"fonts-cache",plugins:[new e.ExpirationPlugin({maxEntries:30,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/\/driver\/.*/,new e.NetworkFirst({cacheName:"driver-pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:86400})]}),"GET")});
if(!self.define){let e,s={};const n=(n,r)=>(n=new URL(n+".js",r).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(r,i)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let o={};const l=e=>n(e,t),c={module:{uri:t},exports:o,require:l};s[t]=Promise.all(r.map(e=>c[e]||l(e))).then(e=>(i(...e),o))}}define(["./workbox-d7f7d914"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"2d094791c49e920331981a2d203b8cdb"},{url:"assets/marker-icon-hN30_KVU.png",revision:null},{url:"assets/layers-BWBAp2CZ.png",revision:null},{url:"assets/layers-2x-Bpkbi35X.png",revision:null},{url:"assets/app-Gkl9XGUK.css",revision:null},{url:"assets/app-GNqTWY09.js",revision:null},{url:"manifest.webmanifest",revision:"d9cbc35793758c64f87f24da203c23b4"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(/\.(?:woff2?|ttf|eot|otf)$/,new e.CacheFirst({cacheName:"fonts-cache",plugins:[new e.ExpirationPlugin({maxEntries:30,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/\/driver\/.*/,new e.NetworkFirst({cacheName:"driver-pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:86400})]}),"GET")});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -206,7 +206,12 @@
<div x-show="autoNotify" x-transition>
<x-input-label for="notify_recipients" :value="__('customer_object.field_notify_recipients')" />
<x-text-input id="notify_recipients" name="notify_recipients" type="text" class="mt-1 block w-full" :value="old('notify_recipients', $object->notify_recipients ?? '')" />
@php($notifyRecipients = old('notify_recipients', $object->notify_recipients ?? 'customer'))
<select id="notify_recipients" name="notify_recipients" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="customer" @selected($notifyRecipients === 'customer')>{{ __('customer_object.notify_recipients_customer') }}</option>
<option value="object" @selected($notifyRecipients === 'object')>{{ __('customer_object.notify_recipients_object') }}</option>
<option value="both" @selected($notifyRecipients === 'both')>{{ __('customer_object.notify_recipients_both') }}</option>
</select>
<x-input-error :messages="$errors->get('notify_recipients')" class="mt-2" />
</div>
</div>

View file

@ -22,9 +22,9 @@
@endphp
<form method="POST" action="{{ route('admin.jobs.manual.store') }}" class="mt-6"
x-data="{
selectedCustomerId: {{ old('customer_id', 'null') }},
selectedObjectId: {{ old('customer_object_id', 'null') }},
allObjects: @json($allObjects),
selectedCustomerId: {{ old('customer_id') ? (int) old('customer_id') : 'null' }},
selectedObjectId: {{ old('customer_object_id') ? (int) old('customer_object_id') : 'null' }},
allObjects: {{ json_encode($allObjects) }},
get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); },
onCustomerChange() {
const objs = this.objects;

View file

@ -1,47 +1,74 @@
<x-admin-layout>
<x-slot name="header">{{ __('admin.page_settings') }} <x-help-icon topic="settings" /></x-slot>
@php
$cards = [
[
'route' => 'admin.settings.branding',
'title' => __('ui.branding_title'),
'desc' => __('ui.branding_description'),
'icon' => 'M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42',
],
[
'route' => 'admin.settings.email',
'title' => __('notification.settings_card_email'),
'desc' => __('notification.settings_card_email_desc'),
'icon' => 'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
],
[
'route' => 'admin.settings.notification-log',
'title' => __('notification.settings_card_log'),
'desc' => __('notification.settings_card_log_desc'),
'icon' => 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z',
],
[
'route' => 'admin.settings.company',
'title' => __('settings.company_title'),
'desc' => __('settings.company_description'),
'icon' => 'M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z',
],
[
'route' => 'admin.settings.retention',
'title' => __('settings.retention_title'),
'desc' => __('settings.retention_description'),
'icon' => 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
],
[
'route' => 'admin.settings.weather',
'title' => __('weather.settings_title'),
'desc' => __('weather.settings_description'),
'icon' => 'M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z',
],
[
'route' => 'admin.settings.update',
'title' => __('update.settings_title'),
'desc' => __('update.settings_description'),
'icon' => 'M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99',
],
[
'route' => 'admin.settings.modules.index',
'title' => __('modules.settings_card_title'),
'desc' => __('modules.settings_card_description'),
'icon' => 'm21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9',
],
];
@endphp
<div class="max-w-2xl space-y-4">
<a href="{{ route('admin.settings.branding') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('ui.branding_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('ui.branding_description') }}</p>
</a>
<a href="{{ route('admin.settings.email') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('notification.settings_card_email') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('notification.settings_card_email_desc') }}</p>
</a>
<a href="{{ route('admin.settings.notification-log') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('notification.settings_card_log') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('notification.settings_card_log_desc') }}</p>
</a>
<a href="{{ route('admin.settings.company') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('settings.company_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('settings.company_description') }}</p>
</a>
<a href="{{ route('admin.settings.retention') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('settings.retention_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('settings.retention_description') }}</p>
</a>
<a href="{{ route('admin.settings.weather') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('weather.settings_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('weather.settings_description') }}</p>
</a>
<a href="{{ route('admin.settings.update') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<h3 class="text-sm font-medium text-gray-900">{{ __('update.settings_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('update.settings_description') }}</p>
</a>
<a href="{{ route('admin.settings.modules.index') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
@foreach ($cards as $card)
<a href="{{ route($card['route']) }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $card['icon'] }}" />
</svg>
</div>
<div>
<h3 class="text-sm font-medium text-gray-900">{{ $card['title'] }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ $card['desc'] }}</p>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-gray-900">{{ __('modules.settings_card_title') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('modules.settings_card_description') }}</p>
</div>
</div>
</a>
</a>
@endforeach
</div>
</x-admin-layout>

View file

@ -84,6 +84,18 @@
</span>
@endif
</div>
@if(!empty($module['requires_permissions']))
<div class="mt-2 flex flex-wrap gap-1">
@foreach($module['requires_permissions'] as $permission)
<span class="inline-flex items-center rounded bg-purple-50 px-1.5 py-0.5 text-xs text-purple-600" title="{{ __('modules.permission_tooltip') }}">
<svg class="mr-0.5 h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
{{ $permission }}
</span>
@endforeach
</div>
@endif
</div>
{{-- Action Buttons --}}
@ -165,6 +177,18 @@
</span>
@endif
</div>
@if(!empty($module['requires_permissions']))
<div class="mt-2 flex flex-wrap gap-1">
@foreach($module['requires_permissions'] as $permission)
<span class="inline-flex items-center rounded bg-purple-50 px-1.5 py-0.5 text-xs text-purple-600" title="{{ __('modules.permission_tooltip') }}">
<svg class="mr-0.5 h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
{{ $permission }}
</span>
@endforeach
</div>
@endif
</div>
</div>

View file

@ -15,9 +15,9 @@
@endphp
<form method="POST" action="{{ route('driver.job.manual.store') }}" class="space-y-5"
x-data="{
selectedCustomerId: {{ old('customer_id', 'null') }},
selectedObjectId: {{ old('customer_object_id', 'null') }},
allObjects: @json($allObjects),
selectedCustomerId: {{ old('customer_id') ? (int) old('customer_id') : 'null' }},
selectedObjectId: {{ old('customer_object_id') ? (int) old('customer_object_id') : 'null' }},
allObjects: {{ json_encode($allObjects) }},
get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); },
onCustomerChange() {
const objs = this.objects;

View file

@ -0,0 +1,51 @@
# Data Protection Information for Winter Maintenance Staff
## 1. Controller
**[Company name]**
[Address]
Email: [Email]
## 2. Purpose of processing
In the course of your work in winter maintenance, the following personal data is collected and processed:
- **GPS location data** during operational hours, to document the gritting and snow-clearance services performed
- **Operational times** (start and end) for working-time records
- **Vehicle assignment** to enable traceability of the vehicles used
- **Photographs and notes** as evidence of the work carried out
## 3. Legal basis
Processing is carried out on the basis of **Article 6(1)(f) UK GDPR / EU GDPR** (legitimate interests). The legitimate interest of the employer lies in fulfilling the duty to maintain safe traffic conditions and in providing evidence that winter maintenance operations have been carried out properly.
## 4. Collection of GPS data
GPS data is collected via the **OwnTracks** app installed on your work device. Location data is recorded only during active operational hours and transmitted to the employer's server. The app does not track you outside of recorded shifts.
## 5. Retention period
The data collected is retained for the duration of statutory retention obligations (typically three years, in line with limitation periods for traffic-safety liability claims). After this period the data is deleted or anonymised.
## 6. Your rights
Under the UK GDPR / EU GDPR you have the following rights:
- **Right of access** (Article 15): you may request information about the personal data we hold about you.
- **Right to rectification** (Article 16): you may request correction of inaccurate data.
- **Right to erasure** (Article 17): you may request deletion of your data, subject to overriding legal retention obligations.
- **Right to restriction of processing** (Article 18)
- **Right to data portability** (Article 20)
- **Right to object** (Article 21)
- **Right to lodge a complaint** with the competent supervisory authority (ICO in the UK; the national DPA in other jurisdictions).
## 7. Contact for data protection enquiries
For any questions regarding data protection please contact:
**[Data Protection Officer / Contact]**
Email: [DPO email]
---
*This is a template review and adapt the wording to your organisation and jurisdiction before publishing to staff.*

View file

@ -1 +0,0 @@
{"current_version":"1.0.1","last_counter":1}

View file

@ -0,0 +1,17 @@
<?php
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
trait CreatesApplication
{
public function createApplication(): \Illuminate\Foundation\Application
{
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
}

View file

@ -0,0 +1,202 @@
<?php
namespace Tests\Feature;
use App\Services\Extension\FilterRegistry;
use App\Services\Extension\NavigationRegistry;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
class FilterRegistryTest extends TestCase
{
use LazilyRefreshDatabase;
private FilterRegistry $registry;
protected function setUp(): void
{
parent::setUp();
$this->registry = new FilterRegistry;
}
public function test_apply_returns_original_value_when_no_filters_registered(): void
{
$result = $this->registry->apply('schneespur.nonexistent.hook', ['a', 'b']);
$this->assertSame(['a', 'b'], $result);
}
public function test_filters_execute_in_priority_order(): void
{
$order = [];
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'priority-200';
return $value;
}, 200);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'priority-50';
return $value;
}, 50);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'priority-100';
return $value;
}, 100);
$this->registry->apply('test.hook', 'start');
$this->assertSame(['priority-50', 'priority-100', 'priority-200'], $order);
}
public function test_equal_priority_preserves_registration_order(): void
{
$order = [];
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'first';
return $value;
}, 100);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'second';
return $value;
}, 100);
$this->registry->register('test.hook', function ($value) use (&$order) {
$order[] = 'third';
return $value;
}, 100);
$this->registry->apply('test.hook', 'start');
$this->assertSame(['first', 'second', 'third'], $order);
}
public function test_apply_passes_context_to_callbacks(): void
{
$receivedContext = [];
$this->registry->register('test.hook', function ($value, $ctxA, $ctxB) use (&$receivedContext) {
$receivedContext = [$ctxA, $ctxB];
return $value;
});
$this->registry->apply('test.hook', 'value', 'context-a', 'context-b');
$this->assertSame(['context-a', 'context-b'], $receivedContext);
}
public function test_throwing_callback_logs_warning_and_preserves_previous_value(): void
{
Log::shouldReceive('warning')
->once()
->withArgs(function ($message, $context) {
return $message === 'FilterRegistry: callback failed'
&& $context['hook'] === 'test.hook'
&& str_contains($context['error'], 'Intentional test exception');
});
$this->registry->register('test.hook', function (array $value) {
$value[] = 'before-error';
return $value;
}, 10);
$this->registry->register('test.hook', function ($value) {
throw new \RuntimeException('Intentional test exception');
}, 20);
$this->registry->register('test.hook', function (array $value) {
$value[] = 'after-error';
return $value;
}, 30);
$result = $this->registry->apply('test.hook', []);
$this->assertSame(['before-error', 'after-error'], $result);
}
public function test_callbacks_can_transform_value_through_chain(): void
{
$this->registry->register('test.hook', function (int $value) {
return $value + 10;
}, 10);
$this->registry->register('test.hook', function (int $value) {
return $value * 2;
}, 20);
$result = $this->registry->apply('test.hook', 5);
$this->assertSame(30, $result);
}
public function test_single_callback_executes(): void
{
$this->registry->register('test.hook', function (array $items) {
$items[] = 'added';
return $items;
});
$result = $this->registry->apply('test.hook', ['original']);
$this->assertSame(['original', 'added'], $result);
}
public function test_navigation_items_hook_is_applied(): void
{
$this->bootExampleModule();
$nav = $this->app->make(NavigationRegistry::class);
$nav->addGroup('modules', 'Modules', 100);
$items = $nav->getItems();
$allSlugs = [];
foreach ($items as $groupItems) {
foreach ($groupItems as $item) {
$allSlugs[] = $item['slug'];
}
}
$this->assertContains('example-filter', $allSlugs);
}
public function test_dashboard_kpis_hook_is_applied(): void
{
$this->bootExampleModule();
$filterRegistry = $this->app->make(FilterRegistry::class);
$widgets = $filterRegistry->apply('schneespur.dashboard.kpis', [
['key' => 'original-widget', 'label' => 'Original'],
]);
$keys = array_column($widgets, 'key');
$this->assertContains('example-filter-widget', $keys);
}
private function bootExampleModule(): void
{
$modulePath = base_path('modules/example/src');
spl_autoload_register(function (string $class) use ($modulePath) {
$prefix = 'Schneespur\\Module\\Example\\';
if (! str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $modulePath . '/' . str_replace('\\', '/', $relative) . '.php';
if (file_exists($file)) {
require_once $file;
}
});
putenv('EXAMPLE_MODULE_ENABLED=true');
$_ENV['EXAMPLE_MODULE_ENABLED'] = true;
$this->app->register(\Schneespur\Module\Example\ExampleServiceProvider::class);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
}

View file

@ -1,22 +0,0 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInitfc2407b1a509d7fcbbc5146f46a2c921::getLoader();

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 barryvdh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,64 +0,0 @@
{
"name": "barryvdh/laravel-dompdf",
"description": "A DOMPDF Wrapper for Laravel",
"license": "MIT",
"keywords": [
"laravel",
"dompdf",
"pdf"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"require": {
"php": "^8.1",
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12|^13.0"
},
"require-dev": {
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
"squizlabs/php_codesniffer": "^3.5",
"phpro/grumphp": "^2.5",
"larastan/larastan": "^2.7|^3.0"
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Barryvdh\\DomPDF\\Tests\\": "tests"
}
},
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
},
"laravel": {
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
],
"aliases": {
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf",
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf"
}
}
},
"scripts": {
"test": "phpunit",
"check-style": "phpcs -p --standard=psr12 src/",
"fix-style": "phpcbf -p --standard=psr12 src/",
"phpstan": "phpstan analyze --memory-limit=-1"
},
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
"allow-plugins": {
"phpro/grumphp": true
}
}
}

View file

@ -1,301 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Settings
|--------------------------------------------------------------------------
|
| Set some default values. It is possible to add all defines that can be set
| in dompdf_config.inc.php. You can also override the entire config file.
|
*/
'show_warnings' => false, // Throw an Exception on warnings from dompdf
'public_path' => null, // Override the public path if needed
/*
* Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show and £.
*/
'convert_entities' => true,
'options' => [
/**
* The location of the DOMPDF font directory
*
* The location of the directory where DOMPDF will store fonts and font metrics
* Note: This directory must exist and be writable by the webserver process.
* *Please note the trailing slash.*
*
* Notes regarding fonts:
* Additional .afm font metrics can be added by executing load_font.php from command line.
*
* Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must
* be embedded in the pdf file or the PDF may not display correctly. This can significantly
* increase file size unless font subsetting is enabled. Before embedding a font please
* review your rights under the font license.
*
* Any font specification in the source HTML is translated to the closest font available
* in the font directory.
*
* The pdf standard "Base 14 fonts" are:
* Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique,
* Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique,
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats.
*/
'font_dir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/**
* The location of the DOMPDF font cache directory
*
* This directory contains the cached font metrics for the fonts used by DOMPDF.
* This directory can be the same as DOMPDF_FONT_DIR
*
* Note: This directory must exist and be writable by the webserver process.
*/
'font_cache' => storage_path('fonts'),
/**
* The location of a temporary directory.
*
* The directory specified must be writeable by the webserver process.
* The temporary directory is required to download remote images and when
* using the PDFLib back end.
*/
'temp_dir' => sys_get_temp_dir(),
/**
* ==== IMPORTANT ====
*
* dompdf's "chroot": Prevents dompdf from accessing system files or other
* files on the webserver. All local files opened by dompdf must be in a
* subdirectory of this directory. DO NOT set it to '/' since this could
* allow an attacker to use dompdf to read any files on the server. This
* should be an absolute path.
* This is only checked on command line call by dompdf.php, but not by
* direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/
'chroot' => realpath(base_path()),
/**
* Protocol whitelist
*
* Protocols and PHP wrappers allowed in URIs, and the validation rules
* that determine if a resouce may be loaded. Full support is not guaranteed
* for the protocols/wrappers specified
* by this array.
*
* @var array
*/
'allowed_protocols' => [
'data://' => ['rules' => []],
'file://' => ['rules' => []],
'http://' => ['rules' => []],
'https://' => ['rules' => []],
],
/**
* Operational artifact (log files, temporary files) path validation
*/
'artifactPathValidation' => null,
/**
* @var string
*/
'log_output_file' => null,
/**
* Whether to enable font subsetting or not.
*/
'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use
*
* Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
* 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
* fall back on CPDF. 'GD' renders PDFs to graphic files.
* {@link * Canvas_Factory} ultimately determines which rendering class to
* instantiate based on this setting.
*
* Both PDFLib & CPDF rendering backends provide sufficient rendering
* capabilities for dompdf, however additional features (e.g. object,
* image and font support, etc.) differ between backends. Please see
* {@link PDFLib_Adapter} for more information on the PDFLib backend
* and {@link CPDF_Adapter} and lib/class.pdf.php for more information
* on CPDF. Also see the documentation for each backend at the links
* below.
*
* The GD rendering backend is a little different than PDFLib and
* CPDF. Several features of CPDF and PDFLib are not supported or do
* not make any sense when creating image files. For example,
* multiple pages are not supported, nor are PDF 'objects'. Have a
* look at {@link GD_Adapter} for more information. GD support is
* experimental, so use it at your own risk.
*
* @link http://www.pdflib.com
* @link http://www.ros.co.nz/pdf
* @link http://www.php.net/image
*/
'pdf_backend' => 'CPDF',
/**
* html target media view which should be rendered into pdf.
* List of types and parsing rules for future extensions:
* http://www.w3.org/TR/REC-html40/types.html
* screen, tty, tv, projection, handheld, print, braille, aural, all
* Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3.
* Note, even though the generated pdf file is intended for print output,
* the desired content might be different (e.g. screen or projection view of html file).
* Therefore allow specification of content here.
*/
'default_media_type' => 'screen',
/**
* The default paper size.
*
* North America standard is "letter"; other countries generally "a4"
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => 'a4',
/**
* The default paper orientation.
*
* The orientation of the page (portrait or landscape).
*
* @var string
*/
'default_paper_orientation' => 'portrait',
/**
* The default font family
*
* Used if no suitable fonts can be found. This must exist in the font folder.
*
* @var string
*/
'default_font' => 'serif',
/**
* Image DPI setting
*
* This setting determines the default DPI setting for images and fonts. The
* DPI may be overridden for inline images by explictly setting the
* image's width & height style attributes (i.e. if the image's native
* width is 600 pixels and you specify the image's width as 72 points,
* the image will have a DPI of 600 in the rendered PDF. The DPI of
* background images can not be overridden and is controlled entirely
* via this parameter.
*
* For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI).
* If a size in html is given as px (or without unit as image size),
* this tells the corresponding size in pt.
* This adjusts the relative sizes to be similar to the rendering of the
* html page in a reference browser.
*
* In pdf, always 1 pt = 1/72 inch
*
* Rendering resolution of various browsers in px per inch:
* Windows Firefox and Internet Explorer:
* SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:?
* Linux Firefox:
* about:config *resolution: Default:96
* (xorg screen dimension in mm and Desktop font dpi settings are ignored)
*
* Take care about extra font/image zoom factor of browser.
*
* In images, <img> size in pixel attribute, img css style, are overriding
* the real image dimension in px for rendering.
*
* @var int
*/
'dpi' => 96,
/**
* Enable embedded PHP
*
* If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained
* within <script type="text/php"> ... </script> tags.
*
* ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages)
* is a security risk.
* Embedded scripts are run with the same level of system access available to dompdf.
* Set this option to false (recommended) if you wish to process untrusted documents.
* This setting may increase the risk of system exploit.
* Do not change this settings without understanding the consequences.
* Additional documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_php' => false,
/**
* Enable inline JavaScript
*
* If this setting is set to true then DOMPDF will automatically insert JavaScript code contained
* within <script type="text/javascript"> ... </script> tags as written into the PDF.
* NOTE: This is PDF-based JavaScript to be executed by the PDF viewer,
* not browser-based JavaScript executed by Dompdf.
*
* @var bool
*/
'enable_javascript' => true,
/**
* Enable remote file access
*
* If this setting is set to true, DOMPDF will access remote sites for
* images and CSS files as required.
*
* ==== IMPORTANT ====
* This can be a security risk, in particular in combination with isPhpEnabled and
* allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...);
* This allows anonymous users to download legally doubtful internet content which on
* tracing back appears to being downloaded by your server, or allows malicious php code
* in remote html pages to be executed by your server with your account privileges.
*
* This setting may increase the risk of system exploit. Do not change
* this settings without understanding the consequences. Additional
* documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_remote' => false,
/**
* List of allowed remote hosts
*
* Each value of the array must be a valid hostname.
*
* This will be used to filter which resources can be loaded in combination with
* isRemoteEnabled. If enable_remote is FALSE, then this will have no effect.
*
* Leave to NULL to allow any remote host.
*
* @var array|null
*/
'allowed_remote_hosts' => null,
/**
* A ratio applied to the fonts height to be more like browsers' line height
*/
'font_height_ratio' => 1.1,
/**
* Use the HTML5 Lib parser
*
* @deprecated This feature is now always on in dompdf 2.x
*
* @var bool
*/
'enable_html5_parser' => true,
],
];

View file

@ -1,22 +0,0 @@
grumphp:
tasks:
phpunit:
config_file: ~
testsuite: ~
group: []
always_execute: false
phpcs:
standard: PSR12
warning_severity: ~
ignore_patterns:
- tests/
triggered_by: [php]
phpstan:
autoload_file: ~
configuration: ~
level: null
force_patterns: [ ]
ignore_patterns: [ ]
triggered_by: [ 'php' ]
memory_limit: "-1"
use_grumphp_paths: true

View file

@ -1,68 +0,0 @@
<?php
namespace Barryvdh\DomPDF\Facade;
use Barryvdh\DomPDF\PDF as BasePDF;
use Illuminate\Support\Facades\Facade as IlluminateFacade;
use RuntimeException;
/**
* @method static BasePDF setBaseHost(string $baseHost)
* @method static BasePDF setBasePath(string $basePath)
* @method static BasePDF setCanvas(\Dompdf\Canvas $canvas)
* @method static BasePDF setCallbacks(array<string, mixed> $callbacks)
* @method static BasePDF setCss(\Dompdf\Css\Stylesheet $css)
* @method static BasePDF setDefaultView(string $defaultView, array<string, mixed> $options)
* @method static BasePDF setDom(\DOMDocument $dom)
* @method static BasePDF setFontMetrics(\Dompdf\FontMetrics $fontMetrics)
* @method static BasePDF setHttpContext(resource|array<string, mixed> $httpContext)
* @method static BasePDF setPaper(string|float[] $paper, string $orientation = 'portrait')
* @method static BasePDF setProtocol(string $protocol)
* @method static BasePDF setTree(\Dompdf\Frame\FrameTree $tree)
* @method static BasePDF setWarnings(bool $warnings)
* @method static BasePDF setOption(array<string, mixed>|string $attribute, $value = null)
* @method static BasePDF setOptions(array<string, mixed> $options)
* @method static BasePDF loadView(string $view, array<string, mixed> $data = [], array<string, mixed> $mergeData = [], ?string $encoding = null)
* @method static BasePDF loadHTML(string $string, ?string $encoding = null)
* @method static BasePDF loadFile(string $file)
* @method static BasePDF addInfo(array<string, string> $info)
* @method static string output(array<string, int> $options = [])
* @method static BasePDF save()
* @method static \Illuminate\Http\Response download(string $filename = 'document.pdf')
* @method static \Illuminate\Http\Response stream(string $filename = 'document.pdf')
*/
class Pdf extends IlluminateFacade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'dompdf.wrapper';
}
/**
* Handle dynamic, static calls to the object.
*
* @param string $method
* @param array<mixed> $args
* @return mixed
*
* @throws \RuntimeException
*/
public static function __callStatic($method, $args)
{
/** @var \Illuminate\Contracts\Foundation\Application|null */
$app = static::getFacadeApplication();
if (! $app) {
throw new RuntimeException('Facade application has not been set.');
}
// Resolve a new instance, avoid using a cached instance
$instance = $app->make(static::getFacadeAccessor());
return $instance->$method(...$args);
}
}

View file

@ -1,319 +0,0 @@
<?php
namespace Barryvdh\DomPDF;
use Dompdf\Adapter\CPDF;
use Dompdf\Dompdf;
use Dompdf\Options;
use Exception;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\HeaderUtils;
/**
* A Laravel wrapper for Dompdf
*
* @package laravel-dompdf
* @author Barry vd. Heuvel
*
* @method PDF setBaseHost(string $baseHost)
* @method PDF setBasePath(string $basePath)
* @method PDF setCanvas(\Dompdf\Canvas $canvas)
* @method PDF setCallbacks(array<string, mixed> $callbacks)
* @method PDF setCss(\Dompdf\Css\Stylesheet $css)
* @method PDF setDefaultView(string $defaultView, array<string, mixed> $options)
* @method PDF setDom(\DOMDocument $dom)
* @method PDF setFontMetrics(\Dompdf\FontMetrics $fontMetrics)
* @method PDF setHttpContext(resource|array<string, mixed> $httpContext)
* @method PDF setPaper(string|float[] $paper, string $orientation = 'portrait')
* @method PDF setProtocol(string $protocol)
* @method PDF setTree(\Dompdf\Frame\FrameTree $tree)
* @method string getBaseHost()
* @method string getBasePath()
* @method \Dompdf\Canvas getCanvas()
* @method array<string, mixed> getCallbacks()
* @method \Dompdf\Css\Stylesheet getCss()
* @method \DOMDocument getDom()
* @method \Dompdf\FontMetrics getFontMetrics()
* @method resource getHttpContext()
* @method Options getOptions()
* @method \Dompdf\Frame\FrameTree getTree()
* @method string getPaperOrientation()
* @method float[] getPaperSize()
* @method string getProtocol()
*/
class PDF
{
/** @var Dompdf */
protected $dompdf;
/** @var \Illuminate\Contracts\Config\Repository */
protected $config;
/** @var \Illuminate\Filesystem\Filesystem */
protected $files;
/** @var \Illuminate\Contracts\View\Factory */
protected $view;
/** @var bool */
protected $rendered = false;
/** @var bool */
protected $showWarnings;
/** @var string */
protected $public_path;
public function __construct(Dompdf $dompdf, ConfigRepository $config, Filesystem $files, ViewFactory $view)
{
$this->dompdf = $dompdf;
$this->config = $config;
$this->files = $files;
$this->view = $view;
$this->showWarnings = $this->config->get('dompdf.show_warnings', false);
}
/**
* Get the DomPDF instance
*/
public function getDomPDF(): Dompdf
{
return $this->dompdf;
}
/**
* Show or hide warnings
*/
public function setWarnings(bool $warnings): self
{
$this->showWarnings = $warnings;
return $this;
}
/**
* Load a HTML string
*
* @param string|null $encoding Not used yet
*/
public function loadHTML(string $string, ?string $encoding = null): self
{
$string = $this->convertEntities($string);
$this->dompdf->loadHtml($string, $encoding);
$this->rendered = false;
return $this;
}
/**
* Load a HTML file
*/
public function loadFile(string $file): self
{
$this->dompdf->loadHtmlFile($file);
$this->rendered = false;
return $this;
}
/**
* Add metadata info
* @param array<string, string> $info
*/
public function addInfo(array $info): self
{
foreach ($info as $name => $value) {
$this->dompdf->add_info($name, $value);
}
return $this;
}
/**
* Load a View and convert to HTML
* @param array<string, mixed> $data
* @param array<string, mixed> $mergeData
* @param string|null $encoding Not used yet
*/
public function loadView(string $view, array $data = [], array $mergeData = [], ?string $encoding = null): self
{
$html = $this->view->make($view, $data, $mergeData)->render();
return $this->loadHTML($html, $encoding);
}
/**
* Set/Change an option (or array of options) in Dompdf
*
* @param array<string, mixed>|string $attribute
* @param null|mixed $value
*/
public function setOption($attribute, $value = null): self
{
$this->dompdf->getOptions()->set($attribute, $value);
return $this;
}
/**
* Replace all the Options from DomPDF
*
* @param array<string, mixed> $options
*/
public function setOptions(array $options, bool $mergeWithDefaults = false): self
{
if ($mergeWithDefaults) {
$options = array_merge(app()->make('dompdf.options'), $options);
}
$this->dompdf->setOptions(new Options($options));
return $this;
}
/**
* Output the PDF as a string.
*
* The options parameter controls the output. Accepted options are:
*
* 'compress' = > 1 or 0 - apply content stream compression, this is
* on (1) by default
*
* @param array<string, int> $options
*
* @return string The rendered PDF as string
*/
public function output(array $options = []): string
{
if (!$this->rendered) {
$this->render();
}
return (string) $this->dompdf->output($options);
}
/**
* Save the PDF to a file
*/
public function save(string $filename, ?string $disk = null): self
{
$disk = $disk ?: $this->config->get('dompdf.disk');
if (! is_null($disk)) {
Storage::disk($disk)->put($filename, $this->output());
return $this;
}
$this->files->put($filename, $this->output());
return $this;
}
/**
* Make the PDF downloadable by the user
*/
public function download(string $filename = 'document.pdf'): Response
{
$output = $this->output();
$fallback = $this->fallbackName($filename);
return new Response($output, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, $fallback),
'Content-Length' => strlen($output),
]);
}
/**
* Return a response with the PDF to show in the browser
*/
public function stream(string $filename = 'document.pdf'): Response
{
$output = $this->output();
$fallback = $this->fallbackName($filename);
return new Response($output, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => HeaderUtils::makeDisposition('inline', $filename, $fallback),
]);
}
/**
* Render the PDF
*/
public function render(): void
{
$this->dompdf->render();
if ($this->showWarnings) {
global $_dompdf_warnings;
if (!empty($_dompdf_warnings) && count($_dompdf_warnings)) {
$warnings = '';
foreach ($_dompdf_warnings as $msg) {
$warnings .= $msg . "\n";
}
// $warnings .= $this->dompdf->get_canvas()->get_cpdf()->messages;
if (!empty($warnings)) {
throw new Exception($warnings);
}
}
}
$this->rendered = true;
}
/** @param array<string> $pc */
public function setEncryption(string $password, string $ownerpassword = '', array $pc = []): void
{
$this->render();
$canvas = $this->dompdf->getCanvas();
if (! $canvas instanceof CPDF) {
throw new \RuntimeException('Encryption is only supported when using CPDF');
}
$canvas->get_cpdf()->setEncryption($password, $ownerpassword, $pc);
}
protected function convertEntities(string $subject): string
{
if (false === $this->config->get('dompdf.convert_entities', true)) {
return $subject;
}
$entities = [
'€' => '&euro;',
'£' => '&pound;',
];
foreach ($entities as $search => $replace) {
$subject = str_replace($search, $replace, $subject);
}
return $subject;
}
/**
* Dynamically handle calls into the dompdf instance.
*
* @param string $method
* @param array<mixed> $parameters
* @return $this|mixed
*/
public function __call($method, $parameters)
{
if (method_exists($this, $method)) {
return $this->$method(...$parameters);
}
if (method_exists($this->dompdf, $method)) {
$return = $this->dompdf->$method(...$parameters);
return $return == $this->dompdf ? $this : $return;
}
throw new \UnexpectedValueException("Method [{$method}] does not exist on PDF instance.");
}
/**
* Make a safe fallback filename
*/
protected function fallbackName(string $filename): string
{
return str_replace('%', '', Str::ascii($filename));
}
}

View file

@ -1,94 +0,0 @@
<?php
namespace Barryvdh\DomPDF;
use Dompdf\Dompdf;
use Exception;
use Illuminate\Support\Str;
use Illuminate\Support\ServiceProvider as IlluminateServiceProvider;
class ServiceProvider extends IlluminateServiceProvider
{
/**
* Indicates if loading of the provider is deferred.
*
* @var bool
*/
protected $defer = false;
/**
* Register the service provider.
*
* @throws \Exception
* @return void
*/
public function register(): void
{
$configPath = __DIR__ . '/../config/dompdf.php';
$this->mergeConfigFrom($configPath, 'dompdf');
$this->app->bind('dompdf.options', function ($app) {
$defines = $app['config']->get('dompdf.defines');
if ($defines) {
$options = [];
/**
* @var string $key
* @var mixed $value
*/
foreach ($defines as $key => $value) {
$key = strtolower(str_replace('DOMPDF_', '', $key));
$options[$key] = $value;
}
} else {
$options = $app['config']->get('dompdf.options');
}
return $options;
});
$this->app->bind('dompdf', function ($app) {
$options = $app->make('dompdf.options');
$dompdf = new Dompdf($options);
$path = realpath($app['config']->get('dompdf.public_path') ?: base_path('public'));
if ($path === false) {
throw new \RuntimeException('Cannot resolve public path');
}
$dompdf->setBasePath($path);
return $dompdf;
});
$this->app->alias('dompdf', Dompdf::class);
$this->app->bind('dompdf.wrapper', function ($app) {
return new PDF($app['dompdf'], $app['config'], $app['files'], $app['view']);
});
}
/**
* Check if package is running under Lumen app
*/
protected function isLumen(): bool
{
return Str::contains($this->app->version(), 'Lumen') === true;
}
public function boot(): void
{
if (! $this->isLumen()) {
$configPath = __DIR__ . '/../config/dompdf.php';
$this->publishes([$configPath => config_path('dompdf.php')], 'config');
}
}
/**
* Get the services provided by the provider.
*
* @return array<string>
*/
public function provides(): array
{
return ['dompdf', 'dompdf.options', 'dompdf.wrapper'];
}
}

View file

@ -1,119 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../nesbot/carbon/bin/carbon)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nesbot/carbon/bin/carbon');
}
}
return include __DIR__ . '/..'.'/nesbot/carbon/bin/carbon';

View file

@ -1,119 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/error-handler/Resources/bin/patch-type-declarations)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations');
}
}
return include __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations';

View file

@ -1,119 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../nikic/php-parser/bin/php-parse)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse');
}
}
return include __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse';

View file

@ -1,119 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../psy/psysh/bin/psysh)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/psy/psysh/bin/psysh');
}
}
return include __DIR__ . '/..'.'/psy/psysh/bin/psysh';

View file

@ -1,119 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/var-dumper/Resources/bin/var-dump-server)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server');
}
}
return include __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server';

View file

@ -1,20 +0,0 @@
The MIT License (MIT)
Copyright (c) 2013-present Benjamin Morel
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,39 +0,0 @@
{
"name": "brick/math",
"description": "Arbitrary-precision arithmetic library",
"type": "library",
"keywords": [
"Brick",
"Math",
"Mathematics",
"Arbitrary-precision",
"Arithmetic",
"BigInteger",
"BigDecimal",
"BigRational",
"BigNumber",
"Bignum",
"Decimal",
"Rational",
"Integer"
],
"license": "MIT",
"require": {
"php": "^8.2"
},
"require-dev": {
"phpunit/phpunit": "^11.5",
"php-coveralls/php-coveralls": "^2.2",
"phpstan/phpstan": "2.1.22"
},
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Brick\\Math\\Tests\\": "tests/"
}
}
}

View file

@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
use Brick\Math\Internal\Calculator;
use Brick\Math\Internal\CalculatorRegistry;
require __DIR__ . '/vendor/autoload.php';
function getCalculatorImplementation(): Calculator
{
switch ($calculator = getenv('CALCULATOR')) {
case 'GMP':
$calculator = new Calculator\GmpCalculator();
break;
case 'BCMath':
$calculator = new Calculator\BcMathCalculator();
break;
case 'Native':
$calculator = new Calculator\NativeCalculator();
break;
default:
if ($calculator === false) {
echo 'CALCULATOR environment variable not set!' . PHP_EOL;
} else {
echo 'Unknown calculator: ' . $calculator . PHP_EOL;
}
echo 'Example usage: CALCULATOR={calculator} vendor/bin/phpunit' . PHP_EOL;
echo 'Available calculators: GMP, BCMath, Native' . PHP_EOL;
exit(1);
}
echo 'Using ', get_class($calculator), PHP_EOL;
return $calculator;
}
CalculatorRegistry::set(getCalculatorImplementation());
$scale = getenv('BCMATH_DEFAULT_SCALE');
if ($scale !== false) {
echo "Using bcscale($scale)", PHP_EOL;
bcscale((int) $scale);
}

View file

@ -1,193 +0,0 @@
<?php
/**
* This script stress tests calculators with random large numbers and ensures that all implementations return the same
* results. It is designed to run in an infinite loop unless a bug is found.
*/
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Brick\Math\Internal\Calculator;
if ($argc !== 2) {
echo 'Usage: php random-tests.php <max number of digits>', PHP_EOL;
exit(1);
}
$maxDigits = (int) $argv[1];
if ($maxDigits < 1) {
echo 'Max digits must be > 1', PHP_EOL;
exit(1);
}
(new class($maxDigits) {
private readonly Calculator\GmpCalculator $gmp;
private readonly Calculator\BcMathCalculator $bcmath;
private readonly Calculator\NativeCalculator $native;
private int $testCounter = 0;
private float $lastOutputTime = 0.0;
private int $currentSecond = 0;
private int $currentSecondTestCounter = 0;
private int $testsPerSecond = 0;
public function __construct(
private readonly int $maxDigits,
) {
$this->gmp = new Calculator\GmpCalculator();
$this->bcmath = new Calculator\BcMathCalculator();
$this->native = new Calculator\NativeCalculator();
}
public function __invoke(): void
{
for (; ;) {
$a = $this->generateRandomNumber();
$b = $this->generateRandomNumber();
$c = $this->generateRandomNumber();
$this->runTests($a, $b);
$this->runTests($b, $a);
if ($a !== '0') {
$this->runTests("-$a", $b);
$this->runTests($b, "-$a");
}
if ($b !== '0') {
$this->runTests($a, "-$b");
$this->runTests("-$b", $a);
}
if ($a !== '0' && $b !== '0') {
$this->runTests("-$a", "-$b");
$this->runTests("-$b", "-$a");
}
if ($c !== '0') {
$this->test("$a POW $b MOD $c", fn (Calculator $calc) => $calc->modPow($a, $b, $c));
$this->test("-$a POW $b MOD $c", fn (Calculator $calc) => $calc->modPow("-$a", $b, $c));
}
foreach ([$a, $b] as $n) {
$this->test("SQRT $n", fn (Calculator $c) => $c->sqrt($n));
for ($exp = 0; $exp <= 3; $exp++) {
$this->test("$n POW $exp", fn (Calculator $calc) => $calc->pow($n, $exp));
if ($n !== '0') {
$this->test("-$n POW $exp", fn (Calculator $calc) => $calc->pow("-$n", $exp));
}
}
}
}
}
/**
* @param string $a The left operand.
* @param string $b The right operand.
*/
private function runTests(string $a, string $b): void
{
$this->test("$a + $b", fn (Calculator $c) => $c->add($a, $b));
$this->test("$a - $b", fn (Calculator $c) => $c->sub($a, $b));
$this->test("$a * $b", fn (Calculator $c) => $c->mul($a, $b));
if ($b !== '0') {
$this->test("$a / $b", fn (Calculator $c) => $c->divQR($a, $b));
$this->test("$a MOD $b", fn (Calculator $c) => $c->mod($a, $b));
}
if ($b !== '0' && $b[0] !== '-') {
$this->test("INV $a MOD $b", fn (Calculator $c) => $c->modInverse($a, $b));
}
$this->test("GCD $a, $b", fn (Calculator $c) => $c->gcd($a, $b));
$this->test("LCM $a, $b", fn (Calculator $c) => $c->lcm($a, $b));
$this->test("$a AND $b", fn (Calculator $c) => $c->and($a, $b));
$this->test("$a OR $b", fn (Calculator $c) => $c->or($a, $b));
$this->test("$a XOR $b", fn (Calculator $c) => $c->xor($a, $b));
}
/**
* @param string $test A string representing the test being executed.
* @param Closure(Calculator): mixed $callback A callback function accepting a Calculator instance and returning a calculation result.
*/
private function test(string $test, Closure $callback): void
{
$gmpResult = $callback($this->gmp);
$bcmathResult = $callback($this->bcmath);
$nativeResult = $callback($this->native);
if ($gmpResult !== $bcmathResult) {
$this->failure('GMP', 'BCMath', $test, $gmpResult, $bcmathResult);
}
if ($gmpResult !== $nativeResult) {
$this->failure('GMP', 'Native', $test, $gmpResult, $nativeResult);
}
$this->testCounter++;
$this->currentSecondTestCounter++;
$time = microtime(true);
$second = (int) $time;
if ($second !== $this->currentSecond) {
$this->currentSecond = $second;
$this->testsPerSecond = $this->currentSecondTestCounter;
$this->currentSecondTestCounter = 0;
}
if ($time - $this->lastOutputTime >= 0.1) {
echo "\r", number_format($this->testCounter), ' (', number_format($this->testsPerSecond) . ' / s)';
$this->lastOutputTime = $time;
}
}
/**
* @param string $c1 The name of the first calculator.
* @param string $c2 The name of the second calculator.
* @param string $test A string representing the test being executed.
* @param string $v1 The value returned by the first calculator.
* @param string $v2 The value returned by the second calculator.
*/
private function failure(string $c1, string $c2, string $test, string $v1, string $v2): never
{
echo PHP_EOL;
echo 'FAILURE!', PHP_EOL;
echo $c1, ' vs ', $c2, PHP_EOL;
echo "$v1 != $v2", PHP_EOL;
echo $test, PHP_EOL;
exit(1);
}
private function generateRandomNumber(): string
{
$length = random_int(1, $this->maxDigits);
$number = '';
for ($i = 0; $i < $length; $i++) {
$number .= random_int(0, 9);
}
$number = ltrim($number, '0');
if ($number === '') {
return '0';
}
return $number;
}
})();

View file

@ -1,975 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
use Brick\Math\Exception\DivisionByZeroException;
use Brick\Math\Exception\MathException;
use Brick\Math\Exception\NegativeNumberException;
use Brick\Math\Exception\RoundingNecessaryException;
use Brick\Math\Internal\Calculator;
use Brick\Math\Internal\CalculatorRegistry;
use InvalidArgumentException;
use LogicException;
use Override;
use function func_num_args;
use function in_array;
use function intdiv;
use function max;
use function rtrim;
use function sprintf;
use function str_pad;
use function str_repeat;
use function strlen;
use function substr;
use function trigger_error;
use const E_USER_DEPRECATED;
use const STR_PAD_LEFT;
/**
* An arbitrarily large decimal number.
*
* This class is immutable.
*
* The scale of the number is the number of digits after the decimal point. It is always positive or zero.
*/
final readonly class BigDecimal extends BigNumber
{
/**
* The unscaled value of this decimal number.
*
* This is a string of digits with an optional leading minus sign.
* No leading zero must be present.
* No leading minus sign must be present if the value is 0.
*/
private string $value;
/**
* The scale (number of digits after the decimal point) of this decimal number.
*
* This must be zero or more.
*/
private int $scale;
/**
* Protected constructor. Use a factory method to obtain an instance.
*
* @param string $value The unscaled value, validated.
* @param int $scale The scale, validated.
*
* @pure
*/
protected function __construct(string $value, int $scale = 0)
{
$this->value = $value;
$this->scale = $scale;
}
/**
* Creates a BigDecimal from an unscaled value and a scale.
*
* Example: `(12345, 3)` will result in the BigDecimal `12.345`.
*
* A negative scale is normalized to zero by appending zeros to the unscaled value.
*
* Example: `(12345, -3)` will result in the BigDecimal `12345000`.
*
* @param BigNumber|int|float|string $value The unscaled value. Must be convertible to a BigInteger.
* @param int $scale The scale of the number. If negative, the scale will be set to zero
* and the unscaled value will be adjusted accordingly.
*
* @throws MathException If the value is not valid, or is not convertible to a BigInteger.
*
* @pure
*/
public static function ofUnscaledValue(BigNumber|int|float|string $value, int $scale = 0): BigDecimal
{
$value = BigInteger::of($value)->toString();
if ($scale < 0) {
if ($value !== '0') {
$value .= str_repeat('0', -$scale);
}
$scale = 0;
}
return new BigDecimal($value, $scale);
}
/**
* Returns a BigDecimal representing zero, with a scale of zero.
*
* @pure
*/
public static function zero(): BigDecimal
{
/** @var BigDecimal|null $zero */
static $zero;
if ($zero === null) {
$zero = new BigDecimal('0');
}
return $zero;
}
/**
* Returns a BigDecimal representing one, with a scale of zero.
*
* @pure
*/
public static function one(): BigDecimal
{
/** @var BigDecimal|null $one */
static $one;
if ($one === null) {
$one = new BigDecimal('1');
}
return $one;
}
/**
* Returns a BigDecimal representing ten, with a scale of zero.
*
* @pure
*/
public static function ten(): BigDecimal
{
/** @var BigDecimal|null $ten */
static $ten;
if ($ten === null) {
$ten = new BigDecimal('10');
}
return $ten;
}
/**
* Returns the sum of this number and the given one.
*
* The result has a scale of `max($this->scale, $that->scale)`.
*
* @param BigNumber|int|float|string $that The number to add. Must be convertible to a BigDecimal.
*
* @throws MathException If the number is not valid, or is not convertible to a BigDecimal.
*
* @pure
*/
public function plus(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->value === '0' && $that->scale <= $this->scale) {
return $this;
}
if ($this->value === '0' && $this->scale <= $that->scale) {
return $that;
}
[$a, $b] = $this->scaleValues($this, $that);
$value = CalculatorRegistry::get()->add($a, $b);
$scale = max($this->scale, $that->scale);
return new BigDecimal($value, $scale);
}
/**
* Returns the difference of this number and the given one.
*
* The result has a scale of `max($this->scale, $that->scale)`.
*
* @param BigNumber|int|float|string $that The number to subtract. Must be convertible to a BigDecimal.
*
* @throws MathException If the number is not valid, or is not convertible to a BigDecimal.
*
* @pure
*/
public function minus(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->value === '0' && $that->scale <= $this->scale) {
return $this;
}
[$a, $b] = $this->scaleValues($this, $that);
$value = CalculatorRegistry::get()->sub($a, $b);
$scale = max($this->scale, $that->scale);
return new BigDecimal($value, $scale);
}
/**
* Returns the product of this number and the given one.
*
* The result has a scale of `$this->scale + $that->scale`.
*
* @param BigNumber|int|float|string $that The multiplier. Must be convertible to a BigDecimal.
*
* @throws MathException If the multiplier is not valid, or is not convertible to a BigDecimal.
*
* @pure
*/
public function multipliedBy(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->value === '1' && $that->scale === 0) {
return $this;
}
if ($this->value === '1' && $this->scale === 0) {
return $that;
}
$value = CalculatorRegistry::get()->mul($this->value, $that->value);
$scale = $this->scale + $that->scale;
return new BigDecimal($value, $scale);
}
/**
* Returns the result of the division of this number by the given one, at the given scale.
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
* @param int|null $scale The desired scale. Omitting this parameter is deprecated; it will be required in 0.15.
* @param RoundingMode $roundingMode An optional rounding mode, defaults to Unnecessary.
*
* @throws InvalidArgumentException If the scale is negative.
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is used and the result cannot be represented
* exactly at the given scale.
*
* @pure
*/
public function dividedBy(BigNumber|int|float|string $that, ?int $scale = null, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
if ($scale === null) {
// @phpstan-ignore-next-line
trigger_error(
'Not passing a $scale to BigDecimal::dividedBy() is deprecated. ' .
'Use $a->dividedBy($b, $a->getScale(), $roundingMode) to retain current behavior.',
E_USER_DEPRECATED,
);
$scale = $this->scale;
} elseif ($scale < 0) {
throw new InvalidArgumentException('Scale must not be negative.');
}
if ($that->value === '1' && $that->scale === 0 && $scale === $this->scale) {
return $this;
}
$p = $this->valueWithMinScale($that->scale + $scale);
$q = $that->valueWithMinScale($this->scale - $scale);
$result = CalculatorRegistry::get()->divRound($p, $q, $roundingMode);
return new BigDecimal($result, $scale);
}
/**
* Returns the exact result of the division of this number by the given one.
*
* The scale of the result is automatically calculated to fit all the fraction digits.
*
* @deprecated Will be removed in 0.15. Use dividedByExact() instead.
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @throws MathException If the divisor is not a valid number, is not convertible to a BigDecimal, is zero,
* or the result yields an infinite number of digits.
*/
public function exactlyDividedBy(BigNumber|int|float|string $that): BigDecimal
{
trigger_error(
'BigDecimal::exactlyDividedBy() is deprecated and will be removed in 0.15. Use dividedByExact() instead.',
E_USER_DEPRECATED,
);
return $this->dividedByExact($that);
}
/**
* Returns the exact result of the division of this number by the given one.
*
* The scale of the result is automatically calculated to fit all the fraction digits.
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
* @throws RoundingNecessaryException If the result yields an infinite number of digits.
*
* @pure
*/
public function dividedByExact(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->value === '0') {
throw DivisionByZeroException::divisionByZero();
}
[, $b] = $this->scaleValues($this, $that);
$d = rtrim($b, '0');
$scale = strlen($b) - strlen($d);
$calculator = CalculatorRegistry::get();
foreach ([5, 2] as $prime) {
for (; ;) {
$lastDigit = (int) $d[-1];
if ($lastDigit % $prime !== 0) {
break;
}
$d = $calculator->divQ($d, (string) $prime);
$scale++;
}
}
return $this->dividedBy($that, $scale)->strippedOfTrailingZeros();
}
/**
* Returns this number exponentiated to the given value.
*
* The result has a scale of `$this->scale * $exponent`.
*
* @throws InvalidArgumentException If the exponent is not in the range 0 to 1,000,000.
*
* @pure
*/
public function power(int $exponent): BigDecimal
{
if ($exponent === 0) {
return BigDecimal::one();
}
if ($exponent === 1) {
return $this;
}
if ($exponent < 0 || $exponent > Calculator::MAX_POWER) {
throw new InvalidArgumentException(sprintf(
'The exponent %d is not in the range 0 to %d.',
$exponent,
Calculator::MAX_POWER,
));
}
return new BigDecimal(CalculatorRegistry::get()->pow($this->value, $exponent), $this->scale * $exponent);
}
/**
* Returns the quotient of the division of this number by the given one.
*
* The quotient has a scale of `0`.
*
* Examples:
*
* - `7.5` quotient `3` returns `2`
* - `7.5` quotient `-3` returns `-2`
* - `-7.5` quotient `3` returns `-2`
* - `-7.5` quotient `-3` returns `2`
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
*
* @pure
*/
public function quotient(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
$p = $this->valueWithMinScale($that->scale);
$q = $that->valueWithMinScale($this->scale);
$quotient = CalculatorRegistry::get()->divQ($p, $q);
return new BigDecimal($quotient, 0);
}
/**
* Returns the remainder of the division of this number by the given one.
*
* The remainder has a scale of `max($this->scale, $that->scale)`.
* The remainder, when non-zero, has the same sign as the dividend.
*
* Examples:
*
* - `7.5` remainder `3` returns `1.5`
* - `7.5` remainder `-3` returns `1.5`
* - `-7.5` remainder `3` returns `-1.5`
* - `-7.5` remainder `-3` returns `-1.5`
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
*
* @pure
*/
public function remainder(BigNumber|int|float|string $that): BigDecimal
{
$that = BigDecimal::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
$p = $this->valueWithMinScale($that->scale);
$q = $that->valueWithMinScale($this->scale);
$remainder = CalculatorRegistry::get()->divR($p, $q);
$scale = max($this->scale, $that->scale);
return new BigDecimal($remainder, $scale);
}
/**
* Returns the quotient and remainder of the division of this number by the given one.
*
* The quotient has a scale of `0`, and the remainder has a scale of `max($this->scale, $that->scale)`.
*
* Examples:
*
* - `7.5` quotientAndRemainder `3` returns [`2`, `1.5`]
* - `7.5` quotientAndRemainder `-3` returns [`-2`, `1.5`]
* - `-7.5` quotientAndRemainder `3` returns [`-2`, `-1.5`]
* - `-7.5` quotientAndRemainder `-3` returns [`2`, `-1.5`]
*
* @param BigNumber|int|float|string $that The divisor. Must be convertible to a BigDecimal.
*
* @return array{BigDecimal, BigDecimal} An array containing the quotient and the remainder.
*
* @throws MathException If the divisor is not valid, or is not convertible to a BigDecimal.
* @throws DivisionByZeroException If the divisor is zero.
*
* @pure
*/
public function quotientAndRemainder(BigNumber|int|float|string $that): array
{
$that = BigDecimal::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
$p = $this->valueWithMinScale($that->scale);
$q = $that->valueWithMinScale($this->scale);
[$quotient, $remainder] = CalculatorRegistry::get()->divQR($p, $q);
$scale = max($this->scale, $that->scale);
$quotient = new BigDecimal($quotient, 0);
$remainder = new BigDecimal($remainder, $scale);
return [$quotient, $remainder];
}
/**
* Returns the square root of this number, rounded to the given scale according to the given rounding mode.
*
* @param int $scale The target scale. Must be non-negative.
* @param RoundingMode $roundingMode The rounding mode to use, defaults to Down.
* ⚠️ WARNING: the default rounding mode was kept as Down for backward
* compatibility, but will change to Unnecessary in version 0.15. Pass a rounding
* mode explicitly to avoid this upcoming breaking change.
*
* @throws InvalidArgumentException If the scale is negative.
* @throws NegativeNumberException If this number is negative.
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is used and the result cannot be represented
* exactly at the given scale.
*
* @pure
*/
public function sqrt(int $scale, RoundingMode $roundingMode = RoundingMode::Down): BigDecimal
{
if (func_num_args() === 1) {
// @phpstan-ignore-next-line
trigger_error(
'The default rounding mode of BigDecimal::sqrt() will change from Down to Unnecessary in version 0.15. ' .
'Pass a rounding mode explicitly to avoid this breaking change.',
E_USER_DEPRECATED,
);
}
if ($scale < 0) {
throw new InvalidArgumentException('Scale must not be negative.');
}
if ($this->value === '0') {
return new BigDecimal('0', $scale);
}
if ($this->value[0] === '-') {
throw new NegativeNumberException('Cannot calculate the square root of a negative number.');
}
$value = $this->value;
$inputScale = $this->scale;
if ($inputScale % 2 !== 0) {
$value .= '0';
$inputScale++;
}
$calculator = CalculatorRegistry::get();
// Keep one extra digit for rounding.
$intermediateScale = max($scale, intdiv($inputScale, 2)) + 1;
$value .= str_repeat('0', 2 * $intermediateScale - $inputScale);
$sqrt = $calculator->sqrt($value);
$isExact = $calculator->mul($sqrt, $sqrt) === $value;
if (! $isExact) {
if ($roundingMode === RoundingMode::Unnecessary) {
throw RoundingNecessaryException::roundingNecessary();
}
// Non-perfect-square sqrt is irrational, so the true value is strictly above this sqrt floor.
// Add one at the intermediate scale to guarantee Up/Ceiling round up at the target scale.
if (in_array($roundingMode, [RoundingMode::Up, RoundingMode::Ceiling], true)) {
$sqrt = $calculator->add($sqrt, '1');
}
// Irrational sqrt cannot land exactly on a midpoint; treat tie-to-down modes as HalfUp.
elseif (in_array($roundingMode, [RoundingMode::HalfDown, RoundingMode::HalfEven, RoundingMode::HalfFloor], true)) {
$roundingMode = RoundingMode::HalfUp;
}
}
return (new BigDecimal($sqrt, $intermediateScale))->toScale($scale, $roundingMode);
}
/**
* Returns a copy of this BigDecimal with the decimal point moved to the left by the given number of places.
*
* @pure
*/
public function withPointMovedLeft(int $n): BigDecimal
{
if ($n === 0) {
return $this;
}
if ($n < 0) {
return $this->withPointMovedRight(-$n);
}
return new BigDecimal($this->value, $this->scale + $n);
}
/**
* Returns a copy of this BigDecimal with the decimal point moved to the right by the given number of places.
*
* @pure
*/
public function withPointMovedRight(int $n): BigDecimal
{
if ($n === 0) {
return $this;
}
if ($n < 0) {
return $this->withPointMovedLeft(-$n);
}
$value = $this->value;
$scale = $this->scale - $n;
if ($scale < 0) {
if ($value !== '0') {
$value .= str_repeat('0', -$scale);
}
$scale = 0;
}
return new BigDecimal($value, $scale);
}
/**
* Returns a copy of this BigDecimal with any trailing zeros removed from the fractional part.
*
* @deprecated Use strippedOfTrailingZeros() instead.
*/
public function stripTrailingZeros(): BigDecimal
{
trigger_error(
'BigDecimal::stripTrailingZeros() is deprecated, use strippedOfTrailingZeros() instead.',
E_USER_DEPRECATED,
);
return $this->strippedOfTrailingZeros();
}
/**
* Returns a copy of this BigDecimal with any trailing zeros removed from the fractional part.
*
* @pure
*/
public function strippedOfTrailingZeros(): BigDecimal
{
if ($this->scale === 0) {
return $this;
}
$trimmedValue = rtrim($this->value, '0');
if ($trimmedValue === '') {
return BigDecimal::zero();
}
$trimmableZeros = strlen($this->value) - strlen($trimmedValue);
if ($trimmableZeros === 0) {
return $this;
}
if ($trimmableZeros > $this->scale) {
$trimmableZeros = $this->scale;
}
$value = substr($this->value, 0, -$trimmableZeros);
$scale = $this->scale - $trimmableZeros;
return new BigDecimal($value, $scale);
}
#[Override]
public function negated(): static
{
return new BigDecimal(CalculatorRegistry::get()->neg($this->value), $this->scale);
}
#[Override]
public function compareTo(BigNumber|int|float|string $that): int
{
$that = BigNumber::of($that);
if ($that instanceof BigInteger) {
$that = $that->toBigDecimal();
}
if ($that instanceof BigDecimal) {
[$a, $b] = $this->scaleValues($this, $that);
return CalculatorRegistry::get()->cmp($a, $b);
}
return -$that->compareTo($this);
}
#[Override]
public function getSign(): int
{
return ($this->value === '0') ? 0 : (($this->value[0] === '-') ? -1 : 1);
}
/**
* @pure
*/
public function getUnscaledValue(): BigInteger
{
return self::newBigInteger($this->value);
}
/**
* @pure
*/
public function getScale(): int
{
return $this->scale;
}
/**
* Returns the number of significant digits in the number.
*
* This is the number of digits to both sides of the decimal point, stripped of leading zeros.
* The sign has no impact on the result.
*
* Examples:
* 0 => 0
* 0.0 => 0
* 123 => 3
* 123.456 => 6
* 0.00123 => 3
* 0.0012300 => 5
*
* @pure
*/
public function getPrecision(): int
{
$value = $this->value;
if ($value === '0') {
return 0;
}
$length = strlen($value);
return ($value[0] === '-') ? $length - 1 : $length;
}
/**
* Returns a string representing the integral part of this decimal number.
*
* Example: `-123.456` => `-123`.
*
* @deprecated Will be removed in 0.15 and re-introduced as returning BigInteger in 0.16.
*/
public function getIntegralPart(): string
{
trigger_error(
'BigDecimal::getIntegralPart() is deprecated and will be removed in 0.15. It will be re-introduced as returning BigInteger in 0.16.',
E_USER_DEPRECATED,
);
if ($this->scale === 0) {
return $this->value;
}
$value = $this->getUnscaledValueWithLeadingZeros();
return substr($value, 0, -$this->scale);
}
/**
* Returns a string representing the fractional part of this decimal number.
*
* If the scale is zero, an empty string is returned.
*
* Examples: `-123.456` => '456', `123` => ''.
*
* @deprecated Will be removed in 0.15 and re-introduced as returning BigDecimal with a different meaning in 0.16.
*/
public function getFractionalPart(): string
{
trigger_error(
'BigDecimal::getFractionalPart() is deprecated and will be removed in 0.15. It will be re-introduced as returning BigDecimal with a different meaning in 0.16.',
E_USER_DEPRECATED,
);
if ($this->scale === 0) {
return '';
}
$value = $this->getUnscaledValueWithLeadingZeros();
return substr($value, -$this->scale);
}
/**
* Returns whether this decimal number has a non-zero fractional part.
*
* @pure
*/
public function hasNonZeroFractionalPart(): bool
{
if ($this->scale === 0) {
return false;
}
$value = $this->getUnscaledValueWithLeadingZeros();
return substr($value, -$this->scale) !== str_repeat('0', $this->scale);
}
#[Override]
public function toBigInteger(): BigInteger
{
$zeroScaleDecimal = $this->scale === 0 ? $this : $this->dividedBy(1, 0);
return self::newBigInteger($zeroScaleDecimal->value);
}
#[Override]
public function toBigDecimal(): BigDecimal
{
return $this;
}
#[Override]
public function toBigRational(): BigRational
{
$numerator = self::newBigInteger($this->value);
$denominator = self::newBigInteger('1' . str_repeat('0', $this->scale));
return self::newBigRational($numerator, $denominator, false);
}
#[Override]
public function toScale(int $scale, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal
{
if ($scale === $this->scale) {
return $this;
}
return $this->dividedBy(BigDecimal::one(), $scale, $roundingMode);
}
#[Override]
public function toInt(): int
{
return $this->toBigInteger()->toInt();
}
#[Override]
public function toFloat(): float
{
return (float) $this->toString();
}
/**
* @return numeric-string
*/
#[Override]
public function toString(): string
{
if ($this->scale === 0) {
/** @var numeric-string */
return $this->value;
}
$value = $this->getUnscaledValueWithLeadingZeros();
/** @phpstan-ignore return.type */
return substr($value, 0, -$this->scale) . '.' . substr($value, -$this->scale);
}
/**
* This method is required for serializing the object and SHOULD NOT be accessed directly.
*
* @internal
*
* @return array{value: string, scale: int}
*/
public function __serialize(): array
{
return ['value' => $this->value, 'scale' => $this->scale];
}
/**
* This method is only here to allow unserializing the object and cannot be accessed directly.
*
* @internal
*
* @param array{value: string, scale: int} $data
*
* @throws LogicException
*/
public function __unserialize(array $data): void
{
/** @phpstan-ignore isset.initializedProperty */
if (isset($this->value)) {
throw new LogicException('__unserialize() is an internal function, it must not be called directly.');
}
/** @phpstan-ignore deadCode.unreachable */
$this->value = $data['value'];
$this->scale = $data['scale'];
}
#[Override]
protected static function from(BigNumber $number): static
{
return $number->toBigDecimal();
}
/**
* Puts the internal values of the given decimal numbers on the same scale.
*
* @return array{string, string} The scaled integer values of $x and $y.
*
* @pure
*/
private function scaleValues(BigDecimal $x, BigDecimal $y): array
{
$a = $x->value;
$b = $y->value;
if ($b !== '0' && $x->scale > $y->scale) {
$b .= str_repeat('0', $x->scale - $y->scale);
} elseif ($a !== '0' && $x->scale < $y->scale) {
$a .= str_repeat('0', $y->scale - $x->scale);
}
return [$a, $b];
}
/**
* @pure
*/
private function valueWithMinScale(int $scale): string
{
$value = $this->value;
if ($this->value !== '0' && $scale > $this->scale) {
$value .= str_repeat('0', $scale - $this->scale);
}
return $value;
}
/**
* Adds leading zeros if necessary to the unscaled value to represent the full decimal number.
*
* @pure
*/
private function getUnscaledValueWithLeadingZeros(): string
{
$value = $this->value;
$targetLength = $this->scale + 1;
$negative = ($value[0] === '-');
$length = strlen($value);
if ($negative) {
$length--;
}
if ($length >= $targetLength) {
return $this->value;
}
if ($negative) {
$value = substr($value, 1);
}
$value = str_pad($value, $targetLength, '0', STR_PAD_LEFT);
if ($negative) {
$value = '-' . $value;
}
return $value;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,712 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
use Brick\Math\Exception\DivisionByZeroException;
use Brick\Math\Exception\MathException;
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\Exception\RoundingNecessaryException;
use InvalidArgumentException;
use JsonSerializable;
use Override;
use Stringable;
use function array_shift;
use function assert;
use function filter_var;
use function is_float;
use function is_int;
use function is_nan;
use function is_null;
use function ltrim;
use function preg_match;
use function str_contains;
use function str_repeat;
use function strlen;
use function substr;
use function trigger_error;
use const E_USER_DEPRECATED;
use const FILTER_VALIDATE_INT;
use const PREG_UNMATCHED_AS_NULL;
/**
* Base class for arbitrary-precision numbers.
*
* This class is sealed: it is part of the public API but should not be subclassed in userland.
* Protected methods may change in any version.
*
* @phpstan-sealed BigInteger|BigDecimal|BigRational
*/
abstract readonly class BigNumber implements JsonSerializable, Stringable
{
/**
* The regular expression used to parse integer or decimal numbers.
*/
private const PARSE_REGEXP_NUMERICAL =
'/^' .
'(?<sign>[\-\+])?' .
'(?<integral>[0-9]+)?' .
'(?<point>\.)?' .
'(?<fractional>[0-9]+)?' .
'(?:[eE](?<exponent>[\-\+]?[0-9]+))?' .
'$/';
/**
* The regular expression used to parse rational numbers.
*/
private const PARSE_REGEXP_RATIONAL =
'/^' .
'(?<sign>[\-\+])?' .
'(?<numerator>[0-9]+)' .
'\/' .
'(?<denominator>[0-9]+)' .
'$/';
/**
* Creates a BigNumber of the given value.
*
* When of() is called on BigNumber, the concrete return type is dependent on the given value, with the following
* rules:
*
* - BigNumber instances are returned as is
* - integer numbers are returned as BigInteger
* - floating point numbers are converted to a string then parsed as such (deprecated, will be removed in 0.15)
* - strings containing a `/` character are returned as BigRational
* - strings containing a `.` character or using an exponential notation are returned as BigDecimal
* - strings containing only digits with an optional leading `+` or `-` sign are returned as BigInteger
*
* When of() is called on BigInteger, BigDecimal, or BigRational, the resulting number is converted to an instance
* of the subclass when possible; otherwise a RoundingNecessaryException exception is thrown.
*
* @throws NumberFormatException If the format of the number is not valid.
* @throws DivisionByZeroException If the value represents a rational number with a denominator of zero.
* @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding.
*
* @pure
*/
final public static function of(BigNumber|int|float|string $value): static
{
$value = self::_of($value);
if (static::class === BigNumber::class) {
assert($value instanceof static);
return $value;
}
return static::from($value);
}
/**
* Creates a BigNumber of the given value, or returns null if the input is null.
*
* Behaves like of() for non-null values.
*
* @see BigNumber::of()
*
* @throws NumberFormatException If the format of the number is not valid.
* @throws DivisionByZeroException If the value represents a rational number with a denominator of zero.
* @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding.
*
* @pure
*/
final public static function ofNullable(BigNumber|int|float|string|null $value): ?static
{
if (is_null($value)) {
return null;
}
return static::of($value);
}
/**
* Returns the minimum of the given values.
*
* If several values are equal and minimal, the first one is returned.
* This can affect the concrete return type when calling this method on BigNumber.
*
* @param BigNumber|int|float|string ...$values The numbers to compare. All the numbers must be convertible to an
* instance of the class this method is called on.
*
* @throws InvalidArgumentException If no values are given.
* @throws MathException If a number is not valid, or is not convertible to an instance of the class
* this method is called on.
*
* @pure
*/
final public static function min(BigNumber|int|float|string ...$values): static
{
$min = null;
foreach ($values as $value) {
$value = static::of($value);
if ($min === null || $value->isLessThan($min)) {
$min = $value;
}
}
if ($min === null) {
throw new InvalidArgumentException(__METHOD__ . '() expects at least one value.');
}
return $min;
}
/**
* Returns the maximum of the given values.
*
* If several values are equal and maximal, the first one is returned.
* This can affect the concrete return type when calling this method on BigNumber.
*
* @param BigNumber|int|float|string ...$values The numbers to compare. All the numbers must be convertible to an
* instance of the class this method is called on.
*
* @throws InvalidArgumentException If no values are given.
* @throws MathException If a number is not valid, or is not convertible to an instance of the class
* this method is called on.
*
* @pure
*/
final public static function max(BigNumber|int|float|string ...$values): static
{
$max = null;
foreach ($values as $value) {
$value = static::of($value);
if ($max === null || $value->isGreaterThan($max)) {
$max = $value;
}
}
if ($max === null) {
throw new InvalidArgumentException(__METHOD__ . '() expects at least one value.');
}
return $max;
}
/**
* Returns the sum of the given values.
*
* When called on BigNumber, sum() accepts any supported type and returns a result whose type is the widest among
* the given values (BigInteger < BigDecimal < BigRational).
*
* When called on BigInteger, BigDecimal, or BigRational, sum() requires that all values can be converted to that
* specific subclass, and returns a result of the same type.
*
* @param BigNumber|int|float|string ...$values The numbers to add. All the numbers must be convertible to an
* instance of the class this method is called on.
*
* @throws InvalidArgumentException If no values are given.
* @throws MathException If a number is not valid, or is not convertible to an instance of the class
* this method is called on.
*
* @pure
*/
final public static function sum(BigNumber|int|float|string ...$values): static
{
$first = array_shift($values);
if ($first === null) {
throw new InvalidArgumentException(__METHOD__ . '() expects at least one value.');
}
$sum = static::of($first);
foreach ($values as $value) {
$sum = self::add($sum, static::of($value));
}
assert($sum instanceof static);
return $sum;
}
/**
* Checks if this number is equal to the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isEqualTo(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) === 0;
}
/**
* Checks if this number is strictly less than the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isLessThan(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) < 0;
}
/**
* Checks if this number is less than or equal to the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isLessThanOrEqualTo(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) <= 0;
}
/**
* Checks if this number is strictly greater than the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isGreaterThan(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) > 0;
}
/**
* Checks if this number is greater than or equal to the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isGreaterThanOrEqualTo(BigNumber|int|float|string $that): bool
{
return $this->compareTo($that) >= 0;
}
/**
* Checks if this number equals zero.
*
* @pure
*/
final public function isZero(): bool
{
return $this->getSign() === 0;
}
/**
* Checks if this number is strictly negative.
*
* @pure
*/
final public function isNegative(): bool
{
return $this->getSign() < 0;
}
/**
* Checks if this number is negative or zero.
*
* @pure
*/
final public function isNegativeOrZero(): bool
{
return $this->getSign() <= 0;
}
/**
* Checks if this number is strictly positive.
*
* @pure
*/
final public function isPositive(): bool
{
return $this->getSign() > 0;
}
/**
* Checks if this number is positive or zero.
*
* @pure
*/
final public function isPositiveOrZero(): bool
{
return $this->getSign() >= 0;
}
/**
* Returns the absolute value of this number.
*
* @pure
*/
final public function abs(): static
{
return $this->isNegative() ? $this->negated() : $this;
}
/**
* Returns the negated value of this number.
*
* @pure
*/
abstract public function negated(): static;
/**
* Returns the sign of this number.
*
* Returns -1 if the number is negative, 0 if zero, 1 if positive.
*
* @return -1|0|1
*
* @pure
*/
abstract public function getSign(): int;
/**
* Compares this number to the given one.
*
* Returns -1 if `$this` is lower than, 0 if equal to, 1 if greater than `$that`.
*
* @return -1|0|1
*
* @throws MathException If the number is not valid.
*
* @pure
*/
abstract public function compareTo(BigNumber|int|float|string $that): int;
/**
* Limits (clamps) this number between the given minimum and maximum values.
*
* If the number is lower than $min, returns $min.
* If the number is greater than $max, returns $max.
* Otherwise, returns this number unchanged.
*
* @param BigNumber|int|float|string $min The minimum. Must be convertible to an instance of the class this method is called on.
* @param BigNumber|int|float|string $max The maximum. Must be convertible to an instance of the class this method is called on.
*
* @throws MathException If min/max are not convertible to an instance of the class this method is called on.
* @throws InvalidArgumentException If min is greater than max.
*
* @pure
*/
final public function clamp(BigNumber|int|float|string $min, BigNumber|int|float|string $max): static
{
$min = static::of($min);
$max = static::of($max);
if ($min->isGreaterThan($max)) {
throw new InvalidArgumentException('Minimum value must be less than or equal to maximum value.');
}
if ($this->isLessThan($min)) {
return $min;
}
if ($this->isGreaterThan($max)) {
return $max;
}
return $this;
}
/**
* Converts this number to a BigInteger.
*
* @throws RoundingNecessaryException If this number cannot be converted to a BigInteger without rounding.
*
* @pure
*/
abstract public function toBigInteger(): BigInteger;
/**
* Converts this number to a BigDecimal.
*
* @throws RoundingNecessaryException If this number cannot be converted to a BigDecimal without rounding.
*
* @pure
*/
abstract public function toBigDecimal(): BigDecimal;
/**
* Converts this number to a BigRational.
*
* @pure
*/
abstract public function toBigRational(): BigRational;
/**
* Converts this number to a BigDecimal with the given scale, using rounding if necessary.
*
* @param int $scale The scale of the resulting `BigDecimal`. Must be non-negative.
* @param RoundingMode $roundingMode An optional rounding mode, defaults to Unnecessary.
*
* @throws InvalidArgumentException If the scale is negative.
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is used, and this number cannot be converted to
* the given scale without rounding.
*
* @pure
*/
abstract public function toScale(int $scale, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal;
/**
* Returns the exact value of this number as a native integer.
*
* If this number cannot be converted to a native integer without losing precision, an exception is thrown.
* Note that the acceptable range for an integer depends on the platform and differs for 32-bit and 64-bit.
*
* @throws MathException If this number cannot be exactly converted to a native integer.
*
* @pure
*/
abstract public function toInt(): int;
/**
* Returns an approximation of this number as a floating-point value.
*
* Note that this method can discard information as the precision of a floating-point value
* is inherently limited.
*
* If the number is greater than the largest representable floating point number, positive infinity is returned.
* If the number is less than the smallest representable floating point number, negative infinity is returned.
* This method never returns NaN.
*
* @pure
*/
abstract public function toFloat(): float;
/**
* Returns a string representation of this number.
*
* The output of this method can be parsed by the `of()` factory method; this will yield an object equal to this
* one, but possibly of a different type if instantiated through `BigNumber::of()`.
*
* @pure
*/
abstract public function toString(): string;
#[Override]
final public function jsonSerialize(): string
{
return $this->toString();
}
/**
* @pure
*/
final public function __toString(): string
{
return $this->toString();
}
/**
* Overridden by subclasses to convert a BigNumber to an instance of the subclass.
*
* @throws RoundingNecessaryException If the value cannot be converted.
*
* @pure
*/
abstract protected static function from(BigNumber $number): static;
/**
* Proxy method to access BigInteger's protected constructor from sibling classes.
*
* @internal
*
* @pure
*/
final protected function newBigInteger(string $value): BigInteger
{
return new BigInteger($value);
}
/**
* Proxy method to access BigDecimal's protected constructor from sibling classes.
*
* @internal
*
* @pure
*/
final protected function newBigDecimal(string $value, int $scale = 0): BigDecimal
{
return new BigDecimal($value, $scale);
}
/**
* Proxy method to access BigRational's protected constructor from sibling classes.
*
* @internal
*
* @pure
*/
final protected function newBigRational(BigInteger $numerator, BigInteger $denominator, bool $checkDenominator): BigRational
{
return new BigRational($numerator, $denominator, $checkDenominator);
}
/**
* @throws NumberFormatException If the format of the number is not valid.
* @throws DivisionByZeroException If the value represents a rational number with a denominator of zero.
*
* @pure
*/
private static function _of(BigNumber|int|float|string $value): BigNumber
{
if ($value instanceof BigNumber) {
return $value;
}
if (is_int($value)) {
return new BigInteger((string) $value);
}
if (is_float($value)) {
// @phpstan-ignore-next-line
trigger_error(
'Passing floats to BigNumber::of() and arithmetic methods is deprecated and will be removed in 0.15. ' .
'Cast the float to string explicitly to preserve the previous behaviour.',
E_USER_DEPRECATED,
);
if (is_nan($value)) {
$value = 'NAN';
} else {
$value = (string) $value;
}
}
if (str_contains($value, '/')) {
// Rational number
if (preg_match(self::PARSE_REGEXP_RATIONAL, $value, $matches, PREG_UNMATCHED_AS_NULL) !== 1) {
throw NumberFormatException::invalidFormat($value);
}
$sign = $matches['sign'];
$numerator = $matches['numerator'];
$denominator = $matches['denominator'];
$numerator = self::cleanUp($sign, $numerator);
$denominator = self::cleanUp(null, $denominator);
if ($denominator === '0') {
throw DivisionByZeroException::denominatorMustNotBeZero();
}
return new BigRational(
new BigInteger($numerator),
new BigInteger($denominator),
false,
);
} else {
// Integer or decimal number
if (preg_match(self::PARSE_REGEXP_NUMERICAL, $value, $matches, PREG_UNMATCHED_AS_NULL) !== 1) {
throw NumberFormatException::invalidFormat($value);
}
$sign = $matches['sign'];
$point = $matches['point'];
$integral = $matches['integral'];
$fractional = $matches['fractional'];
$exponent = $matches['exponent'];
if ($integral === null && $fractional === null) {
throw NumberFormatException::invalidFormat($value);
}
if ($integral === null) {
$integral = '0';
}
if ($point !== null || $exponent !== null) {
$fractional ??= '';
if ($exponent !== null) {
if ($exponent[0] === '-') {
$exponent = ltrim(substr($exponent, 1), '0') ?: '0';
$exponent = filter_var($exponent, FILTER_VALIDATE_INT);
if ($exponent !== false) {
$exponent = -$exponent;
}
} else {
if ($exponent[0] === '+') {
$exponent = substr($exponent, 1);
}
$exponent = ltrim($exponent, '0') ?: '0';
$exponent = filter_var($exponent, FILTER_VALIDATE_INT);
}
} else {
$exponent = 0;
}
if ($exponent === false) {
throw new NumberFormatException('Exponent too large.');
}
$unscaledValue = self::cleanUp($sign, $integral . $fractional);
$scale = strlen($fractional) - $exponent;
if ($scale < 0) {
if ($unscaledValue !== '0') {
$unscaledValue .= str_repeat('0', -$scale);
}
$scale = 0;
}
return new BigDecimal($unscaledValue, $scale);
}
$integral = self::cleanUp($sign, $integral);
return new BigInteger($integral);
}
}
/**
* Removes optional leading zeros and applies sign.
*
* @param string|null $sign The sign, '+' or '-', optional. Null is allowed for convenience and treated as '+'.
* @param string $number The number, validated as a string of digits.
*
* @pure
*/
private static function cleanUp(string|null $sign, string $number): string
{
$number = ltrim($number, '0');
if ($number === '') {
return '0';
}
return $sign === '-' ? '-' . $number : $number;
}
/**
* Adds two BigNumber instances in the correct order to avoid a RoundingNecessaryException.
*
* @pure
*/
private static function add(BigNumber $a, BigNumber $b): BigNumber
{
if ($a instanceof BigRational) {
return $a->plus($b);
}
if ($b instanceof BigRational) {
return $b->plus($a);
}
if ($a instanceof BigDecimal) {
return $a->plus($b);
}
if ($b instanceof BigDecimal) {
return $b->plus($a);
}
return $a->plus($b);
}
}

View file

@ -1,606 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
use Brick\Math\Exception\DivisionByZeroException;
use Brick\Math\Exception\MathException;
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\Exception\RoundingNecessaryException;
use InvalidArgumentException;
use LogicException;
use Override;
use function is_finite;
use function max;
use function min;
use function strlen;
use function substr;
use function trigger_error;
use const E_USER_DEPRECATED;
/**
* An arbitrarily large rational number.
*
* This class is immutable.
*
* Fractions are automatically simplified to lowest terms. For example, `2/4` becomes `1/2`.
* The denominator is always strictly positive; the sign is carried by the numerator.
*/
final readonly class BigRational extends BigNumber
{
/**
* The numerator.
*/
private BigInteger $numerator;
/**
* The denominator. Always strictly positive.
*/
private BigInteger $denominator;
/**
* Protected constructor. Use a factory method to obtain an instance.
*
* @param BigInteger $numerator The numerator.
* @param BigInteger $denominator The denominator.
* @param bool $checkDenominator Whether to check the denominator for negative and zero.
*
* @throws DivisionByZeroException If the denominator is zero.
*
* @pure
*/
protected function __construct(BigInteger $numerator, BigInteger $denominator, bool $checkDenominator)
{
if ($checkDenominator) {
if ($denominator->isZero()) {
throw DivisionByZeroException::denominatorMustNotBeZero();
}
if ($denominator->isNegative()) {
$numerator = $numerator->negated();
$denominator = $denominator->negated();
}
}
$this->numerator = $numerator;
$this->denominator = $denominator;
}
/**
* Creates a BigRational out of a numerator and a denominator.
*
* If the denominator is negative, the signs of both the numerator and the denominator
* will be inverted to ensure that the denominator is always positive.
*
* @deprecated Use ofFraction() instead.
*
* @param BigNumber|int|float|string $numerator The numerator. Must be convertible to a BigInteger.
* @param BigNumber|int|float|string $denominator The denominator. Must be convertible to a BigInteger.
*
* @throws NumberFormatException If an argument does not represent a valid number.
* @throws RoundingNecessaryException If an argument represents a non-integer number.
* @throws DivisionByZeroException If the denominator is zero.
*/
public static function nd(
BigNumber|int|float|string $numerator,
BigNumber|int|float|string $denominator,
): BigRational {
trigger_error(
'The BigRational::nd() method is deprecated, use BigRational::ofFraction() instead.',
E_USER_DEPRECATED,
);
return self::ofFraction($numerator, $denominator);
}
/**
* Creates a BigRational out of a numerator and a denominator.
*
* If the denominator is negative, the signs of both the numerator and the denominator
* will be inverted to ensure that the denominator is always positive.
*
* @param BigNumber|int|float|string $numerator The numerator. Must be convertible to a BigInteger.
* @param BigNumber|int|float|string $denominator The denominator. Must be convertible to a BigInteger.
*
* @throws MathException If an argument is not valid, or is not convertible to a BigInteger.
* @throws DivisionByZeroException If the denominator is zero.
*
* @pure
*/
public static function ofFraction(
BigNumber|int|float|string $numerator,
BigNumber|int|float|string $denominator,
): BigRational {
$numerator = BigInteger::of($numerator);
$denominator = BigInteger::of($denominator);
return new BigRational($numerator, $denominator, true);
}
/**
* Returns a BigRational representing zero.
*
* @pure
*/
public static function zero(): BigRational
{
/** @var BigRational|null $zero */
static $zero;
if ($zero === null) {
$zero = new BigRational(BigInteger::zero(), BigInteger::one(), false);
}
return $zero;
}
/**
* Returns a BigRational representing one.
*
* @pure
*/
public static function one(): BigRational
{
/** @var BigRational|null $one */
static $one;
if ($one === null) {
$one = new BigRational(BigInteger::one(), BigInteger::one(), false);
}
return $one;
}
/**
* Returns a BigRational representing ten.
*
* @pure
*/
public static function ten(): BigRational
{
/** @var BigRational|null $ten */
static $ten;
if ($ten === null) {
$ten = new BigRational(BigInteger::ten(), BigInteger::one(), false);
}
return $ten;
}
/**
* @pure
*/
public function getNumerator(): BigInteger
{
return $this->numerator;
}
/**
* @pure
*/
public function getDenominator(): BigInteger
{
return $this->denominator;
}
/**
* Returns the quotient of the division of the numerator by the denominator.
*
* @deprecated Will be removed in 0.15. Use getIntegralPart() instead.
*/
public function quotient(): BigInteger
{
trigger_error(
'BigRational::quotient() is deprecated and will be removed in 0.15. Use getIntegralPart() instead.',
E_USER_DEPRECATED,
);
return $this->numerator->quotient($this->denominator);
}
/**
* Returns the remainder of the division of the numerator by the denominator.
*
* @deprecated Will be removed in 0.15. Use `$number->getNumerator()->remainder($number->getDenominator())` instead.
*/
public function remainder(): BigInteger
{
trigger_error(
'BigRational::remainder() is deprecated and will be removed in 0.15. Use `$number->getNumerator()->remainder($number->getDenominator())` instead.',
E_USER_DEPRECATED,
);
return $this->numerator->remainder($this->denominator);
}
/**
* Returns the quotient and remainder of the division of the numerator by the denominator.
*
* @deprecated Will be removed in 0.15. Use `$number->getNumerator()->quotientAndRemainder($number->getDenominator())` instead.
*
* @return array{BigInteger, BigInteger}
*/
public function quotientAndRemainder(): array
{
trigger_error(
'BigRational::quotientAndRemainder() is deprecated and will be removed in 0.15. Use `$number->getNumerator()->quotientAndRemainder($number->getDenominator())` instead.',
E_USER_DEPRECATED,
);
return $this->numerator->quotientAndRemainder($this->denominator);
}
/**
* Returns the integral part of this rational number.
*
* Examples:
*
* - `7/3` returns `2` (since 7/3 = 2 + 1/3)
* - `-7/3` returns `-2` (since -7/3 = -2 + (-1/3))
*
* The following identity holds: `$r->isEqualTo($r->getFractionalPart()->plus($r->getIntegralPart()))`.
*
* @pure
*/
public function getIntegralPart(): BigInteger
{
return $this->numerator->quotient($this->denominator);
}
/**
* Returns the fractional part of this rational number.
*
* Examples:
*
* - `7/3` returns `1/3` (since 7/3 = 2 + 1/3)
* - `-7/3` returns `-1/3` (since -7/3 = -2 + (-1/3))
*
* The following identity holds: `$r->isEqualTo($r->getFractionalPart()->plus($r->getIntegralPart()))`.
*
* @pure
*/
public function getFractionalPart(): BigRational
{
return new BigRational($this->numerator->remainder($this->denominator), $this->denominator, false);
}
/**
* Returns the sum of this number and the given one.
*
* @param BigNumber|int|float|string $that The number to add.
*
* @throws MathException If the number is not valid.
*
* @pure
*/
public function plus(BigNumber|int|float|string $that): BigRational
{
$that = BigRational::of($that);
$numerator = $this->numerator->multipliedBy($that->denominator);
$numerator = $numerator->plus($that->numerator->multipliedBy($this->denominator));
$denominator = $this->denominator->multipliedBy($that->denominator);
return new BigRational($numerator, $denominator, false);
}
/**
* Returns the difference of this number and the given one.
*
* @param BigNumber|int|float|string $that The number to subtract.
*
* @throws MathException If the number is not valid.
*
* @pure
*/
public function minus(BigNumber|int|float|string $that): BigRational
{
$that = BigRational::of($that);
$numerator = $this->numerator->multipliedBy($that->denominator);
$numerator = $numerator->minus($that->numerator->multipliedBy($this->denominator));
$denominator = $this->denominator->multipliedBy($that->denominator);
return new BigRational($numerator, $denominator, false);
}
/**
* Returns the product of this number and the given one.
*
* @param BigNumber|int|float|string $that The multiplier.
*
* @throws MathException If the multiplier is not valid.
*
* @pure
*/
public function multipliedBy(BigNumber|int|float|string $that): BigRational
{
$that = BigRational::of($that);
$numerator = $this->numerator->multipliedBy($that->numerator);
$denominator = $this->denominator->multipliedBy($that->denominator);
return new BigRational($numerator, $denominator, false);
}
/**
* Returns the result of the division of this number by the given one.
*
* @param BigNumber|int|float|string $that The divisor.
*
* @throws MathException If the divisor is not valid.
* @throws DivisionByZeroException If the divisor is zero.
*
* @pure
*/
public function dividedBy(BigNumber|int|float|string $that): BigRational
{
$that = BigRational::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
$numerator = $this->numerator->multipliedBy($that->denominator);
$denominator = $this->denominator->multipliedBy($that->numerator);
return new BigRational($numerator, $denominator, true);
}
/**
* Returns this number exponentiated to the given value.
*
* @throws InvalidArgumentException If the exponent is not in the range 0 to 1,000,000.
*
* @pure
*/
public function power(int $exponent): BigRational
{
if ($exponent === 0) {
return BigRational::one();
}
if ($exponent === 1) {
return $this;
}
return new BigRational(
$this->numerator->power($exponent),
$this->denominator->power($exponent),
false,
);
}
/**
* Returns the reciprocal of this BigRational.
*
* The reciprocal has the numerator and denominator swapped.
*
* @throws DivisionByZeroException If the numerator is zero.
*
* @pure
*/
public function reciprocal(): BigRational
{
return new BigRational($this->denominator, $this->numerator, true);
}
#[Override]
public function negated(): static
{
return new BigRational($this->numerator->negated(), $this->denominator, false);
}
/**
* Returns the simplified value of this BigRational.
*
* @pure
*/
public function simplified(): BigRational
{
$gcd = $this->numerator->gcd($this->denominator);
$numerator = $this->numerator->quotient($gcd);
$denominator = $this->denominator->quotient($gcd);
return new BigRational($numerator, $denominator, false);
}
#[Override]
public function compareTo(BigNumber|int|float|string $that): int
{
$that = BigRational::of($that);
if ($this->denominator->isEqualTo($that->denominator)) {
return $this->numerator->compareTo($that->numerator);
}
return $this->numerator
->multipliedBy($that->denominator)
->compareTo($that->numerator->multipliedBy($this->denominator));
}
#[Override]
public function getSign(): int
{
return $this->numerator->getSign();
}
#[Override]
public function toBigInteger(): BigInteger
{
$simplified = $this->simplified();
if (! $simplified->denominator->isEqualTo(1)) {
throw new RoundingNecessaryException('This rational number cannot be represented as an integer value without rounding.');
}
return $simplified->numerator;
}
#[Override]
public function toBigDecimal(): BigDecimal
{
return $this->numerator->toBigDecimal()->dividedByExact($this->denominator);
}
#[Override]
public function toBigRational(): BigRational
{
return $this;
}
#[Override]
public function toScale(int $scale, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal
{
return $this->numerator->toBigDecimal()->dividedBy($this->denominator, $scale, $roundingMode);
}
#[Override]
public function toInt(): int
{
return $this->toBigInteger()->toInt();
}
#[Override]
public function toFloat(): float
{
$simplified = $this->simplified();
$numeratorFloat = $simplified->numerator->toFloat();
$denominatorFloat = $simplified->denominator->toFloat();
if (is_finite($numeratorFloat) && is_finite($denominatorFloat)) {
return $numeratorFloat / $denominatorFloat;
}
// At least one side overflows to INF; use a decimal approximation instead.
// We need ~17 significant digits for double precision (we use 20 for some margin). Since $scale controls
// decimal places (not significant digits), we subtract the estimated order of magnitude so that large results
// use fewer decimal places and small results use more (to look past leading zeros). Clamped to [0, 350] as
// doubles range from e-324 to e308 (350 ≈ 324 + 20 significant digits + margin).
$magnitude = strlen($simplified->numerator->abs()->toString()) - strlen($simplified->denominator->toString());
$scale = min(350, max(0, 20 - $magnitude));
return $simplified->numerator
->toBigDecimal()
->dividedBy($simplified->denominator, $scale, RoundingMode::HalfEven)
->toFloat();
}
#[Override]
public function toString(): string
{
$numerator = $this->numerator->toString();
$denominator = $this->denominator->toString();
if ($denominator === '1') {
return $numerator;
}
return $numerator . '/' . $denominator;
}
/**
* Returns the decimal representation of this rational number, with repeating decimals in parentheses.
*
* WARNING: This method is unbounded.
* The length of the repeating decimal period can be as large as `denominator - 1`.
* For fractions with large denominators, this method can use excessive memory and CPU time.
* For example, `1/100019` has a repeating period of 100,018 digits.
*
* Examples:
*
* - `10/3` returns `3.(3)`
* - `171/70` returns `2.4(428571)`
* - `1/2` returns `0.5`
*
* @pure
*/
public function toRepeatingDecimalString(): string
{
if ($this->numerator->isZero()) {
return '0';
}
$sign = $this->numerator->isNegative() ? '-' : '';
$numerator = $this->numerator->abs();
$denominator = $this->denominator;
$integral = $numerator->quotient($denominator);
$remainder = $numerator->remainder($denominator);
$integralString = $integral->toString();
if ($remainder->isZero()) {
return $sign . $integralString;
}
$digits = '';
$remainderPositions = [];
$index = 0;
while (! $remainder->isZero()) {
$remainderString = $remainder->toString();
if (isset($remainderPositions[$remainderString])) {
$repeatIndex = $remainderPositions[$remainderString];
$nonRepeating = substr($digits, 0, $repeatIndex);
$repeating = substr($digits, $repeatIndex);
return $sign . $integralString . '.' . $nonRepeating . '(' . $repeating . ')';
}
$remainderPositions[$remainderString] = $index;
$remainder = $remainder->multipliedBy(10);
$digits .= $remainder->quotient($denominator)->toString();
$remainder = $remainder->remainder($denominator);
$index++;
}
return $sign . $integralString . '.' . $digits;
}
/**
* This method is required for serializing the object and SHOULD NOT be accessed directly.
*
* @internal
*
* @return array{numerator: BigInteger, denominator: BigInteger}
*/
public function __serialize(): array
{
return ['numerator' => $this->numerator, 'denominator' => $this->denominator];
}
/**
* This method is only here to allow unserializing the object and cannot be accessed directly.
*
* @internal
*
* @param array{numerator: BigInteger, denominator: BigInteger} $data
*
* @throws LogicException
*/
public function __unserialize(array $data): void
{
/** @phpstan-ignore isset.initializedProperty */
if (isset($this->numerator)) {
throw new LogicException('__unserialize() is an internal function, it must not be called directly.');
}
/** @phpstan-ignore deadCode.unreachable */
$this->numerator = $data['numerator'];
$this->denominator = $data['denominator'];
}
#[Override]
protected static function from(BigNumber $number): static
{
return $number->toBigRational();
}
}

View file

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
/**
* Exception thrown when a division by zero occurs.
*/
final class DivisionByZeroException extends MathException
{
/**
* @pure
*/
public static function divisionByZero(): DivisionByZeroException
{
return new self('Division by zero.');
}
/**
* @pure
*/
public static function modulusMustNotBeZero(): DivisionByZeroException
{
return new self('The modulus must not be zero.');
}
/**
* @pure
*/
public static function denominatorMustNotBeZero(): DivisionByZeroException
{
return new self('The denominator of a rational number cannot be zero.');
}
}

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use Brick\Math\BigInteger;
use function sprintf;
use const PHP_INT_MAX;
use const PHP_INT_MIN;
/**
* Exception thrown when an integer overflow occurs.
*/
final class IntegerOverflowException extends MathException
{
/**
* @pure
*/
public static function toIntOverflow(BigInteger $value): IntegerOverflowException
{
$message = '%s is out of range %d to %d and cannot be represented as an integer.';
return new self(sprintf($message, $value->toString(), PHP_INT_MIN, PHP_INT_MAX));
}
}

View file

@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use RuntimeException;
/**
* Base class for all math exceptions.
*/
class MathException extends RuntimeException
{
}

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
/**
* Exception thrown when attempting to perform an unsupported operation, such as a square root, on a negative number.
*/
final class NegativeNumberException extends MathException
{
}

View file

@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use function dechex;
use function ord;
use function sprintf;
use function strtoupper;
/**
* Exception thrown when attempting to create a number from a string with an invalid format.
*/
final class NumberFormatException extends MathException
{
/**
* @pure
*/
public static function invalidFormat(string $value): self
{
return new self(sprintf(
'The given value "%s" does not represent a valid number.',
$value,
));
}
/**
* @param string $char The failing character.
*
* @pure
*/
public static function charNotInAlphabet(string $char): self
{
return new self(sprintf(
'Character %s is not valid in the given alphabet.',
self::charToString($char),
));
}
/**
* @pure
*/
private static function charToString(string $char): string
{
$ord = ord($char);
if ($ord < 32 || $ord > 126) {
$char = strtoupper(dechex($ord));
if ($ord < 16) {
$char = '0' . $char;
}
return '0x' . $char;
}
return '"' . $char . '"';
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
/**
* Exception thrown when a number cannot be represented at the requested scale without rounding.
*/
final class RoundingNecessaryException extends MathException
{
/**
* @pure
*/
public static function roundingNecessary(): RoundingNecessaryException
{
return new self('Rounding is necessary to represent the result of the operation at this scale.');
}
}

View file

@ -1,704 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal;
use Brick\Math\Exception\RoundingNecessaryException;
use Brick\Math\RoundingMode;
use function chr;
use function ltrim;
use function ord;
use function str_repeat;
use function strlen;
use function strpos;
use function strrev;
use function strtolower;
use function substr;
/**
* Performs basic operations on arbitrary size integers.
*
* Unless otherwise specified, all parameters must be validated as non-empty strings of digits,
* without leading zero, and with an optional leading minus sign if the number is not zero.
*
* Any other parameter format will lead to undefined behaviour.
* All methods must return strings respecting this format, unless specified otherwise.
*
* @internal
*/
abstract readonly class Calculator
{
/**
* The maximum exponent value allowed for the pow() method.
*/
public const MAX_POWER = 1_000_000;
/**
* The alphabet for converting from and to base 2 to 36, lowercase.
*/
public const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
/**
* Returns the absolute value of a number.
*
* @pure
*/
final public function abs(string $n): string
{
return ($n[0] === '-') ? substr($n, 1) : $n;
}
/**
* Negates a number.
*
* @pure
*/
final public function neg(string $n): string
{
if ($n === '0') {
return '0';
}
if ($n[0] === '-') {
return substr($n, 1);
}
return '-' . $n;
}
/**
* Compares two numbers.
*
* Returns -1 if the first number is less than, 0 if equal to, 1 if greater than the second number.
*
* @return -1|0|1
*
* @pure
*/
final public function cmp(string $a, string $b): int
{
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
if ($aNeg && ! $bNeg) {
return -1;
}
if ($bNeg && ! $aNeg) {
return 1;
}
$aLen = strlen($aDig);
$bLen = strlen($bDig);
if ($aLen < $bLen) {
$result = -1;
} elseif ($aLen > $bLen) {
$result = 1;
} else {
$result = $aDig <=> $bDig;
}
return $aNeg ? -$result : $result;
}
/**
* Adds two numbers.
*
* @pure
*/
abstract public function add(string $a, string $b): string;
/**
* Subtracts two numbers.
*
* @pure
*/
abstract public function sub(string $a, string $b): string;
/**
* Multiplies two numbers.
*
* @pure
*/
abstract public function mul(string $a, string $b): string;
/**
* Returns the quotient of the division of two numbers.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
*
* @return string The quotient.
*
* @pure
*/
abstract public function divQ(string $a, string $b): string;
/**
* Returns the remainder of the division of two numbers.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
*
* @return string The remainder.
*
* @pure
*/
abstract public function divR(string $a, string $b): string;
/**
* Returns the quotient and remainder of the division of two numbers.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
*
* @return array{string, string} An array containing the quotient and remainder.
*
* @pure
*/
abstract public function divQR(string $a, string $b): array;
/**
* Exponentiates a number.
*
* @param string $a The base number.
* @param int $e The exponent, validated as an integer between 0 and MAX_POWER.
*
* @return string The power.
*
* @pure
*/
abstract public function pow(string $a, int $e): string;
/**
* @param string $b The modulus; must not be zero.
*
* @pure
*/
public function mod(string $a, string $b): string
{
return $this->divR($this->add($this->divR($a, $b), $b), $b);
}
/**
* Returns the modular multiplicative inverse of $x modulo $m.
*
* If $x has no multiplicative inverse mod m, this method must return null.
*
* This method can be overridden by the concrete implementation if the underlying library has built-in support.
*
* @param string $m The modulus; must not be negative or zero.
*
* @pure
*/
public function modInverse(string $x, string $m): ?string
{
if ($m === '1') {
return '0';
}
$modVal = $x;
if ($x[0] === '-' || ($this->cmp($this->abs($x), $m) >= 0)) {
$modVal = $this->mod($x, $m);
}
[$g, $x] = $this->gcdExtended($modVal, $m);
if ($g !== '1') {
return null;
}
return $this->mod($this->add($this->mod($x, $m), $m), $m);
}
/**
* Raises a number into power with modulo.
*
* @param string $base The base number.
* @param string $exp The exponent; must be positive or zero.
* @param string $mod The modulus; must be strictly positive.
*
* @pure
*/
abstract public function modPow(string $base, string $exp, string $mod): string;
/**
* Returns the greatest common divisor of the two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for GCD calculations.
*
* @return string The GCD, always positive, or zero if both arguments are zero.
*
* @pure
*/
public function gcd(string $a, string $b): string
{
if ($a === '0') {
return $this->abs($b);
}
if ($b === '0') {
return $this->abs($a);
}
return $this->gcd($b, $this->divR($a, $b));
}
/**
* Returns the least common multiple of the two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for LCM calculations.
*
* @return string The LCM, always positive, or zero if at least one argument is zero.
*
* @pure
*/
public function lcm(string $a, string $b): string
{
if ($a === '0' || $b === '0') {
return '0';
}
return $this->divQ($this->abs($this->mul($a, $b)), $this->gcd($a, $b));
}
/**
* Returns the square root of the given number, rounded down.
*
* The result is the largest x such that n.
* The input MUST NOT be negative.
*
* @pure
*/
abstract public function sqrt(string $n): string;
/**
* Converts a number from an arbitrary base.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for base conversion.
*
* @param string $number The number, positive or zero, non-empty, case-insensitively validated for the given base.
* @param int $base The base of the number, validated from 2 to 36.
*
* @return string The converted number, following the Calculator conventions.
*
* @pure
*/
public function fromBase(string $number, int $base): string
{
return $this->fromArbitraryBase(strtolower($number), self::ALPHABET, $base);
}
/**
* Converts a number to an arbitrary base.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for base conversion.
*
* @param string $number The number to convert, following the Calculator conventions.
* @param int $base The base to convert to, validated from 2 to 36.
*
* @return string The converted number, lowercase.
*
* @pure
*/
public function toBase(string $number, int $base): string
{
$negative = ($number[0] === '-');
if ($negative) {
$number = substr($number, 1);
}
$number = $this->toArbitraryBase($number, self::ALPHABET, $base);
if ($negative) {
return '-' . $number;
}
return $number;
}
/**
* Converts a non-negative number in an arbitrary base using a custom alphabet, to base 10.
*
* @param string $number The number to convert, validated as a non-empty string,
* containing only chars in the given alphabet/base.
* @param string $alphabet The alphabet that contains every digit, validated as 2 chars minimum.
* @param int $base The base of the number, validated from 2 to alphabet length.
*
* @return string The number in base 10, following the Calculator conventions.
*
* @pure
*/
final public function fromArbitraryBase(string $number, string $alphabet, int $base): string
{
// remove leading "zeros"
$number = ltrim($number, $alphabet[0]);
if ($number === '') {
return '0';
}
// optimize for "one"
if ($number === $alphabet[1]) {
return '1';
}
$result = '0';
$power = '1';
$base = (string) $base;
for ($i = strlen($number) - 1; $i >= 0; $i--) {
$index = strpos($alphabet, $number[$i]);
if ($index !== 0) {
$result = $this->add(
$result,
($index === 1) ? $power : $this->mul($power, (string) $index),
);
}
if ($i !== 0) {
$power = $this->mul($power, $base);
}
}
return $result;
}
/**
* Converts a non-negative number to an arbitrary base using a custom alphabet.
*
* @param string $number The number to convert, positive or zero, following the Calculator conventions.
* @param string $alphabet The alphabet that contains every digit, validated as 2 chars minimum.
* @param int $base The base to convert to, validated from 2 to alphabet length.
*
* @return string The converted number in the given alphabet.
*
* @pure
*/
final public function toArbitraryBase(string $number, string $alphabet, int $base): string
{
if ($number === '0') {
return $alphabet[0];
}
$base = (string) $base;
$result = '';
while ($number !== '0') {
[$number, $remainder] = $this->divQR($number, $base);
$remainder = (int) $remainder;
$result .= $alphabet[$remainder];
}
return strrev($result);
}
/**
* Performs a rounded division.
*
* Rounding is performed when the remainder of the division is not zero.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
* @param RoundingMode $roundingMode The rounding mode.
*
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is provided but rounding is necessary.
*
* @pure
*/
final public function divRound(string $a, string $b, RoundingMode $roundingMode): string
{
[$quotient, $remainder] = $this->divQR($a, $b);
$hasDiscardedFraction = ($remainder !== '0');
$isPositiveOrZero = ($a[0] === '-') === ($b[0] === '-');
$discardedFractionSign = function () use ($remainder, $b): int {
$r = $this->abs($this->mul($remainder, '2'));
$b = $this->abs($b);
return $this->cmp($r, $b);
};
$increment = false;
switch ($roundingMode) {
case RoundingMode::Unnecessary:
if ($hasDiscardedFraction) {
throw RoundingNecessaryException::roundingNecessary();
}
break;
case RoundingMode::Up:
$increment = $hasDiscardedFraction;
break;
case RoundingMode::Down:
break;
case RoundingMode::Ceiling:
$increment = $hasDiscardedFraction && $isPositiveOrZero;
break;
case RoundingMode::Floor:
$increment = $hasDiscardedFraction && ! $isPositiveOrZero;
break;
case RoundingMode::HalfUp:
$increment = $discardedFractionSign() >= 0;
break;
case RoundingMode::HalfDown:
$increment = $discardedFractionSign() > 0;
break;
case RoundingMode::HalfCeiling:
$increment = $isPositiveOrZero ? $discardedFractionSign() >= 0 : $discardedFractionSign() > 0;
break;
case RoundingMode::HalfFloor:
$increment = $isPositiveOrZero ? $discardedFractionSign() > 0 : $discardedFractionSign() >= 0;
break;
case RoundingMode::HalfEven:
$lastDigit = (int) $quotient[-1];
$lastDigitIsEven = ($lastDigit % 2 === 0);
$increment = $lastDigitIsEven ? $discardedFractionSign() > 0 : $discardedFractionSign() >= 0;
break;
}
if ($increment) {
return $this->add($quotient, $isPositiveOrZero ? '1' : '-1');
}
return $quotient;
}
/**
* Calculates bitwise AND of two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for bitwise operations.
*
* @pure
*/
public function and(string $a, string $b): string
{
return $this->bitwise('and', $a, $b);
}
/**
* Calculates bitwise OR of two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for bitwise operations.
*
* @pure
*/
public function or(string $a, string $b): string
{
return $this->bitwise('or', $a, $b);
}
/**
* Calculates bitwise XOR of two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for bitwise operations.
*
* @pure
*/
public function xor(string $a, string $b): string
{
return $this->bitwise('xor', $a, $b);
}
/**
* Extracts the sign & digits of the operands.
*
* @return array{bool, bool, string, string} Whether $a and $b are negative, followed by their digits.
*
* @pure
*/
final protected function init(string $a, string $b): array
{
return [
$aNeg = ($a[0] === '-'),
$bNeg = ($b[0] === '-'),
$aNeg ? substr($a, 1) : $a,
$bNeg ? substr($b, 1) : $b,
];
}
/**
* @return array{string, string, string} GCD, X, Y
*
* @pure
*/
private function gcdExtended(string $a, string $b): array
{
if ($a === '0') {
return [$b, '0', '1'];
}
[$gcd, $x1, $y1] = $this->gcdExtended($this->mod($b, $a), $a);
$x = $this->sub($y1, $this->mul($this->divQ($b, $a), $x1));
$y = $x1;
return [$gcd, $x, $y];
}
/**
* Performs a bitwise operation on a decimal number.
*
* @param 'and'|'or'|'xor' $operator The operator to use.
* @param string $a The left operand.
* @param string $b The right operand.
*
* @pure
*/
private function bitwise(string $operator, string $a, string $b): string
{
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
$aBin = $this->toBinary($aDig);
$bBin = $this->toBinary($bDig);
$aLen = strlen($aBin);
$bLen = strlen($bBin);
if ($aLen > $bLen) {
$bBin = str_repeat("\x00", $aLen - $bLen) . $bBin;
} elseif ($bLen > $aLen) {
$aBin = str_repeat("\x00", $bLen - $aLen) . $aBin;
}
if ($aNeg) {
$aBin = $this->twosComplement($aBin);
}
if ($bNeg) {
$bBin = $this->twosComplement($bBin);
}
$value = match ($operator) {
'and' => $aBin & $bBin,
'or' => $aBin | $bBin,
'xor' => $aBin ^ $bBin,
};
$negative = match ($operator) {
'and' => $aNeg and $bNeg,
'or' => $aNeg or $bNeg,
'xor' => $aNeg xor $bNeg,
};
if ($negative) {
$value = $this->twosComplement($value);
}
$result = $this->toDecimal($value);
return $negative ? $this->neg($result) : $result;
}
/**
* @param string $number A positive, binary number.
*
* @pure
*/
private function twosComplement(string $number): string
{
$xor = str_repeat("\xff", strlen($number));
$number ^= $xor;
for ($i = strlen($number) - 1; $i >= 0; $i--) {
$byte = ord($number[$i]);
if (++$byte !== 256) {
$number[$i] = chr($byte);
break;
}
$number[$i] = "\x00";
if ($i === 0) {
$number = "\x01" . $number;
}
}
return $number;
}
/**
* Converts a decimal number to a binary string.
*
* @param string $number The number to convert, positive or zero, only digits.
*
* @pure
*/
private function toBinary(string $number): string
{
$result = '';
while ($number !== '0') {
[$number, $remainder] = $this->divQR($number, '256');
$result .= chr((int) $remainder);
}
return strrev($result);
}
/**
* Returns the positive decimal representation of a binary number.
*
* @param string $bytes The bytes representing the number.
*
* @pure
*/
private function toDecimal(string $bytes): string
{
$result = '0';
$power = '1';
for ($i = strlen($bytes) - 1; $i >= 0; $i--) {
$index = ord($bytes[$i]);
if ($index !== 0) {
$result = $this->add(
$result,
($index === 1) ? $power : $this->mul($power, (string) $index),
);
}
if ($i !== 0) {
$power = $this->mul($power, '256');
}
}
return $result;
}
}

View file

@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal\Calculator;
use Brick\Math\Internal\Calculator;
use Override;
use function bcadd;
use function bcdiv;
use function bcmod;
use function bcmul;
use function bcpow;
use function bcpowmod;
use function bcsqrt;
use function bcsub;
/**
* Calculator implementation built around the bcmath library.
*
* @internal
*/
final readonly class BcMathCalculator extends Calculator
{
#[Override]
public function add(string $a, string $b): string
{
return bcadd($a, $b, 0);
}
#[Override]
public function sub(string $a, string $b): string
{
return bcsub($a, $b, 0);
}
#[Override]
public function mul(string $a, string $b): string
{
return bcmul($a, $b, 0);
}
#[Override]
public function divQ(string $a, string $b): string
{
return bcdiv($a, $b, 0);
}
#[Override]
public function divR(string $a, string $b): string
{
return bcmod($a, $b, 0);
}
#[Override]
public function divQR(string $a, string $b): array
{
$q = bcdiv($a, $b, 0);
$r = bcmod($a, $b, 0);
return [$q, $r];
}
#[Override]
public function pow(string $a, int $e): string
{
return bcpow($a, (string) $e, 0);
}
#[Override]
public function modPow(string $base, string $exp, string $mod): string
{
// normalize to Euclidean representative so modPow() stays consistent with mod()
$base = $this->mod($base, $mod);
return bcpowmod($base, $exp, $mod, 0);
}
#[Override]
public function sqrt(string $n): string
{
return bcsqrt($n, 0);
}
}

View file

@ -1,152 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal\Calculator;
use Brick\Math\Internal\Calculator;
use GMP;
use Override;
use function gmp_add;
use function gmp_and;
use function gmp_div_q;
use function gmp_div_qr;
use function gmp_div_r;
use function gmp_gcd;
use function gmp_init;
use function gmp_invert;
use function gmp_lcm;
use function gmp_mul;
use function gmp_or;
use function gmp_pow;
use function gmp_powm;
use function gmp_sqrt;
use function gmp_strval;
use function gmp_sub;
use function gmp_xor;
/**
* Calculator implementation built around the GMP library.
*
* @internal
*/
final readonly class GmpCalculator extends Calculator
{
#[Override]
public function add(string $a, string $b): string
{
return gmp_strval(gmp_add($a, $b));
}
#[Override]
public function sub(string $a, string $b): string
{
return gmp_strval(gmp_sub($a, $b));
}
#[Override]
public function mul(string $a, string $b): string
{
return gmp_strval(gmp_mul($a, $b));
}
#[Override]
public function divQ(string $a, string $b): string
{
return gmp_strval(gmp_div_q($a, $b));
}
#[Override]
public function divR(string $a, string $b): string
{
return gmp_strval(gmp_div_r($a, $b));
}
#[Override]
public function divQR(string $a, string $b): array
{
[$q, $r] = gmp_div_qr($a, $b);
/**
* @var GMP $q
* @var GMP $r
*/
return [
gmp_strval($q),
gmp_strval($r),
];
}
#[Override]
public function pow(string $a, int $e): string
{
return gmp_strval(gmp_pow($a, $e));
}
#[Override]
public function modInverse(string $x, string $m): ?string
{
$result = gmp_invert($x, $m);
if ($result === false) {
return null;
}
return gmp_strval($result);
}
#[Override]
public function modPow(string $base, string $exp, string $mod): string
{
return gmp_strval(gmp_powm($base, $exp, $mod));
}
#[Override]
public function gcd(string $a, string $b): string
{
return gmp_strval(gmp_gcd($a, $b));
}
#[Override]
public function lcm(string $a, string $b): string
{
return gmp_strval(gmp_lcm($a, $b));
}
#[Override]
public function fromBase(string $number, int $base): string
{
return gmp_strval(gmp_init($number, $base));
}
#[Override]
public function toBase(string $number, int $base): string
{
return gmp_strval($number, $base);
}
#[Override]
public function and(string $a, string $b): string
{
return gmp_strval(gmp_and($a, $b));
}
#[Override]
public function or(string $a, string $b): string
{
return gmp_strval(gmp_or($a, $b));
}
#[Override]
public function xor(string $a, string $b): string
{
return gmp_strval(gmp_xor($a, $b));
}
#[Override]
public function sqrt(string $n): string
{
return gmp_strval(gmp_sqrt($n));
}
}

View file

@ -1,616 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal\Calculator;
use Brick\Math\Internal\Calculator;
use Override;
use function assert;
use function in_array;
use function intdiv;
use function is_int;
use function ltrim;
use function str_pad;
use function str_repeat;
use function strcmp;
use function strlen;
use function substr;
use const PHP_INT_SIZE;
use const STR_PAD_LEFT;
/**
* Calculator implementation using only native PHP code.
*
* @internal
*/
final readonly class NativeCalculator extends Calculator
{
/**
* The max number of digits the platform can natively add, subtract, multiply or divide without overflow.
* For multiplication, this represents the max sum of the lengths of both operands.
*
* In addition, it is assumed that an extra digit can hold a carry (1) without overflowing.
* Example: 32-bit: max number 1,999,999,999 (9 digits + carry)
* 64-bit: max number 1,999,999,999,999,999,999 (18 digits + carry)
*/
private int $maxDigits;
/**
* @pure
*
* @codeCoverageIgnore
*/
public function __construct()
{
$this->maxDigits = match (PHP_INT_SIZE) {
4 => 9,
8 => 18,
};
}
#[Override]
public function add(string $a, string $b): string
{
/**
* @var numeric-string $a
* @var numeric-string $b
*/
$result = $a + $b;
if (is_int($result)) {
return (string) $result;
}
if ($a === '0') {
return $b;
}
if ($b === '0') {
return $a;
}
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
$result = $aNeg === $bNeg ? $this->doAdd($aDig, $bDig) : $this->doSub($aDig, $bDig);
if ($aNeg) {
$result = $this->neg($result);
}
return $result;
}
#[Override]
public function sub(string $a, string $b): string
{
return $this->add($a, $this->neg($b));
}
#[Override]
public function mul(string $a, string $b): string
{
/**
* @var numeric-string $a
* @var numeric-string $b
*/
$result = $a * $b;
if (is_int($result)) {
return (string) $result;
}
if ($a === '0' || $b === '0') {
return '0';
}
if ($a === '1') {
return $b;
}
if ($b === '1') {
return $a;
}
if ($a === '-1') {
return $this->neg($b);
}
if ($b === '-1') {
return $this->neg($a);
}
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
$result = $this->doMul($aDig, $bDig);
if ($aNeg !== $bNeg) {
$result = $this->neg($result);
}
return $result;
}
#[Override]
public function divQ(string $a, string $b): string
{
return $this->divQR($a, $b)[0];
}
#[Override]
public function divR(string $a, string $b): string
{
return $this->divQR($a, $b)[1];
}
#[Override]
public function divQR(string $a, string $b): array
{
if ($a === '0') {
return ['0', '0'];
}
if ($a === $b) {
return ['1', '0'];
}
if ($b === '1') {
return [$a, '0'];
}
if ($b === '-1') {
return [$this->neg($a), '0'];
}
/** @var numeric-string $a */
$na = $a * 1; // cast to number
if (is_int($na)) {
/** @var numeric-string $b */
$nb = $b * 1;
if (is_int($nb)) {
// the only division that may overflow is PHP_INT_MIN / -1,
// which cannot happen here as we've already handled a divisor of -1 above.
$q = intdiv($na, $nb);
$r = $na % $nb;
return [
(string) $q,
(string) $r,
];
}
}
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
[$q, $r] = $this->doDiv($aDig, $bDig);
if ($aNeg !== $bNeg) {
$q = $this->neg($q);
}
if ($aNeg) {
$r = $this->neg($r);
}
return [$q, $r];
}
#[Override]
public function pow(string $a, int $e): string
{
if ($e === 0) {
return '1';
}
if ($e === 1) {
return $a;
}
$odd = $e % 2;
$e -= $odd;
$aa = $this->mul($a, $a);
$result = $this->pow($aa, $e / 2);
if ($odd === 1) {
$result = $this->mul($result, $a);
}
return $result;
}
/**
* Algorithm from: https://www.geeksforgeeks.org/modular-exponentiation-power-in-modular-arithmetic/.
*/
#[Override]
public function modPow(string $base, string $exp, string $mod): string
{
// normalize to Euclidean representative so modPow() stays consistent with mod()
$base = $this->mod($base, $mod);
// special case: the algorithm below fails with power 0 mod 1 (returns 1 instead of 0)
if ($exp === '0' && $mod === '1') {
return '0';
}
$x = $base;
$res = '1';
// numbers are positive, so we can use remainder instead of modulo
$x = $this->divR($x, $mod);
while ($exp !== '0') {
if (in_array($exp[-1], ['1', '3', '5', '7', '9'])) { // odd
$res = $this->divR($this->mul($res, $x), $mod);
}
$exp = $this->divQ($exp, '2');
$x = $this->divR($this->mul($x, $x), $mod);
}
return $res;
}
/**
* Adapted from https://cp-algorithms.com/num_methods/roots_newton.html.
*/
#[Override]
public function sqrt(string $n): string
{
if ($n === '0') {
return '0';
}
// initial approximation
$x = str_repeat('9', intdiv(strlen($n), 2) ?: 1);
$decreased = false;
for (; ;) {
$nx = $this->divQ($this->add($x, $this->divQ($n, $x)), '2');
if ($x === $nx || $this->cmp($nx, $x) > 0 && $decreased) {
break;
}
$decreased = $this->cmp($nx, $x) < 0;
$x = $nx;
}
return $x;
}
/**
* Performs the addition of two non-signed large integers.
*
* @pure
*/
private function doAdd(string $a, string $b): string
{
[$a, $b, $length] = $this->pad($a, $b);
$carry = 0;
$result = '';
for ($i = $length - $this->maxDigits; ; $i -= $this->maxDigits) {
$blockLength = $this->maxDigits;
if ($i < 0) {
$blockLength += $i;
$i = 0;
}
/** @var numeric-string $blockA */
$blockA = substr($a, $i, $blockLength);
/** @var numeric-string $blockB */
$blockB = substr($b, $i, $blockLength);
$sum = (string) ($blockA + $blockB + $carry);
$sumLength = strlen($sum);
if ($sumLength > $blockLength) {
$sum = substr($sum, 1);
$carry = 1;
} else {
if ($sumLength < $blockLength) {
$sum = str_repeat('0', $blockLength - $sumLength) . $sum;
}
$carry = 0;
}
$result = $sum . $result;
if ($i === 0) {
break;
}
}
if ($carry === 1) {
$result = '1' . $result;
}
return $result;
}
/**
* Performs the subtraction of two non-signed large integers.
*
* @pure
*/
private function doSub(string $a, string $b): string
{
if ($a === $b) {
return '0';
}
// Ensure that we always subtract to a positive result: biggest minus smallest.
$cmp = $this->doCmp($a, $b);
$invert = ($cmp === -1);
if ($invert) {
$c = $a;
$a = $b;
$b = $c;
}
[$a, $b, $length] = $this->pad($a, $b);
$carry = 0;
$result = '';
$complement = 10 ** $this->maxDigits;
for ($i = $length - $this->maxDigits; ; $i -= $this->maxDigits) {
$blockLength = $this->maxDigits;
if ($i < 0) {
$blockLength += $i;
$i = 0;
}
/** @var numeric-string $blockA */
$blockA = substr($a, $i, $blockLength);
/** @var numeric-string $blockB */
$blockB = substr($b, $i, $blockLength);
$sum = $blockA - $blockB - $carry;
if ($sum < 0) {
$sum += $complement;
$carry = 1;
} else {
$carry = 0;
}
$sum = (string) $sum;
$sumLength = strlen($sum);
if ($sumLength < $blockLength) {
$sum = str_repeat('0', $blockLength - $sumLength) . $sum;
}
$result = $sum . $result;
if ($i === 0) {
break;
}
}
// Carry cannot be 1 when the loop ends, as a > b
assert($carry === 0);
$result = ltrim($result, '0');
if ($invert) {
$result = $this->neg($result);
}
return $result;
}
/**
* Performs the multiplication of two non-signed large integers.
*
* @pure
*/
private function doMul(string $a, string $b): string
{
$x = strlen($a);
$y = strlen($b);
$maxDigits = intdiv($this->maxDigits, 2);
$complement = 10 ** $maxDigits;
$result = '0';
for ($i = $x - $maxDigits; ; $i -= $maxDigits) {
$blockALength = $maxDigits;
if ($i < 0) {
$blockALength += $i;
$i = 0;
}
$blockA = (int) substr($a, $i, $blockALength);
$line = '';
$carry = 0;
for ($j = $y - $maxDigits; ; $j -= $maxDigits) {
$blockBLength = $maxDigits;
if ($j < 0) {
$blockBLength += $j;
$j = 0;
}
$blockB = (int) substr($b, $j, $blockBLength);
$mul = $blockA * $blockB + $carry;
$value = $mul % $complement;
$carry = ($mul - $value) / $complement;
$value = (string) $value;
$value = str_pad($value, $maxDigits, '0', STR_PAD_LEFT);
$line = $value . $line;
if ($j === 0) {
break;
}
}
if ($carry !== 0) {
$line = $carry . $line;
}
$line = ltrim($line, '0');
if ($line !== '') {
$line .= str_repeat('0', $x - $blockALength - $i);
$result = $this->add($result, $line);
}
if ($i === 0) {
break;
}
}
return $result;
}
/**
* Performs the division of two non-signed large integers.
*
* @return string[] The quotient and remainder.
*
* @pure
*/
private function doDiv(string $a, string $b): array
{
$cmp = $this->doCmp($a, $b);
if ($cmp === -1) {
return ['0', $a];
}
$x = strlen($a);
$y = strlen($b);
// we now know that a >= b && x >= y
$q = '0'; // quotient
$r = $a; // remainder
$z = $y; // focus length, always $y or $y+1
/** @var numeric-string $b */
$nb = $b * 1; // cast to number
// performance optimization in cases where the remainder will never cause int overflow
if (is_int(($nb - 1) * 10 + 9)) {
$r = (int) substr($a, 0, $z - 1);
for ($i = $z - 1; $i < $x; $i++) {
$n = $r * 10 + (int) $a[$i];
/** @var int $nb */
$q .= intdiv($n, $nb);
$r = $n % $nb;
}
return [ltrim($q, '0') ?: '0', (string) $r];
}
for (; ;) {
$focus = substr($a, 0, $z);
$cmp = $this->doCmp($focus, $b);
if ($cmp === -1) {
if ($z === $x) { // remainder < dividend
break;
}
$z++;
}
$zeros = str_repeat('0', $x - $z);
$q = $this->add($q, '1' . $zeros);
$a = $this->sub($a, $b . $zeros);
$r = $a;
if ($r === '0') { // remainder == 0
break;
}
$x = strlen($a);
if ($x < $y) { // remainder < dividend
break;
}
$z = $y;
}
return [$q, $r];
}
/**
* Compares two non-signed large numbers.
*
* @return -1|0|1
*
* @pure
*/
private function doCmp(string $a, string $b): int
{
$x = strlen($a);
$y = strlen($b);
$cmp = $x <=> $y;
if ($cmp !== 0) {
return $cmp;
}
return strcmp($a, $b) <=> 0; // enforce -1|0|1
}
/**
* Pads the left of one of the given numbers with zeros if necessary to make both numbers the same length.
*
* The numbers must only consist of digits, without leading minus sign.
*
* @return array{string, string, int}
*
* @pure
*/
private function pad(string $a, string $b): array
{
$x = strlen($a);
$y = strlen($b);
if ($x > $y) {
$b = str_repeat('0', $x - $y) . $b;
return [$a, $b, $x];
}
if ($x < $y) {
$a = str_repeat('0', $y - $x) . $a;
return [$a, $b, $y];
}
return [$a, $b, $x];
}
}

View file

@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal;
use function extension_loaded;
/**
* Stores the current Calculator instance used by BigNumber classes.
*
* @internal
*/
final class CalculatorRegistry
{
/**
* The Calculator instance in use.
*/
private static ?Calculator $instance = null;
/**
* Sets the Calculator instance to use.
*
* An instance is typically set only in unit tests: autodetect is usually the best option.
*
* @param Calculator|null $calculator The calculator instance, or null to revert to autodetect.
*/
final public static function set(?Calculator $calculator): void
{
self::$instance = $calculator;
}
/**
* Returns the Calculator instance to use.
*
* If none has been explicitly set, the fastest available implementation will be returned.
*
* Note: even though this method is not technically pure, it is considered pure when used in a normal context, when
* only relying on autodetect.
*
* @pure
*/
final public static function get(): Calculator
{
/** @phpstan-ignore impure.staticPropertyAccess */
if (self::$instance === null) {
/** @phpstan-ignore impure.propertyAssign */
self::$instance = self::detect();
}
/** @phpstan-ignore impure.staticPropertyAccess */
return self::$instance;
}
/**
* Returns the fastest available Calculator implementation.
*
* @pure
*
* @codeCoverageIgnore
*/
private static function detect(): Calculator
{
if (extension_loaded('gmp')) {
return new Calculator\GmpCalculator();
}
if (extension_loaded('bcmath')) {
return new Calculator\BcMathCalculator();
}
return new Calculator\NativeCalculator();
}
}

View file

@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
/**
* Specifies rounding behavior by defining how discarded digits affect the returned result when an exact value cannot
* be represented at the requested scale.
*/
enum RoundingMode
{
/**
* Asserts that the requested operation has an exact result, hence no rounding is necessary.
*
* If this rounding mode is specified on an operation that yields a result that
* cannot be represented at the requested scale, a RoundingNecessaryException is thrown.
*/
case Unnecessary;
/**
* Rounds away from zero.
*
* Always increments the digit prior to a nonzero discarded fraction.
* Note that this rounding mode never decreases the magnitude of the calculated value.
*/
case Up;
/**
* Rounds towards zero.
*
* Never increments the digit prior to a discarded fraction (i.e., truncates).
* Note that this rounding mode never increases the magnitude of the calculated value.
*/
case Down;
/**
* Rounds towards positive infinity.
*
* If the result is positive, behaves as for Up; if negative, behaves as for Down.
* Note that this rounding mode never decreases the calculated value.
*/
case Ceiling;
/**
* Rounds towards negative infinity.
*
* If the result is positive, behaves as for Down; if negative, behaves as for Up.
* Note that this rounding mode never increases the calculated value.
*/
case Floor;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round up.
*
* Behaves as for Up if the discarded fraction is >= 0.5; otherwise, behaves as for Down.
* Note that this is the rounding mode commonly taught at school.
*/
case HalfUp;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round down.
*
* Behaves as for Up if the discarded fraction is > 0.5; otherwise, behaves as for Down.
*/
case HalfDown;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round towards positive infinity.
*
* If the result is positive, behaves as for HalfUp; if negative, behaves as for HalfDown.
*/
case HalfCeiling;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round towards negative infinity.
*
* If the result is positive, behaves as for HalfDown; if negative, behaves as for HalfUp.
*/
case HalfFloor;
/**
* Rounds towards the "nearest neighbor" unless both neighbors are equidistant, in which case rounds towards the even neighbor.
*
* Behaves as for HalfUp if the digit to the left of the discarded fraction is odd;
* behaves as for HalfDown if it's even.
*
* Note that this is the rounding mode that statistically minimizes
* cumulative error when applied repeatedly over a sequence of calculations.
* It is sometimes known as "Banker's rounding", and is chiefly used in the USA.
*/
case HalfEven;
/**
* @deprecated Use RoundingMode::Unnecessary instead.
*/
public const UNNECESSARY = self::Unnecessary;
/**
* @deprecated Use RoundingMode::Up instead.
*/
public const UP = self::Up;
/**
* @deprecated Use RoundingMode::Down instead.
*/
public const DOWN = self::Down;
/**
* @deprecated Use RoundingMode::Ceiling instead.
*/
public const CEILING = self::Ceiling;
/**
* @deprecated Use RoundingMode::Floor instead.
*/
public const FLOOR = self::Floor;
/**
* @deprecated Use RoundingMode::HalfUp instead.
*/
public const HALF_UP = self::HalfUp;
/**
* @deprecated Use RoundingMode::HalfDown instead.
*/
public const HALF_DOWN = self::HalfDown;
/**
* @deprecated Use RoundingMode::HalfCeiling instead.
*/
public const HALF_CEILING = self::HalfCeiling;
/**
* @deprecated Use RoundingMode::HalfFloor instead.
*/
public const HALF_FLOOR = self::HalfFloor;
/**
* @deprecated Use RoundingMode::HalfEven instead.
*/
public const HALF_EVEN = self::HalfEven;
}

View file

@ -1,10 +0,0 @@
{
"require": {
"brick/coding-standard": "v4"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": false
}
}
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Fixer\ClassNotation\OrderedTypesFixer;
use PhpCsFixer\Fixer\Phpdoc\PhpdocTypesOrderFixer;
use SlevomatCodingStandard\Sniffs\Whitespaces\DuplicateSpacesSniff;
use Symplify\EasyCodingStandard\Config\ECSConfig;
return static function (ECSConfig $ecsConfig): void {
$ecsConfig->import(__DIR__ . '/vendor/brick/coding-standard/ecs.php');
$libRootPath = realpath(__DIR__ . '/../..');
$ecsConfig->paths(
[
$libRootPath . '/src',
$libRootPath . '/tests',
$libRootPath . '/phpunit.php',
$libRootPath . '/random-tests.php',
__FILE__,
],
);
$ecsConfig->skip([
// Allows alignment in test providers
DuplicateSpacesSniff::class => [$libRootPath . '/tests'],
// We want to keep BigNumber|int|float|string order
OrderedTypesFixer::class => null,
PhpdocTypesOrderFixer::class => null,
]);
};

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 Carbon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,36 +0,0 @@
{
"name": "carbonphp/carbon-doctrine-types",
"description": "Types to use Carbon in Doctrine",
"type": "library",
"keywords": [
"date",
"time",
"DateTime",
"Carbon",
"Doctrine"
],
"require": {
"php": "^8.1"
},
"require-dev": {
"doctrine/dbal": "^4.0.0",
"nesbot/carbon": "^2.71.0 || ^3.0.0",
"phpunit/phpunit": "^10.3"
},
"conflict": {
"doctrine/dbal": "<4.0.0 || >=5.0.0"
},
"license": "MIT",
"autoload": {
"psr-4": {
"Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
}
},
"authors": [
{
"name": "KyleKatarn",
"email": "kylekatarnls@gmail.com"
}
],
"minimum-stability": "dev"
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Doctrine\DBAL\Platforms\AbstractPlatform;
interface CarbonDoctrineType
{
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform);
public function convertToPHPValue(mixed $value, AbstractPlatform $platform);
public function convertToDatabaseValue($value, AbstractPlatform $platform);
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
class CarbonImmutableType extends DateTimeImmutableType implements CarbonDoctrineType
{
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
class CarbonType extends DateTimeType implements CarbonDoctrineType
{
}

View file

@ -1,131 +0,0 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DateTimeInterface;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\DB2Platform;
use Doctrine\DBAL\Platforms\OraclePlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use Doctrine\DBAL\Types\Exception\InvalidType;
use Doctrine\DBAL\Types\Exception\ValueNotConvertible;
use Exception;
/**
* @template T of CarbonInterface
*/
trait CarbonTypeConverter
{
/**
* This property differentiates types installed by carbonphp/carbon-doctrine-types
* from the ones embedded previously in nesbot/carbon source directly.
*
* @readonly
*/
public bool $external = true;
/**
* @return class-string<T>
*/
protected function getCarbonClassName(): string
{
return Carbon::class;
}
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string
{
$precision = min(
$fieldDeclaration['precision'] ?? DateTimeDefaultPrecision::get(),
$this->getMaximumPrecision($platform),
);
$type = parent::getSQLDeclaration($fieldDeclaration, $platform);
if (!$precision) {
return $type;
}
if (str_contains($type, '(')) {
return preg_replace('/\(\d+\)/', "($precision)", $type);
}
[$before, $after] = explode(' ', "$type ");
return trim("$before($precision) $after");
}
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value === null) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format('Y-m-d H:i:s.u');
}
throw InvalidType::new(
$value,
static::class,
['null', 'DateTime', 'Carbon']
);
}
private function doConvertToPHPValue(mixed $value)
{
$class = $this->getCarbonClassName();
if ($value === null || is_a($value, $class)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $class::instance($value);
}
$date = null;
$error = null;
try {
$date = $class::parse($value);
} catch (Exception $exception) {
$error = $exception;
}
if (!$date) {
throw ValueNotConvertible::new(
$value,
static::class,
'Y-m-d H:i:s.u or any format supported by '.$class.'::parse()',
$error
);
}
return $date;
}
private function getMaximumPrecision(AbstractPlatform $platform): int
{
if ($platform instanceof DB2Platform) {
return 12;
}
if ($platform instanceof OraclePlatform) {
return 9;
}
if ($platform instanceof SQLServerPlatform || $platform instanceof SQLitePlatform) {
return 3;
}
return 6;
}
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
class DateTimeDefaultPrecision
{
private static $precision = 6;
/**
* Change the default Doctrine datetime and datetime_immutable precision.
*
* @param int $precision
*/
public static function set(int $precision): void
{
self::$precision = $precision;
}
/**
* Get the default Doctrine datetime and datetime_immutable precision.
*
* @return int
*/
public static function get(): int
{
return self::$precision;
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Carbon\CarbonImmutable;
use DateTimeImmutable;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\VarDateTimeImmutableType;
class DateTimeImmutableType extends VarDateTimeImmutableType implements CarbonDoctrineType
{
/** @use CarbonTypeConverter<CarbonImmutable> */
use CarbonTypeConverter;
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?CarbonImmutable
{
return $this->doConvertToPHPValue($value);
}
/**
* @return class-string<CarbonImmutable>
*/
protected function getCarbonClassName(): string
{
return CarbonImmutable::class;
}
}

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Carbon\Carbon;
use DateTime;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\VarDateTimeType;
class DateTimeType extends VarDateTimeType implements CarbonDoctrineType
{
/** @use CarbonTypeConverter<Carbon> */
use CarbonTypeConverter;
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?Carbon
{
return $this->doConvertToPHPValue($value);
}
}

View file

@ -1,579 +0,0 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Autoload;
/**
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
*
* $loader = new \Composer\Autoload\ClassLoader();
*
* // register classes with namespaces
* $loader->add('Symfony\Component', __DIR__.'/component');
* $loader->add('Symfony', __DIR__.'/framework');
*
* // activate the autoloader
* $loader->register();
*
* // to enable searching the include path (eg. for PEAR packages)
* $loader->setUseIncludePath(true);
*
* In this example, if you try to use a class in the Symfony\Component
* namespace or one of its children (Symfony\Component\Console for instance),
* the autoloader will first look for the class under the component/
* directory, and it will then fallback to the framework/ directory if not
* found before giving up.
*
* This class is loosely based on the Symfony UniversalClassLoader.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @see https://www.php-fig.org/psr/psr-0/
* @see https://www.php-fig.org/psr/psr-4/
*/
class ClassLoader
{
/** @var \Closure(string):void */
private static $includeFile;
/** @var string|null */
private $vendorDir;
// PSR-4
/**
* @var array<string, array<string, int>>
*/
private $prefixLengthsPsr4 = array();
/**
* @var array<string, list<string>>
*/
private $prefixDirsPsr4 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr4 = array();
// PSR-0
/**
* List of PSR-0 prefixes
*
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
*
* @var array<string, array<string, list<string>>>
*/
private $prefixesPsr0 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr0 = array();
/** @var bool */
private $useIncludePath = false;
/**
* @var array<string, string>
*/
private $classMap = array();
/** @var bool */
private $classMapAuthoritative = false;
/**
* @var array<string, bool>
*/
private $missingClasses = array();
/** @var string|null */
private $apcuPrefix;
/**
* @var array<string, self>
*/
private static $registeredLoaders = array();
/**
* @param string|null $vendorDir
*/
public function __construct($vendorDir = null)
{
$this->vendorDir = $vendorDir;
self::initializeIncludeClosure();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
/**
* @return list<string>
*/
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
/**
* @return list<string>
*/
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
/**
* @return array<string, string> Array of classname => path
*/
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array<string, string> $classMap Class to filename map
*
* @return void
*/
public function addClassMap(array $classMap)
{
if ($this->classMap) {
$this->classMap = array_merge($this->classMap, $classMap);
} else {
$this->classMap = $classMap;
}
}
/**
* Registers a set of PSR-0 directories for a given prefix, either
* appending or prepending to the ones previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 root directories
* @param bool $prepend Whether to prepend the directories
*
* @return void
*/
public function add($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
if ($prepend) {
$this->fallbackDirsPsr0 = array_merge(
$paths,
$this->fallbackDirsPsr0
);
} else {
$this->fallbackDirsPsr0 = array_merge(
$this->fallbackDirsPsr0,
$paths
);
}
return;
}
$first = $prefix[0];
if (!isset($this->prefixesPsr0[$first][$prefix])) {
$this->prefixesPsr0[$first][$prefix] = $paths;
return;
}
if ($prepend) {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$paths,
$this->prefixesPsr0[$first][$prefix]
);
} else {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$this->prefixesPsr0[$first][$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-4 directories for a given namespace, either
* appending or prepending to the ones previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
* @param bool $prepend Whether to prepend the directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function addPsr4($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
// Register directories for the root namespace.
if ($prepend) {
$this->fallbackDirsPsr4 = array_merge(
$paths,
$this->fallbackDirsPsr4
);
} else {
$this->fallbackDirsPsr4 = array_merge(
$this->fallbackDirsPsr4,
$paths
);
}
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
// Register directories for a new namespace.
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = $paths;
} elseif ($prepend) {
// Prepend directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$paths,
$this->prefixDirsPsr4[$prefix]
);
} else {
// Append directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$this->prefixDirsPsr4[$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-0 directories for a given prefix,
* replacing any others previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 base directories
*
* @return void
*/
public function set($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr0 = (array) $paths;
} else {
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
}
}
/**
* Registers a set of PSR-4 directories for a given namespace,
* replacing any others previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function setPsr4($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr4 = (array) $paths;
} else {
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
}
}
/**
* Turns on searching the include path for class files.
*
* @param bool $useIncludePath
*
* @return void
*/
public function setUseIncludePath($useIncludePath)
{
$this->useIncludePath = $useIncludePath;
}
/**
* Can be used to check if the autoloader uses the include path to check
* for classes.
*
* @return bool
*/
public function getUseIncludePath()
{
return $this->useIncludePath;
}
/**
* Turns off searching the prefix and fallback directories for classes
* that have not been registered with the class map.
*
* @param bool $classMapAuthoritative
*
* @return void
*/
public function setClassMapAuthoritative($classMapAuthoritative)
{
$this->classMapAuthoritative = $classMapAuthoritative;
}
/**
* Should class lookup fail if not found in the current class map?
*
* @return bool
*/
public function isClassMapAuthoritative()
{
return $this->classMapAuthoritative;
}
/**
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
*
* @param string|null $apcuPrefix
*
* @return void
*/
public function setApcuPrefix($apcuPrefix)
{
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
}
/**
* The APCu prefix in use, or null if APCu caching is not enabled.
*
* @return string|null
*/
public function getApcuPrefix()
{
return $this->apcuPrefix;
}
/**
* Registers this instance as an autoloader.
*
* @param bool $prepend Whether to prepend the autoloader or not
*
* @return void
*/
public function register($prepend = false)
{
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
if (null === $this->vendorDir) {
return;
}
if ($prepend) {
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
} else {
unset(self::$registeredLoaders[$this->vendorDir]);
self::$registeredLoaders[$this->vendorDir] = $this;
}
}
/**
* Unregisters this instance as an autoloader.
*
* @return void
*/
public function unregister()
{
spl_autoload_unregister(array($this, 'loadClass'));
if (null !== $this->vendorDir) {
unset(self::$registeredLoaders[$this->vendorDir]);
}
}
/**
* Loads the given class or interface.
*
* @param string $class The name of the class
* @return true|null True if loaded, null otherwise
*/
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
$includeFile = self::$includeFile;
$includeFile($file);
return true;
}
return null;
}
/**
* Finds the path to the file where the class is defined.
*
* @param string $class The name of the class
*
* @return string|false The path if found, false otherwise
*/
public function findFile($class)
{
// class map lookup
if (isset($this->classMap[$class])) {
return $this->classMap[$class];
}
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
return false;
}
if (null !== $this->apcuPrefix) {
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
if ($hit) {
return $file;
}
}
$file = $this->findFileWithExtension($class, '.php');
// Search for Hack files if we are running on HHVM
if (false === $file && defined('HHVM_VERSION')) {
$file = $this->findFileWithExtension($class, '.hh');
}
if (null !== $this->apcuPrefix) {
apcu_add($this->apcuPrefix.$class, $file);
}
if (false === $file) {
// Remember that this class does not exist.
$this->missingClasses[$class] = true;
}
return $file;
}
/**
* Returns the currently registered loaders keyed by their corresponding vendor directories.
*
* @return array<string, self>
*/
public static function getRegisteredLoaders()
{
return self::$registeredLoaders;
}
/**
* @param string $class
* @param string $ext
* @return string|false
*/
private function findFileWithExtension($class, $ext)
{
// PSR-4 lookup
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
$first = $class[0];
if (isset($this->prefixLengthsPsr4[$first])) {
$subPath = $class;
while (false !== $lastPos = strrpos($subPath, '\\')) {
$subPath = substr($subPath, 0, $lastPos);
$search = $subPath . '\\';
if (isset($this->prefixDirsPsr4[$search])) {
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
foreach ($this->prefixDirsPsr4[$search] as $dir) {
if (file_exists($file = $dir . $pathEnd)) {
return $file;
}
}
}
}
}
// PSR-4 fallback dirs
foreach ($this->fallbackDirsPsr4 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
return $file;
}
}
// PSR-0 lookup
if (false !== $pos = strrpos($class, '\\')) {
// namespaced class name
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
} else {
// PEAR-like class name
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
}
if (isset($this->prefixesPsr0[$first])) {
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
if (0 === strpos($class, $prefix)) {
foreach ($dirs as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
}
}
}
// PSR-0 fallback dirs
foreach ($this->fallbackDirsPsr0 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
// PSR-0 include paths.
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
return $file;
}
return false;
}
/**
* @return void
*/
private static function initializeIncludeClosure()
{
if (self::$includeFile !== null) {
return;
}
/**
* Scope isolated include.
*
* Prevents access to $this/self from included files.
*
* @param string $file
* @return void
*/
self::$includeFile = \Closure::bind(static function($file) {
include $file;
}, null, null);
}
}

View file

@ -1,380 +0,0 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer;
use Composer\Autoload\ClassLoader;
use Composer\Semver\VersionParser;
/**
* This class is copied in every Composer installed project and available to all
*
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
*
* To require its presence, you can require `composer-runtime-api ^2.0`
*
* @final
*/
class InstalledVersions
{
/**
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
* @internal
*/
private static $selfDir = null;
/**
* @var mixed[]|null
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static $installedByVendor = array();
/**
* Returns a list of all package names which are present, either by being installed, replaced or provided
*
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackages()
{
$packages = array();
foreach (self::getInstalled() as $installed) {
$packages[] = array_keys($installed['versions']);
}
if (1 === \count($packages)) {
return $packages[0];
}
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
}
/**
* Returns a list of all package names with a specific type e.g. 'library'
*
* @param string $type
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackagesByType($type)
{
$packagesByType = array();
foreach (self::getInstalled() as $installed) {
foreach ($installed['versions'] as $name => $package) {
if (isset($package['type']) && $package['type'] === $type) {
$packagesByType[] = $name;
}
}
}
return $packagesByType;
}
/**
* Checks whether the given package is installed
*
* This also returns true if the package name is provided or replaced by another package
*
* @param string $packageName
* @param bool $includeDevRequirements
* @return bool
*/
public static function isInstalled($packageName, $includeDevRequirements = true)
{
foreach (self::getInstalled() as $installed) {
if (isset($installed['versions'][$packageName])) {
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
}
}
return false;
}
/**
* Checks whether the given package satisfies a version constraint
*
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
*
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
*
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
* @param string $packageName
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
* @return bool
*/
public static function satisfies(VersionParser $parser, $packageName, $constraint)
{
$constraint = $parser->parseConstraints((string) $constraint);
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
return $provided->matches($constraint);
}
/**
* Returns a version constraint representing all the range(s) which are installed for a given package
*
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
* whether a given version of a package is installed, and not just whether it exists
*
* @param string $packageName
* @return string Version constraint usable with composer/semver
*/
public static function getVersionRanges($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
$ranges = array();
if (isset($installed['versions'][$packageName]['pretty_version'])) {
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
}
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
}
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
}
if (array_key_exists('provided', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
}
return implode(' || ', $ranges);
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['version'])) {
return null;
}
return $installed['versions'][$packageName]['version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getPrettyVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
return null;
}
return $installed['versions'][$packageName]['pretty_version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
*/
public static function getReference($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['reference'])) {
return null;
}
return $installed['versions'][$packageName]['reference'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
*/
public static function getInstallPath($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @return array
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
*/
public static function getRootPackage()
{
$installed = self::getInstalled();
return $installed[0]['root'];
}
/**
* Returns the raw installed.php data for custom implementations
*
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
* @return array[]
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
public static function getRawData()
{
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
}
}
return self::$installed;
}
/**
* Returns the raw data of all installed.php which are currently loaded for custom implementations
*
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
public static function getAllRawData()
{
return self::getInstalled();
}
/**
* Lets you reload the static array from another file
*
* This is only useful for complex integrations in which a project needs to use
* this class but then also needs to execute another project's autoloader in process,
* and wants to ensure both projects have access to their version of installed.php.
*
* A typical case would be PHPUnit, where it would need to make sure it reads all
* the data it needs from this class, then call reload() with
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
* the project in which it runs can then also use this class safely, without
* interference between PHPUnit's dependencies and the project's dependencies.
*
* @param array[] $data A vendor/composer/installed.php data set
* @return void
*
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
*/
public static function reload($data)
{
self::$installed = $data;
self::$installedByVendor = array();
}
/**
* @return string
*/
private static function getSelfDir()
{
if (self::$selfDir === null) {
self::$selfDir = strtr(__DIR__, '\\', '/');
}
return self::$selfDir;
}
/**
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static function getInstalled()
{
if (null === self::$canGetVendors) {
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
self::$installed = $required;
$copiedLocalDir = true;
}
}
}
}
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}
}

View file

@ -1,19 +0,0 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

Some files were not shown because too many files have changed in this diff Show more