Initial
Takes in a file, process it and output a new file with additional columns.
This commit is contained in:
@@ -0,0 +1,300 @@
|
|||||||
|
# Project Handoff
|
||||||
|
|
||||||
|
This document summarizes the current state of the spreadsheet importer so it can be copied into a new project or used as a reference for the next stage.
|
||||||
|
|
||||||
|
## What This App Does
|
||||||
|
|
||||||
|
This is a plain PHP and Bootstrap web app that:
|
||||||
|
|
||||||
|
- accepts an Excel upload
|
||||||
|
- validates the workbook headers
|
||||||
|
- ignores the `Revenue Type` column
|
||||||
|
- normalizes and stores imported row data in PostgreSQL
|
||||||
|
- calculates stage 1 derived fields during import
|
||||||
|
- wipes previous imported data before each new import
|
||||||
|
- shows a 20-row preview on the home page
|
||||||
|
- provides a full DataTables page for the latest imported batch
|
||||||
|
|
||||||
|
The app is structured so future import stages can add more rules without rewriting the UI or database layer.
|
||||||
|
|
||||||
|
## Move Notes
|
||||||
|
|
||||||
|
The codebase is largely relocatable because internal includes and asset URLs are built from the project root.
|
||||||
|
|
||||||
|
Before moving the project into a new folder:
|
||||||
|
|
||||||
|
- set `APP_URL` to the new base URL or folder path
|
||||||
|
- keep `DATABASE_URL` or the values in `config.php` aligned with the target environment
|
||||||
|
- rerun `composer install` if `vendor/` is not copied with the project
|
||||||
|
- confirm the web server is still pointing at `public/` as the document root
|
||||||
|
|
||||||
|
The only folder-sensitive setting in the app itself is the redirect base URL used after imports and clears.
|
||||||
|
|
||||||
|
## Current Stack
|
||||||
|
|
||||||
|
- PHP
|
||||||
|
- Bootstrap 5
|
||||||
|
- PostgreSQL
|
||||||
|
- Composer for the spreadsheet library
|
||||||
|
- OpenSpout for reading Excel files
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
|
- `config.php`
|
||||||
|
- Root config file for app name, URL, and PostgreSQL connection settings.
|
||||||
|
- This replaced `.env`-style configuration.
|
||||||
|
|
||||||
|
- `bootstrap.php`
|
||||||
|
- Starts the session.
|
||||||
|
- Loads Composer's autoloader if present.
|
||||||
|
- Requires the app files manually.
|
||||||
|
- Exposes the `app_config()` helper for config lookup.
|
||||||
|
|
||||||
|
- `index.php`
|
||||||
|
- Root entrypoint.
|
||||||
|
- Loads `public/index.php` so the app can be reached from the project root.
|
||||||
|
|
||||||
|
- `public/index.php`
|
||||||
|
- Main web entrypoint.
|
||||||
|
- Creates the controller and renders a friendly setup error page if startup fails.
|
||||||
|
|
||||||
|
- `public/assets/app.js`
|
||||||
|
- Handles file upload progress.
|
||||||
|
- Drives the chunked import loop with repeated `process-chunk` requests.
|
||||||
|
- Handles the clear-data button.
|
||||||
|
- Reloads the page after import or clear completes.
|
||||||
|
|
||||||
|
- `public/assets/styles.css`
|
||||||
|
- Custom Bootstrap styling and layout.
|
||||||
|
|
||||||
|
- `templates/index.php`
|
||||||
|
- Home page with upload form, status messages, and a 20-row preview table.
|
||||||
|
|
||||||
|
- `templates/table.php`
|
||||||
|
- Full-table page for the latest imported batch.
|
||||||
|
- DataTables server-side search and paging.
|
||||||
|
- ColumnControl header buttons and header filters.
|
||||||
|
|
||||||
|
- `templates/partials/navbar.php`
|
||||||
|
- Shared navigation between the preview and full-table pages.
|
||||||
|
|
||||||
|
- `bin/migrate.php`
|
||||||
|
- Creates the PostgreSQL schema.
|
||||||
|
|
||||||
|
- `app/Database.php`
|
||||||
|
- Builds the PDO connection from `config.php`.
|
||||||
|
|
||||||
|
- `app/Http/AppController.php`
|
||||||
|
- Coordinates upload, clear, render, and JSON responses.
|
||||||
|
|
||||||
|
- `app/Import/SpreadsheetReader.php`
|
||||||
|
- Streams workbook data with OpenSpout.
|
||||||
|
- Builds a temporary chunk cache from the uploaded file.
|
||||||
|
- Detects the real header row.
|
||||||
|
- Ignores `Revenue Type`.
|
||||||
|
- Normalizes cell values.
|
||||||
|
|
||||||
|
- `app/Import/SpreadsheetRowProcessor.php`
|
||||||
|
- Applies all stage 1 calculated field rules.
|
||||||
|
|
||||||
|
- `app/Import/ImportService.php`
|
||||||
|
- Orchestrates validation, clearing, processing, and storing.
|
||||||
|
|
||||||
|
- `app/Import/ImportRepository.php`
|
||||||
|
- Handles PostgreSQL inserts, truncation, counting, and pagination.
|
||||||
|
|
||||||
|
## Database Design
|
||||||
|
|
||||||
|
The schema is intentionally flexible and uses JSONB for future growth.
|
||||||
|
|
||||||
|
### `import_batches`
|
||||||
|
|
||||||
|
Stores metadata for each import run:
|
||||||
|
|
||||||
|
- source filename
|
||||||
|
- original headers
|
||||||
|
- calculated headers
|
||||||
|
- row count
|
||||||
|
- warning count
|
||||||
|
- created timestamp
|
||||||
|
|
||||||
|
### `import_rows`
|
||||||
|
|
||||||
|
Stores each imported row:
|
||||||
|
|
||||||
|
- batch id
|
||||||
|
- source row number
|
||||||
|
- original row data as JSONB
|
||||||
|
- normalized row data as JSONB
|
||||||
|
- calculated row data as JSONB
|
||||||
|
- merged row data as JSONB
|
||||||
|
|
||||||
|
The row-level JSONB storage makes it easier to add more calculated fields and import stages later without changing the schema every time.
|
||||||
|
|
||||||
|
## Import Flow
|
||||||
|
|
||||||
|
The current flow is:
|
||||||
|
|
||||||
|
1. User uploads an Excel workbook.
|
||||||
|
2. The app validates the file extension.
|
||||||
|
3. The upload is stored in a temp file and an import token is returned.
|
||||||
|
4. The workbook is streamed once into a temporary chunk cache.
|
||||||
|
5. The browser repeatedly calls `action=process-chunk` with that token.
|
||||||
|
6. Required columns are checked by header name.
|
||||||
|
7. Existing imported data is wiped.
|
||||||
|
8. A new batch record is created.
|
||||||
|
9. Each cached row is normalized.
|
||||||
|
10. Derived fields are calculated.
|
||||||
|
11. The row is inserted into PostgreSQL.
|
||||||
|
12. The home page reloads and shows a 20-row preview table.
|
||||||
|
13. The full table page provides server-side search and pagination for the latest batch.
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
Required columns:
|
||||||
|
|
||||||
|
- `Accrual Category`
|
||||||
|
- `Local Selling Company (S/T)`
|
||||||
|
- `Contracting Party`
|
||||||
|
- `Rights Holder Liable Flag`
|
||||||
|
- `Global Transfer Pricing Flag`
|
||||||
|
- `Domestic/ Foreign Flag`
|
||||||
|
- `Ownership Type`
|
||||||
|
|
||||||
|
The app also:
|
||||||
|
|
||||||
|
- handles blank and null values safely
|
||||||
|
- preserves formatted text values from Excel
|
||||||
|
- treats numeric-looking values consistently
|
||||||
|
- shows readable import errors
|
||||||
|
|
||||||
|
## Stage 1 Calculated Fields
|
||||||
|
|
||||||
|
### 1. `Actual/Accrual`
|
||||||
|
|
||||||
|
Rule:
|
||||||
|
|
||||||
|
- If `Accrual Category` equals `##`, return `Actual`
|
||||||
|
- Otherwise return `Accrual`
|
||||||
|
|
||||||
|
### 2. `US/ex-US sale`
|
||||||
|
|
||||||
|
Rule:
|
||||||
|
|
||||||
|
- If `Local Selling Company (S/T)` is between `1700` and `1809` inclusive, return `US`
|
||||||
|
- Otherwise return `ex-US`
|
||||||
|
|
||||||
|
### 3. `Contracting Party Filtered`
|
||||||
|
|
||||||
|
Rule:
|
||||||
|
|
||||||
|
- If `Contracting Party` equals `1756`, return `1756`
|
||||||
|
- If `Contracting Party` equals `1100`, return `1100`
|
||||||
|
- If `Contracting Party` equals `1902`, return `1902`
|
||||||
|
- Otherwise return `LOCAL`
|
||||||
|
|
||||||
|
### 4. `String`
|
||||||
|
|
||||||
|
Rule:
|
||||||
|
|
||||||
|
Concatenate, in this exact order with no separator:
|
||||||
|
|
||||||
|
- `Contracting Party Filtered`
|
||||||
|
- `Rights Holder Liable Flag`
|
||||||
|
- `Global Transfer Pricing Flag`
|
||||||
|
- `Domestic/ Foreign Flag`
|
||||||
|
- `Ownership Type`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
- `1756YYFDI`
|
||||||
|
|
||||||
|
### 5. `2110 Applicable X201`
|
||||||
|
|
||||||
|
Rule:
|
||||||
|
|
||||||
|
- If the `String` value is in the approved list, return `X`
|
||||||
|
- Otherwise return blank
|
||||||
|
|
||||||
|
### 6. `Class`
|
||||||
|
|
||||||
|
Rules are applied top to bottom and the first match wins:
|
||||||
|
|
||||||
|
- `A` if `Contracting Party = 1902`
|
||||||
|
- `B` if `Contracting Party = 1756`, `Rights Holder Liable Flag = Y`, and `Local Selling Company (S/T)` is not between `1700` and `1808` inclusive
|
||||||
|
- `C` if `Contracting Party = 1756`, `Local Selling Company (S/T)` is between `1700` and `1808` inclusive, and `2110 Applicable X201 = X`
|
||||||
|
- `D` if `Contracting Party = 1756`, `Local Selling Company (S/T)` is between `1700` and `1808` inclusive, and `2110 Applicable X201` is not `X`
|
||||||
|
- `E` if `Contracting Party = 1100` and `Local Selling Company (S/T)` is not between `1700` and `1808` inclusive
|
||||||
|
- `F` if `Contracting Party = 1100` and `Local Selling Company (S/T)` is between `1700` and `1808` inclusive
|
||||||
|
- `G` if `Contracting Party Filtered = LOCAL`
|
||||||
|
- `H` if `Contracting Party = 1756`, `Rights Holder Liable Flag = N`, `Global Transfer Pricing Flag = Y`, and `Local Selling Company (S/T)` is not between `1700` and `1808` inclusive
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
The Bootstrap UI includes:
|
||||||
|
|
||||||
|
- upload form
|
||||||
|
- navbar for preview/full-table navigation
|
||||||
|
- progress bar
|
||||||
|
- success/error messages
|
||||||
|
- imported row count
|
||||||
|
- clear imported data button
|
||||||
|
- 20-row data preview table
|
||||||
|
- current batch summary
|
||||||
|
- full table page with search and pagination
|
||||||
|
|
||||||
|
The app uses relative asset paths so it works from either:
|
||||||
|
|
||||||
|
- the project root, or
|
||||||
|
- the `public/` directory
|
||||||
|
|
||||||
|
## How To Set This Up In A New Project
|
||||||
|
|
||||||
|
1. Copy these files/folders into the new project:
|
||||||
|
- `config.php`
|
||||||
|
- `bootstrap.php`
|
||||||
|
- `index.php`
|
||||||
|
- `bin/`
|
||||||
|
- `app/`
|
||||||
|
- `public/`
|
||||||
|
- `templates/`
|
||||||
|
- `composer.json`
|
||||||
|
- `composer.lock`
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update `config.php` with the new server's database settings.
|
||||||
|
|
||||||
|
4. Run migrations:
|
||||||
|
```bash
|
||||||
|
composer run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Configure the web server to point at the project root or `public/`.
|
||||||
|
|
||||||
|
6. Open the app in a browser and upload the workbook.
|
||||||
|
|
||||||
|
## Notes For Future Stages
|
||||||
|
|
||||||
|
- Keep the processing logic out of the template.
|
||||||
|
- Add new calculated fields in `SpreadsheetRowProcessor`.
|
||||||
|
- Add new schema fields only if JSONB is no longer sufficient.
|
||||||
|
- If later stages need multiple import histories, stop truncating and switch to batch filtering in the UI.
|
||||||
|
- If the workbook structure changes, update header detection in `SpreadsheetReader`.
|
||||||
|
|
||||||
|
## Current Caveats
|
||||||
|
|
||||||
|
- PostgreSQL credentials must be correct in `config.php`.
|
||||||
|
- The app currently expects the database to exist before migration.
|
||||||
|
- The import path uses PhpSpreadsheet, so Composer remains required.
|
||||||
|
- Large workbooks should continue to use pagination in the preview table.
|
||||||
|
|
||||||
|
## Working Assumptions
|
||||||
|
|
||||||
|
- The header names remain stable.
|
||||||
|
- The example workbook is representative of the real import structure.
|
||||||
|
- `Revenue Type` should always be ignored.
|
||||||
|
- Future stages will extend the current batch/import model rather than replace it.
|
||||||
@@ -1,2 +1,70 @@
|
|||||||
# Warner-Vistex-Automater
|
# Warner-Vistex-Automater
|
||||||
|
|
||||||
|
Stage 1 of a Bootstrap-based PHP app that uploads an Excel workbook, processes the rows, stores the imported data in PostgreSQL, and previews the imported rows in a paginated table.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Upload `.xls` or `.xlsx` files.
|
||||||
|
- Validate required spreadsheet headers before import.
|
||||||
|
- Wipe previous imported rows before each new import.
|
||||||
|
- Compute the stage 1 derived fields during import.
|
||||||
|
- Store normalized imported data plus calculated data in PostgreSQL using `jsonb`.
|
||||||
|
- Process large imports in short chunks to avoid request timeouts.
|
||||||
|
- Review imported rows in a Bootstrap table with pagination.
|
||||||
|
- Show upload progress and import status in the browser.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 8.3 or newer
|
||||||
|
- Composer
|
||||||
|
- PostgreSQL
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Edit `config.php` and update the database settings if your host names differ from the defaults.
|
||||||
|
- Or set `DATABASE_URL`, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@host.docker.internal:5432/devdb?sslmode=prefer
|
||||||
|
```
|
||||||
|
|
||||||
|
- The default host is `localhost` for CLI scripts and `postgres_db` for the web app.
|
||||||
|
- If your setup differs, `DATABASE_URL` is the easiest override.
|
||||||
|
- Set `APP_URL` if the app will run from a different folder or base URL than `http://localhost/warner/`.
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the database migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -S 127.0.0.1:8000 -t public
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Open [http://127.0.0.1:8000](http://127.0.0.1:8000).
|
||||||
|
|
||||||
|
## Import flow
|
||||||
|
|
||||||
|
1. The app validates that a file was uploaded.
|
||||||
|
2. The file must be an Excel workbook.
|
||||||
|
3. Required columns are checked by header name.
|
||||||
|
4. Existing imported rows are deleted in a transaction.
|
||||||
|
5. The workbook rows are normalized and derived fields are calculated.
|
||||||
|
6. The new rows are inserted and then displayed in the preview table.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
The schema uses two tables:
|
||||||
|
|
||||||
|
- `import_batches` stores metadata for each import run.
|
||||||
|
- `import_rows` stores the row payloads using `jsonb` for original, normalized, and calculated data.
|
||||||
|
|
||||||
|
This keeps the model flexible for later import stages and additional derived fields.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class Database
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->pdo = $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromConfig(array $config): self
|
||||||
|
{
|
||||||
|
$database = $config['database'] ?? [];
|
||||||
|
|
||||||
|
$host = (string) ($database['host'] ?? '127.0.0.1');
|
||||||
|
$port = (string) ($database['port'] ?? '5432');
|
||||||
|
$name = (string) ($database['name'] ?? 'spreadsheet_importer');
|
||||||
|
$user = (string) ($database['user'] ?? 'postgres');
|
||||||
|
$password = (string) ($database['password'] ?? '');
|
||||||
|
$sslMode = (string) ($database['sslmode'] ?? 'prefer');
|
||||||
|
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('pgsql:host=%s;port=%s;dbname=%s;sslmode=%s', $host, $port, $name, $sslMode),
|
||||||
|
$user,
|
||||||
|
$password,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return new self($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pdo(): PDO
|
||||||
|
{
|
||||||
|
return $this->pdo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
use App\Database;
|
||||||
|
use App\Import\ImportJobStore;
|
||||||
|
use App\Import\ImportRepository;
|
||||||
|
use App\Import\ImportService;
|
||||||
|
use App\Import\SpreadsheetReader;
|
||||||
|
use App\Import\SpreadsheetRowProcessor;
|
||||||
|
use App\Support\View;
|
||||||
|
use OpenSpout\Common\Entity\Row;
|
||||||
|
use OpenSpout\Common\Entity\Style\Style;
|
||||||
|
use OpenSpout\Writer\XLSX\Writer;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class AppController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ImportService $importService,
|
||||||
|
private readonly ImportJobStore $jobStore,
|
||||||
|
private readonly ImportJobStore $exportJobStore,
|
||||||
|
private readonly View $view,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(): self
|
||||||
|
{
|
||||||
|
$database = Database::fromConfig($GLOBALS['appConfig'] ?? []);
|
||||||
|
$repository = ImportRepository::fromDatabase($database);
|
||||||
|
$jobStore = new ImportJobStore();
|
||||||
|
$exportJobStore = new ImportJobStore(sys_get_temp_dir() . '/warner-export-jobs');
|
||||||
|
$chunkSize = max(1, (int) app_config('import.chunk_size', 5));
|
||||||
|
$service = new ImportService(
|
||||||
|
new SpreadsheetReader(),
|
||||||
|
new SpreadsheetRowProcessor(),
|
||||||
|
$repository,
|
||||||
|
$chunkSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new self($service, $jobStore, $exportJobStore, new View());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = (string) ($_POST['action'] ?? '');
|
||||||
|
if ($action === 'import') {
|
||||||
|
$this->handleImportStart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'process-chunk' || $action === 'import-status') {
|
||||||
|
$this->handleImportChunk();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'clear') {
|
||||||
|
$this->handleClear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = (string) ($_GET['action'] ?? '');
|
||||||
|
if ($action === 'grid-data' || $action === 'datatable' || $action === 'table-data') {
|
||||||
|
$this->handleTableData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'export-run') {
|
||||||
|
$this->handleExportRun();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'export-status') {
|
||||||
|
$this->handleExportStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'export-download') {
|
||||||
|
$this->handleExportDownload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$view = (string) ($_GET['view'] ?? '');
|
||||||
|
|
||||||
|
if ($view === 'table') {
|
||||||
|
$this->renderTable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($view === 'export') {
|
||||||
|
$this->renderExport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->renderIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleImportStart(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$upload = $this->getUpload('spreadsheet');
|
||||||
|
$token = bin2hex(random_bytes(16));
|
||||||
|
|
||||||
|
$targetDir = sys_get_temp_dir() . '/warner-imports';
|
||||||
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0777, true) && !is_dir($targetDir)) {
|
||||||
|
throw new RuntimeException('Unable to create a temporary upload directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
|
||||||
|
$targetPath = rtrim($targetDir, '/\\') . '/' . $token . '.' . $extension;
|
||||||
|
if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
|
||||||
|
throw new RuntimeException('Unable to store the uploaded file for chunked import.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jobStore->save($token, [
|
||||||
|
'token' => $token,
|
||||||
|
'status' => 'ready',
|
||||||
|
'filePath' => $targetPath,
|
||||||
|
'sourceFilename' => $upload['name'],
|
||||||
|
'sourceExtension' => strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION)),
|
||||||
|
'importedRows' => 0,
|
||||||
|
'warningsCount' => 0,
|
||||||
|
'warnings' => [],
|
||||||
|
'processedRows' => 0,
|
||||||
|
'totalRows' => 0,
|
||||||
|
'startedAt' => time(),
|
||||||
|
]);
|
||||||
|
$message = 'Upload received. Processing will continue in chunks.';
|
||||||
|
|
||||||
|
if ($this->isAjaxRequest()) {
|
||||||
|
$this->respondJson([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => $message,
|
||||||
|
'token' => $token,
|
||||||
|
'offset' => 0,
|
||||||
|
'total' => 0,
|
||||||
|
'done' => false,
|
||||||
|
'chunkSize' => $this->importService->chunkSize(),
|
||||||
|
], 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash('success', $message);
|
||||||
|
$this->redirectHome();
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
if ($this->isAjaxRequest()) {
|
||||||
|
$this->respondJson([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $throwable->getMessage(),
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash('danger', $throwable->getMessage());
|
||||||
|
$this->redirectHome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleImportChunk(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
@set_time_limit(0);
|
||||||
|
$token = (string) ($_POST['token'] ?? $_GET['token'] ?? '');
|
||||||
|
if ($token === '') {
|
||||||
|
throw new RuntimeException('Missing import token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $this->jobStore->get($token);
|
||||||
|
if ($state === null) {
|
||||||
|
$this->respondJson([
|
||||||
|
'ok' => false,
|
||||||
|
'done' => true,
|
||||||
|
'status' => 'missing',
|
||||||
|
'message' => 'Import job not found or already finished.',
|
||||||
|
], 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $this->importService->processJobState($state);
|
||||||
|
$this->jobStore->save($token, $state);
|
||||||
|
|
||||||
|
if (in_array((string) ($state['status'] ?? ''), ['completed', 'error'], true)) {
|
||||||
|
$this->cleanupImportJob($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress = $this->calculateProgress($state);
|
||||||
|
$message = (string) ($state['message'] ?? 'Processing import chunk.');
|
||||||
|
if (($state['status'] ?? '') === 'ready') {
|
||||||
|
$message = 'Import is ready to process.';
|
||||||
|
} elseif (($state['status'] ?? '') === 'cached') {
|
||||||
|
$message = sprintf(
|
||||||
|
'Workbook cached. %s row(s) detected and ready for import.',
|
||||||
|
number_format((int) ($state['totalRows'] ?? 0))
|
||||||
|
);
|
||||||
|
} elseif (($state['status'] ?? '') === 'running') {
|
||||||
|
$message = sprintf(
|
||||||
|
'Processed %d row(s) so far. Imported %d row(s).',
|
||||||
|
(int) ($state['processedRows'] ?? 0),
|
||||||
|
(int) ($state['importedRows'] ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalRows = (int) ($state['totalRows'] ?? 0);
|
||||||
|
$processedRows = (int) ($state['processedRows'] ?? 0);
|
||||||
|
|
||||||
|
if ($this->isAjaxRequest()) {
|
||||||
|
$this->respondJson([
|
||||||
|
'ok' => ($state['status'] ?? '') !== 'error',
|
||||||
|
'done' => ($state['status'] ?? '') === 'completed',
|
||||||
|
'status' => $state['status'] ?? 'unknown',
|
||||||
|
'message' => $message,
|
||||||
|
'token' => $token,
|
||||||
|
'importedRows' => (int) ($state['importedRows'] ?? 0),
|
||||||
|
'warningsCount' => (int) ($state['warningsCount'] ?? 0),
|
||||||
|
'warnings' => array_values(array_slice(is_array($state['warnings'] ?? null) ? $state['warnings'] : [], 0, 20)),
|
||||||
|
'processedRows' => $processedRows,
|
||||||
|
'totalRows' => $totalRows,
|
||||||
|
'offset' => min($processedRows, $totalRows),
|
||||||
|
'total' => $totalRows,
|
||||||
|
'processed' => (int) ($state['importedRows'] ?? 0),
|
||||||
|
'skipped' => (int) ($state['warningsCount'] ?? 0),
|
||||||
|
'progressPercent' => $progress,
|
||||||
|
'chunkSize' => $this->importService->chunkSize(),
|
||||||
|
], ($state['status'] ?? '') === 'error' ? 400 : 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash(($state['status'] ?? '') === 'error' ? 'danger' : 'success', $message);
|
||||||
|
$this->redirectHome();
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
if ($this->isAjaxRequest()) {
|
||||||
|
$this->respondJson([
|
||||||
|
'ok' => false,
|
||||||
|
'done' => true,
|
||||||
|
'message' => $throwable->getMessage(),
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash('danger', $throwable->getMessage());
|
||||||
|
$this->redirectHome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleClear(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->importService->clear();
|
||||||
|
if ($this->isAjaxRequest()) {
|
||||||
|
$this->respondJson([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Imported data cleared.',
|
||||||
|
], 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash('success', 'Imported data cleared.');
|
||||||
|
$this->redirectHome();
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
if ($this->isAjaxRequest()) {
|
||||||
|
$this->respondJson([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $throwable->getMessage(),
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash('danger', $throwable->getMessage());
|
||||||
|
$this->redirectHome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderIndex(): void
|
||||||
|
{
|
||||||
|
$latestBatch = $this->importService->latestBatch();
|
||||||
|
$previewLimit = 20;
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$tableHeaders = [];
|
||||||
|
$latestBatchRowCount = 0;
|
||||||
|
if ($latestBatch !== null) {
|
||||||
|
$batchId = (int) $latestBatch['id'];
|
||||||
|
$latestBatchRowCount = $this->importService->countRowsForBatch($batchId);
|
||||||
|
$rows = $this->importService->getPreview($batchId, $previewLimit, 0);
|
||||||
|
$tableHeaders = array_merge($latestBatch['original_headers'], $latestBatch['calculated_headers']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$flash = $this->consumeFlash();
|
||||||
|
|
||||||
|
echo $this->view->render('index', [
|
||||||
|
'appName' => app_config('app.name', 'Spreadsheet Importer'),
|
||||||
|
'flash' => $flash,
|
||||||
|
'latestBatch' => $latestBatch,
|
||||||
|
'latestBatchRowCount' => $latestBatchRowCount,
|
||||||
|
'rows' => $rows,
|
||||||
|
'tableHeaders' => $tableHeaders,
|
||||||
|
'previewLimit' => $previewLimit,
|
||||||
|
'requiredColumns' => $this->importService->requiredColumns(),
|
||||||
|
'currentView' => 'preview',
|
||||||
|
'homeUrl' => '?view=preview',
|
||||||
|
'tableUrl' => '?view=table',
|
||||||
|
'exportUrl' => '?view=export',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderTable(): void
|
||||||
|
{
|
||||||
|
$latestBatch = $this->importService->latestBatch();
|
||||||
|
|
||||||
|
$tableHeaders = [];
|
||||||
|
$latestBatchRowCount = 0;
|
||||||
|
|
||||||
|
if ($latestBatch !== null) {
|
||||||
|
$batchId = (int) $latestBatch['id'];
|
||||||
|
$latestBatchRowCount = $this->importService->countRowsForBatch($batchId);
|
||||||
|
$tableHeaders = array_merge($latestBatch['original_headers'], $latestBatch['calculated_headers']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$flash = $this->consumeFlash();
|
||||||
|
|
||||||
|
echo $this->view->render('table', [
|
||||||
|
'appName' => app_config('app.name', 'Spreadsheet Importer'),
|
||||||
|
'flash' => $flash,
|
||||||
|
'latestBatch' => $latestBatch,
|
||||||
|
'latestBatchRowCount' => $latestBatchRowCount,
|
||||||
|
'tableHeaders' => $tableHeaders,
|
||||||
|
'requiredColumns' => $this->importService->requiredColumns(),
|
||||||
|
'currentView' => 'table',
|
||||||
|
'homeUrl' => '?view=preview',
|
||||||
|
'tableUrl' => '?view=table',
|
||||||
|
'exportUrl' => '?view=export',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderExport(): void
|
||||||
|
{
|
||||||
|
$latestBatch = $this->importService->latestBatch();
|
||||||
|
$latestBatchRowCount = $latestBatch !== null
|
||||||
|
? $this->importService->countRowsForBatch((int) $latestBatch['id'])
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$flash = $this->consumeFlash();
|
||||||
|
|
||||||
|
echo $this->view->render('export', [
|
||||||
|
'appName' => app_config('app.name', 'Spreadsheet Importer'),
|
||||||
|
'flash' => $flash,
|
||||||
|
'latestBatch' => $latestBatch,
|
||||||
|
'latestBatchRowCount' => $latestBatchRowCount,
|
||||||
|
'currentView' => 'export',
|
||||||
|
'homeUrl' => '?view=preview',
|
||||||
|
'tableUrl' => '?view=table',
|
||||||
|
'exportUrl' => '?view=export',
|
||||||
|
'exportRunUrl' => '?action=export-run',
|
||||||
|
'exportStatusUrl' => '?action=export-status',
|
||||||
|
'exportDownloadUrl' => '?action=export-download',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const EXPORT_FONT_NAME = 'Verdana';
|
||||||
|
private const EXPORT_FONT_SIZE = 8;
|
||||||
|
private const EXPORT_ROW_HEIGHT = 16.0;
|
||||||
|
private const EXPORT_COLUMN_WIDTH = 10.0;
|
||||||
|
private const EXPORT_HEADER_TEXT_COLOR = '1D4072';
|
||||||
|
private const EXPORT_HEADER_ACCENT_BG = 'EFD6ED';
|
||||||
|
private const EXPORT_HEADER_DEFAULT_BG = 'D6E1EF';
|
||||||
|
private const EXPORT_CURRENCY_HEADER = 'Net Value USD';
|
||||||
|
private const EXPORT_CURRENCY_FORMAT = '#,##0.00;(#,##0.00)';
|
||||||
|
|
||||||
|
private const EXPORT_ACCENT_HEADERS = [
|
||||||
|
'US/ex-US sale',
|
||||||
|
'Actual/Accrual',
|
||||||
|
'Contracting Party Filtered',
|
||||||
|
'String',
|
||||||
|
'2110 Applicable X201',
|
||||||
|
'Class',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function resolveExportHeaders(array $tableHeaders): array
|
||||||
|
{
|
||||||
|
$headers = array_values(array_filter($tableHeaders, static fn (string $header): bool => $header !== 'x'));
|
||||||
|
|
||||||
|
foreach (['US/ex-US sale', 'Actual/Accrual'] as $header) {
|
||||||
|
$fromIndex = array_search($header, $headers, true);
|
||||||
|
if ($fromIndex === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
array_splice($headers, $fromIndex, 1);
|
||||||
|
$dspIndex = array_search('DSP', $headers, true);
|
||||||
|
array_splice($headers, $dspIndex === false ? count($headers) : $dspIndex, 0, [$header]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildExportHeaderStyle(string $backgroundColor): Style
|
||||||
|
{
|
||||||
|
$style = new Style();
|
||||||
|
$style->setBackgroundColor($backgroundColor);
|
||||||
|
$style->setFontColor(self::EXPORT_HEADER_TEXT_COLOR);
|
||||||
|
$style->setFontBold();
|
||||||
|
$style->setFontName(self::EXPORT_FONT_NAME);
|
||||||
|
$style->setFontSize(self::EXPORT_FONT_SIZE);
|
||||||
|
|
||||||
|
return $style;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildExportDataStyle(): Style
|
||||||
|
{
|
||||||
|
$style = new Style();
|
||||||
|
$style->setFontName(self::EXPORT_FONT_NAME);
|
||||||
|
$style->setFontSize(self::EXPORT_FONT_SIZE);
|
||||||
|
|
||||||
|
return $style;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildExportCurrencyCellStyle(): Style
|
||||||
|
{
|
||||||
|
$style = new Style();
|
||||||
|
$style->setFormat(self::EXPORT_CURRENCY_FORMAT);
|
||||||
|
|
||||||
|
return $style;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function exportFilePath(string $token): string
|
||||||
|
{
|
||||||
|
$safeToken = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
|
||||||
|
if ($safeToken === '') {
|
||||||
|
throw new RuntimeException('Invalid export token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = sys_get_temp_dir() . '/warner-exports';
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
|
||||||
|
throw new RuntimeException('Unable to create the export storage directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dir . '/' . $safeToken . '.xlsx';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleExportRun(): void
|
||||||
|
{
|
||||||
|
$token = (string) ($_GET['token'] ?? '');
|
||||||
|
|
||||||
|
// Release the session file lock immediately: this request runs for
|
||||||
|
// the whole export, and PHP's default session handler otherwise
|
||||||
|
// blocks every other request (including the status polls) from the
|
||||||
|
// same browser session until this one finishes.
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($token === '') {
|
||||||
|
throw new RuntimeException('Missing export token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestBatch = $this->importService->latestBatch();
|
||||||
|
if ($latestBatch === null) {
|
||||||
|
throw new RuntimeException('There is no imported data to export yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$batchId = (int) $latestBatch['id'];
|
||||||
|
$totalRows = $this->importService->countRowsForBatch($batchId);
|
||||||
|
$tableHeaders = $this->resolveExportHeaders(
|
||||||
|
array_merge($latestBatch['original_headers'], $latestBatch['calculated_headers'])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->exportJobStore->save($token, [
|
||||||
|
'status' => 'running',
|
||||||
|
'processedRows' => 0,
|
||||||
|
'totalRows' => $totalRows,
|
||||||
|
'message' => 'Starting export…',
|
||||||
|
]);
|
||||||
|
|
||||||
|
@set_time_limit(0);
|
||||||
|
@ignore_user_abort(true);
|
||||||
|
|
||||||
|
$filePath = $this->exportFilePath($token);
|
||||||
|
|
||||||
|
$writer = new Writer();
|
||||||
|
$writer->getOptions()->DEFAULT_ROW_HEIGHT = self::EXPORT_ROW_HEIGHT;
|
||||||
|
$writer->getOptions()->DEFAULT_COLUMN_WIDTH = self::EXPORT_COLUMN_WIDTH;
|
||||||
|
$writer->openToFile($filePath);
|
||||||
|
|
||||||
|
// Leave the first two rows blank before the header row and data.
|
||||||
|
$writer->addRow(Row::fromValues([]));
|
||||||
|
$writer->addRow(Row::fromValues([]));
|
||||||
|
|
||||||
|
$accentHeaderStyle = $this->buildExportHeaderStyle(self::EXPORT_HEADER_ACCENT_BG);
|
||||||
|
$defaultHeaderStyle = $this->buildExportHeaderStyle(self::EXPORT_HEADER_DEFAULT_BG);
|
||||||
|
$headerColumnStyles = array_map(
|
||||||
|
static fn (string $header): Style => in_array($header, self::EXPORT_ACCENT_HEADERS, true)
|
||||||
|
? $accentHeaderStyle
|
||||||
|
: $defaultHeaderStyle,
|
||||||
|
$tableHeaders
|
||||||
|
);
|
||||||
|
$writer->addRow(Row::fromValuesWithStyles($tableHeaders, null, $headerColumnStyles));
|
||||||
|
|
||||||
|
$dataStyle = $this->buildExportDataStyle();
|
||||||
|
$currencyIndex = array_search(self::EXPORT_CURRENCY_HEADER, $tableHeaders, true);
|
||||||
|
$dataColumnStyles = $currencyIndex === false
|
||||||
|
? []
|
||||||
|
: [$currencyIndex => $this->buildExportCurrencyCellStyle()];
|
||||||
|
|
||||||
|
$chunkSize = 2000;
|
||||||
|
$offset = 0;
|
||||||
|
do {
|
||||||
|
$rows = $this->importService->getExportRows($batchId, $chunkSize, $offset);
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$values = array_map(
|
||||||
|
function (string $header) use ($row): string|float {
|
||||||
|
$value = $row[$header] ?? '';
|
||||||
|
if ($header === self::EXPORT_CURRENCY_HEADER && is_numeric($value)) {
|
||||||
|
return (float) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $value;
|
||||||
|
},
|
||||||
|
$tableHeaders
|
||||||
|
);
|
||||||
|
$writer->addRow(Row::fromValuesWithStyles($values, $dataStyle, $dataColumnStyles));
|
||||||
|
}
|
||||||
|
$offset += $chunkSize;
|
||||||
|
|
||||||
|
$this->exportJobStore->save($token, [
|
||||||
|
'status' => 'running',
|
||||||
|
'processedRows' => min($offset, $totalRows),
|
||||||
|
'totalRows' => $totalRows,
|
||||||
|
'message' => sprintf('Exported %d of %d row(s)…', min($offset, $totalRows), $totalRows),
|
||||||
|
]);
|
||||||
|
} while (count($rows) === $chunkSize);
|
||||||
|
|
||||||
|
$writer->close();
|
||||||
|
|
||||||
|
$this->exportJobStore->save($token, [
|
||||||
|
'status' => 'completed',
|
||||||
|
'processedRows' => $totalRows,
|
||||||
|
'totalRows' => $totalRows,
|
||||||
|
'message' => 'Export complete.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->respondJson(['ok' => true], 200);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
if ($token !== '') {
|
||||||
|
$this->exportJobStore->save($token, [
|
||||||
|
'status' => 'error',
|
||||||
|
'processedRows' => 0,
|
||||||
|
'totalRows' => 0,
|
||||||
|
'message' => $throwable->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->respondJson(['ok' => false, 'message' => $throwable->getMessage()], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleExportStatus(): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = (string) ($_GET['token'] ?? '');
|
||||||
|
$state = $token !== '' ? $this->exportJobStore->get($token) : null;
|
||||||
|
|
||||||
|
if ($state === null) {
|
||||||
|
$this->respondJson(['status' => 'unknown'], 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($state['status'] ?? 'unknown');
|
||||||
|
$totalRows = (int) ($state['totalRows'] ?? 0);
|
||||||
|
$processedRows = (int) ($state['processedRows'] ?? 0);
|
||||||
|
$progressPercent = $status === 'completed'
|
||||||
|
? 100
|
||||||
|
: ($totalRows > 0 ? (int) min(99, round(($processedRows / $totalRows) * 100)) : 0);
|
||||||
|
|
||||||
|
$this->respondJson([
|
||||||
|
'status' => $status,
|
||||||
|
'processedRows' => $processedRows,
|
||||||
|
'totalRows' => $totalRows,
|
||||||
|
'progressPercent' => $progressPercent,
|
||||||
|
'message' => (string) ($state['message'] ?? ''),
|
||||||
|
'downloadUrl' => $status === 'completed' ? '?action=export-download&token=' . rawurlencode($token) : null,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleExportDownload(): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = (string) ($_GET['token'] ?? '');
|
||||||
|
$state = $token !== '' ? $this->exportJobStore->get($token) : null;
|
||||||
|
|
||||||
|
if ($state === null || ($state['status'] ?? '') !== 'completed') {
|
||||||
|
http_response_code(404);
|
||||||
|
echo 'Export not found or not ready yet.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $this->exportFilePath($token);
|
||||||
|
if (!is_file($filePath)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo 'Export file is no longer available.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = 'warner-export-' . date('Y-m-d-His') . '.xlsx';
|
||||||
|
|
||||||
|
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
header('Content-Length: ' . filesize($filePath));
|
||||||
|
header('Cache-Control: max-age=0');
|
||||||
|
|
||||||
|
readfile($filePath);
|
||||||
|
|
||||||
|
@unlink($filePath);
|
||||||
|
$this->exportJobStore->delete($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleTableData(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$latestBatch = $this->importService->latestBatch();
|
||||||
|
|
||||||
|
if ($latestBatch === null) {
|
||||||
|
if (array_key_exists('distinctColumn', $_GET)) {
|
||||||
|
$this->respondJson([
|
||||||
|
'values' => [],
|
||||||
|
], 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->respondJson([
|
||||||
|
'rows' => [],
|
||||||
|
'lastRow' => 0,
|
||||||
|
], 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$batchId = (int) $latestBatch['id'];
|
||||||
|
$tableHeaders = array_merge($latestBatch['original_headers'], $latestBatch['calculated_headers']);
|
||||||
|
|
||||||
|
$distinctColumn = trim((string) ($_GET['distinctColumn'] ?? ''));
|
||||||
|
if ($distinctColumn !== '' || array_key_exists('distinctColumn', $_GET)) {
|
||||||
|
$distinctSearch = trim((string) ($_GET['distinctSearch'] ?? ''));
|
||||||
|
$this->respondJson([
|
||||||
|
'values' => $this->importService->getDistinctValuesForBatchGrid(
|
||||||
|
$batchId,
|
||||||
|
$tableHeaders,
|
||||||
|
$distinctColumn,
|
||||||
|
$distinctSearch,
|
||||||
|
500
|
||||||
|
),
|
||||||
|
], 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = max(0, (int) ($_GET['startRow'] ?? $_GET['start'] ?? 0));
|
||||||
|
$end = max($start + 1, (int) ($_GET['endRow'] ?? ($start + 100)));
|
||||||
|
$length = min(200, max(1, $end - $start));
|
||||||
|
$search = trim((string) ($_GET['search'] ?? ''));
|
||||||
|
$sortModel = $this->decodeGridPayload((string) ($_GET['sortModel'] ?? '[]'));
|
||||||
|
$filterModel = $this->decodeGridPayload((string) ($_GET['filterModel'] ?? '{}'));
|
||||||
|
|
||||||
|
$recordsTotal = $this->importService->countRowsForBatch($batchId);
|
||||||
|
$recordsFiltered = ($search !== '' || $filterModel !== [])
|
||||||
|
? $this->importService->countGridRows($batchId, $search, $tableHeaders, $filterModel)
|
||||||
|
: $recordsTotal;
|
||||||
|
|
||||||
|
$pageRows = $this->importService->getGridRows(
|
||||||
|
$batchId,
|
||||||
|
$length,
|
||||||
|
$start,
|
||||||
|
$search,
|
||||||
|
$tableHeaders,
|
||||||
|
$filterModel,
|
||||||
|
$sortModel
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->respondJson([
|
||||||
|
'rows' => $pageRows,
|
||||||
|
'lastRow' => $recordsFiltered,
|
||||||
|
], 200);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$this->respondJson([
|
||||||
|
'rows' => [],
|
||||||
|
'lastRow' => 0,
|
||||||
|
'error' => $throwable->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getUpload(string $fieldName): array
|
||||||
|
{
|
||||||
|
if (!isset($_FILES[$fieldName])) {
|
||||||
|
throw new RuntimeException('Please choose an Excel file to upload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload = $_FILES[$fieldName];
|
||||||
|
if (!is_array($upload) || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
throw new RuntimeException('Please choose an Excel file to upload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = (string) $upload['name'];
|
||||||
|
$tmpName = (string) $upload['tmp_name'];
|
||||||
|
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($extension, ['xls', 'xlsx'], true)) {
|
||||||
|
throw new RuntimeException('The uploaded file must be an .xls or .xlsx Excel workbook.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_uploaded_file($tmpName)) {
|
||||||
|
throw new RuntimeException('The uploaded file could not be verified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'tmp_name' => $tmpName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function respondJson(array $payload, int $statusCode): void
|
||||||
|
{
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAjaxRequest(): bool
|
||||||
|
{
|
||||||
|
return strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flash(string $type, string $message): void
|
||||||
|
{
|
||||||
|
$_SESSION['flash'] = [
|
||||||
|
'type' => $type,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function consumeFlash(): ?array
|
||||||
|
{
|
||||||
|
if (!isset($_SESSION['flash'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flash = $_SESSION['flash'];
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
return is_array($flash) ? $flash : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculateProgress(array $state): int
|
||||||
|
{
|
||||||
|
$totalRows = (int) ($state['totalRows'] ?? 0);
|
||||||
|
if ($totalRows <= 0) {
|
||||||
|
return ($state['status'] ?? '') === 'completed' ? 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processedRows = (int) ($state['processedRows'] ?? 0);
|
||||||
|
$progress = (int) round(($processedRows / $totalRows) * 100);
|
||||||
|
|
||||||
|
if (($state['status'] ?? '') === 'completed') {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, min(99, $progress));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeTableValue(mixed $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeGridPayload(string $json): array
|
||||||
|
{
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanupImportJob(array $state): void
|
||||||
|
{
|
||||||
|
$filePath = (string) ($state['filePath'] ?? '');
|
||||||
|
if ($filePath !== '' && is_file($filePath)) {
|
||||||
|
@unlink($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheBasePath = (string) ($state['cacheBasePath'] ?? '');
|
||||||
|
if ($cacheBasePath !== '') {
|
||||||
|
$this->importService->deleteCache($cacheBasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = (string) ($state['token'] ?? '');
|
||||||
|
if ($token !== '') {
|
||||||
|
$this->jobStore->delete($token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redirectHome(): never
|
||||||
|
{
|
||||||
|
header('Location: ' . rtrim((string) app_config('app.url', 'http://127.0.0.1:8000'), '/'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\Reader\IReadFilter;
|
||||||
|
|
||||||
|
final class ChunkReadFilter implements IReadFilter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $startRow,
|
||||||
|
private readonly int $endRow,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readCell($columnAddress, $row, $worksheetName = ''): bool
|
||||||
|
{
|
||||||
|
return $row >= $this->startRow && $row <= $this->endRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class ImportJobStore
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $storageDir = '',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $state): string
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(16));
|
||||||
|
$state['token'] = $token;
|
||||||
|
$this->write($token, $state);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $token): ?array
|
||||||
|
{
|
||||||
|
$path = $this->path($token);
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = file_get_contents($path);
|
||||||
|
if ($contents === false || $contents === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = json_decode($contents, true);
|
||||||
|
if (!is_array($state)) {
|
||||||
|
throw new RuntimeException('The import job state is corrupt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(string $token, array $state): void
|
||||||
|
{
|
||||||
|
$state['token'] = $token;
|
||||||
|
$this->write($token, $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $token): void
|
||||||
|
{
|
||||||
|
$path = $this->path($token);
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pathForToken(string $token): string
|
||||||
|
{
|
||||||
|
return $this->path($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function write(string $token, array $state): void
|
||||||
|
{
|
||||||
|
$path = $this->path($token);
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
|
||||||
|
throw new RuntimeException('Unable to create the import job storage directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode($state, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
$tmpPath = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
|
||||||
|
if (file_put_contents($tmpPath, $json) === false) {
|
||||||
|
throw new RuntimeException('Unable to write the import job state.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!@rename($tmpPath, $path)) {
|
||||||
|
@unlink($tmpPath);
|
||||||
|
throw new RuntimeException('Unable to persist the import job state.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function path(string $token): string
|
||||||
|
{
|
||||||
|
$safeToken = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
|
||||||
|
if ($safeToken === '') {
|
||||||
|
throw new RuntimeException('Invalid import job token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$storageDir = $this->storageDir !== '' ? $this->storageDir : sys_get_temp_dir() . '/warner-import-jobs';
|
||||||
|
|
||||||
|
return rtrim($storageDir, '/\\') . '/' . $safeToken . '.json';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,722 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
use App\Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class ImportRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromDatabase(Database $database): self
|
||||||
|
{
|
||||||
|
return new self($database->pdo());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginTransaction(): void
|
||||||
|
{
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(): void
|
||||||
|
{
|
||||||
|
$this->pdo->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollBack(): void
|
||||||
|
{
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearAll(): void
|
||||||
|
{
|
||||||
|
$this->pdo->exec('TRUNCATE TABLE import_rows, import_batches RESTART IDENTITY CASCADE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createBatch(string $sourceFilename, array $originalHeaders, array $calculatedHeaders, string $status = 'processing'): int
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO import_batches (source_filename, original_headers, calculated_headers, status) VALUES (:source_filename, CAST(:original_headers AS jsonb), CAST(:calculated_headers AS jsonb), :status) RETURNING id'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'source_filename' => $sourceFilename,
|
||||||
|
'original_headers' => json_encode(array_values($originalHeaders), JSON_THROW_ON_ERROR),
|
||||||
|
'calculated_headers' => json_encode(array_values($calculatedHeaders), JSON_THROW_ON_ERROR),
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $statement->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateBatchSummary(int $batchId, int $rowCount, int $warningsCount, string $status = 'processing'): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare('UPDATE import_batches SET row_count = :row_count, warnings_count = :warnings_count, status = :status WHERE id = :id');
|
||||||
|
$statement->execute([
|
||||||
|
'row_count' => $rowCount,
|
||||||
|
'warnings_count' => $warningsCount,
|
||||||
|
'status' => $status,
|
||||||
|
'id' => $batchId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insertRow(int $batchId, int $rowNumber, array $sourceRow, array $normalizedRow, array $calculatedRow, array $mergedRow): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO import_rows (batch_id, row_number, source_row, normalized_row, calculated_row, merged_row) VALUES (:batch_id, :row_number, CAST(:source_row AS jsonb), CAST(:normalized_row AS jsonb), CAST(:calculated_row AS jsonb), CAST(:merged_row AS jsonb))'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
'row_number' => $rowNumber,
|
||||||
|
'source_row' => json_encode($sourceRow, JSON_THROW_ON_ERROR),
|
||||||
|
'normalized_row' => json_encode($normalizedRow, JSON_THROW_ON_ERROR),
|
||||||
|
'calculated_row' => json_encode($calculatedRow, JSON_THROW_ON_ERROR),
|
||||||
|
'merged_row' => json_encode($mergedRow, JSON_THROW_ON_ERROR),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLatestBatch(): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->query("SELECT * FROM import_batches WHERE status = 'complete' ORDER BY id DESC LIMIT 1");
|
||||||
|
$batch = $statement->fetch();
|
||||||
|
|
||||||
|
return $batch === false ? null : $this->decodeBatchRow($batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countRowsForBatch(int $batchId): int
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare('SELECT COUNT(*) FROM import_rows WHERE batch_id = :batch_id');
|
||||||
|
$statement->execute(['batch_id' => $batchId]);
|
||||||
|
return (int) $statement->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDistinctValuesForBatch(int $batchId, string $header, int $limit = 200): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
"SELECT DISTINCT NULLIF(TRIM(COALESCE(jsonb_extract_path_text(merged_row, :header), '')), '') AS value
|
||||||
|
FROM import_rows
|
||||||
|
WHERE batch_id = :batch_id
|
||||||
|
ORDER BY value ASC
|
||||||
|
LIMIT :limit"
|
||||||
|
);
|
||||||
|
$statement->bindValue('batch_id', $batchId, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue('header', $header, PDO::PARAM_STR);
|
||||||
|
$statement->bindValue('limit', $limit, PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$values = [];
|
||||||
|
while ($row = $statement->fetch()) {
|
||||||
|
$value = (string) ($row['value'] ?? '');
|
||||||
|
if ($value !== '') {
|
||||||
|
$values[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDistinctValuesForBatchGrid(
|
||||||
|
int $batchId,
|
||||||
|
array $tableHeaders,
|
||||||
|
string $columnKey,
|
||||||
|
string $distinctSearch = '',
|
||||||
|
int $limit = 500
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
if ($columnKey === 'row_number') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$columnSql = $this->resolveGridColumnSql($tableHeaders, $columnKey);
|
||||||
|
if ($columnSql === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = max(1, min(500, $limit));
|
||||||
|
$distinctSearch = trim($distinctSearch);
|
||||||
|
$valueSql = 'COALESCE(' . $columnSql . ", '')";
|
||||||
|
|
||||||
|
$whereParts = ['batch_id = :batch_id'];
|
||||||
|
$parameters = [
|
||||||
|
'batch_id' => $batchId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($distinctSearch !== '') {
|
||||||
|
$whereParts[] = $valueSql . ' ILIKE :distinct_search ESCAPE :distinct_search_esc';
|
||||||
|
$parameters['distinct_search'] = '%' . $this->escapeLikePattern($distinctSearch) . '%';
|
||||||
|
$parameters['distinct_search_esc'] = '\\';
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT distinct_values.value
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ' . $valueSql . ' AS value
|
||||||
|
FROM import_rows
|
||||||
|
WHERE ' . implode(' AND ', $whereParts) . '
|
||||||
|
) AS distinct_values
|
||||||
|
ORDER BY LOWER(distinct_values.value) ASC, distinct_values.value ASC
|
||||||
|
LIMIT :limit'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($parameters as $name => $value) {
|
||||||
|
$statement->bindValue(':' . $name, $value, $name === 'batch_id' ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$values = [];
|
||||||
|
while ($row = $statement->fetch()) {
|
||||||
|
$value = (string) ($row['value'] ?? '');
|
||||||
|
if (!in_array($value, $values, true)) {
|
||||||
|
$values[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginateRows(int $batchId, int $limit, int $offset, string $sortDirection = 'asc'): array
|
||||||
|
{
|
||||||
|
return $this->paginateRowsFiltered($batchId, $limit, $offset, '', [], [], null, $sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countRowsForBatchFiltered(
|
||||||
|
int $batchId,
|
||||||
|
string $query = '',
|
||||||
|
array $tableHeaders = [],
|
||||||
|
array $columnSearches = []
|
||||||
|
): int
|
||||||
|
{
|
||||||
|
[$whereClause, $parameters] = $this->buildDataTableClause($tableHeaders, $query, $columnSearches);
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT COUNT(*)
|
||||||
|
FROM import_rows
|
||||||
|
WHERE batch_id = :batch_id' . $whereClause
|
||||||
|
);
|
||||||
|
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
|
||||||
|
foreach ($parameters as $name => $value) {
|
||||||
|
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
return (int) $statement->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countRowsForBatchGrid(
|
||||||
|
int $batchId,
|
||||||
|
string $query = '',
|
||||||
|
array $tableHeaders = [],
|
||||||
|
array $filterModel = []
|
||||||
|
): int
|
||||||
|
{
|
||||||
|
[$whereClause, $parameters] = $this->buildGridWhereClause($tableHeaders, $query, $filterModel);
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT COUNT(*)
|
||||||
|
FROM import_rows
|
||||||
|
WHERE batch_id = :batch_id' . $whereClause
|
||||||
|
);
|
||||||
|
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
|
||||||
|
foreach ($parameters as $name => $value) {
|
||||||
|
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
return (int) $statement->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginateRowsFiltered(
|
||||||
|
int $batchId,
|
||||||
|
int $limit,
|
||||||
|
int $offset,
|
||||||
|
string $query = '',
|
||||||
|
array $tableHeaders = [],
|
||||||
|
array $columnSearches = [],
|
||||||
|
?int $orderColumn = null,
|
||||||
|
string $sortDirection = 'asc'
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
[$whereClause, $parameters] = $this->buildDataTableClause($tableHeaders, $query, $columnSearches);
|
||||||
|
[$orderClause, $orderParameters] = $this->buildOrderClause($tableHeaders, $orderColumn, $sortDirection);
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT row_number, source_row, normalized_row, calculated_row, merged_row
|
||||||
|
FROM import_rows
|
||||||
|
WHERE batch_id = :batch_id' . $whereClause . '
|
||||||
|
' . $orderClause . '
|
||||||
|
LIMIT :limit OFFSET :offset'
|
||||||
|
);
|
||||||
|
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
|
foreach ($parameters as $name => $value) {
|
||||||
|
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
foreach ($orderParameters as $name => $value) {
|
||||||
|
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
while ($row = $statement->fetch()) {
|
||||||
|
$rows[] = $this->decodeRowPayload($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginateRowsGrid(
|
||||||
|
int $batchId,
|
||||||
|
int $limit,
|
||||||
|
int $offset,
|
||||||
|
string $query = '',
|
||||||
|
array $tableHeaders = [],
|
||||||
|
array $filterModel = [],
|
||||||
|
array $sortModel = []
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
[$whereClause, $parameters] = $this->buildGridWhereClause($tableHeaders, $query, $filterModel);
|
||||||
|
[$orderClause, $orderParameters] = $this->buildGridOrderClause($tableHeaders, $sortModel);
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT row_number, merged_row
|
||||||
|
FROM import_rows
|
||||||
|
WHERE batch_id = :batch_id' . $whereClause . '
|
||||||
|
' . $orderClause . '
|
||||||
|
LIMIT :limit OFFSET :offset'
|
||||||
|
);
|
||||||
|
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
|
foreach ($parameters as $name => $value) {
|
||||||
|
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
foreach ($orderParameters as $name => $value) {
|
||||||
|
$statement->bindValue(':' . $name, $value, PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
while ($row = $statement->fetch()) {
|
||||||
|
$rows[] = $this->decodeGridRowPayload($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllRowsCount(): int
|
||||||
|
{
|
||||||
|
return (int) $this->pdo->query('SELECT COUNT(*) FROM import_rows')->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function exportRowsForBatch(int $batchId, int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT row_number, merged_row
|
||||||
|
FROM import_rows
|
||||||
|
WHERE batch_id = :batch_id
|
||||||
|
ORDER BY row_number ASC
|
||||||
|
LIMIT :limit OFFSET :offset'
|
||||||
|
);
|
||||||
|
$statement->bindValue(':batch_id', $batchId, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
while ($row = $statement->fetch()) {
|
||||||
|
$rows[] = $this->decodeGridRowPayload($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pruneExceptBatch(int $batchId): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare('DELETE FROM import_batches WHERE id <> :batch_id');
|
||||||
|
$statement->execute(['batch_id' => $batchId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeBatchRow(array $row): array
|
||||||
|
{
|
||||||
|
$row['original_headers'] = json_decode((string) $row['original_headers'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$row['calculated_headers'] = json_decode((string) $row['calculated_headers'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeRowPayload(array $row): array
|
||||||
|
{
|
||||||
|
$row['source_row'] = json_decode((string) $row['source_row'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$row['normalized_row'] = json_decode((string) $row['normalized_row'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$row['calculated_row'] = json_decode((string) $row['calculated_row'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$row['merged_row'] = json_decode((string) $row['merged_row'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeGridRowPayload(array $row): array
|
||||||
|
{
|
||||||
|
$mergedRow = json_decode((string) $row['merged_row'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($mergedRow)) {
|
||||||
|
$mergedRow = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($mergedRow, [
|
||||||
|
'row_number' => (int) $row['row_number'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveGridColumnSql(array $tableHeaders, string $columnKey): ?string
|
||||||
|
{
|
||||||
|
if ($columnKey === 'row_number') {
|
||||||
|
return 'row_number';
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerIndex = array_search($columnKey, $tableHeaders, true);
|
||||||
|
if ($headerIndex === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->columnSqlForHeader((string) $tableHeaders[(int) $headerIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:array<string,string>}
|
||||||
|
*/
|
||||||
|
private function buildGridWhereClause(array $tableHeaders, string $globalSearch, array $filterModel): array
|
||||||
|
{
|
||||||
|
$conditions = [];
|
||||||
|
$parameters = [];
|
||||||
|
|
||||||
|
$globalSearch = trim($globalSearch);
|
||||||
|
if ($globalSearch !== '') {
|
||||||
|
$globalQueryRow = 'grid_global_query_row';
|
||||||
|
$globalQueryMerged = 'grid_global_query_merged';
|
||||||
|
$conditions[] = "(
|
||||||
|
CAST(row_number AS TEXT) ILIKE :{$globalQueryRow} ESCAPE :{$globalQueryRow}_esc
|
||||||
|
OR CAST(merged_row AS TEXT) ILIKE :{$globalQueryMerged} ESCAPE :{$globalQueryMerged}_esc
|
||||||
|
)";
|
||||||
|
$escapedGlobalSearch = '%' . $this->escapeLikePattern($globalSearch) . '%';
|
||||||
|
$parameters[$globalQueryRow] = $escapedGlobalSearch;
|
||||||
|
$parameters[$globalQueryRow . '_esc'] = '\\';
|
||||||
|
$parameters[$globalQueryMerged] = $escapedGlobalSearch;
|
||||||
|
$parameters[$globalQueryMerged . '_esc'] = '\\';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($filterModel as $columnId => $model) {
|
||||||
|
if (!is_array($model)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columnKey = (string) $columnId;
|
||||||
|
$columnSql = $this->resolveGridColumnSql($tableHeaders, $columnKey);
|
||||||
|
if ($columnSql === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counter = 0;
|
||||||
|
$clause = $this->buildGridFilterClause($columnSql, $model, 'grid_' . preg_replace('/[^a-zA-Z0-9_]+/', '_', $columnKey), $parameters, $counter);
|
||||||
|
if ($clause !== '') {
|
||||||
|
$conditions[] = $clause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($conditions === []) {
|
||||||
|
return ['', []];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
' AND (' . implode(' AND ', $conditions) . ')',
|
||||||
|
$parameters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:array<string,string>}
|
||||||
|
*/
|
||||||
|
private function buildGridOrderClause(array $tableHeaders, array $sortModel): array
|
||||||
|
{
|
||||||
|
$orderParts = [];
|
||||||
|
$parameters = [];
|
||||||
|
$hasRowNumberSort = false;
|
||||||
|
|
||||||
|
foreach ($sortModel as $index => $sortItem) {
|
||||||
|
if (!is_array($sortItem)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columnKey = (string) ($sortItem['colId'] ?? '');
|
||||||
|
$sortDirection = strtolower((string) ($sortItem['sort'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
|
$columnSql = $this->resolveGridColumnSql($tableHeaders, $columnKey);
|
||||||
|
if ($columnSql === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($columnKey === 'row_number') {
|
||||||
|
$orderParts[] = 'row_number ' . $sortDirection;
|
||||||
|
$hasRowNumberSort = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderParts[] = 'LOWER(COALESCE(' . $columnSql . ", '')) " . $sortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($orderParts === []) {
|
||||||
|
return ['ORDER BY row_number ASC', []];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasRowNumberSort) {
|
||||||
|
$orderParts[] = 'row_number ASC';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ORDER BY ' . implode(', ', $orderParts), $parameters];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function buildGridFilterClause(string $columnSql, array $model, string $paramPrefix, array &$parameters, int &$counter): string
|
||||||
|
{
|
||||||
|
if (isset($model['operator'], $model['condition1'], $model['condition2']) && is_array($model['condition1']) && is_array($model['condition2'])) {
|
||||||
|
$left = $this->buildGridFilterClause($columnSql, $model['condition1'], $paramPrefix . '_c1', $parameters, $counter);
|
||||||
|
$right = $this->buildGridFilterClause($columnSql, $model['condition2'], $paramPrefix . '_c2', $parameters, $counter);
|
||||||
|
$operator = strtoupper((string) $model['operator']) === 'OR' ? 'OR' : 'AND';
|
||||||
|
return '(' . $left . ' ' . $operator . ' ' . $right . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
$filterType = strtolower((string) ($model['filterType'] ?? 'text'));
|
||||||
|
if ($filterType === 'set') {
|
||||||
|
return $this->buildGridSetFilterClause($columnSql, $model, $paramPrefix, $parameters, $counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filterType === 'number') {
|
||||||
|
return $this->buildGridNumberFilterClause($columnSql, $model, $paramPrefix, $parameters, $counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildGridTextFilterClause($columnSql, $model, $paramPrefix, $parameters, $counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function buildGridTextFilterClause(string $columnSql, array $model, string $paramPrefix, array &$parameters, int &$counter): string
|
||||||
|
{
|
||||||
|
$type = strtolower((string) ($model['type'] ?? 'contains'));
|
||||||
|
$filter = trim((string) ($model['filter'] ?? ''));
|
||||||
|
|
||||||
|
$paramName = $paramPrefix . '_' . $counter++;
|
||||||
|
$escapedFilter = $this->escapeLikePattern($filter);
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'blank' => 'COALESCE(TRIM(' . $columnSql . "), '') = ''",
|
||||||
|
'notblank' => 'COALESCE(TRIM(' . $columnSql . "), '') <> ''",
|
||||||
|
'equals' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, true),
|
||||||
|
'notequal' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, false),
|
||||||
|
'startswith' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, true, false, true),
|
||||||
|
'endswith' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, true, true, false),
|
||||||
|
'notcontains' => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, false, true, true),
|
||||||
|
default => $this->addTextFilterParameter($columnSql, $paramName, $escapedFilter, $parameters, true, true, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function buildGridNumberFilterClause(string $columnSql, array $model, string $paramPrefix, array &$parameters, int &$counter): string
|
||||||
|
{
|
||||||
|
$type = strtolower((string) ($model['type'] ?? 'equals'));
|
||||||
|
$filter = $model['filter'] ?? null;
|
||||||
|
$filterTo = $model['filterTo'] ?? null;
|
||||||
|
|
||||||
|
$paramName = $paramPrefix . '_' . $counter++;
|
||||||
|
$from = is_numeric($filter) ? (string) $filter : '';
|
||||||
|
$to = is_numeric($filterTo) ? (string) $filterTo : '';
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'blank' => '1 = 0',
|
||||||
|
'notblank' => '1 = 1',
|
||||||
|
'notequal' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '<>'),
|
||||||
|
'lessthan' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '<'),
|
||||||
|
'lessthanorequal' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '<='),
|
||||||
|
'greaterthan' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '>'),
|
||||||
|
'greaterthanorequal' => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '>='),
|
||||||
|
'inrange' => $this->addNumericRangeFilterParameter($columnSql, $paramName, $from, $to, $parameters),
|
||||||
|
default => $this->addNumericFilterParameter($columnSql, $paramName, $from, $parameters, '='),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function buildGridSetFilterClause(string $columnSql, array $model, string $paramPrefix, array &$parameters, int &$counter): string
|
||||||
|
{
|
||||||
|
$values = $model['values'] ?? [];
|
||||||
|
if (!is_array($values) || $values === []) {
|
||||||
|
return '1 = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if (!is_scalar($value) && $value !== null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paramName = $paramPrefix . '_' . $counter++;
|
||||||
|
$parameters[$paramName] = (string) $value;
|
||||||
|
$placeholders[] = ':' . $paramName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($placeholders === []) {
|
||||||
|
return '1 = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $columnSql . ' IN (' . implode(', ', $placeholders) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addTextFilterParameter(
|
||||||
|
string $columnSql,
|
||||||
|
string $paramName,
|
||||||
|
string $escapedFilter,
|
||||||
|
array &$parameters,
|
||||||
|
bool $positive = true,
|
||||||
|
bool $prependWildcard = false,
|
||||||
|
bool $appendWildcard = false
|
||||||
|
): string
|
||||||
|
{
|
||||||
|
if ($escapedFilter === '') {
|
||||||
|
return '1 = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters[$paramName] = ($prependWildcard ? '%' : '') . $escapedFilter . ($appendWildcard ? '%' : '');
|
||||||
|
$parameters[$paramName . '_esc'] = '\\';
|
||||||
|
$clause = $columnSql . " ILIKE :{$paramName} ESCAPE :{$paramName}_esc";
|
||||||
|
return $positive ? $clause : 'NOT (' . $clause . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addNumericFilterParameter(string $columnSql, string $paramName, string $value, array &$parameters, string $operator): string
|
||||||
|
{
|
||||||
|
if ($value === '') {
|
||||||
|
return '1 = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters[$paramName] = $value;
|
||||||
|
return $columnSql . " {$operator} :{$paramName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addNumericRangeFilterParameter(string $columnSql, string $paramName, string $from, string $to, array &$parameters): string
|
||||||
|
{
|
||||||
|
if ($from === '' || $to === '') {
|
||||||
|
return '1 = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromName = $paramName . '_from';
|
||||||
|
$toName = $paramName . '_to';
|
||||||
|
$parameters[$fromName] = $from;
|
||||||
|
$parameters[$toName] = $to;
|
||||||
|
|
||||||
|
return $columnSql . " BETWEEN :{$fromName} AND :{$toName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function columnSqlForHeader(string $header): string
|
||||||
|
{
|
||||||
|
return 'COALESCE(merged_row ->> ' . $this->pdo->quote($header) . ", '')";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeLikePattern(string $value): string
|
||||||
|
{
|
||||||
|
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAssocArray(array $value): bool
|
||||||
|
{
|
||||||
|
return array_keys($value) !== range(0, count($value) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:array<string,string>}
|
||||||
|
*/
|
||||||
|
private function buildDataTableClause(array $tableHeaders, string $globalSearch, array $columnSearches): array
|
||||||
|
{
|
||||||
|
$conditions = [];
|
||||||
|
$parameters = [];
|
||||||
|
|
||||||
|
$globalSearch = trim($globalSearch);
|
||||||
|
if ($globalSearch !== '') {
|
||||||
|
$conditions[] = "(
|
||||||
|
CAST(row_number AS TEXT) ILIKE :global_query ESCAPE :global_query_esc
|
||||||
|
OR CAST(merged_row AS TEXT) ILIKE :global_query ESCAPE :global_query_esc
|
||||||
|
)";
|
||||||
|
$parameters['global_query'] = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $globalSearch) . '%';
|
||||||
|
$parameters['global_query_esc'] = '\\';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($columnSearches as $index => $search) {
|
||||||
|
$search = trim((string) $search);
|
||||||
|
if ($search === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameterName = 'column_query_' . (int) $index;
|
||||||
|
if ((int) $index === 0) {
|
||||||
|
$conditions[] = "CAST(row_number AS TEXT) = :{$parameterName}";
|
||||||
|
$parameters[$parameterName] = $search;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerIndex = (int) $index - 1;
|
||||||
|
if (!isset($tableHeaders[$headerIndex])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerParameter = 'column_header_' . (int) $index;
|
||||||
|
$conditions[] = "COALESCE(jsonb_extract_path_text(merged_row, :{$headerParameter}), '') = :{$parameterName}";
|
||||||
|
$parameters[$headerParameter] = (string) $tableHeaders[$headerIndex];
|
||||||
|
$parameters[$parameterName] = $search;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($conditions === []) {
|
||||||
|
return ['', []];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
' AND (' . implode(' AND ', $conditions) . ')',
|
||||||
|
$parameters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:array<string,string>}
|
||||||
|
*/
|
||||||
|
private function buildOrderClause(array $tableHeaders, ?int $orderColumn, string $sortDirection): array
|
||||||
|
{
|
||||||
|
$sortDirection = strtoupper($sortDirection) === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
|
if ($orderColumn === null || $orderColumn < 0) {
|
||||||
|
return ['ORDER BY row_number DESC', []];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($orderColumn === 0) {
|
||||||
|
return ['ORDER BY row_number ' . $sortDirection, []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerIndex = $orderColumn - 1;
|
||||||
|
if (!isset($tableHeaders[$headerIndex])) {
|
||||||
|
return ['ORDER BY row_number DESC', []];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ORDER BY LOWER(COALESCE(jsonb_extract_path_text(merged_row, :order_header), \'\')) ' . $sortDirection . ', row_number ASC',
|
||||||
|
[
|
||||||
|
'order_header' => (string) $tableHeaders[$headerIndex],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
final class ImportResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?int $batchId,
|
||||||
|
public readonly int $importedRows,
|
||||||
|
public readonly int $warningsCount,
|
||||||
|
public readonly array $warnings,
|
||||||
|
public readonly array $originalHeaders,
|
||||||
|
public readonly array $calculatedHeaders,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ImportService
|
||||||
|
{
|
||||||
|
private const REQUIRED_COLUMNS = [
|
||||||
|
'Accrual Category',
|
||||||
|
'Local Selling Company (S/T)',
|
||||||
|
'Contracting Party',
|
||||||
|
'Rights Holder Liable Flag',
|
||||||
|
'Global Transfer Pricing Flag',
|
||||||
|
'Domestic/ Foreign Flag',
|
||||||
|
'Ownership Type',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SpreadsheetReader $reader,
|
||||||
|
private readonly SpreadsheetRowProcessor $processor,
|
||||||
|
private readonly ImportRepository $repository,
|
||||||
|
private readonly int $chunkSize = 5,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiredColumns(): array
|
||||||
|
{
|
||||||
|
return self::REQUIRED_COLUMNS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function import(string $filePath, string $sourceFilename): ImportResult
|
||||||
|
{
|
||||||
|
$state = [
|
||||||
|
'filePath' => $filePath,
|
||||||
|
'sourceFilename' => $sourceFilename,
|
||||||
|
'sourceExtension' => strtolower(pathinfo($filePath, PATHINFO_EXTENSION)),
|
||||||
|
'status' => 'pending',
|
||||||
|
'importedRows' => 0,
|
||||||
|
'warningsCount' => 0,
|
||||||
|
'warnings' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
while (!in_array(($state['status'] ?? 'pending'), ['completed', 'error'], true)) {
|
||||||
|
$state = $this->processJobState($state, $this->chunkSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($state['status'] ?? '') === 'error') {
|
||||||
|
throw new RuntimeException('Import failed: ' . ($state['error'] ?? 'Unknown error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ImportResult(
|
||||||
|
isset($state['batchId']) ? (int) $state['batchId'] : null,
|
||||||
|
(int) ($state['importedRows'] ?? 0),
|
||||||
|
(int) ($state['warningsCount'] ?? 0),
|
||||||
|
is_array($state['warnings'] ?? null) ? array_values($state['warnings']) : [],
|
||||||
|
is_array($state['headers'] ?? null) ? array_values($state['headers']) : [],
|
||||||
|
$this->processor->calculatedHeaders()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processJobState(array $state, ?int $chunkSize = null): array
|
||||||
|
{
|
||||||
|
$chunkSize = max(1, $chunkSize ?? $this->chunkSize);
|
||||||
|
$extension = strtolower((string) ($state['sourceExtension'] ?? pathinfo((string) ($state['filePath'] ?? ''), PATHINFO_EXTENSION)));
|
||||||
|
if ($extension === 'xls') {
|
||||||
|
return $this->processLegacyJobState($state, $chunkSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->processStreamingJobState($state, $chunkSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processStreamingJobState(array $state, int $chunkSize): array
|
||||||
|
{
|
||||||
|
if (($state['status'] ?? 'pending') === 'completed' || ($state['status'] ?? 'pending') === 'error') {
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$state['importedRows'] = (int) ($state['importedRows'] ?? 0);
|
||||||
|
$state['warningsCount'] = (int) ($state['warningsCount'] ?? 0);
|
||||||
|
$state['warnings'] = is_array($state['warnings'] ?? null) ? array_values($state['warnings']) : [];
|
||||||
|
|
||||||
|
if (!isset($state['cacheBasePath'])) {
|
||||||
|
$state['cacheBasePath'] = $this->cacheBasePath($state);
|
||||||
|
$cache = $this->reader->buildChunkCache(
|
||||||
|
(string) $state['filePath'],
|
||||||
|
(string) $state['cacheBasePath'],
|
||||||
|
self::REQUIRED_COLUMNS,
|
||||||
|
$chunkSize
|
||||||
|
);
|
||||||
|
$this->reader->validateRequiredColumns($cache['headers'], self::REQUIRED_COLUMNS);
|
||||||
|
|
||||||
|
$state['headers'] = $cache['headers'];
|
||||||
|
$state['headerLookup'] = $cache['header_lookup'];
|
||||||
|
$state['headerRowIndex'] = (int) $cache['header_row_index'];
|
||||||
|
$state['totalRows'] = (int) $cache['total_rows'];
|
||||||
|
$state['chunkCount'] = (int) $cache['chunk_count'];
|
||||||
|
$state['chunkSize'] = (int) $cache['chunk_size'];
|
||||||
|
$state['nextChunkIndex'] = (int) ($state['nextChunkIndex'] ?? 0);
|
||||||
|
$state['status'] = 'cached';
|
||||||
|
$state['done'] = false;
|
||||||
|
$state['message'] = 'Workbook cached and ready for chunked processing.';
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($state['batchId'])) {
|
||||||
|
$state['batchId'] = $this->repository->createBatch(
|
||||||
|
(string) $state['sourceFilename'],
|
||||||
|
is_array($state['headers'] ?? null) ? $state['headers'] : [],
|
||||||
|
$this->processor->calculatedHeaders(),
|
||||||
|
'processing'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunkCount = (int) ($state['chunkCount'] ?? 0);
|
||||||
|
$nextChunkIndex = (int) ($state['nextChunkIndex'] ?? 0);
|
||||||
|
|
||||||
|
if ($chunkCount <= 0 || $nextChunkIndex >= $chunkCount) {
|
||||||
|
$this->repository->updateBatchSummary(
|
||||||
|
(int) $state['batchId'],
|
||||||
|
(int) $state['importedRows'],
|
||||||
|
(int) $state['warningsCount'],
|
||||||
|
'complete'
|
||||||
|
);
|
||||||
|
$this->repository->pruneExceptBatch((int) $state['batchId']);
|
||||||
|
$state['status'] = 'completed';
|
||||||
|
$state['done'] = true;
|
||||||
|
$state['message'] = 'Import complete.';
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunk = $this->reader->readChunkFromCache((string) $state['cacheBasePath'], $nextChunkIndex);
|
||||||
|
$rows = is_array($chunk['rows'] ?? null) ? $chunk['rows'] : [];
|
||||||
|
|
||||||
|
$processedInChunk = 0;
|
||||||
|
$this->repository->beginTransaction();
|
||||||
|
try {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!is_array($row) || !isset($row['row_number'], $row['data']) || !is_array($row['data'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processedInChunk++;
|
||||||
|
$rowNumber = (int) $row['row_number'];
|
||||||
|
$sourceRow = $row['data'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$processed = $this->processor->process($sourceRow);
|
||||||
|
$this->repository->insertRow(
|
||||||
|
(int) $state['batchId'],
|
||||||
|
$rowNumber,
|
||||||
|
$sourceRow,
|
||||||
|
$processed['normalized'],
|
||||||
|
$processed['calculated'],
|
||||||
|
$processed['merged']
|
||||||
|
);
|
||||||
|
$state['importedRows']++;
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$state['warningsCount']++;
|
||||||
|
if (count($state['warnings']) < 100) {
|
||||||
|
$state['warnings'][] = sprintf('Row %d skipped: %s', $rowNumber, $throwable->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$state['processedRows'] = (int) ($state['processedRows'] ?? 0) + $processedInChunk;
|
||||||
|
$state['nextChunkIndex'] = $nextChunkIndex + 1;
|
||||||
|
|
||||||
|
$isComplete = ((int) $state['nextChunkIndex']) >= $chunkCount;
|
||||||
|
$this->repository->updateBatchSummary(
|
||||||
|
(int) $state['batchId'],
|
||||||
|
(int) $state['importedRows'],
|
||||||
|
(int) $state['warningsCount'],
|
||||||
|
$isComplete ? 'complete' : 'processing'
|
||||||
|
);
|
||||||
|
$this->repository->commit();
|
||||||
|
|
||||||
|
if ($isComplete) {
|
||||||
|
$this->repository->pruneExceptBatch((int) $state['batchId']);
|
||||||
|
$state['status'] = 'completed';
|
||||||
|
$state['done'] = true;
|
||||||
|
$state['message'] = 'Import complete.';
|
||||||
|
} else {
|
||||||
|
$state['status'] = 'running';
|
||||||
|
$state['done'] = false;
|
||||||
|
$state['message'] = sprintf('Processed %d row(s) in the latest chunk.', $processedInChunk);
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$this->repository->rollBack();
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$state['status'] = 'error';
|
||||||
|
$state['done'] = true;
|
||||||
|
$state['message'] = 'Import failed: ' . $throwable->getMessage();
|
||||||
|
$state['error'] = $throwable->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processLegacyJobState(array $state, int $chunkSize): array
|
||||||
|
{
|
||||||
|
if (($state['status'] ?? 'pending') === 'completed' || ($state['status'] ?? 'pending') === 'error') {
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isset($state['batchId'])) {
|
||||||
|
$headerInfo = $this->reader->detectHeader((string) $state['filePath'], self::REQUIRED_COLUMNS);
|
||||||
|
$this->reader->validateRequiredColumns($headerInfo['headers'], self::REQUIRED_COLUMNS);
|
||||||
|
|
||||||
|
$state['batchId'] = $this->repository->createBatch(
|
||||||
|
(string) $state['sourceFilename'],
|
||||||
|
$headerInfo['headers'],
|
||||||
|
$this->processor->calculatedHeaders(),
|
||||||
|
'processing'
|
||||||
|
);
|
||||||
|
$state['headers'] = $headerInfo['headers'];
|
||||||
|
$state['headerLookup'] = $headerInfo['header_lookup'];
|
||||||
|
$state['headerRowIndex'] = (int) $headerInfo['header_row_index'];
|
||||||
|
$state['highestDataRow'] = (int) $headerInfo['highest_data_row'];
|
||||||
|
$state['nextRow'] = ((int) $headerInfo['header_row_index']) + 1;
|
||||||
|
$state['totalRows'] = max(0, (int) $headerInfo['highest_data_row'] - (int) $headerInfo['header_row_index']);
|
||||||
|
$state['importedRows'] = (int) ($state['importedRows'] ?? 0);
|
||||||
|
$state['warningsCount'] = (int) ($state['warningsCount'] ?? 0);
|
||||||
|
$state['warnings'] = is_array($state['warnings'] ?? null) ? array_values($state['warnings']) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextRow = (int) ($state['nextRow'] ?? 0);
|
||||||
|
$highestDataRow = (int) ($state['highestDataRow'] ?? 0);
|
||||||
|
|
||||||
|
if ($nextRow > $highestDataRow) {
|
||||||
|
$this->repository->updateBatchSummary(
|
||||||
|
(int) $state['batchId'],
|
||||||
|
(int) ($state['importedRows'] ?? 0),
|
||||||
|
(int) ($state['warningsCount'] ?? 0),
|
||||||
|
'complete'
|
||||||
|
);
|
||||||
|
$this->repository->pruneExceptBatch((int) $state['batchId']);
|
||||||
|
$state['status'] = 'completed';
|
||||||
|
$state['done'] = true;
|
||||||
|
$state['message'] = 'Import complete.';
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowsInWindow = max(0, min($chunkSize, $highestDataRow - $nextRow + 1));
|
||||||
|
$chunk = $this->reader->readChunk(
|
||||||
|
(string) $state['filePath'],
|
||||||
|
$nextRow,
|
||||||
|
$chunkSize,
|
||||||
|
$state['headerLookup']
|
||||||
|
);
|
||||||
|
|
||||||
|
$processedInChunk = 0;
|
||||||
|
$this->repository->beginTransaction();
|
||||||
|
try {
|
||||||
|
foreach ($chunk['rows'] as $row) {
|
||||||
|
$processedInChunk++;
|
||||||
|
$rowNumber = (int) $row['row_number'];
|
||||||
|
$sourceRow = $row['data'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$processed = $this->processor->process($sourceRow);
|
||||||
|
$this->repository->insertRow(
|
||||||
|
(int) $state['batchId'],
|
||||||
|
$rowNumber,
|
||||||
|
$sourceRow,
|
||||||
|
$processed['normalized'],
|
||||||
|
$processed['calculated'],
|
||||||
|
$processed['merged']
|
||||||
|
);
|
||||||
|
$state['importedRows'] = (int) ($state['importedRows'] ?? 0) + 1;
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$state['warningsCount'] = (int) ($state['warningsCount'] ?? 0) + 1;
|
||||||
|
if (!isset($state['warnings']) || !is_array($state['warnings'])) {
|
||||||
|
$state['warnings'] = [];
|
||||||
|
}
|
||||||
|
if (count($state['warnings']) < 100) {
|
||||||
|
$state['warnings'][] = sprintf('Row %d skipped: %s', $rowNumber, $throwable->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$state['nextRow'] = $nextRow + $rowsInWindow;
|
||||||
|
$state['processedRows'] = (int) ($state['processedRows'] ?? 0) + $rowsInWindow;
|
||||||
|
|
||||||
|
$isComplete = (int) $state['nextRow'] > $highestDataRow;
|
||||||
|
$this->repository->updateBatchSummary(
|
||||||
|
(int) $state['batchId'],
|
||||||
|
(int) $state['importedRows'],
|
||||||
|
(int) $state['warningsCount'],
|
||||||
|
$isComplete ? 'complete' : 'processing'
|
||||||
|
);
|
||||||
|
$this->repository->commit();
|
||||||
|
|
||||||
|
if ($isComplete) {
|
||||||
|
$this->repository->pruneExceptBatch((int) $state['batchId']);
|
||||||
|
$state['status'] = 'completed';
|
||||||
|
$state['done'] = true;
|
||||||
|
$state['message'] = 'Import complete.';
|
||||||
|
} else {
|
||||||
|
$state['status'] = 'running';
|
||||||
|
$state['done'] = false;
|
||||||
|
$state['message'] = sprintf('Processed %d row(s) in the latest chunk.', $processedInChunk);
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$this->repository->rollBack();
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$state['status'] = 'error';
|
||||||
|
$state['done'] = true;
|
||||||
|
$state['message'] = 'Import failed: ' . $throwable->getMessage();
|
||||||
|
$state['error'] = $throwable->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latestBatch(): ?array
|
||||||
|
{
|
||||||
|
return $this->repository->getLatestBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->repository->beginTransaction();
|
||||||
|
try {
|
||||||
|
$this->repository->clearAll();
|
||||||
|
$this->repository->commit();
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$this->repository->rollBack();
|
||||||
|
throw new RuntimeException('Clear failed: ' . $throwable->getMessage(), 0, $throwable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPreview(int $batchId, int $limit, int $offset, string $sortDirection = 'asc'): array
|
||||||
|
{
|
||||||
|
return $this->repository->paginateRows($batchId, $limit, $offset, $sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilteredPreview(
|
||||||
|
int $batchId,
|
||||||
|
int $limit,
|
||||||
|
int $offset,
|
||||||
|
string $query,
|
||||||
|
array $tableHeaders = [],
|
||||||
|
array $columnSearches = [],
|
||||||
|
?int $orderColumn = null,
|
||||||
|
string $sortDirection = 'asc'
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
return $this->repository->paginateRowsFiltered(
|
||||||
|
$batchId,
|
||||||
|
$limit,
|
||||||
|
$offset,
|
||||||
|
$query,
|
||||||
|
$tableHeaders,
|
||||||
|
$columnSearches,
|
||||||
|
$orderColumn,
|
||||||
|
$sortDirection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGridRows(
|
||||||
|
int $batchId,
|
||||||
|
int $limit,
|
||||||
|
int $offset,
|
||||||
|
string $query = '',
|
||||||
|
array $tableHeaders = [],
|
||||||
|
array $filterModel = [],
|
||||||
|
array $sortModel = []
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
return $this->repository->paginateRowsGrid(
|
||||||
|
$batchId,
|
||||||
|
$limit,
|
||||||
|
$offset,
|
||||||
|
$query,
|
||||||
|
$tableHeaders,
|
||||||
|
$filterModel,
|
||||||
|
$sortModel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countRowsForBatch(int $batchId): int
|
||||||
|
{
|
||||||
|
return $this->repository->countRowsForBatch($batchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function getExportRows(int $batchId, int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
return $this->repository->exportRowsForBatch($batchId, $limit, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDistinctValuesForBatch(int $batchId, string $header, int $limit = 200): array
|
||||||
|
{
|
||||||
|
return $this->repository->getDistinctValuesForBatch($batchId, $header, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDistinctValuesForBatchGrid(
|
||||||
|
int $batchId,
|
||||||
|
array $tableHeaders,
|
||||||
|
string $columnKey,
|
||||||
|
string $distinctSearch = '',
|
||||||
|
int $limit = 500
|
||||||
|
): array
|
||||||
|
{
|
||||||
|
return $this->repository->getDistinctValuesForBatchGrid($batchId, $tableHeaders, $columnKey, $distinctSearch, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countFilteredRowsForBatch(int $batchId, string $query, array $tableHeaders = [], array $columnSearches = []): int
|
||||||
|
{
|
||||||
|
return $this->repository->countRowsForBatchFiltered($batchId, $query, $tableHeaders, $columnSearches);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countGridRows(
|
||||||
|
int $batchId,
|
||||||
|
string $query = '',
|
||||||
|
array $tableHeaders = [],
|
||||||
|
array $filterModel = []
|
||||||
|
): int
|
||||||
|
{
|
||||||
|
return $this->repository->countRowsForBatchGrid($batchId, $query, $tableHeaders, $filterModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chunkSize(): int
|
||||||
|
{
|
||||||
|
return $this->chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteCache(string $cacheBasePath): void
|
||||||
|
{
|
||||||
|
$this->reader->deleteCache($cacheBasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cacheBasePath(array $state): string
|
||||||
|
{
|
||||||
|
$token = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($state['token'] ?? ''));
|
||||||
|
if ($token === '') {
|
||||||
|
throw new RuntimeException('Missing import token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return sys_get_temp_dir() . '/warner-import-cache/' . $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
use OpenSpout\Reader\CSV\Reader as CsvReader;
|
||||||
|
use OpenSpout\Reader\ODS\Reader as OdsReader;
|
||||||
|
use OpenSpout\Reader\ReaderInterface as SpoutReaderInterface;
|
||||||
|
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class SpreadsheetReader
|
||||||
|
{
|
||||||
|
public function detectHeader(string $filePath, array $expectedHeaders = [], int $scanChunkSize = 100, int $maxScanRows = 5000): array
|
||||||
|
{
|
||||||
|
for ($startRow = 1; $startRow <= $maxScanRows; $startRow += $scanChunkSize) {
|
||||||
|
$endRow = min($maxScanRows, $startRow + $scanChunkSize - 1);
|
||||||
|
$window = $this->loadWindow($filePath, $startRow, $endRow);
|
||||||
|
|
||||||
|
foreach ($window['rows'] as $rowIndex => $row) {
|
||||||
|
$normalizedRow = [];
|
||||||
|
foreach ($row as $columnLetter => $cellValue) {
|
||||||
|
$normalizedRow[$columnLetter] = $this->normalizeHeader($cellValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isHeaderRow($normalizedRow, $expectedHeaders)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = [];
|
||||||
|
$headerLookup = [];
|
||||||
|
foreach ($normalizedRow as $columnLetter => $headerValue) {
|
||||||
|
if ($headerValue === '' || $headerValue === 'Revenue Type') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers[] = $headerValue;
|
||||||
|
$headerLookup[$columnLetter] = $headerValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'header_row_index' => (int) $rowIndex,
|
||||||
|
'headers' => $headers,
|
||||||
|
'header_lookup' => $headerLookup,
|
||||||
|
'highest_data_row' => (int) $window['highest_data_row'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $window['highest_data_row'] < $endRow) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Could not locate the header row in the uploaded workbook.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readChunk(string $filePath, int $startRow, int $chunkSize, array $headerLookup): array
|
||||||
|
{
|
||||||
|
$endRow = $startRow + $chunkSize - 1;
|
||||||
|
$window = $this->loadWindow($filePath, $startRow, $endRow);
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($window['rows'] as $rowIndex => $row) {
|
||||||
|
$mappedRow = [];
|
||||||
|
foreach ($headerLookup as $columnLetter => $header) {
|
||||||
|
$mappedRow[$header] = $this->normalizeCellValue($row[$columnLetter] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isBlankRow($mappedRow)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'row_number' => (int) $rowIndex,
|
||||||
|
'data' => $mappedRow,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $rows,
|
||||||
|
'highest_data_row' => (int) $window['highest_data_row'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read(string $filePath, array $expectedHeaders = []): array
|
||||||
|
{
|
||||||
|
$header = $this->detectHeader($filePath, $expectedHeaders);
|
||||||
|
$allRows = [];
|
||||||
|
$nextRow = $header['header_row_index'] + 1;
|
||||||
|
|
||||||
|
while ($nextRow <= $header['highest_data_row']) {
|
||||||
|
$chunk = $this->readChunk($filePath, $nextRow, 500, $header['header_lookup']);
|
||||||
|
$allRows = array_merge($allRows, $chunk['rows']);
|
||||||
|
$nextRow += 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'headers' => $header['headers'],
|
||||||
|
'rows' => $allRows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildChunkCache(string $filePath, string $cacheBasePath, array $expectedHeaders = [], int $chunkSize = 50): array
|
||||||
|
{
|
||||||
|
if ($chunkSize < 1) {
|
||||||
|
throw new RuntimeException('Chunk size must be at least 1.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reader = $this->createSpoutReader($filePath);
|
||||||
|
$metaPath = $this->cacheMetaPath($cacheBasePath);
|
||||||
|
$rowsPath = $this->cacheRowsPath($cacheBasePath);
|
||||||
|
$cacheDir = dirname($metaPath);
|
||||||
|
if (!is_dir($cacheDir) && !mkdir($cacheDir, 0777, true) && !is_dir($cacheDir)) {
|
||||||
|
throw new RuntimeException('Unable to create the spreadsheet cache directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowsHandle = fopen($rowsPath, 'wb');
|
||||||
|
if ($rowsHandle === false) {
|
||||||
|
throw new RuntimeException('Unable to create the spreadsheet cache file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerFound = false;
|
||||||
|
$rowIndex = 0;
|
||||||
|
$chunkRows = [];
|
||||||
|
$chunkOffsets = [];
|
||||||
|
$totalRows = 0;
|
||||||
|
$headerRowIndex = 0;
|
||||||
|
$headers = [];
|
||||||
|
$headerLookup = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reader->open($filePath);
|
||||||
|
|
||||||
|
foreach ($reader->getSheetIterator() as $sheet) {
|
||||||
|
foreach ($sheet->getRowIterator() as $row) {
|
||||||
|
$rowIndex++;
|
||||||
|
$values = $row->toArray();
|
||||||
|
|
||||||
|
if (!$headerFound) {
|
||||||
|
$normalizedRow = [];
|
||||||
|
foreach ($values as $columnIndex => $cellValue) {
|
||||||
|
$normalizedRow[$columnIndex] = $this->normalizeHeader($cellValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isHeaderRow($normalizedRow, $expectedHeaders)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($normalizedRow as $columnIndex => $headerValue) {
|
||||||
|
if ($headerValue === '' || $headerValue === 'Revenue Type') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers[] = $headerValue;
|
||||||
|
$headerLookup[(int) $columnIndex] = $headerValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerRowIndex = $rowIndex;
|
||||||
|
$headerFound = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappedRow = [];
|
||||||
|
foreach ($headerLookup as $columnIndex => $header) {
|
||||||
|
$mappedRow[$header] = $this->normalizeCellValue($values[$columnIndex] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isBlankRow($mappedRow)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunkRows[] = [
|
||||||
|
'row_number' => $rowIndex,
|
||||||
|
'data' => $mappedRow,
|
||||||
|
];
|
||||||
|
$totalRows++;
|
||||||
|
|
||||||
|
if (count($chunkRows) >= $chunkSize) {
|
||||||
|
$chunkOffsets[] = ftell($rowsHandle);
|
||||||
|
$this->writeChunkRow($rowsHandle, $chunkRows);
|
||||||
|
$chunkRows = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
throw new RuntimeException('Unable to build spreadsheet cache: ' . $throwable->getMessage(), 0, $throwable);
|
||||||
|
} finally {
|
||||||
|
$reader->close();
|
||||||
|
fclose($rowsHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$headerFound) {
|
||||||
|
@unlink($rowsPath);
|
||||||
|
throw new RuntimeException('Could not locate the header row in the uploaded workbook.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($chunkRows !== []) {
|
||||||
|
$chunkOffsets[] = file_exists($rowsPath) ? filesize($rowsPath) : 0;
|
||||||
|
$rowsHandle = fopen($rowsPath, 'ab');
|
||||||
|
if ($rowsHandle === false) {
|
||||||
|
throw new RuntimeException('Unable to reopen the spreadsheet cache file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->writeChunkRow($rowsHandle, $chunkRows);
|
||||||
|
} finally {
|
||||||
|
fclose($rowsHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = [
|
||||||
|
'file_path' => $filePath,
|
||||||
|
'headers' => $headers,
|
||||||
|
'header_lookup' => $headerLookup,
|
||||||
|
'header_row_index' => $headerRowIndex,
|
||||||
|
'total_rows' => $totalRows,
|
||||||
|
'chunk_size' => $chunkSize,
|
||||||
|
'chunk_count' => count($chunkOffsets),
|
||||||
|
'chunk_offsets' => $chunkOffsets,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (file_put_contents(
|
||||||
|
$metaPath,
|
||||||
|
json_encode($cache, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||||
|
) === false) {
|
||||||
|
throw new RuntimeException('Unable to write the spreadsheet cache metadata.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readChunkFromCache(string $cacheBasePath, int $chunkIndex): array
|
||||||
|
{
|
||||||
|
$cache = $this->readCacheMeta($cacheBasePath);
|
||||||
|
$chunkOffsets = $cache['chunk_offsets'] ?? [];
|
||||||
|
if (!is_array($chunkOffsets) || !array_key_exists($chunkIndex, $chunkOffsets)) {
|
||||||
|
return [
|
||||||
|
'rows' => [],
|
||||||
|
'cache' => $cache,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowsPath = $this->cacheRowsPath($cacheBasePath);
|
||||||
|
$rowsHandle = fopen($rowsPath, 'rb');
|
||||||
|
if ($rowsHandle === false) {
|
||||||
|
throw new RuntimeException('Unable to open the spreadsheet cache file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fseek($rowsHandle, (int) $chunkOffsets[$chunkIndex]) !== 0) {
|
||||||
|
throw new RuntimeException('Unable to seek to the requested spreadsheet chunk.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$line = fgets($rowsHandle);
|
||||||
|
if ($line === false) {
|
||||||
|
throw new RuntimeException('Unable to read the requested spreadsheet chunk.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = json_decode(trim($line), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
throw new RuntimeException('Spreadsheet chunk data is invalid.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($rowsHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $rows,
|
||||||
|
'cache' => $cache,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteCache(string $cacheBasePath): void
|
||||||
|
{
|
||||||
|
@unlink($this->cacheMetaPath($cacheBasePath));
|
||||||
|
@unlink($this->cacheRowsPath($cacheBasePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateRequiredColumns(array $headers, array $requiredColumns): void
|
||||||
|
{
|
||||||
|
$missing = [];
|
||||||
|
$headerMap = array_fill_keys($headers, true);
|
||||||
|
|
||||||
|
foreach ($requiredColumns as $requiredColumn) {
|
||||||
|
if (!isset($headerMap[$requiredColumn])) {
|
||||||
|
$missing[] = $requiredColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing !== []) {
|
||||||
|
throw new RuntimeException('Missing required column(s): ' . implode(', ', $missing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeHeader(mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isHeaderRow(array $row, array $expectedHeaders): bool
|
||||||
|
{
|
||||||
|
$nonEmptyValues = array_values(array_filter($row, static fn (?string $value): bool => $value !== null && $value !== ''));
|
||||||
|
if ($nonEmptyValues === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expectedHeaders === []) {
|
||||||
|
return count($nonEmptyValues) >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lookup = array_fill_keys($nonEmptyValues, true);
|
||||||
|
foreach ($expectedHeaders as $expectedHeader) {
|
||||||
|
if (!isset($lookup[$expectedHeader])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadWindow(string $filePath, int $startRow, int $endRow): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$reader = IOFactory::createReaderForFile($filePath);
|
||||||
|
$reader->setReadDataOnly(true);
|
||||||
|
|
||||||
|
$sheetNames = $reader->listWorksheetNames($filePath);
|
||||||
|
if ($sheetNames !== []) {
|
||||||
|
$reader->setLoadSheetsOnly([$sheetNames[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reader->setReadFilter(new ChunkReadFilter($startRow, $endRow));
|
||||||
|
$spreadsheet = $reader->load($filePath);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
throw new RuntimeException('Unable to read the uploaded Excel file: ' . $throwable->getMessage(), 0, $throwable);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$worksheet = $spreadsheet->getSheet(0);
|
||||||
|
$rows = $worksheet->toArray(null, false, true, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $rows,
|
||||||
|
'highest_data_row' => $worksheet->getHighestDataRow(),
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
unset($spreadsheet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSpoutReader(string $filePath): SpoutReaderInterface
|
||||||
|
{
|
||||||
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
return match ($extension) {
|
||||||
|
'csv' => new CsvReader(),
|
||||||
|
'ods' => new OdsReader(),
|
||||||
|
'xlsx', 'xlsm' => new XlsxReader(),
|
||||||
|
'xls' => throw new RuntimeException('XLS files are not supported by the streaming reader. Please save the workbook as XLSX before importing.'),
|
||||||
|
default => new XlsxReader(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cacheMetaPath(string $cacheBasePath): string
|
||||||
|
{
|
||||||
|
return rtrim($cacheBasePath, '/\\') . '.meta.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cacheRowsPath(string $cacheBasePath): string
|
||||||
|
{
|
||||||
|
return rtrim($cacheBasePath, '/\\') . '.rows.ndjson';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readCacheMeta(string $cacheBasePath): array
|
||||||
|
{
|
||||||
|
$metaPath = $this->cacheMetaPath($cacheBasePath);
|
||||||
|
if (!is_file($metaPath)) {
|
||||||
|
throw new RuntimeException('Spreadsheet cache metadata is missing. Please restart the import.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = file_get_contents($metaPath);
|
||||||
|
if ($contents === false || $contents === '') {
|
||||||
|
throw new RuntimeException('Spreadsheet cache metadata is unreadable. Please restart the import.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($cache)) {
|
||||||
|
throw new RuntimeException('Spreadsheet cache metadata is invalid. Please restart the import.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeChunkRow($handle, array $chunkRows): void
|
||||||
|
{
|
||||||
|
$json = json_encode($chunkRows, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
if (fwrite($handle, $json . PHP_EOL) === false) {
|
||||||
|
throw new RuntimeException('Unable to write spreadsheet cache chunk data.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeCellValue(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof \DateTimeInterface) {
|
||||||
|
return $value->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value)) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_float($value)) {
|
||||||
|
if (fmod($value, 1.0) === 0.0) {
|
||||||
|
return (string) (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(rtrim(sprintf('%.15F', $value), '0'), '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$string = trim((string) $value);
|
||||||
|
if ($string === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $string;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isBlankRow(array $row): bool
|
||||||
|
{
|
||||||
|
foreach ($row as $value) {
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
final class SpreadsheetRowProcessor
|
||||||
|
{
|
||||||
|
private const CALCULATED_HEADERS = [
|
||||||
|
'Actual/Accrual',
|
||||||
|
'US/ex-US sale',
|
||||||
|
'Contracting Party Filtered',
|
||||||
|
'String',
|
||||||
|
'2110 Applicable X201',
|
||||||
|
'Class',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const X201_ELIGIBLE_STRINGS = [
|
||||||
|
'1756YYFDI',
|
||||||
|
'1756YYFLI',
|
||||||
|
'1756YYFPA',
|
||||||
|
'1756YYFWO',
|
||||||
|
'1756YYDDI',
|
||||||
|
'1756YYDWO',
|
||||||
|
'1756YYDLI',
|
||||||
|
'1756YYDPA',
|
||||||
|
'1100NYDDI',
|
||||||
|
'1100NYDWO',
|
||||||
|
'1100YYDWO',
|
||||||
|
'1100YYFDI',
|
||||||
|
'1100YYFLI',
|
||||||
|
'1100YYFPA',
|
||||||
|
'1100YYFWO',
|
||||||
|
'1100NYDLI',
|
||||||
|
'1100NYDPA',
|
||||||
|
'1100YYDLI',
|
||||||
|
'1100YYDDI',
|
||||||
|
'1100YYDPA',
|
||||||
|
'1902NYDDI',
|
||||||
|
'1902NYDLI',
|
||||||
|
'1902NYDWO',
|
||||||
|
'1902YYFDI',
|
||||||
|
'1902YYFLI',
|
||||||
|
'1902YYFPA',
|
||||||
|
'1902YYFWO',
|
||||||
|
'1902YYDDI',
|
||||||
|
'1902YYDWO',
|
||||||
|
'1902NYDPA',
|
||||||
|
'1902YYDLI',
|
||||||
|
'1902YYDPA',
|
||||||
|
'LOCALYYDWO',
|
||||||
|
'LOCALYYDDI',
|
||||||
|
'LOCALYYDLI',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function calculatedHeaders(): array
|
||||||
|
{
|
||||||
|
return self::CALCULATED_HEADERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(array $row): array
|
||||||
|
{
|
||||||
|
$normalized = $this->normalize($row);
|
||||||
|
$calculated = $this->calculateDerivedFields($normalized);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'normalized' => $normalized,
|
||||||
|
'calculated' => $calculated,
|
||||||
|
'merged' => array_merge($normalized, $calculated),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalize(array $row): array
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($row as $header => $value) {
|
||||||
|
$normalized[$header] = $this->normalizeValue($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeValue(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim((string) $value);
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculateDerivedFields(array $row): array
|
||||||
|
{
|
||||||
|
$contractingParty = $this->normalizeCode($row['Contracting Party'] ?? null);
|
||||||
|
$localSellingCompany = $this->normalizeCode($row['Local Selling Company (S/T)'] ?? null);
|
||||||
|
$rightsHolderLiableFlag = $this->normalizeFlag($row['Rights Holder Liable Flag'] ?? null);
|
||||||
|
$globalTransferPricingFlag = $this->normalizeFlag($row['Global Transfer Pricing Flag'] ?? null);
|
||||||
|
$domesticForeignFlag = $this->normalizeFlag($row['Domestic/ Foreign Flag'] ?? null);
|
||||||
|
$ownershipType = $this->normalizeValue($row['Ownership Type'] ?? null) ?? '';
|
||||||
|
|
||||||
|
// Business rule: Actual vs accrual is driven by the raw Accrual Category value.
|
||||||
|
$actualAccrual = (($row['Accrual Category'] ?? null) === '##') ? 'Actual' : 'Accrual';
|
||||||
|
|
||||||
|
// Business rule: US/ex-US depends on the local selling company range.
|
||||||
|
$usExUs = $this->inRange($localSellingCompany, 1700, 1809) ? 'US' : 'ex-US';
|
||||||
|
|
||||||
|
// Business rule: Contracting Party is narrowed to the special codes; all others become LOCAL.
|
||||||
|
$contractingPartyFiltered = in_array($contractingParty, ['1756', '1100', '1902'], true)
|
||||||
|
? $contractingParty
|
||||||
|
: 'LOCAL';
|
||||||
|
|
||||||
|
// Business rule: String is a direct concatenation of the filtered contracting party plus the flags.
|
||||||
|
$string = $contractingPartyFiltered
|
||||||
|
. $rightsHolderLiableFlag
|
||||||
|
. $globalTransferPricingFlag
|
||||||
|
. $domesticForeignFlag
|
||||||
|
. $ownershipType;
|
||||||
|
|
||||||
|
// Business rule: 2110 applicability is a fixed whitelist lookup against the derived String.
|
||||||
|
$applicableX201 = in_array($string, self::X201_ELIGIBLE_STRINGS, true) ? 'X' : '';
|
||||||
|
|
||||||
|
// Business rule: Class uses ordered precedence. The first matching rule wins.
|
||||||
|
$class = $this->calculateClass($contractingParty, $localSellingCompany, $rightsHolderLiableFlag, $globalTransferPricingFlag, $applicableX201, $contractingPartyFiltered);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Actual/Accrual' => $actualAccrual,
|
||||||
|
'US/ex-US sale' => $usExUs,
|
||||||
|
'Contracting Party Filtered' => $contractingPartyFiltered,
|
||||||
|
'String' => $string,
|
||||||
|
'2110 Applicable X201' => $applicableX201,
|
||||||
|
'Class' => $class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculateClass(
|
||||||
|
?string $contractingParty,
|
||||||
|
?string $localSellingCompany,
|
||||||
|
string $rightsHolderLiableFlag,
|
||||||
|
string $globalTransferPricingFlag,
|
||||||
|
string $applicableX201,
|
||||||
|
string $contractingPartyFiltered
|
||||||
|
): string {
|
||||||
|
if ($contractingParty === '1902') {
|
||||||
|
return 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$contractingParty === '1756'
|
||||||
|
&& $rightsHolderLiableFlag === 'Y'
|
||||||
|
&& !$this->inRange($localSellingCompany, 1700, 1808)
|
||||||
|
) {
|
||||||
|
return 'B';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contractingParty === '1756' && $this->inRange($localSellingCompany, 1700, 1808) && $applicableX201 === 'X') {
|
||||||
|
return 'C';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contractingParty === '1756' && $this->inRange($localSellingCompany, 1700, 1808) && $applicableX201 !== 'X') {
|
||||||
|
return 'D';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contractingParty === '1100' && !$this->inRange($localSellingCompany, 1700, 1808)) {
|
||||||
|
return 'E';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contractingParty === '1100' && $this->inRange($localSellingCompany, 1700, 1808)) {
|
||||||
|
return 'F';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contractingPartyFiltered === 'LOCAL') {
|
||||||
|
return 'G';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$contractingParty === '1756'
|
||||||
|
&& $rightsHolderLiableFlag === 'N'
|
||||||
|
&& $globalTransferPricingFlag === 'Y'
|
||||||
|
&& !$this->inRange($localSellingCompany, 1700, 1808)
|
||||||
|
) {
|
||||||
|
return 'H';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeCode(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$value = $this->normalizeValue($value);
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value) && (float) $value == (int) $value) {
|
||||||
|
return (string) (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeFlag(mixed $value): string
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeValue($value);
|
||||||
|
return $normalized ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inRange(?string $value, int $min, int $max): bool
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_numeric($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$number = (int) $value;
|
||||||
|
return $number >= $min && $number <= $max;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
final class View
|
||||||
|
{
|
||||||
|
public function render(string $template, array $data = []): string
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
require __DIR__ . '/../../templates/' . $template . '.php';
|
||||||
|
|
||||||
|
return (string) ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') {
|
||||||
|
fwrite(STDERR, "This script must be run from the command line.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = (string) ($argv[1] ?? '');
|
||||||
|
if ($token === '') {
|
||||||
|
fwrite(STDERR, "Missing import token.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@set_time_limit(0);
|
||||||
|
@ini_set('memory_limit', '-1');
|
||||||
|
|
||||||
|
$database = \App\Database::fromConfig($GLOBALS['appConfig'] ?? []);
|
||||||
|
$repository = \App\Import\ImportRepository::fromDatabase($database);
|
||||||
|
$jobStore = new \App\Import\ImportJobStore();
|
||||||
|
$service = new \App\Import\ImportService(
|
||||||
|
new \App\Import\SpreadsheetReader(),
|
||||||
|
new \App\Import\SpreadsheetRowProcessor(),
|
||||||
|
$repository,
|
||||||
|
);
|
||||||
|
|
||||||
|
$state = $jobStore->get($token);
|
||||||
|
if (!is_array($state)) {
|
||||||
|
fwrite(STDERR, "Import job not found.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!in_array(($state['status'] ?? 'pending'), ['completed', 'error'], true)) {
|
||||||
|
$state = $service->processJobState($state);
|
||||||
|
$jobStore->save($token, $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($state['status'] ?? '') === 'error') {
|
||||||
|
fwrite(STDERR, (string) ($state['message'] ?? 'Import failed.') . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "Import completed.\n");
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$database = \App\Database::fromConfig($appConfig ?? []);
|
||||||
|
$pdo = $database->pdo();
|
||||||
|
|
||||||
|
$statements = [
|
||||||
|
<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS import_batches (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
source_filename TEXT NOT NULL,
|
||||||
|
original_headers JSONB NOT NULL,
|
||||||
|
calculated_headers JSONB NOT NULL,
|
||||||
|
row_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
warnings_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'complete',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
SQL,
|
||||||
|
"ALTER TABLE import_batches ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'complete'",
|
||||||
|
<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS import_rows (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
batch_id BIGINT NOT NULL REFERENCES import_batches(id) ON DELETE CASCADE,
|
||||||
|
row_number INTEGER NOT NULL,
|
||||||
|
source_row JSONB NOT NULL,
|
||||||
|
normalized_row JSONB NOT NULL,
|
||||||
|
calculated_row JSONB NOT NULL,
|
||||||
|
merged_row JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
SQL,
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_import_rows_batch_row_number ON import_rows (batch_id, row_number)',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($statements as $statement) {
|
||||||
|
$pdo->exec($statement);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "Migration completed.\n");
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
fwrite(STDERR, "Migration failed: " . $throwable->getMessage() . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$appConfig = require __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
$vendorAutoload = __DIR__ . '/vendor/autoload.php';
|
||||||
|
if (is_file($vendorAutoload)) {
|
||||||
|
require_once $vendorAutoload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_config(string $path, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
global $appConfig;
|
||||||
|
|
||||||
|
$segments = explode('.', $path);
|
||||||
|
$value = $appConfig;
|
||||||
|
|
||||||
|
foreach ($segments as $segment) {
|
||||||
|
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $value[$segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asset_url(string $path): string
|
||||||
|
{
|
||||||
|
$path = ltrim($path, '/');
|
||||||
|
$scriptName = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
|
||||||
|
$scriptDir = rtrim(dirname($scriptName), '/');
|
||||||
|
$documentRoot = rtrim(str_replace('\\', '/', (string) ($_SERVER['DOCUMENT_ROOT'] ?? '')), '/');
|
||||||
|
|
||||||
|
if ($documentRoot !== '' && str_ends_with($documentRoot, '/public')) {
|
||||||
|
$url = '/assets/' . $path;
|
||||||
|
} elseif ($scriptDir === '' || $scriptDir === '.') {
|
||||||
|
$url = '/public/assets/' . $path;
|
||||||
|
} elseif (str_ends_with($scriptDir, '/public')) {
|
||||||
|
$url = $scriptDir . '/assets/' . $path;
|
||||||
|
} else {
|
||||||
|
$url = $scriptDir . '/public/assets/' . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filesystemPath = __DIR__ . '/public/assets/' . $path;
|
||||||
|
if (is_file($filesystemPath)) {
|
||||||
|
$url .= (str_contains($url, '?') ? '&' : '?') . 'v=' . filemtime($filesystemPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
__DIR__ . '/app/Database.php',
|
||||||
|
__DIR__ . '/app/Support/View.php',
|
||||||
|
__DIR__ . '/app/Import/ImportResult.php',
|
||||||
|
__DIR__ . '/app/Import/ChunkReadFilter.php',
|
||||||
|
__DIR__ . '/app/Import/ImportJobStore.php',
|
||||||
|
__DIR__ . '/app/Import/SpreadsheetReader.php',
|
||||||
|
__DIR__ . '/app/Import/SpreadsheetRowProcessor.php',
|
||||||
|
__DIR__ . '/app/Import/ImportRepository.php',
|
||||||
|
__DIR__ . '/app/Import/ImportService.php',
|
||||||
|
__DIR__ . '/app/Http/AppController.php',
|
||||||
|
] as $file) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "warner/spreadsheet-importer",
|
||||||
|
"description": "Bootstrap-based PHP app for uploading, processing, and reviewing spreadsheet data.",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"ext-pgsql": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"openspout/openspout": "^4.0",
|
||||||
|
"phpoffice/phpspreadsheet": "^1.29 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"migrate": "php bin/migrate.php"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort-packages": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+537
@@ -0,0 +1,537 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "f97de54670bb87e8856062e1fb6a3213",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "composer/pcre",
|
||||||
|
"version": "3.4.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/pcre.git",
|
||||||
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan": "<2.2.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||||
|
"keywords": [
|
||||||
|
"PCRE",
|
||||||
|
"preg",
|
||||||
|
"regex",
|
||||||
|
"regular expression"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
|
"source": "https://github.com/composer/pcre/tree/3.4.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-06-07T11:47:49+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maennchen/zipstream-php",
|
||||||
|
"version": "3.2.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"php-64bit": "^8.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.7",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
|
"mikey179/vfsstream": "^1.6",
|
||||||
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
|
"phpunit/phpunit": "^12.0",
|
||||||
|
"vimeo/psalm": "^6.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"guzzlehttp/psr7": "^2.4",
|
||||||
|
"psr/http-message": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ZipStream\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paul Duncan",
|
||||||
|
"email": "pabs@pablotron.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonatan Männchen",
|
||||||
|
"email": "jonatan@maennchen.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Donat",
|
||||||
|
"email": "donatj@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "András Kolesár",
|
||||||
|
"email": "kolesar@kolesar.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||||
|
"keywords": [
|
||||||
|
"stream",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||||
|
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/maennchen",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-11T18:38:28+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/complex",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Complex\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@lange.demon.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with complex numbers",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||||
|
"keywords": [
|
||||||
|
"complex",
|
||||||
|
"mathematics"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||||
|
},
|
||||||
|
"time": "2022-12-06T16:21:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/matrix",
|
||||||
|
"version": "3.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpdocumentor/phpdocumentor": "2.*",
|
||||||
|
"phploc/phploc": "^4.0",
|
||||||
|
"phpmd/phpmd": "2.*",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"sebastian/phpcpd": "^4.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Matrix\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@demon-angel.eu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with matrices",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||||
|
"keywords": [
|
||||||
|
"mathematics",
|
||||||
|
"matrix",
|
||||||
|
"vector"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||||
|
},
|
||||||
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "openspout/openspout",
|
||||||
|
"version": "v4.32.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/openspout/openspout.git",
|
||||||
|
"reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/openspout/openspout/zipball/41f045c1f632e1474e15d4c7bc3abcb4a153563d",
|
||||||
|
"reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"php": "~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86.0",
|
||||||
|
"infection/infection": "^0.31.2",
|
||||||
|
"phpbench/phpbench": "^1.4.1",
|
||||||
|
"phpstan/phpstan": "^2.1.22",
|
||||||
|
"phpstan/phpstan-phpunit": "^2.0.7",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2.0.6",
|
||||||
|
"phpunit/phpunit": "^12.3.7"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)",
|
||||||
|
"ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"OpenSpout\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Adrien Loison",
|
||||||
|
"email": "adrien@box.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way",
|
||||||
|
"homepage": "https://github.com/openspout/openspout",
|
||||||
|
"keywords": [
|
||||||
|
"OOXML",
|
||||||
|
"csv",
|
||||||
|
"excel",
|
||||||
|
"memory",
|
||||||
|
"odf",
|
||||||
|
"ods",
|
||||||
|
"office",
|
||||||
|
"open",
|
||||||
|
"php",
|
||||||
|
"read",
|
||||||
|
"scale",
|
||||||
|
"spreadsheet",
|
||||||
|
"stream",
|
||||||
|
"write",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/openspout/openspout/issues",
|
||||||
|
"source": "https://github.com/openspout/openspout/tree/v4.32.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://paypal.me/filippotessarotto",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Slamdunk",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-09-03T16:03:54+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phpoffice/phpspreadsheet",
|
||||||
|
"version": "3.10.6",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
|
"reference": "8de8215d6ff984f8db77a4a49dc089a396209e1b"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/8de8215d6ff984f8db77a4a49dc089a396209e1b",
|
||||||
|
"reference": "8de8215d6ff984f8db77a4a49dc089a396209e1b",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/pcre": "^1 || ^2 || ^3",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
|
"markbaker/complex": "^3.0",
|
||||||
|
"markbaker/matrix": "^3.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
|
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"mitoteam/jpgraph": "^10.5",
|
||||||
|
"mpdf/mpdf": "^8.1.1",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpstan/phpstan": "^1.1",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0",
|
||||||
|
"phpunit/phpunit": "^10.5",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
|
"tecnickcom/tcpdf": "^6.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"ext-intl": "PHP Internationalization Functions, required for NumberFormatter Wizard",
|
||||||
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Maarten Balliauw",
|
||||||
|
"homepage": "https://blog.maartenballiauw.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"homepage": "https://markbakeruk.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Franck Lefevre",
|
||||||
|
"homepage": "https://rootslabs.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Erik Tilt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adrien Crivelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owen Leibman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||||
|
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||||
|
"keywords": [
|
||||||
|
"OpenXML",
|
||||||
|
"excel",
|
||||||
|
"gnumeric",
|
||||||
|
"ods",
|
||||||
|
"php",
|
||||||
|
"spreadsheet",
|
||||||
|
"xls",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/3.10.6"
|
||||||
|
},
|
||||||
|
"time": "2026-06-07T02:39:57+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "psr/simple-cache",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-fig/simple-cache.git",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Psr\\SimpleCache\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "PHP-FIG",
|
||||||
|
"homepage": "https://www.php-fig.org/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common interfaces for simple caching",
|
||||||
|
"keywords": [
|
||||||
|
"cache",
|
||||||
|
"caching",
|
||||||
|
"psr",
|
||||||
|
"psr-16",
|
||||||
|
"simple-cache"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
|
||||||
|
},
|
||||||
|
"time": "2021-10-29T13:26:27+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": {},
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"ext-pgsql": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-zip": "*"
|
||||||
|
},
|
||||||
|
"platform-dev": {},
|
||||||
|
"plugin-api-version": "2.9.0"
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$database = [
|
||||||
|
'host' => (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') ? 'localhost' : 'postgres_db',
|
||||||
|
'port' => '5432',
|
||||||
|
'name' => 'warner',
|
||||||
|
'user' => 'postgres',
|
||||||
|
'password' => 'secret',
|
||||||
|
'sslmode' => 'prefer',
|
||||||
|
];
|
||||||
|
|
||||||
|
$databaseUrl = getenv('DATABASE_URL');
|
||||||
|
if (is_string($databaseUrl) && $databaseUrl !== '') {
|
||||||
|
$parsed = parse_url($databaseUrl);
|
||||||
|
if (is_array($parsed) && in_array(($parsed['scheme'] ?? ''), ['postgres', 'postgresql'], true)) {
|
||||||
|
$database['host'] = (string) ($parsed['host'] ?? $database['host']);
|
||||||
|
$database['port'] = (string) ($parsed['port'] ?? $database['port']);
|
||||||
|
$database['name'] = ltrim((string) ($parsed['path'] ?? ''), '/') ?: $database['name'];
|
||||||
|
$database['user'] = (string) ($parsed['user'] ?? $database['user']);
|
||||||
|
$database['password'] = (string) ($parsed['pass'] ?? $database['password']);
|
||||||
|
|
||||||
|
if (!empty($parsed['query'])) {
|
||||||
|
parse_str($parsed['query'], $query);
|
||||||
|
if (is_array($query) && isset($query['sslmode'])) {
|
||||||
|
$database['sslmode'] = (string) $query['sslmode'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$appUrl = getenv('APP_URL');
|
||||||
|
if (!is_string($appUrl) || $appUrl === '') {
|
||||||
|
$appUrl = 'http://localhost/warner/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'app' => [
|
||||||
|
'name' => 'Spreadsheet Importer',
|
||||||
|
'url' => rtrim($appUrl, '/') . '/',
|
||||||
|
],
|
||||||
|
'import' => [
|
||||||
|
'chunk_size' => 100,
|
||||||
|
],
|
||||||
|
'database' => $database,
|
||||||
|
];
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/public/index.php';
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
(() => {
|
||||||
|
const form = document.getElementById('importForm');
|
||||||
|
const clearButton = document.getElementById('clearButton');
|
||||||
|
const progressBar = document.getElementById('importProgress');
|
||||||
|
const progressLabel = document.getElementById('progressLabel');
|
||||||
|
const progressPercent = document.getElementById('progressPercent');
|
||||||
|
const totalRowsLabel = document.getElementById('importTotalRows');
|
||||||
|
const chunkSizeLabel = document.getElementById('importChunkSize');
|
||||||
|
const statusAlert = document.getElementById('statusAlert');
|
||||||
|
const fileInput = document.getElementById('spreadsheet');
|
||||||
|
const tokenStorageKey = 'warnerImportToken';
|
||||||
|
|
||||||
|
let importToken = window.sessionStorage.getItem(tokenStorageKey) || '';
|
||||||
|
let chunkTimer = null;
|
||||||
|
let chunkInFlight = false;
|
||||||
|
|
||||||
|
if (!form || !progressBar || !progressLabel || !progressPercent || !statusAlert) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setStatus = (type, message) => {
|
||||||
|
const classes = {
|
||||||
|
success: 'border-emerald-200 bg-emerald-50 text-emerald-900',
|
||||||
|
danger: 'border-rose-200 bg-rose-50 text-rose-900',
|
||||||
|
warning: 'border-amber-200 bg-amber-50 text-amber-900',
|
||||||
|
info: 'border-sky-200 bg-sky-50 text-sky-900',
|
||||||
|
};
|
||||||
|
|
||||||
|
statusAlert.className = `mt-4 rounded-2xl border px-4 py-3 text-sm font-medium ${classes[type] || classes.info}`;
|
||||||
|
statusAlert.textContent = message;
|
||||||
|
statusAlert.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProgress = (value, label) => {
|
||||||
|
const percent = Math.max(0, Math.min(100, Math.round(value)));
|
||||||
|
progressBar.style.width = `${percent}%`;
|
||||||
|
progressBar.setAttribute('aria-valuenow', String(percent));
|
||||||
|
progressPercent.textContent = `${percent}%`;
|
||||||
|
progressLabel.textContent = label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRowMetrics = (totalRows, chunkSize) => {
|
||||||
|
if (totalRowsLabel) {
|
||||||
|
totalRowsLabel.textContent = Number.isFinite(totalRows) ? String(totalRows.toLocaleString()) : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkSizeLabel && Number.isFinite(chunkSize)) {
|
||||||
|
chunkSizeLabel.textContent = String(chunkSize.toLocaleString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearImportState = () => {
|
||||||
|
importToken = '';
|
||||||
|
window.sessionStorage.removeItem(tokenStorageKey);
|
||||||
|
if (chunkTimer !== null) {
|
||||||
|
window.clearTimeout(chunkTimer);
|
||||||
|
chunkTimer = null;
|
||||||
|
}
|
||||||
|
chunkInFlight = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleChunk = (offset, delay = 200) => {
|
||||||
|
if (!importToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkTimer !== null) {
|
||||||
|
window.clearTimeout(chunkTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkTimer = window.setTimeout(() => {
|
||||||
|
processChunk(offset);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseJsonResponse = (xhr) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(xhr.responseText);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDestructiveAction = (message) => window.confirm(message);
|
||||||
|
|
||||||
|
const deriveProgressPercent = (response) => {
|
||||||
|
if (response && response.progressPercent !== undefined) {
|
||||||
|
return response.progressPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = Number(response && response.total ? response.total : 0);
|
||||||
|
const offset = Number(response && response.offset ? response.offset : 0);
|
||||||
|
if (total <= 0) {
|
||||||
|
return response && response.done ? 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(99, Math.round((offset / total) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
const processChunk = (offset = 0) => {
|
||||||
|
if (!importToken || chunkInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkInFlight = true;
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', window.APP_CONFIG.processChunkUrl, true);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||||
|
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkInFlight = false;
|
||||||
|
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const response = parseJsonResponse(xhr);
|
||||||
|
if (!response) {
|
||||||
|
setStatus('danger', 'Import response was invalid.');
|
||||||
|
clearImportState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = deriveProgressPercent(response);
|
||||||
|
const message = response.message || 'Processing workbook...';
|
||||||
|
setProgress(progress, message);
|
||||||
|
if (response.totalRows !== undefined || response.chunkSize !== undefined) {
|
||||||
|
setRowMetrics(
|
||||||
|
Number(response.totalRows ?? 0),
|
||||||
|
Number(response.chunkSize ?? (window.APP_CONFIG.importChunkSize ?? 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.done) {
|
||||||
|
setProgress(100, response.message || 'Import complete');
|
||||||
|
setStatus('success', response.message || 'Import complete. Reloading preview...');
|
||||||
|
clearImportState();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.href = window.APP_CONFIG.reloadUrl;
|
||||||
|
}, 800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('info', message);
|
||||||
|
scheduleChunk(response.offset !== undefined ? response.offset : (offset + 1), 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = parseJsonResponse(xhr);
|
||||||
|
const responseMessage = response && response.message ? response.message : 'Import failed.';
|
||||||
|
setProgress(0, 'Ready to import');
|
||||||
|
setStatus('danger', responseMessage);
|
||||||
|
clearImportState();
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(
|
||||||
|
`action=process-chunk&token=${encodeURIComponent(importToken)}&offset=${encodeURIComponent(String(offset))}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
statusAlert.classList.add('hidden');
|
||||||
|
|
||||||
|
if (!fileInput.files || fileInput.files.length === 0) {
|
||||||
|
setStatus('danger', 'Please choose an Excel file before importing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmDestructiveAction('Importing a new file will wipe the current imported data before processing. Continue?')) {
|
||||||
|
setStatus('info', 'Import cancelled.');
|
||||||
|
setProgress(0, 'Ready to import');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearImportState();
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', window.APP_CONFIG.importUrl, true);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable) {
|
||||||
|
setProgress(65, 'Uploading file...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = Math.max(1, (event.loaded / event.total) * 70);
|
||||||
|
setProgress(percent, 'Uploading file...');
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const response = parseJsonResponse(xhr);
|
||||||
|
if (response && response.token) {
|
||||||
|
importToken = response.token;
|
||||||
|
window.sessionStorage.setItem(tokenStorageKey, importToken);
|
||||||
|
setProgress(5, response.message || 'Upload received. Starting chunked import...');
|
||||||
|
setRowMetrics(0, Number(window.APP_CONFIG.importChunkSize || 0));
|
||||||
|
setStatus('info', response.message || 'Upload received. Starting chunked import...');
|
||||||
|
scheduleChunk(0, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('danger', 'Upload succeeded, but the server did not return an import token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = parseJsonResponse(xhr);
|
||||||
|
const responseMessage = response && response.message ? response.message : 'Import failed.';
|
||||||
|
setProgress(0, 'Ready to import');
|
||||||
|
setStatus('danger', responseMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
setProgress(5, 'Preparing upload...');
|
||||||
|
xhr.send(formData);
|
||||||
|
setProgress(20, 'Uploading file...');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clearButton) {
|
||||||
|
clearButton.addEventListener('click', () => {
|
||||||
|
if (!confirmDestructiveAction('This will permanently wipe all imported rows. Continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', window.APP_CONFIG.clearUrl, true);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||||
|
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
setProgress(0, 'Ready to import');
|
||||||
|
setStatus('success', 'Imported data cleared. Reloading page...');
|
||||||
|
clearImportState();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.href = window.APP_CONFIG.reloadUrl;
|
||||||
|
}, 800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = parseJsonResponse(xhr);
|
||||||
|
const responseMessage = response && response.message ? response.message : 'Clear failed.';
|
||||||
|
setStatus('danger', responseMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send('action=clear');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importToken) {
|
||||||
|
setProgress(10, 'Resuming import...');
|
||||||
|
setStatus('info', 'Resuming the last import job...');
|
||||||
|
setRowMetrics(0, Number(window.APP_CONFIG.importChunkSize || 0));
|
||||||
|
scheduleChunk(0, 200);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
:root {
|
||||||
|
--app-bg: #f3f6fb;
|
||||||
|
--app-ink: #16202a;
|
||||||
|
--app-muted: #5d6b7a;
|
||||||
|
--app-accent: #0f6efc;
|
||||||
|
--app-accent-soft: rgba(15, 110, 252, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--app-ink);
|
||||||
|
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(15, 110, 252, 0.12), transparent 28%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(13, 202, 240, 0.16), transparent 30%),
|
||||||
|
var(--app-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-navbar {
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datatable-toolbar {
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-visibility-menu {
|
||||||
|
min-width: 18rem;
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datatable-page .ag-theme-quartz {
|
||||||
|
--ag-font-family: inherit;
|
||||||
|
--ag-header-background-color: rgba(255, 255, 255, 0.98);
|
||||||
|
--ag-header-foreground-color: var(--app-ink);
|
||||||
|
--ag-background-color: rgba(255, 255, 255, 0.95);
|
||||||
|
--ag-foreground-color: var(--app-ink);
|
||||||
|
--ag-border-color: rgba(22, 32, 42, 0.12);
|
||||||
|
--ag-row-hover-color: rgba(15, 110, 252, 0.06);
|
||||||
|
--ag-selected-row-background-color: rgba(15, 110, 252, 0.12);
|
||||||
|
--ag-header-column-separator-display: none;
|
||||||
|
--ag-row-border-color: rgba(22, 32, 42, 0.08);
|
||||||
|
--ag-cell-horizontal-padding: 14px;
|
||||||
|
width: 100%;
|
||||||
|
height: 72vh;
|
||||||
|
min-height: 620px;
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1rem 2.5rem rgba(22, 32, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datatable-page .ag-theme-quartz .ag-header {
|
||||||
|
border-bottom: 1px solid rgba(22, 32, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datatable-page .ag-theme-quartz .ag-header-cell-label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datatable-page .ag-theme-quartz .ag-floating-filter {
|
||||||
|
border-top: 1px solid rgba(22, 32, 42, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datatable-page .ag-theme-quartz .ag-cell {
|
||||||
|
padding-top: 0.2rem;
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datatable-page .ag-theme-quartz .ag-row {
|
||||||
|
border-bottom: 1px solid rgba(22, 32, 42, 0.06);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../bootstrap.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$controller = \App\Http\AppController::create();
|
||||||
|
$controller->handle();
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
http_response_code(500);
|
||||||
|
$message = htmlspecialchars($throwable->getMessage(), ENT_QUOTES, 'UTF-8');
|
||||||
|
$appName = htmlspecialchars((string) app_config('app.name', 'Spreadsheet Importer'), ENT_QUOTES, 'UTF-8');
|
||||||
|
$assetUrl = htmlspecialchars(asset_url('styles.css'), ENT_QUOTES, 'UTF-8');
|
||||||
|
echo <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{$appName}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
tailwind = {
|
||||||
|
config: {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="{$assetUrl}" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-100 text-slate-900 antialiased">
|
||||||
|
<main class="flex min-h-screen items-center justify-center px-4 py-12">
|
||||||
|
<section class="w-full max-w-2xl rounded-[2rem] border border-white/70 bg-white/90 p-6 shadow-[0_24px_80px_rgba(15,23,42,0.1)] backdrop-blur sm:p-8">
|
||||||
|
<div class="inline-flex items-center rounded-full bg-rose-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-rose-700">Setup issue</div>
|
||||||
|
<h1 class="mt-5 text-3xl font-semibold tracking-tight">{$appName} cannot start</h1>
|
||||||
|
<p class="mt-3 text-sm leading-6 text-slate-500">The app hit a startup error while connecting to the database or loading a required dependency.</p>
|
||||||
|
<div class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-900">{$message}</div>
|
||||||
|
<div class="mt-5 text-sm leading-6 text-slate-500">
|
||||||
|
Check the PostgreSQL credentials in <code class="rounded bg-slate-100 px-1.5 py-0.5 text-slate-700">config.php</code>, run <code class="rounded bg-slate-100 px-1.5 py-0.5 text-slate-700">composer run migrate</code>, and make sure the database server is running.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$latestBatchRowCount = (int) ($latestBatchRowCount ?? 0);
|
||||||
|
$hasData = $latestBatch !== null && $latestBatchRowCount > 0;
|
||||||
|
$exportRunUrl = (string) ($exportRunUrl ?? '?action=export-run');
|
||||||
|
$exportStatusUrl = (string) ($exportStatusUrl ?? '?action=export-status');
|
||||||
|
$exportDownloadUrl = (string) ($exportDownloadUrl ?? '?action=export-download');
|
||||||
|
|
||||||
|
function h(mixed $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashClasses(?array $flash): string
|
||||||
|
{
|
||||||
|
$type = (string) ($flash['type'] ?? 'info');
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'success' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
|
||||||
|
'danger' => 'border-rose-200 bg-rose-50 text-rose-900',
|
||||||
|
'warning' => 'border-amber-200 bg-amber-50 text-amber-900',
|
||||||
|
default => 'border-sky-200 bg-sky-50 text-sky-900',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= h($appName) ?></title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
tailwind = {
|
||||||
|
config: {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="<?= h(asset_url('styles.css')) ?>" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-100 text-slate-900 antialiased">
|
||||||
|
<div class="app-shell">
|
||||||
|
<?php require __DIR__ . '/partials/navbar.php'; ?>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-4 lg:px-8 lg:py-6">
|
||||||
|
<section class="mb-4 overflow-hidden rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||||
|
<div class="p-6 sm:p-8 lg:p-10">
|
||||||
|
<span class="inline-flex items-center rounded-md bg-slate-900 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white">Export</span>
|
||||||
|
<h1 class="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Export data</h1>
|
||||||
|
<p class="mt-4 max-w-2xl text-base leading-7 text-slate-600 sm:text-lg">
|
||||||
|
Download the imported data in the format you need.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if ($flash !== null): ?>
|
||||||
|
<div class="mb-4 rounded-md border px-4 py-3 text-sm font-medium shadow-sm <?= h(flashClasses($flash)) ?>">
|
||||||
|
<?= h($flash['message'] ?? '') ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||||
|
<div class="p-6 sm:p-8 lg:p-10">
|
||||||
|
<h2 class="text-2xl font-semibold tracking-tight">Export options</h2>
|
||||||
|
|
||||||
|
<?php if (!$hasData): ?>
|
||||||
|
<p class="mt-4 text-sm leading-6 text-slate-500">No imported data yet. Upload a workbook first to enable exports.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="mt-6 rounded-md border border-slate-200/80 bg-slate-50/80 p-6">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-slate-900">Full table</div>
|
||||||
|
<p class="mt-1 max-w-xl text-sm leading-6 text-slate-500">
|
||||||
|
Every imported row (<?= number_format($latestBatchRowCount) ?> total), exported as an Excel workbook.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="exportButton"
|
||||||
|
class="inline-flex shrink-0 items-center justify-center rounded-md bg-sky-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Export as Excel (.xlsx)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="exportProgressWrap" class="mt-5 hidden">
|
||||||
|
<div class="mb-2 flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
<span id="exportProgressLabel">Starting export…</span>
|
||||||
|
<span id="exportProgressPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-3 overflow-hidden rounded-md bg-slate-200">
|
||||||
|
<div id="exportProgressBar" class="h-full w-0 rounded-md bg-sky-500 transition-all duration-300"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="exportError" class="mt-4 hidden rounded-md border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-900"></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const exportButton = document.getElementById('exportButton');
|
||||||
|
if (!exportButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressWrap = document.getElementById('exportProgressWrap');
|
||||||
|
const progressBar = document.getElementById('exportProgressBar');
|
||||||
|
const progressLabel = document.getElementById('exportProgressLabel');
|
||||||
|
const progressPercent = document.getElementById('exportProgressPercent');
|
||||||
|
const errorBox = document.getElementById('exportError');
|
||||||
|
|
||||||
|
const runUrl = <?= json_encode($exportRunUrl, JSON_THROW_ON_ERROR) ?>;
|
||||||
|
const statusUrl = <?= json_encode($exportStatusUrl, JSON_THROW_ON_ERROR) ?>;
|
||||||
|
const downloadUrl = <?= json_encode($exportDownloadUrl, JSON_THROW_ON_ERROR) ?>;
|
||||||
|
|
||||||
|
const makeToken = () => (window.crypto && crypto.randomUUID)
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
const setProgress = (percent, label) => {
|
||||||
|
progressBar.style.width = `${percent}%`;
|
||||||
|
progressPercent.textContent = `${percent}%`;
|
||||||
|
progressLabel.textContent = label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = (message) => {
|
||||||
|
errorBox.textContent = message;
|
||||||
|
errorBox.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
exportButton.addEventListener('click', () => {
|
||||||
|
const token = makeToken();
|
||||||
|
|
||||||
|
exportButton.disabled = true;
|
||||||
|
errorBox.classList.add('hidden');
|
||||||
|
progressWrap.classList.remove('hidden');
|
||||||
|
setProgress(0, 'Starting export…');
|
||||||
|
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
window.clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
stopPolling();
|
||||||
|
exportButton.disabled = false;
|
||||||
|
window.setTimeout(() => progressWrap.classList.add('hidden'), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
pollTimer = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${statusUrl}&token=${encodeURIComponent(token)}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'running') {
|
||||||
|
setProgress(data.progressPercent ?? 0, data.message || 'Exporting…');
|
||||||
|
} else if (data.status === 'completed') {
|
||||||
|
setProgress(100, 'Export complete. Downloading…');
|
||||||
|
finish();
|
||||||
|
window.location.href = data.downloadUrl || `${downloadUrl}&token=${encodeURIComponent(token)}`;
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
finish();
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
showError(data.message || 'Export failed.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
finish();
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
showError('Lost connection while checking export progress.');
|
||||||
|
}
|
||||||
|
}, 700);
|
||||||
|
|
||||||
|
fetch(`${runUrl}&token=${encodeURIComponent(token)}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
}).catch(() => {
|
||||||
|
finish();
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
showError('Could not start the export.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$previewLimit = (int) ($previewLimit ?? 20);
|
||||||
|
$latestBatchRowCount = (int) ($latestBatchRowCount ?? 0);
|
||||||
|
$previewCount = count($rows ?? []);
|
||||||
|
$hasData = $latestBatch !== null && $latestBatchRowCount > 0;
|
||||||
|
|
||||||
|
function h(mixed $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCell(mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return '<span class="text-slate-400">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return h($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashClasses(?array $flash): string
|
||||||
|
{
|
||||||
|
$type = (string) ($flash['type'] ?? 'info');
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'success' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
|
||||||
|
'danger' => 'border-rose-200 bg-rose-50 text-rose-900',
|
||||||
|
'warning' => 'border-amber-200 bg-amber-50 text-amber-900',
|
||||||
|
default => 'border-sky-200 bg-sky-50 text-sky-900',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= h($appName) ?></title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
tailwind = {
|
||||||
|
config: {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="<?= h(asset_url('styles.css')) ?>" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
th {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-100 text-slate-900 antialiased">
|
||||||
|
<div class="app-shell">
|
||||||
|
<?php require __DIR__ . '/partials/navbar.php'; ?>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-4 lg:px-8 lg:py-6">
|
||||||
|
<section class="mb-4 overflow-hidden rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||||
|
<div class="p-6 sm:p-8 lg:p-10">
|
||||||
|
<div class="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<span class="inline-flex items-center rounded-md bg-slate-900 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white">Stage 1</span>
|
||||||
|
<h1 class="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl"><?= h($appName) ?></h1>
|
||||||
|
<p class="mt-4 max-w-2xl text-base leading-7 text-slate-600 sm:text-lg">
|
||||||
|
Upload an Excel workbook, wipe the current imported dataset, process the rows, and review the stored results with derived fields.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-slate-200/80 bg-slate-50/80 px-6 py-5 text-left lg:min-w-72 lg:text-right">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">Current imported rows</div>
|
||||||
|
<div class="mt-2 text-5xl font-semibold tracking-tight text-slate-900"><?= number_format($latestBatchRowCount) ?></div>
|
||||||
|
<div class="mt-2 text-sm text-slate-500">
|
||||||
|
Showing a preview of the latest <?= number_format(min($previewLimit, $previewCount)) ?> row(s).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if ($flash !== null): ?>
|
||||||
|
<div class="mb-4 rounded-md border px-4 py-3 text-sm font-medium shadow-sm <?= h(flashClasses($flash)) ?>">
|
||||||
|
<?= h($flash['message'] ?? '') ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-12">
|
||||||
|
<section class="lg:col-span-8">
|
||||||
|
<div class="h-full rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||||
|
<div class="p-6 sm:p-8">
|
||||||
|
<div class="mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold tracking-tight">Upload and import</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
The previous imported data is cleared before the new workbook is inserted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center rounded-md border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
|
||||||
|
id="clearButton"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear imported data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="importForm" class="space-y-5" method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="action" value="import">
|
||||||
|
<div>
|
||||||
|
<label for="spreadsheet" class="mb-2 block text-sm font-semibold text-slate-700">Excel file</label>
|
||||||
|
<input
|
||||||
|
class="block w-full cursor-pointer rounded-md border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700 outline-none transition file:mr-4 file:rounded-md file:border-0 file:bg-slate-900 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white hover:border-slate-300 focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||||
|
type="file"
|
||||||
|
id="spreadsheet"
|
||||||
|
name="spreadsheet"
|
||||||
|
accept=".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Supported files: <code class="rounded bg-slate-100 px-1.5 py-0.5 text-slate-700">.xls</code> and <code class="rounded bg-slate-100 px-1.5 py-0.5 text-slate-700">.xlsx</code>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<div class="mb-2 flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
<span id="progressLabel">Ready to import</span>
|
||||||
|
<span id="progressPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-3 overflow-hidden rounded-md bg-slate-200">
|
||||||
|
<div id="importProgress" class="h-full w-0 rounded-md bg-sky-500 transition-all duration-300"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap justify-between gap-3 text-sm text-slate-500">
|
||||||
|
<span>Rows detected: <strong id="importTotalRows" class="text-slate-800">0</strong></span>
|
||||||
|
<span>Chunk size: <strong id="importChunkSize" class="text-slate-800"><?= number_format((int) app_config('import.chunk_size', 5)) ?></strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button type="submit" class="inline-flex items-center justify-center rounded-md bg-sky-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:bg-sky-500">
|
||||||
|
Process and import
|
||||||
|
</button>
|
||||||
|
<button type="reset" class="inline-flex items-center justify-center rounded-md border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||||
|
Reset form
|
||||||
|
</button>
|
||||||
|
<a class="inline-flex items-center justify-center rounded-md border border-sky-200 bg-sky-50 px-5 py-3 text-sm font-semibold text-sky-700 transition hover:bg-sky-100" href="<?= h($tableUrl) ?>">
|
||||||
|
Open full table
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
|
||||||
|
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">1</span>
|
||||||
|
Validate file
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
|
||||||
|
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">2</span>
|
||||||
|
Wipe existing rows
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
|
||||||
|
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">3</span>
|
||||||
|
Normalize and derive fields
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
|
||||||
|
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">4</span>
|
||||||
|
Store in PostgreSQL
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
|
||||||
|
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">5</span>
|
||||||
|
Review preview table
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="statusAlert" class="mt-6 hidden rounded-md border px-4 py-3 text-sm font-medium" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="lg:col-span-4">
|
||||||
|
<div class="h-full rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||||
|
<div class="p-6 sm:p-8">
|
||||||
|
<h2 class="text-2xl font-semibold tracking-tight">Import rules in stage 1</h2>
|
||||||
|
<div class="mt-5 space-y-3">
|
||||||
|
<?php foreach ($requiredColumns as $column): ?>
|
||||||
|
<div class="flex items-center justify-between gap-4 rounded-md border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<span class="text-sm font-medium text-slate-700"><?= h($column) ?></span>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-white px-3 py-1 text-xs font-semibold text-slate-500 ring-1 ring-slate-200">Required</span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<p class="mt-5 text-sm leading-6 text-slate-500">
|
||||||
|
The workbook header <strong class="text-slate-700">Revenue Type</strong> is ignored. All other columns are imported and the calculated columns are appended to the preview.
|
||||||
|
</p>
|
||||||
|
<?php if ($latestBatch !== null): ?>
|
||||||
|
<div class="mt-8 rounded-md border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Last imported file</div>
|
||||||
|
<div class="mt-2 text-sm font-semibold text-slate-900"><?= h($latestBatch['source_filename'] ?? '') ?></div>
|
||||||
|
<div class="mt-1 text-sm text-slate-500"><?= h($latestBatch['created_at'] ?? '') ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto w-full px-2 pb-6">
|
||||||
|
<section class="rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||||
|
<div class="p-6 sm:p-8">
|
||||||
|
<div class="mb-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold tracking-tight">Imported data preview</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
<?= $hasData ? 'Showing the latest 20 rows from the current batch.' : 'No imported data yet. Upload a workbook to populate the table.' ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php if ($hasData): ?>
|
||||||
|
<div class="text-sm text-slate-500">
|
||||||
|
Showing <strong class="text-slate-900"><?= number_format($previewCount) ?></strong> of <strong class="text-slate-900"><?= number_format($latestBatchRowCount) ?></strong> row(s)
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($hasData): ?>
|
||||||
|
<div class="overflow-x-auto rounded-md border border-slate-200">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th class="whitespace-nowrap px-4 py-3">Row</th>
|
||||||
|
<?php foreach ($tableHeaders as $header): ?>
|
||||||
|
<th class="whitespace-nowrap px-4 py-3"><?= h($header) ?></th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100 bg-white">
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<tr class="hover:bg-slate-50/80">
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 font-medium text-slate-500"><?= number_format((int) $row['row_number']) ?></td>
|
||||||
|
<?php foreach ($tableHeaders as $header): ?>
|
||||||
|
<td class="px-4 py-3 align-top text-slate-700"><?= renderCell($row['merged_row'][$header] ?? null) ?></td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($latestBatchRowCount > $previewLimit): ?>
|
||||||
|
<div class="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="text-sm text-slate-500">
|
||||||
|
Preview is limited to <?= number_format($previewLimit) ?> row(s). Open the full table for search and pagination.
|
||||||
|
</div>
|
||||||
|
<a class="inline-flex items-center justify-center rounded-md border border-sky-200 bg-sky-50 px-4 py-2 text-sm font-semibold text-sky-700 transition hover:bg-sky-100" href="<?= h($tableUrl) ?>">
|
||||||
|
Go to full table
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="rounded-3xl border border-dashed border-slate-300 bg-slate-50/70 p-10 text-center">
|
||||||
|
<div class="text-2xl font-semibold text-slate-900">No data loaded</div>
|
||||||
|
<p class="mt-3 text-sm leading-6 text-slate-500">Choose an Excel workbook and run the import to display the latest processed rows here.</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.APP_CONFIG = {
|
||||||
|
importUrl: window.location.pathname + '?action=import',
|
||||||
|
processChunkUrl: window.location.pathname + '?action=process-chunk',
|
||||||
|
clearUrl: window.location.pathname + '?action=clear',
|
||||||
|
reloadUrl: window.location.pathname,
|
||||||
|
importChunkSize: <?= (int) app_config('import.chunk_size', 5) ?>
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="<?= h(asset_url('app.js')) ?>"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$currentView = (string) ($currentView ?? 'preview');
|
||||||
|
$homeUrl = (string) ($homeUrl ?? '?view=preview');
|
||||||
|
$tableUrl = (string) ($tableUrl ?? '?view=table');
|
||||||
|
$exportUrl = (string) ($exportUrl ?? '?view=export');
|
||||||
|
$appName = (string) ($appName ?? 'Spreadsheet Importer');
|
||||||
|
|
||||||
|
$previewActive = $currentView === 'preview';
|
||||||
|
$tableActive = $currentView === 'table';
|
||||||
|
$exportActive = $currentView === 'export';
|
||||||
|
?>
|
||||||
|
<nav class="app-navbar sticky top-0 z-30 border-b border-white/60 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-xl" aria-label="Primary">
|
||||||
|
<div class="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 lg:flex-row lg:items-center lg:justify-between lg:px-8">
|
||||||
|
<a href="<?= h($homeUrl) ?>" class="inline-flex items-center gap-3 self-start">
|
||||||
|
<span class="flex h-10 w-10 items-center justify-center rounded-md bg-sky-600 text-sm font-semibold text-white shadow-lg shadow-sky-500/25">W</span>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold uppercase tracking-[0.24em] text-slate-400">Warner</div>
|
||||||
|
<div class="text-lg font-semibold text-slate-900"><?= h($appName) ?></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<ul class="flex flex-wrap gap-2">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center rounded-md px-4 py-2 text-sm font-semibold transition
|
||||||
|
<?= $previewActive ? 'bg-slate-900 text-white shadow-lg shadow-slate-900/20' : 'bg-white/90 text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50' ?>"
|
||||||
|
href="<?= h($homeUrl) ?>"
|
||||||
|
<?= $previewActive ? 'aria-current="page"' : '' ?>
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center rounded-md px-4 py-2 text-sm font-semibold transition
|
||||||
|
<?= $tableActive ? 'bg-sky-600 text-white shadow-lg shadow-sky-500/20' : 'bg-white/90 text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50' ?>"
|
||||||
|
href="<?= h($tableUrl) ?>"
|
||||||
|
<?= $tableActive ? 'aria-current="page"' : '' ?>
|
||||||
|
>
|
||||||
|
Full table
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center rounded-md px-4 py-2 text-sm font-semibold transition
|
||||||
|
<?= $exportActive ? 'bg-sky-600 text-white shadow-lg shadow-sky-500/20' : 'bg-white/90 text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50' ?>"
|
||||||
|
href="<?= h($exportUrl) ?>"
|
||||||
|
<?= $exportActive ? 'aria-current="page"' : '' ?>
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<?php if ($tableActive): ?>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="blurToggleButton"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-white/90 px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50"
|
||||||
|
aria-pressed="false"
|
||||||
|
title="Blur table data for privacy"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8Z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
<span data-blur-label>Blur data</span>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$latestBatchRowCount = (int) ($latestBatchRowCount ?? 0);
|
||||||
|
$hasData = $latestBatch !== null && $latestBatchRowCount > 0;
|
||||||
|
|
||||||
|
function h(mixed $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashClasses(?array $flash): string
|
||||||
|
{
|
||||||
|
$type = (string) ($flash['type'] ?? 'info');
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'success' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
|
||||||
|
'danger' => 'border-rose-200 bg-rose-50 text-rose-900',
|
||||||
|
'warning' => 'border-amber-200 bg-amber-50 text-amber-900',
|
||||||
|
default => 'border-sky-200 bg-sky-50 text-sky-900',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= h($appName) ?></title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
tailwind = {
|
||||||
|
config: {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/ag-grid-community/styles/ag-theme-quartz.css" rel="stylesheet">
|
||||||
|
<link href="<?= h(asset_url('styles.css')) ?>" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.cell-flag-positive { background-color: #dcfce7; color: #166534; font-weight: 600; }
|
||||||
|
.cell-flag-negative { background-color: #fee2e2; color: #991b1b; font-weight: 600; }
|
||||||
|
|
||||||
|
.pin-toggle-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border-radius: 4px; border: 1px solid transparent;
|
||||||
|
color: #94a3b8; background: transparent; cursor: pointer; transition: all .12s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pin-toggle-btn:hover { background: #f1f5f9; color: #475569; }
|
||||||
|
.pin-toggle-btn.is-pinned { color: #fff; background: #0284c7; }
|
||||||
|
.pin-toggle-btn.is-pinned:hover { background: #0369a1; }
|
||||||
|
|
||||||
|
.pinnable-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
width: 100%; gap: 6px;
|
||||||
|
}
|
||||||
|
.pinnable-header__label {
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: break-spaces;
|
||||||
|
cursor: pointer; min-width: 0;
|
||||||
|
}
|
||||||
|
.pinnable-header__buttons {
|
||||||
|
display: flex; align-items: center; gap: 2px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pin-toggle-btn--header { width: 22px; height: 22px; }
|
||||||
|
.pin-toggle-btn--header svg { width: 11px; height: 11px; }
|
||||||
|
.filter-toggle-btn.is-active { color: #fff; background: #f59e0b; }
|
||||||
|
.filter-toggle-btn.is-active:hover { background: #d97706; }
|
||||||
|
|
||||||
|
#blurToggleButton.is-active { border-color: transparent; background: #0f172a; color: #fff; }
|
||||||
|
#blurToggleButton.is-active:hover { background: #1e293b; }
|
||||||
|
|
||||||
|
#fullDataGrid.is-privacy-blurred { filter: blur(7px); transition: filter .15s ease; }
|
||||||
|
|
||||||
|
.excel-filter { display: flex; flex-direction: column; width: 240px; padding: 10px; gap: 8px; font-family: inherit; }
|
||||||
|
.excel-filter__search-input {
|
||||||
|
width: 100%; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 8px;
|
||||||
|
padding: 6px 10px; font-size: 13px; outline: none;
|
||||||
|
}
|
||||||
|
.excel-filter__search-input:focus { border-color: #38bdf8; box-shadow: 0 0 0 3px rgba(56,189,248,.15); }
|
||||||
|
.excel-filter__actions { display: flex; justify-content: space-between; }
|
||||||
|
.excel-filter__link { background: none; border: none; color: #0284c7; font-size: 12px; font-weight: 600; cursor: pointer; padding: 0; }
|
||||||
|
.excel-filter__link:hover { text-decoration: underline; }
|
||||||
|
.excel-filter__list { max-height: 220px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; padding: 4px; }
|
||||||
|
.excel-filter__item { display: flex; align-items: center; gap: 8px; padding: 4px 6px; font-size: 13px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.excel-filter__item:hover { background: #f8fafc; }
|
||||||
|
.excel-filter__loading { padding: 8px 6px; font-size: 12px; color: #94a3b8; }
|
||||||
|
.excel-filter__footer { display: flex; justify-content: flex-end; }
|
||||||
|
.excel-filter__apply {
|
||||||
|
background: #0284c7; color: #fff; border: none; border-radius: 8px;
|
||||||
|
padding: 6px 14px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.excel-filter__apply:hover { background: #0369a1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="datatable-page min-h-screen bg-slate-100 text-slate-900 antialiased">
|
||||||
|
<div class="app-shell">
|
||||||
|
<?php require __DIR__ . '/partials/navbar.php'; ?>
|
||||||
|
|
||||||
|
<div class="hidden mx-auto max-w-7xl px-4 py-4 lg:px-8 lg:py-6">
|
||||||
|
<section class="mb-4 overflow-hidden rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
|
||||||
|
<div class="p-6 sm:p-8 lg:p-10">
|
||||||
|
<div class="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<span class="inline-flex items-center rounded-md bg-slate-900 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white">AG Grid</span>
|
||||||
|
<h1 class="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl"><?= h($appName) ?></h1>
|
||||||
|
<p class="mt-4 max-w-2xl text-base leading-7 text-slate-600 sm:text-lg">
|
||||||
|
Browse the latest imported batch with server-side search, sorting, and column filters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-slate-200/80 bg-slate-50/80 px-6 py-5 text-left lg:min-w-72 lg:text-right">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">Rows in latest batch</div>
|
||||||
|
<div class="mt-2 text-5xl font-semibold tracking-tight text-slate-900"><?= number_format($latestBatchRowCount) ?></div>
|
||||||
|
<div class="mt-2 text-sm text-slate-500">Rows are loaded from the server only as needed.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if ($flash !== null): ?>
|
||||||
|
<div class="mb-4 rounded-md border px-4 py-3 text-sm font-medium shadow-sm <?= h(flashClasses($flash)) ?>">
|
||||||
|
<?= h($flash['message'] ?? '') ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-full px-2 py-6">
|
||||||
|
<div class="hidden mb-4 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="hidden text-2xl font-semibold tracking-tight">Imported data</h2>
|
||||||
|
<p class="hidden mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
<?= $hasData ? 'AG Grid streams rows from the server and applies column filters without loading the whole batch into memory.' : 'No imported data yet. Upload a workbook to populate the grid.' ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<?php if ($hasData): ?>
|
||||||
|
<div class="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="flex flex-col gap-2 md:flex-row md:items-center">
|
||||||
|
<div class="flex min-w-0 items-center overflow-hidden rounded-md border border-slate-200 bg-white shadow-sm">
|
||||||
|
<span class="px-4 py-2 text-sm font-semibold text-slate-500">Search</span>
|
||||||
|
<input
|
||||||
|
id="tableSearchInput"
|
||||||
|
type="search"
|
||||||
|
class="w-full min-w-0 border-0 bg-transparent px-0 py-2 pr-4 text-sm text-slate-700 outline-none placeholder:text-slate-400"
|
||||||
|
placeholder="Search imported rows"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50"
|
||||||
|
type="button"
|
||||||
|
id="columnVisibilityButton"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Columns
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="columnVisibilityMenu"
|
||||||
|
class="invisible absolute left-0 z-20 mt-2 w-80 rounded-md border border-slate-200 bg-white p-4 opacity-0 shadow-[0_20px_60px_rgba(15,23,42,0.12)] transition duration-150"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input
|
||||||
|
id="columnVisibilitySearch"
|
||||||
|
type="search"
|
||||||
|
class="w-full rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||||
|
placeholder="Filter columns"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div id="columnVisibilityItems" class="max-h-96 space-y-1 overflow-y-auto pr-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start gap-2 lg:items-end">
|
||||||
|
<div class="flex items-center gap-4 text-xs font-medium text-slate-500">
|
||||||
|
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-md bg-emerald-200"></span>Y / N<span class="h-2.5 w-2.5 rounded-md bg-rose-200"></span></span>
|
||||||
|
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-md bg-emerald-200"></span>F / D<span class="h-2.5 w-2.5 rounded-md bg-rose-200"></span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fullDataGrid" class="ag-theme-quartz datagrid-shell" style="height: 85vh; min-height: 620px; width: 100%;"></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="rounded-md border border-dashed border-slate-300 bg-slate-50/70 p-10 text-center">
|
||||||
|
<div class="text-2xl font-semibold text-slate-900">No data loaded</div>
|
||||||
|
<p class="mt-3 text-sm leading-6 text-slate-500">Choose an Excel workbook and run the import to display the latest processed rows here.</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/ag-grid-community/dist/ag-grid-community.min.js"></script>
|
||||||
|
<script>
|
||||||
|
window.APP_CONFIG = {
|
||||||
|
tableDataUrl: window.location.pathname + '?view=table&action=grid-data'
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const gridElement = document.getElementById('fullDataGrid');
|
||||||
|
if (!gridElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('tableSearchInput');
|
||||||
|
const visibilityButton = document.getElementById('columnVisibilityButton');
|
||||||
|
const visibilityMenu = document.getElementById('columnVisibilityMenu');
|
||||||
|
const visibilitySearch = document.getElementById('columnVisibilitySearch');
|
||||||
|
const visibilityItems = document.getElementById('columnVisibilityItems');
|
||||||
|
const blurToggleButton = document.getElementById('blurToggleButton');
|
||||||
|
const headerLabels = <?= json_encode(array_values($tableHeaders), JSON_THROW_ON_ERROR) ?>;
|
||||||
|
|
||||||
|
// Move these columns to appear right before "DSP" instead of their
|
||||||
|
// default import order.
|
||||||
|
['US/ex-US sale', 'Actual/Accrual'].forEach((header) => {
|
||||||
|
const fromIndex = headerLabels.indexOf(header);
|
||||||
|
if (fromIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
headerLabels.splice(fromIndex, 1);
|
||||||
|
const dspIndex = headerLabels.indexOf('DSP');
|
||||||
|
headerLabels.splice(dspIndex === -1 ? headerLabels.length : dspIndex, 0, header);
|
||||||
|
});
|
||||||
|
|
||||||
|
let gridApi = null;
|
||||||
|
let currentSearch = searchInput ? searchInput.value.trim() : '';
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => {
|
||||||
|
switch (character) {
|
||||||
|
case '&':
|
||||||
|
return '&';
|
||||||
|
case '<':
|
||||||
|
return '<';
|
||||||
|
case '>':
|
||||||
|
return '>';
|
||||||
|
case '"':
|
||||||
|
return '"';
|
||||||
|
case '\'':
|
||||||
|
return ''';
|
||||||
|
default:
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeVisibilityMenu = () => {
|
||||||
|
if (!visibilityMenu || !visibilityButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibilityMenu.classList.add('invisible', 'opacity-0', 'pointer-events-none');
|
||||||
|
visibilityMenu.classList.remove('visible', 'opacity-100');
|
||||||
|
visibilityButton.setAttribute('aria-expanded', 'false');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openVisibilityMenu = () => {
|
||||||
|
if (!visibilityMenu || !visibilityButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibilityMenu.classList.remove('invisible', 'opacity-0', 'pointer-events-none');
|
||||||
|
visibilityMenu.classList.add('visible', 'opacity-100');
|
||||||
|
visibilityButton.setAttribute('aria-expanded', 'true');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleVisibilityMenu = () => {
|
||||||
|
if (!visibilityMenu || visibilityMenu.classList.contains('invisible')) {
|
||||||
|
openVisibilityMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeVisibilityMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const currencyHeaderPattern = /usd/i;
|
||||||
|
const flagHeaderPattern = /flag$/i;
|
||||||
|
const FLAG_POSITIVE = new Set(['Y', 'F']);
|
||||||
|
const FLAG_NEGATIVE = new Set(['N', 'D']);
|
||||||
|
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
const formatted = Math.abs(number).toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
return number < 0 ? `(${formatted})` : formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Pinned columns ---------------------------------------------------
|
||||||
|
// Each data column gets a pin toggle in its header (see PinnableHeader
|
||||||
|
// component below). Clicking it sets that column's AG Grid `pinned`
|
||||||
|
// state to 'left' or null via applyColumnState. This is native AG Grid
|
||||||
|
// column pinning, so it works correctly with horizontal scroll,
|
||||||
|
// resizing, and column reordering out of the box.
|
||||||
|
const pinnedColumnIds = new Set();
|
||||||
|
|
||||||
|
const isColumnPinned = (colId) => pinnedColumnIds.has(colId);
|
||||||
|
|
||||||
|
const toggleColumnPin = (colId) => {
|
||||||
|
if (!gridApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const willPin = !isColumnPinned(colId);
|
||||||
|
if (willPin) {
|
||||||
|
pinnedColumnIds.add(colId);
|
||||||
|
} else {
|
||||||
|
pinnedColumnIds.delete(colId);
|
||||||
|
}
|
||||||
|
|
||||||
|
gridApi.applyColumnState({
|
||||||
|
state: [{ colId, pinned: willPin ? 'left' : null }],
|
||||||
|
defaultState: { pinned: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh the header so the pin icon reflects the new state.
|
||||||
|
gridApi.refreshHeader();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pinIconSvg = '<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor"><path d="M14.7 2.5a1 1 0 0 1 1.4 0l5.4 5.4a1 1 0 0 1 0 1.4l-1.5 1.5a1 1 0 0 1-1.4 0l-.3-.3-2.8 2.8.6 3.6a1 1 0 0 1-.27.9l-.9.9a1 1 0 0 1-1.42 0l-3.3-3.3-4.6 4.6a1 1 0 0 1-1.42-1.42l4.6-4.6-3.3-3.3a1 1 0 0 1 0-1.42l.9-.9a1 1 0 0 1 .9-.27l3.6.6 2.8-2.8-.3-.3a1 1 0 0 1 0-1.4Z"/></svg>';
|
||||||
|
const filterIconSvg = '<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 .8 1.6l-6.3 8.2v6a1 1 0 0 1-.5.87l-3 1.7A1 1 0 0 1 9.5 21v-8.2L3.2 4.6A1 1 0 0 1 3 4Z"/></svg>';
|
||||||
|
|
||||||
|
// Custom header component: renders the default header label/sort icon
|
||||||
|
// plus a pin toggle button. Registered per-column via headerComponent.
|
||||||
|
class PinnableHeader {
|
||||||
|
init(params) {
|
||||||
|
this.params = params;
|
||||||
|
this.eGui = document.createElement('div');
|
||||||
|
this.eGui.className = 'pinnable-header';
|
||||||
|
|
||||||
|
this.eLabel = document.createElement('span');
|
||||||
|
this.eLabel.className = 'pinnable-header__label';
|
||||||
|
this.eLabel.textContent = params.displayName;
|
||||||
|
|
||||||
|
this.eButton = document.createElement('button');
|
||||||
|
this.eButton.type = 'button';
|
||||||
|
this.eButton.className = 'pin-toggle-btn pin-toggle-btn--header';
|
||||||
|
this.eButton.innerHTML = pinIconSvg;
|
||||||
|
this.updateButtonState();
|
||||||
|
|
||||||
|
this.onClick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleColumnPin(params.column.getColId());
|
||||||
|
};
|
||||||
|
this.eButton.addEventListener('click', this.onClick);
|
||||||
|
|
||||||
|
this.eGui.appendChild(this.eLabel);
|
||||||
|
|
||||||
|
this.eButtons = document.createElement('div');
|
||||||
|
this.eButtons.className = 'pinnable-header__buttons';
|
||||||
|
|
||||||
|
// A custom headerComponent replaces AG Grid's built-in header
|
||||||
|
// entirely, including the filter funnel icon it would normally
|
||||||
|
// add — so for any column that has a filter, add our own
|
||||||
|
// funnel button that opens that column's filter popup.
|
||||||
|
if (params.column.getColDef().filter) {
|
||||||
|
this.eFilterButton = document.createElement('button');
|
||||||
|
this.eFilterButton.type = 'button';
|
||||||
|
this.eFilterButton.className = 'pin-toggle-btn pin-toggle-btn--header filter-toggle-btn';
|
||||||
|
this.eFilterButton.innerHTML = filterIconSvg;
|
||||||
|
this.updateFilterButtonState();
|
||||||
|
|
||||||
|
this.onFilterClick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
params.api.showColumnFilter(params.column.getColId());
|
||||||
|
};
|
||||||
|
this.eFilterButton.addEventListener('click', this.onFilterClick);
|
||||||
|
this.eButtons.appendChild(this.eFilterButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eButtons.appendChild(this.eButton);
|
||||||
|
this.eGui.appendChild(this.eButtons);
|
||||||
|
|
||||||
|
// Allow clicking the label to sort, same as a default header.
|
||||||
|
this.onLabelClick = () => params.progressSort();
|
||||||
|
this.eLabel.addEventListener('click', this.onLabelClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateButtonState() {
|
||||||
|
const pinned = isColumnPinned(this.params.column.getColId());
|
||||||
|
this.eButton.classList.toggle('is-pinned', pinned);
|
||||||
|
this.eButton.title = pinned ? 'Unpin column' : 'Pin column to left';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilterButtonState() {
|
||||||
|
if (!this.eFilterButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isActive = this.params.column.isFilterActive();
|
||||||
|
this.eFilterButton.classList.toggle('is-active', isActive);
|
||||||
|
this.eFilterButton.title = isActive ? 'Filter active — click to edit' : 'Filter this column';
|
||||||
|
}
|
||||||
|
|
||||||
|
getGui() {
|
||||||
|
return this.eGui;
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.updateButtonState();
|
||||||
|
this.updateFilterButtonState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.eButton.removeEventListener('click', this.onClick);
|
||||||
|
this.eLabel.removeEventListener('click', this.onLabelClick);
|
||||||
|
if (this.eFilterButton) {
|
||||||
|
this.eFilterButton.removeEventListener('click', this.onFilterClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Excel-style "select values" filter --------------------------------
|
||||||
|
// AG Grid Community doesn't ship the Set Filter (that's Enterprise-only),
|
||||||
|
// so this is a hand-rolled equivalent. It needs a matching backend
|
||||||
|
// endpoint: same tableDataUrl, with distinctColumn / distinctSearch
|
||||||
|
// params, returning { values: [...] }. The grid-data endpoint also
|
||||||
|
// needs to treat a filterModel entry of { filterType: 'set', values }
|
||||||
|
// as an IN-list match rather than a text "contains" match.
|
||||||
|
class ExcelStyleFilter {
|
||||||
|
init(params) {
|
||||||
|
this.params = params;
|
||||||
|
this.selected = new Set();
|
||||||
|
this.values = [];
|
||||||
|
this.eGui = document.createElement('div');
|
||||||
|
this.eGui.className = 'excel-filter';
|
||||||
|
this.eGui.innerHTML = `
|
||||||
|
<div class="excel-filter__search">
|
||||||
|
<input type="search" placeholder="Search values" class="excel-filter__search-input">
|
||||||
|
</div>
|
||||||
|
<div class="excel-filter__actions">
|
||||||
|
<button type="button" data-action="select-all" class="excel-filter__link">Select all</button>
|
||||||
|
<button type="button" data-action="clear" class="excel-filter__link">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="excel-filter__list"></div>
|
||||||
|
<div class="excel-filter__footer">
|
||||||
|
<button type="button" data-action="apply" class="excel-filter__apply">Apply</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.eList = this.eGui.querySelector('.excel-filter__list');
|
||||||
|
this.eSearch = this.eGui.querySelector('.excel-filter__search-input');
|
||||||
|
|
||||||
|
this.eSearch.addEventListener('input', () => {
|
||||||
|
window.clearTimeout(this._searchTimer);
|
||||||
|
this._searchTimer = window.setTimeout(() => this.loadValues(this.eSearch.value.trim()), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eGui.querySelector('[data-action="select-all"]').addEventListener('click', () => {
|
||||||
|
this.values.forEach((value) => this.selected.add(value));
|
||||||
|
this.renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eGui.querySelector('[data-action="clear"]').addEventListener('click', () => {
|
||||||
|
this.selected.clear();
|
||||||
|
this.renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eGui.querySelector('[data-action="apply"]').addEventListener('click', () => {
|
||||||
|
this.params.filterChangedCallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadValues('');
|
||||||
|
}
|
||||||
|
|
||||||
|
getGui() {
|
||||||
|
return this.eGui;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadValues(searchTerm) {
|
||||||
|
this.eList.innerHTML = '<div class="excel-filter__loading">Loading values…</div>';
|
||||||
|
try {
|
||||||
|
const url = new URL(window.APP_CONFIG.tableDataUrl, window.location.origin);
|
||||||
|
url.searchParams.set('distinctColumn', this.params.colDef.colId);
|
||||||
|
url.searchParams.set('distinctSearch', searchTerm ?? '');
|
||||||
|
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } });
|
||||||
|
const payload = await response.json();
|
||||||
|
this.values = Array.isArray(payload.values) ? payload.values : [];
|
||||||
|
this.renderList();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.eList.innerHTML = '<div class="excel-filter__loading">Could not load values</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
this.eList.innerHTML = this.values.map((value) => `
|
||||||
|
<label class="excel-filter__item">
|
||||||
|
<input type="checkbox" value="${escapeHtml(value)}" ${this.selected.has(value) ? 'checked' : ''}>
|
||||||
|
<span>${value === '' ? '(blank)' : escapeHtml(value)}</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
this.eList.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
|
||||||
|
checkbox.addEventListener('change', (event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
this.selected.add(event.target.value);
|
||||||
|
} else {
|
||||||
|
this.selected.delete(event.target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isFilterActive() {
|
||||||
|
return this.selected.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getModel() {
|
||||||
|
return this.isFilterActive()
|
||||||
|
? { filterType: 'set', values: Array.from(this.selected) }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModel(model) {
|
||||||
|
this.selected = new Set(model?.values ?? []);
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
doesFilterPass() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================================================
|
||||||
|
// FEATURE TOGGLES — flip these to `false` to switch a feature off
|
||||||
|
// without deleting any of the code below.
|
||||||
|
// ======================================================================
|
||||||
|
const ENABLE_CURRENCY_FORMATTING = true; // controls Net Value USD formatting (2dp, brackets for negatives)
|
||||||
|
const ENABLE_FLAG_COLOURS = true; // controls green/red cell colours on *Flag columns
|
||||||
|
|
||||||
|
const columnDefs = [
|
||||||
|
// {
|
||||||
|
// colId: 'row_number',
|
||||||
|
// headerName: 'Row',
|
||||||
|
// field: 'row_number',
|
||||||
|
// width: 110,
|
||||||
|
// pinned: 'left',
|
||||||
|
// lockPinned: true,
|
||||||
|
// sortable: true,
|
||||||
|
// filter: 'agNumberColumnFilter',
|
||||||
|
// floatingFilter: false,
|
||||||
|
// resizable: true,
|
||||||
|
// sort: 'asc',
|
||||||
|
// suppressHeaderMenuButton: false,
|
||||||
|
// suppressMovable: true,
|
||||||
|
// },
|
||||||
|
...headerLabels.map((header) => {
|
||||||
|
const isCurrency = currencyHeaderPattern.test(header);
|
||||||
|
const isFlag = flagHeaderPattern.test(header);
|
||||||
|
|
||||||
|
const columnDef = {
|
||||||
|
colId: header,
|
||||||
|
headerName: header,
|
||||||
|
headerComponent: PinnableHeader,
|
||||||
|
valueGetter: (params) => (params.data ? (params.data[header] ?? '') : ''),
|
||||||
|
minWidth: isCurrency ? 140 : 160,
|
||||||
|
sortable: true,
|
||||||
|
resizable: true,
|
||||||
|
suppressHeaderMenuButton: false,
|
||||||
|
hide: header === 'x',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCurrency) {
|
||||||
|
columnDef.filter = 'agNumberColumnFilter';
|
||||||
|
columnDef.floatingFilter = false;
|
||||||
|
|
||||||
|
// --- NET VALUE USD FORMATTING ---------------------------
|
||||||
|
// Set ENABLE_CURRENCY_FORMATTING (above) to false to show
|
||||||
|
// the raw numeric value instead of the 2dp/bracket format.
|
||||||
|
if (ENABLE_CURRENCY_FORMATTING) {
|
||||||
|
columnDef.valueFormatter = (params) => formatCurrency(params.value);
|
||||||
|
columnDef.cellClass = 'text-right font-mono tabular-nums';
|
||||||
|
}
|
||||||
|
// --- END NET VALUE USD FORMATTING -----------------------
|
||||||
|
} else {
|
||||||
|
columnDef.filter = ExcelStyleFilter;
|
||||||
|
columnDef.floatingFilter = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FLAG COLOURS (Y/F green, N/D red) ----------------------
|
||||||
|
// Set ENABLE_FLAG_COLOURS (above) to false to disable the
|
||||||
|
// green/red cell backgrounds on columns ending in "Flag".
|
||||||
|
if (isFlag && ENABLE_FLAG_COLOURS) {
|
||||||
|
columnDef.cellClassRules = {
|
||||||
|
'cell-flag-positive': (params) => FLAG_POSITIVE.has(String(params.value).trim().toUpperCase()),
|
||||||
|
'cell-flag-negative': (params) => FLAG_NEGATIVE.has(String(params.value).trim().toUpperCase()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// --- END FLAG COLOURS ----------------------------------------
|
||||||
|
|
||||||
|
return columnDef;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const datasource = {
|
||||||
|
getRows: async (params) => {
|
||||||
|
const url = new URL(window.APP_CONFIG.tableDataUrl, window.location.origin);
|
||||||
|
url.searchParams.set('startRow', String(params.startRow ?? 0));
|
||||||
|
url.searchParams.set('endRow', String(params.endRow ?? ((params.startRow ?? 0) + 100)));
|
||||||
|
url.searchParams.set('search', currentSearch);
|
||||||
|
url.searchParams.set('sortModel', JSON.stringify(params.sortModel ?? []));
|
||||||
|
url.searchParams.set('filterModel', JSON.stringify(params.filterModel ?? {}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || payload.error) {
|
||||||
|
throw new Error(payload.error || `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||||
|
const lastRow = Number.isFinite(Number(payload.lastRow)) ? Number(payload.lastRow) : rows.length;
|
||||||
|
params.successCallback(rows, lastRow);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
params.failCallback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDatasource = () => {
|
||||||
|
if (!gridApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof gridApi.setGridOption === 'function') {
|
||||||
|
gridApi.setGridOption('datasource', datasource);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof gridApi.setDatasource === 'function') {
|
||||||
|
gridApi.setDatasource(datasource);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshGrid = () => {
|
||||||
|
if (!gridApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDatasource();
|
||||||
|
|
||||||
|
if (typeof gridApi.refreshInfiniteCache === 'function') {
|
||||||
|
gridApi.refreshInfiniteCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof gridApi.purgeInfiniteCache === 'function') {
|
||||||
|
gridApi.purgeInfiniteCache();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVisibilityMenu = () => {
|
||||||
|
if (!visibilityItems) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = columnDefs
|
||||||
|
.filter((column) => column.colId !== 'row_number')
|
||||||
|
.map((column) => `
|
||||||
|
<label class="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
|
||||||
|
<input class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" type="checkbox" data-col-id="${escapeHtml(column.colId)}" ${column.hide ? '' : 'checked'}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">${escapeHtml(column.headerName ?? column.colId)}</span>
|
||||||
|
</label>
|
||||||
|
`);
|
||||||
|
|
||||||
|
visibilityItems.innerHTML = items.join('');
|
||||||
|
visibilityItems.querySelectorAll('input[type="checkbox"][data-col-id]').forEach((checkbox) => {
|
||||||
|
checkbox.addEventListener('change', function () {
|
||||||
|
if (!gridApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colId = this.getAttribute('data-col-id');
|
||||||
|
gridApi.applyColumnState({
|
||||||
|
state: [
|
||||||
|
{
|
||||||
|
colId,
|
||||||
|
hide: !this.checked,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
applyOrder: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVisibilitySearch = () => {
|
||||||
|
if (!visibilitySearch || !visibilityItems) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = visibilitySearch.value.trim().toLowerCase();
|
||||||
|
visibilityItems.querySelectorAll('label').forEach((item) => {
|
||||||
|
const text = item.textContent.trim().toLowerCase();
|
||||||
|
item.classList.toggle('hidden', filter !== '' && !text.includes(filter));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
window.clearTimeout(searchTimer);
|
||||||
|
currentSearch = this.value.trim();
|
||||||
|
searchTimer = window.setTimeout(() => refreshGrid(), 180);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibilityButton) {
|
||||||
|
visibilityButton.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleVisibilityMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibilityMenu) {
|
||||||
|
visibilityMenu.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibilitySearch) {
|
||||||
|
visibilitySearch.addEventListener('input', updateVisibilitySearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blurToggleButton) {
|
||||||
|
const BLUR_STORAGE_KEY = 'warner:tableBlurred';
|
||||||
|
|
||||||
|
const setBlurred = (blurred) => {
|
||||||
|
gridElement.classList.toggle('is-privacy-blurred', blurred);
|
||||||
|
blurToggleButton.setAttribute('aria-pressed', String(blurred));
|
||||||
|
blurToggleButton.classList.toggle('is-active', blurred);
|
||||||
|
const label = blurToggleButton.querySelector('[data-blur-label]');
|
||||||
|
if (label) {
|
||||||
|
label.textContent = blurred ? 'Unblur data' : 'Blur data';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(BLUR_STORAGE_KEY, blurred ? '1' : '0');
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore storage errors (e.g. private browsing).
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let initiallyBlurred = false;
|
||||||
|
try {
|
||||||
|
initiallyBlurred = window.localStorage.getItem(BLUR_STORAGE_KEY) === '1';
|
||||||
|
} catch (error) {
|
||||||
|
initiallyBlurred = false;
|
||||||
|
}
|
||||||
|
setBlurred(initiallyBlurred);
|
||||||
|
|
||||||
|
blurToggleButton.addEventListener('click', () => {
|
||||||
|
setBlurred(!gridElement.classList.contains('is-privacy-blurred'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (visibilityMenu && visibilityButton && !visibilityMenu.contains(event.target) && !visibilityButton.contains(event.target)) {
|
||||||
|
closeVisibilityMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridOptions = {
|
||||||
|
columnDefs,
|
||||||
|
defaultColDef: {
|
||||||
|
sortable: true,
|
||||||
|
resizable: true,
|
||||||
|
filter: true,
|
||||||
|
floatingFilter: false,
|
||||||
|
suppressHeaderMenuButton: false,
|
||||||
|
minWidth: 140,
|
||||||
|
},
|
||||||
|
rowModelType: 'infinite',
|
||||||
|
cacheBlockSize: 100,
|
||||||
|
maxBlocksInCache: 5,
|
||||||
|
infiniteInitialRowCount: 1,
|
||||||
|
pagination: true,
|
||||||
|
paginationPageSize: 100,
|
||||||
|
animateRows: false,
|
||||||
|
datasource,
|
||||||
|
getRowId: (params) => String(params.data?.row_number ?? ''),
|
||||||
|
onGridReady: (params) => {
|
||||||
|
gridApi = params.api;
|
||||||
|
setDatasource();
|
||||||
|
updateVisibilityMenu();
|
||||||
|
refreshGrid();
|
||||||
|
},
|
||||||
|
// Refreshes header icons (funnel active-state) whenever any filter
|
||||||
|
// changes, including from the custom Excel-style filter popup.
|
||||||
|
onFilterChanged: () => {
|
||||||
|
if (gridApi) {
|
||||||
|
gridApi.refreshHeader();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
agGrid.createGrid(gridElement, gridOptions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
<?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 ComposerAutoloaderInit10aa853b22dc2b485c671be2f56f1240::getLoader();
|
||||||
Vendored
+579
@@ -0,0 +1,579 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+396
@@ -0,0 +1,396 @@
|
|||||||
|
<?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
|
||||||
|
*/
|
||||||
|
private static $installedIsLocalDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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();
|
||||||
|
|
||||||
|
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
|
||||||
|
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
|
||||||
|
// so we have to assume it does not, and that may result in duplicate data being returned when listing
|
||||||
|
// all installed packages for example
|
||||||
|
self::$installedIsLocalDir = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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) {
|
||||||
|
$selfDir = self::getSelfDir();
|
||||||
|
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||||
|
$vendorDir = strtr($vendorDir, '\\', '/');
|
||||||
|
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 (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
|
||||||
|
self::$installed = $required;
|
||||||
|
self::$installedIsLocalDir = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_classmap.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||||
|
);
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_namespaces.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
);
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_psr4.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/src'),
|
||||||
|
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
|
||||||
|
'PhpOffice\\PhpSpreadsheet\\' => array($vendorDir . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet'),
|
||||||
|
'OpenSpout\\' => array($vendorDir . '/openspout/openspout/src'),
|
||||||
|
'Matrix\\' => array($vendorDir . '/markbaker/matrix/classes/src'),
|
||||||
|
'Composer\\Pcre\\' => array($vendorDir . '/composer/pcre/src'),
|
||||||
|
'Complex\\' => array($vendorDir . '/markbaker/complex/classes/src'),
|
||||||
|
);
|
||||||
Vendored
+38
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_real.php @generated by Composer
|
||||||
|
|
||||||
|
class ComposerAutoloaderInit10aa853b22dc2b485c671be2f56f1240
|
||||||
|
{
|
||||||
|
private static $loader;
|
||||||
|
|
||||||
|
public static function loadClassLoader($class)
|
||||||
|
{
|
||||||
|
if ('Composer\Autoload\ClassLoader' === $class) {
|
||||||
|
require __DIR__ . '/ClassLoader.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Composer\Autoload\ClassLoader
|
||||||
|
*/
|
||||||
|
public static function getLoader()
|
||||||
|
{
|
||||||
|
if (null !== self::$loader) {
|
||||||
|
return self::$loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . '/platform_check.php';
|
||||||
|
|
||||||
|
spl_autoload_register(array('ComposerAutoloaderInit10aa853b22dc2b485c671be2f56f1240', 'loadClassLoader'), true, true);
|
||||||
|
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||||
|
spl_autoload_unregister(array('ComposerAutoloaderInit10aa853b22dc2b485c671be2f56f1240', 'loadClassLoader'));
|
||||||
|
|
||||||
|
require __DIR__ . '/autoload_static.php';
|
||||||
|
call_user_func(\Composer\Autoload\ComposerStaticInit10aa853b22dc2b485c671be2f56f1240::getInitializer($loader));
|
||||||
|
|
||||||
|
$loader->register(true);
|
||||||
|
|
||||||
|
return $loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+78
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_static.php @generated by Composer
|
||||||
|
|
||||||
|
namespace Composer\Autoload;
|
||||||
|
|
||||||
|
class ComposerStaticInit10aa853b22dc2b485c671be2f56f1240
|
||||||
|
{
|
||||||
|
public static $prefixLengthsPsr4 = array (
|
||||||
|
'Z' =>
|
||||||
|
array (
|
||||||
|
'ZipStream\\' => 10,
|
||||||
|
),
|
||||||
|
'P' =>
|
||||||
|
array (
|
||||||
|
'Psr\\SimpleCache\\' => 16,
|
||||||
|
'PhpOffice\\PhpSpreadsheet\\' => 25,
|
||||||
|
),
|
||||||
|
'O' =>
|
||||||
|
array (
|
||||||
|
'OpenSpout\\' => 10,
|
||||||
|
),
|
||||||
|
'M' =>
|
||||||
|
array (
|
||||||
|
'Matrix\\' => 7,
|
||||||
|
),
|
||||||
|
'C' =>
|
||||||
|
array (
|
||||||
|
'Composer\\Pcre\\' => 14,
|
||||||
|
'Complex\\' => 8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
public static $prefixDirsPsr4 = array (
|
||||||
|
'ZipStream\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/maennchen/zipstream-php/src',
|
||||||
|
),
|
||||||
|
'Psr\\SimpleCache\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/psr/simple-cache/src',
|
||||||
|
),
|
||||||
|
'PhpOffice\\PhpSpreadsheet\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet',
|
||||||
|
),
|
||||||
|
'OpenSpout\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/openspout/openspout/src',
|
||||||
|
),
|
||||||
|
'Matrix\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/markbaker/matrix/classes/src',
|
||||||
|
),
|
||||||
|
'Composer\\Pcre\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/composer/pcre/src',
|
||||||
|
),
|
||||||
|
'Complex\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/markbaker/complex/classes/src',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
public static $classMap = array (
|
||||||
|
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||||
|
);
|
||||||
|
|
||||||
|
public static function getInitializer(ClassLoader $loader)
|
||||||
|
{
|
||||||
|
return \Closure::bind(function () use ($loader) {
|
||||||
|
$loader->prefixLengthsPsr4 = ComposerStaticInit10aa853b22dc2b485c671be2f56f1240::$prefixLengthsPsr4;
|
||||||
|
$loader->prefixDirsPsr4 = ComposerStaticInit10aa853b22dc2b485c671be2f56f1240::$prefixDirsPsr4;
|
||||||
|
$loader->classMap = ComposerStaticInit10aa853b22dc2b485c671be2f56f1240::$classMap;
|
||||||
|
|
||||||
|
}, null, ClassLoader::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+538
@@ -0,0 +1,538 @@
|
|||||||
|
{
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "composer/pcre",
|
||||||
|
"version": "3.4.0",
|
||||||
|
"version_normalized": "3.4.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/pcre.git",
|
||||||
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan": "<2.2.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "^9"
|
||||||
|
},
|
||||||
|
"time": "2026-06-07T11:47:49+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||||
|
"keywords": [
|
||||||
|
"PCRE",
|
||||||
|
"preg",
|
||||||
|
"regex",
|
||||||
|
"regular expression"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
|
"source": "https://github.com/composer/pcre/tree/3.4.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"install-path": "./pcre"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maennchen/zipstream-php",
|
||||||
|
"version": "3.2.2",
|
||||||
|
"version_normalized": "3.2.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"php-64bit": "^8.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.7",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
|
"mikey179/vfsstream": "^1.6",
|
||||||
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
|
"phpunit/phpunit": "^12.0",
|
||||||
|
"vimeo/psalm": "^6.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"guzzlehttp/psr7": "^2.4",
|
||||||
|
"psr/http-message": "^2.0"
|
||||||
|
},
|
||||||
|
"time": "2026-04-11T18:38:28+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ZipStream\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paul Duncan",
|
||||||
|
"email": "pabs@pablotron.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonatan Männchen",
|
||||||
|
"email": "jonatan@maennchen.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Donat",
|
||||||
|
"email": "donatj@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "András Kolesár",
|
||||||
|
"email": "kolesar@kolesar.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||||
|
"keywords": [
|
||||||
|
"stream",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||||
|
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/maennchen",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"install-path": "../maennchen/zipstream-php"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/complex",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"version_normalized": "3.0.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"time": "2022-12-06T16:21:08+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Complex\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@lange.demon.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with complex numbers",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||||
|
"keywords": [
|
||||||
|
"complex",
|
||||||
|
"mathematics"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||||
|
},
|
||||||
|
"install-path": "../markbaker/complex"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/matrix",
|
||||||
|
"version": "3.0.1",
|
||||||
|
"version_normalized": "3.0.1.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpdocumentor/phpdocumentor": "2.*",
|
||||||
|
"phploc/phploc": "^4.0",
|
||||||
|
"phpmd/phpmd": "2.*",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"sebastian/phpcpd": "^4.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"time": "2022-12-02T22:17:43+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Matrix\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@demon-angel.eu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with matrices",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||||
|
"keywords": [
|
||||||
|
"mathematics",
|
||||||
|
"matrix",
|
||||||
|
"vector"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||||
|
},
|
||||||
|
"install-path": "../markbaker/matrix"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "openspout/openspout",
|
||||||
|
"version": "v4.32.0",
|
||||||
|
"version_normalized": "4.32.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/openspout/openspout.git",
|
||||||
|
"reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/openspout/openspout/zipball/41f045c1f632e1474e15d4c7bc3abcb4a153563d",
|
||||||
|
"reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"php": "~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86.0",
|
||||||
|
"infection/infection": "^0.31.2",
|
||||||
|
"phpbench/phpbench": "^1.4.1",
|
||||||
|
"phpstan/phpstan": "^2.1.22",
|
||||||
|
"phpstan/phpstan-phpunit": "^2.0.7",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2.0.6",
|
||||||
|
"phpunit/phpunit": "^12.3.7"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)",
|
||||||
|
"ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)"
|
||||||
|
},
|
||||||
|
"time": "2025-09-03T16:03:54+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"OpenSpout\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Adrien Loison",
|
||||||
|
"email": "adrien@box.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way",
|
||||||
|
"homepage": "https://github.com/openspout/openspout",
|
||||||
|
"keywords": [
|
||||||
|
"OOXML",
|
||||||
|
"csv",
|
||||||
|
"excel",
|
||||||
|
"memory",
|
||||||
|
"odf",
|
||||||
|
"ods",
|
||||||
|
"office",
|
||||||
|
"open",
|
||||||
|
"php",
|
||||||
|
"read",
|
||||||
|
"scale",
|
||||||
|
"spreadsheet",
|
||||||
|
"stream",
|
||||||
|
"write",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/openspout/openspout/issues",
|
||||||
|
"source": "https://github.com/openspout/openspout/tree/v4.32.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://paypal.me/filippotessarotto",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Slamdunk",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"install-path": "../openspout/openspout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phpoffice/phpspreadsheet",
|
||||||
|
"version": "3.10.6",
|
||||||
|
"version_normalized": "3.10.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
|
"reference": "8de8215d6ff984f8db77a4a49dc089a396209e1b"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/8de8215d6ff984f8db77a4a49dc089a396209e1b",
|
||||||
|
"reference": "8de8215d6ff984f8db77a4a49dc089a396209e1b",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/pcre": "^1 || ^2 || ^3",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
|
"markbaker/complex": "^3.0",
|
||||||
|
"markbaker/matrix": "^3.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
|
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"mitoteam/jpgraph": "^10.5",
|
||||||
|
"mpdf/mpdf": "^8.1.1",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpstan/phpstan": "^1.1",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0",
|
||||||
|
"phpunit/phpunit": "^10.5",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
|
"tecnickcom/tcpdf": "^6.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"ext-intl": "PHP Internationalization Functions, required for NumberFormatter Wizard",
|
||||||
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
|
},
|
||||||
|
"time": "2026-06-07T02:39:57+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Maarten Balliauw",
|
||||||
|
"homepage": "https://blog.maartenballiauw.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"homepage": "https://markbakeruk.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Franck Lefevre",
|
||||||
|
"homepage": "https://rootslabs.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Erik Tilt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adrien Crivelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owen Leibman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||||
|
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||||
|
"keywords": [
|
||||||
|
"OpenXML",
|
||||||
|
"excel",
|
||||||
|
"gnumeric",
|
||||||
|
"ods",
|
||||||
|
"php",
|
||||||
|
"spreadsheet",
|
||||||
|
"xls",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/3.10.6"
|
||||||
|
},
|
||||||
|
"install-path": "../phpoffice/phpspreadsheet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "psr/simple-cache",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"version_normalized": "3.0.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-fig/simple-cache.git",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"time": "2021-10-29T13:26:27+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Psr\\SimpleCache\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "PHP-FIG",
|
||||||
|
"homepage": "https://www.php-fig.org/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common interfaces for simple caching",
|
||||||
|
"keywords": [
|
||||||
|
"cache",
|
||||||
|
"caching",
|
||||||
|
"psr",
|
||||||
|
"psr-16",
|
||||||
|
"simple-cache"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
|
||||||
|
},
|
||||||
|
"install-path": "../psr/simple-cache"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"dev-package-names": []
|
||||||
|
}
|
||||||
Vendored
+86
@@ -0,0 +1,86 @@
|
|||||||
|
<?php return array(
|
||||||
|
'root' => array(
|
||||||
|
'name' => 'warner/spreadsheet-importer',
|
||||||
|
'pretty_version' => '1.0.0+no-version-set',
|
||||||
|
'version' => '1.0.0.0',
|
||||||
|
'reference' => null,
|
||||||
|
'type' => 'project',
|
||||||
|
'install_path' => __DIR__ . '/../../',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev' => true,
|
||||||
|
),
|
||||||
|
'versions' => array(
|
||||||
|
'composer/pcre' => array(
|
||||||
|
'pretty_version' => '3.4.0',
|
||||||
|
'version' => '3.4.0.0',
|
||||||
|
'reference' => 'd5a341b3fb61f3001970940afb1d332968a183ed',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/./pcre',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'maennchen/zipstream-php' => array(
|
||||||
|
'pretty_version' => '3.2.2',
|
||||||
|
'version' => '3.2.2.0',
|
||||||
|
'reference' => '77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../maennchen/zipstream-php',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'markbaker/complex' => array(
|
||||||
|
'pretty_version' => '3.0.2',
|
||||||
|
'version' => '3.0.2.0',
|
||||||
|
'reference' => '95c56caa1cf5c766ad6d65b6344b807c1e8405b9',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../markbaker/complex',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'markbaker/matrix' => array(
|
||||||
|
'pretty_version' => '3.0.1',
|
||||||
|
'version' => '3.0.1.0',
|
||||||
|
'reference' => '728434227fe21be27ff6d86621a1b13107a2562c',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../markbaker/matrix',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'openspout/openspout' => array(
|
||||||
|
'pretty_version' => 'v4.32.0',
|
||||||
|
'version' => '4.32.0.0',
|
||||||
|
'reference' => '41f045c1f632e1474e15d4c7bc3abcb4a153563d',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../openspout/openspout',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'phpoffice/phpspreadsheet' => array(
|
||||||
|
'pretty_version' => '3.10.6',
|
||||||
|
'version' => '3.10.6.0',
|
||||||
|
'reference' => '8de8215d6ff984f8db77a4a49dc089a396209e1b',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'psr/simple-cache' => array(
|
||||||
|
'pretty_version' => '3.0.0',
|
||||||
|
'version' => '3.0.0.0',
|
||||||
|
'reference' => '764e0b3939f5ca87cb904f570ef9be2d78a07865',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../psr/simple-cache',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'warner/spreadsheet-importer' => array(
|
||||||
|
'pretty_version' => '1.0.0+no-version-set',
|
||||||
|
'version' => '1.0.0.0',
|
||||||
|
'reference' => null,
|
||||||
|
'type' => 'project',
|
||||||
|
'install_path' => __DIR__ . '/../../',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (C) 2021 Composer
|
||||||
|
|
||||||
|
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.
|
||||||
Vendored
+189
@@ -0,0 +1,189 @@
|
|||||||
|
composer/pcre
|
||||||
|
=============
|
||||||
|
|
||||||
|
PCRE wrapping library that offers type-safe `preg_*` replacements.
|
||||||
|
|
||||||
|
This library gives you a way to ensure `preg_*` functions do not fail silently, returning
|
||||||
|
unexpected `null`s that may not be handled.
|
||||||
|
|
||||||
|
As of 3.0 this library enforces [`PREG_UNMATCHED_AS_NULL`](#preg_unmatched_as_null) usage
|
||||||
|
for all matching and replaceCallback functions, [read more below](#preg_unmatched_as_null)
|
||||||
|
to understand the implications.
|
||||||
|
|
||||||
|
It thus makes it easier to work with static analysis tools like PHPStan or Psalm as it
|
||||||
|
simplifies and reduces the possible return values from all the `preg_*` functions which
|
||||||
|
are quite packed with edge cases. As of v2.2.0 / v3.2.0 the library also comes with a
|
||||||
|
[PHPStan extension](#phpstan-extension) for parsing regular expressions and giving you even better output types.
|
||||||
|
|
||||||
|
This library is a thin wrapper around `preg_*` functions with [some limitations](#restrictions--limitations).
|
||||||
|
If you are looking for a richer API to handle regular expressions have a look at
|
||||||
|
[rawr/t-regx](https://packagist.org/packages/rawr/t-regx) instead.
|
||||||
|
|
||||||
|
[](https://github.com/composer/pcre/actions)
|
||||||
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
------------
|
||||||
|
|
||||||
|
Install the latest version with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ composer require composer/pcre
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
------------
|
||||||
|
|
||||||
|
* PHP 7.4.0 is required for 3.x versions
|
||||||
|
* PHP 7.2.0 is required for 2.x versions
|
||||||
|
* PHP 5.3.2 is required for 1.x versions
|
||||||
|
|
||||||
|
|
||||||
|
Basic usage
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Instead of:
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (preg_match('{fo+}', $string, $matches)) { ... }
|
||||||
|
if (preg_match('{fo+}', $string, $matches, PREG_OFFSET_CAPTURE)) { ... }
|
||||||
|
if (preg_match_all('{fo+}', $string, $matches)) { ... }
|
||||||
|
$newString = preg_replace('{fo+}', 'bar', $string);
|
||||||
|
$newString = preg_replace_callback('{fo+}', function ($match) { return strtoupper($match[0]); }, $string);
|
||||||
|
$newString = preg_replace_callback_array(['{fo+}' => fn ($match) => strtoupper($match[0])], $string);
|
||||||
|
$filtered = preg_grep('{[a-z]}', $elements);
|
||||||
|
$array = preg_split('{[a-z]+}', $string);
|
||||||
|
```
|
||||||
|
|
||||||
|
You can now call these on the `Preg` class:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Composer\Pcre\Preg;
|
||||||
|
|
||||||
|
if (Preg::match('{fo+}', $string, $matches)) { ... }
|
||||||
|
if (Preg::matchWithOffsets('{fo+}', $string, $matches)) { ... }
|
||||||
|
if (Preg::matchAll('{fo+}', $string, $matches)) { ... }
|
||||||
|
$newString = Preg::replace('{fo+}', 'bar', $string);
|
||||||
|
$newString = Preg::replaceCallback('{fo+}', function ($match) { return strtoupper($match[0]); }, $string);
|
||||||
|
$newString = Preg::replaceCallbackArray(['{fo+}' => fn ($match) => strtoupper($match[0])], $string);
|
||||||
|
$filtered = Preg::grep('{[a-z]}', $elements);
|
||||||
|
$array = Preg::split('{[a-z]+}', $string);
|
||||||
|
```
|
||||||
|
|
||||||
|
The main difference is if anything fails to match/replace/.., it will throw a `Composer\Pcre\PcreException`
|
||||||
|
instead of returning `null` (or false in some cases), so you can now use the return values safely relying on
|
||||||
|
the fact that they can only be strings (for replace), ints (for match) or arrays (for grep/split).
|
||||||
|
|
||||||
|
Additionally the `Preg` class provides match methods that return `bool` rather than `int`, for stricter type safety
|
||||||
|
when the number of pattern matches is not useful:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Composer\Pcre\Preg;
|
||||||
|
|
||||||
|
if (Preg::isMatch('{fo+}', $string, $matches)) // bool
|
||||||
|
if (Preg::isMatchAll('{fo+}', $string, $matches)) // bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally the `Preg` class provides a few `*StrictGroups` method variants that ensure match groups
|
||||||
|
are always present and thus non-nullable, making it easier to write type-safe code:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Composer\Pcre\Preg;
|
||||||
|
|
||||||
|
// $matches is guaranteed to be an array of strings, if a subpattern does not match and produces a null it will throw
|
||||||
|
if (Preg::matchStrictGroups('{fo+}', $string, $matches))
|
||||||
|
if (Preg::matchAllStrictGroups('{fo+}', $string, $matches))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** This is generally safe to use as long as you do not have optional subpatterns (i.e. `(something)?`
|
||||||
|
or `(something)*` or branches with a `|` that result in some groups not being matched at all).
|
||||||
|
A subpattern that can match an empty string like `(.*)` is **not** optional, it will be present as an
|
||||||
|
empty string in the matches. A non-matching subpattern, even if optional like `(?:foo)?` will anyway not be present in
|
||||||
|
matches so it is also not a problem to use these with `*StrictGroups` methods.
|
||||||
|
|
||||||
|
If you would prefer a slightly more verbose usage, replacing by-ref arguments by result objects, you can use the `Regex` class:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Composer\Pcre\Regex;
|
||||||
|
|
||||||
|
// this is useful when you are just interested in knowing if something matched
|
||||||
|
// as it returns a bool instead of int(1/0) for match
|
||||||
|
$bool = Regex::isMatch('{fo+}', $string);
|
||||||
|
|
||||||
|
$result = Regex::match('{fo+}', $string);
|
||||||
|
if ($result->matched) { something($result->matches); }
|
||||||
|
|
||||||
|
$result = Regex::matchWithOffsets('{fo+}', $string);
|
||||||
|
if ($result->matched) { something($result->matches); }
|
||||||
|
|
||||||
|
$result = Regex::matchAll('{fo+}', $string);
|
||||||
|
if ($result->matched && $result->count > 3) { something($result->matches); }
|
||||||
|
|
||||||
|
$newString = Regex::replace('{fo+}', 'bar', $string)->result;
|
||||||
|
$newString = Regex::replaceCallback('{fo+}', function ($match) { return strtoupper($match[0]); }, $string)->result;
|
||||||
|
$newString = Regex::replaceCallbackArray(['{fo+}' => fn ($match) => strtoupper($match[0])], $string)->result;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that `preg_grep` and `preg_split` are only callable via the `Preg` class as they do not have
|
||||||
|
complex return types warranting a specific result object.
|
||||||
|
|
||||||
|
See the [MatchResult](src/MatchResult.php), [MatchWithOffsetsResult](src/MatchWithOffsetsResult.php), [MatchAllResult](src/MatchAllResult.php),
|
||||||
|
[MatchAllWithOffsetsResult](src/MatchAllWithOffsetsResult.php), and [ReplaceResult](src/ReplaceResult.php) class sources for more details.
|
||||||
|
|
||||||
|
Restrictions / Limitations
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
Due to type safety requirements a few restrictions are in place.
|
||||||
|
|
||||||
|
- matching using `PREG_OFFSET_CAPTURE` is made available via `matchWithOffsets` and `matchAllWithOffsets`.
|
||||||
|
You cannot pass the flag to `match`/`matchAll`.
|
||||||
|
- `Preg::split` will also reject `PREG_SPLIT_OFFSET_CAPTURE` and you should use `splitWithOffsets`
|
||||||
|
instead.
|
||||||
|
- `matchAll` rejects `PREG_SET_ORDER` as it also changes the shape of the returned matches. There
|
||||||
|
is no alternative provided as you can fairly easily code around it.
|
||||||
|
- `preg_filter` is not supported as it has a rather crazy API, most likely you should rather
|
||||||
|
use `Preg::grep` in combination with some loop and `Preg::replace`.
|
||||||
|
- `replace`, `replaceCallback` and `replaceCallbackArray` do not support an array `$subject`,
|
||||||
|
only simple strings.
|
||||||
|
- As of 2.0, the library always uses `PREG_UNMATCHED_AS_NULL` for matching, which offers [much
|
||||||
|
saner/more predictable results](#preg_unmatched_as_null). As of 3.0 the flag is also set for
|
||||||
|
`replaceCallback` and `replaceCallbackArray`.
|
||||||
|
|
||||||
|
#### PREG_UNMATCHED_AS_NULL
|
||||||
|
|
||||||
|
As of 2.0, this library always uses PREG_UNMATCHED_AS_NULL for all `match*` and `isMatch*`
|
||||||
|
functions. As of 3.0 it is also done for `replaceCallback` and `replaceCallbackArray`.
|
||||||
|
|
||||||
|
This means your matches will always contain all matching groups, either as null if unmatched
|
||||||
|
or as string if it matched.
|
||||||
|
|
||||||
|
The advantages in clarity and predictability are clearer if you compare the two outputs of
|
||||||
|
running this with and without PREG_UNMATCHED_AS_NULL in $flags:
|
||||||
|
|
||||||
|
```php
|
||||||
|
preg_match('/(a)(b)*(c)(d)*/', 'ac', $matches, $flags);
|
||||||
|
```
|
||||||
|
|
||||||
|
| no flag | PREG_UNMATCHED_AS_NULL |
|
||||||
|
| --- | --- |
|
||||||
|
| array (size=4) | array (size=5) |
|
||||||
|
| 0 => string 'ac' (length=2) | 0 => string 'ac' (length=2) |
|
||||||
|
| 1 => string 'a' (length=1) | 1 => string 'a' (length=1) |
|
||||||
|
| 2 => string '' (length=0) | 2 => null |
|
||||||
|
| 3 => string 'c' (length=1) | 3 => string 'c' (length=1) |
|
||||||
|
| | 4 => null |
|
||||||
|
| group 2 (any unmatched group preceding one that matched) is set to `''`. You cannot tell if it matched an empty string or did not match at all | group 2 is `null` when unmatched and a string if it matched, easy to check for |
|
||||||
|
| group 4 (any optional group without a matching one following) is missing altogether. So you have to check with `isset()`, but really you want `isset($m[4]) && $m[4] !== ''` for safety unless you are very careful to check that a non-optional group follows it | group 4 is always set, and null in this case as there was no match, easy to check for with `$m[4] !== null` |
|
||||||
|
|
||||||
|
PHPStan Extension
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
To use the PHPStan extension if you do not use `phpstan/extension-installer` you can include `vendor/composer/pcre/extension.neon` in your PHPStan config.
|
||||||
|
|
||||||
|
The extension provides much better type information for $matches as well as regex validation where possible.
|
||||||
|
|
||||||
|
License
|
||||||
|
-------
|
||||||
|
|
||||||
|
composer/pcre is licensed under the MIT License, see the LICENSE file for details.
|
||||||
Vendored
+58
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "composer/pcre",
|
||||||
|
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||||
|
"type": "library",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"pcre",
|
||||||
|
"regex",
|
||||||
|
"preg",
|
||||||
|
"regular expression"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9",
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan": "<2.2.2"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "tests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
},
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": [
|
||||||
|
"@php vendor/bin/phpunit",
|
||||||
|
"@php vendor/bin/phpunit --testsuite phpstan"
|
||||||
|
],
|
||||||
|
"phpstan": "@php phpstan analyse"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# composer/pcre PHPStan extensions
|
||||||
|
#
|
||||||
|
# These can be reused by third party packages by including 'vendor/composer/pcre/extension.neon'
|
||||||
|
# in your phpstan config
|
||||||
|
|
||||||
|
services:
|
||||||
|
-
|
||||||
|
class: Composer\Pcre\PHPStan\PregMatchParameterOutTypeExtension
|
||||||
|
tags:
|
||||||
|
- phpstan.staticMethodParameterOutTypeExtension
|
||||||
|
-
|
||||||
|
class: Composer\Pcre\PHPStan\PregMatchTypeSpecifyingExtension
|
||||||
|
tags:
|
||||||
|
- phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension
|
||||||
|
-
|
||||||
|
class: Composer\Pcre\PHPStan\PregReplaceCallbackClosureTypeExtension
|
||||||
|
tags:
|
||||||
|
- phpstan.staticMethodParameterClosureTypeExtension
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- Composer\Pcre\PHPStan\UnsafeStrictGroupsCallRule
|
||||||
|
- Composer\Pcre\PHPStan\InvalidRegexPatternRule
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
final class MatchAllResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* An array of match group => list of matched strings
|
||||||
|
*
|
||||||
|
* @readonly
|
||||||
|
* @var array<int|string, list<string|null>>
|
||||||
|
*/
|
||||||
|
public $matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var 0|positive-int
|
||||||
|
*/
|
||||||
|
public $count;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $matched;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param 0|positive-int $count
|
||||||
|
* @param array<int|string, list<string|null>> $matches
|
||||||
|
*/
|
||||||
|
public function __construct(int $count, array $matches)
|
||||||
|
{
|
||||||
|
$this->matches = $matches;
|
||||||
|
$this->matched = (bool) $count;
|
||||||
|
$this->count = $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
final class MatchAllStrictGroupsResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* An array of match group => list of matched strings
|
||||||
|
*
|
||||||
|
* @readonly
|
||||||
|
* @var array<int|string, list<string>>
|
||||||
|
*/
|
||||||
|
public $matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var 0|positive-int
|
||||||
|
*/
|
||||||
|
public $count;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $matched;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param 0|positive-int $count
|
||||||
|
* @param array<list<string>> $matches
|
||||||
|
*/
|
||||||
|
public function __construct(int $count, array $matches)
|
||||||
|
{
|
||||||
|
$this->matches = $matches;
|
||||||
|
$this->matched = (bool) $count;
|
||||||
|
$this->count = $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
final class MatchAllWithOffsetsResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* An array of match group => list of matches, every match being a pair of string matched + offset in bytes (or -1 if no match)
|
||||||
|
*
|
||||||
|
* @readonly
|
||||||
|
* @var array<int|string, list<array{string|null, int}>>
|
||||||
|
* @phpstan-var array<int|string, list<array{string|null, int<-1, max>}>>
|
||||||
|
*/
|
||||||
|
public $matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var 0|positive-int
|
||||||
|
*/
|
||||||
|
public $count;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $matched;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param 0|positive-int $count
|
||||||
|
* @param array<int|string, list<array{string|null, int}>> $matches
|
||||||
|
* @phpstan-param array<int|string, list<array{string|null, int<-1, max>}>> $matches
|
||||||
|
*/
|
||||||
|
public function __construct(int $count, array $matches)
|
||||||
|
{
|
||||||
|
$this->matches = $matches;
|
||||||
|
$this->matched = (bool) $count;
|
||||||
|
$this->count = $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
final class MatchResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* An array of match group => string matched
|
||||||
|
*
|
||||||
|
* @readonly
|
||||||
|
* @var array<int|string, string|null>
|
||||||
|
*/
|
||||||
|
public $matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $matched;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param 0|positive-int $count
|
||||||
|
* @param array<string|null> $matches
|
||||||
|
*/
|
||||||
|
public function __construct(int $count, array $matches)
|
||||||
|
{
|
||||||
|
$this->matches = $matches;
|
||||||
|
$this->matched = (bool) $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
final class MatchStrictGroupsResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* An array of match group => string matched
|
||||||
|
*
|
||||||
|
* @readonly
|
||||||
|
* @var array<int|string, string>
|
||||||
|
*/
|
||||||
|
public $matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $matched;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param 0|positive-int $count
|
||||||
|
* @param array<string> $matches
|
||||||
|
*/
|
||||||
|
public function __construct(int $count, array $matches)
|
||||||
|
{
|
||||||
|
$this->matches = $matches;
|
||||||
|
$this->matched = (bool) $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
final class MatchWithOffsetsResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* An array of match group => pair of string matched + offset in bytes (or -1 if no match)
|
||||||
|
*
|
||||||
|
* @readonly
|
||||||
|
* @var array<int|string, array{string|null, int}>
|
||||||
|
* @phpstan-var array<int|string, array{string|null, int<-1, max>}>
|
||||||
|
*/
|
||||||
|
public $matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $matched;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param 0|positive-int $count
|
||||||
|
* @param array<array{string|null, int}> $matches
|
||||||
|
* @phpstan-param array<int|string, array{string|null, int<-1, max>}> $matches
|
||||||
|
*/
|
||||||
|
public function __construct(int $count, array $matches)
|
||||||
|
{
|
||||||
|
$this->matches = $matches;
|
||||||
|
$this->matched = (bool) $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php declare(strict_types = 1);
|
||||||
|
|
||||||
|
namespace Composer\Pcre\PHPStan;
|
||||||
|
|
||||||
|
use Composer\Pcre\Preg;
|
||||||
|
use Composer\Pcre\Regex;
|
||||||
|
use Composer\Pcre\PcreException;
|
||||||
|
use Nette\Utils\RegexpException;
|
||||||
|
use Nette\Utils\Strings;
|
||||||
|
use PhpParser\Node;
|
||||||
|
use PhpParser\Node\Expr\StaticCall;
|
||||||
|
use PhpParser\Node\Name\FullyQualified;
|
||||||
|
use PHPStan\Analyser\Scope;
|
||||||
|
use PHPStan\Rules\Rule;
|
||||||
|
use PHPStan\Rules\RuleErrorBuilder;
|
||||||
|
use function in_array;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy of PHPStan's RegularExpressionPatternRule
|
||||||
|
*
|
||||||
|
* @implements Rule<StaticCall>
|
||||||
|
*/
|
||||||
|
class InvalidRegexPatternRule implements Rule
|
||||||
|
{
|
||||||
|
public function getNodeType(): string
|
||||||
|
{
|
||||||
|
return StaticCall::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processNode(Node $node, Scope $scope): array
|
||||||
|
{
|
||||||
|
$patterns = $this->extractPatterns($node, $scope);
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
$errorMessage = $this->validatePattern($pattern);
|
||||||
|
if ($errorMessage === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors[] = RuleErrorBuilder::message(sprintf('Regex pattern is invalid: %s', $errorMessage))->identifier('regexp.pattern')->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function extractPatterns(StaticCall $node, Scope $scope): array
|
||||||
|
{
|
||||||
|
if (!$node->class instanceof FullyQualified) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$isRegex = $node->class->toString() === Regex::class;
|
||||||
|
$isPreg = $node->class->toString() === Preg::class;
|
||||||
|
if (!$isRegex && !$isPreg) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!$node->name instanceof Node\Identifier || !Preg::isMatch('{^(match|isMatch|grep|replace|split)}', $node->name->name)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$functionName = $node->name->name;
|
||||||
|
if (!isset($node->getArgs()[0])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$patternNode = $node->getArgs()[0]->value;
|
||||||
|
$patternType = $scope->getType($patternNode);
|
||||||
|
|
||||||
|
$patternStrings = [];
|
||||||
|
|
||||||
|
foreach ($patternType->getConstantStrings() as $constantStringType) {
|
||||||
|
if ($functionName === 'replaceCallbackArray') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$patternStrings[] = $constantStringType->getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($patternType->getConstantArrays() as $constantArrayType) {
|
||||||
|
if (
|
||||||
|
in_array($functionName, [
|
||||||
|
'replace',
|
||||||
|
'replaceCallback',
|
||||||
|
], true)
|
||||||
|
) {
|
||||||
|
foreach ($constantArrayType->getValueTypes() as $arrayKeyType) {
|
||||||
|
foreach ($arrayKeyType->getConstantStrings() as $constantString) {
|
||||||
|
$patternStrings[] = $constantString->getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($functionName !== 'replaceCallbackArray') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($constantArrayType->getKeyTypes() as $arrayKeyType) {
|
||||||
|
foreach ($arrayKeyType->getConstantStrings() as $constantString) {
|
||||||
|
$patternStrings[] = $constantString->getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $patternStrings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatePattern(string $pattern): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$msg = null;
|
||||||
|
$prev = set_error_handler(function (int $severity, string $message, string $file) use (&$msg): bool {
|
||||||
|
$msg = preg_replace("#^preg_match(_all)?\\(.*?\\): #", '', $message);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($pattern === '') {
|
||||||
|
return 'Empty string is not a valid regular expression';
|
||||||
|
}
|
||||||
|
|
||||||
|
Preg::match($pattern, '');
|
||||||
|
if ($msg !== null) {
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
} catch (PcreException $e) {
|
||||||
|
if ($e->getCode() === PREG_INTERNAL_ERROR && $msg !== null) {
|
||||||
|
return $msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_replace('{.*? failed executing ".*": }', '', $e->getMessage());
|
||||||
|
} finally {
|
||||||
|
restore_error_handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Composer\Pcre\PHPStan;
|
||||||
|
|
||||||
|
use PHPStan\Analyser\Scope;
|
||||||
|
use PHPStan\Type\ArrayType;
|
||||||
|
use PHPStan\Type\Constant\ConstantArrayType;
|
||||||
|
use PHPStan\Type\Constant\ConstantIntegerType;
|
||||||
|
use PHPStan\Type\IntersectionType;
|
||||||
|
use PHPStan\Type\TypeCombinator;
|
||||||
|
use PHPStan\Type\Type;
|
||||||
|
use PhpParser\Node\Arg;
|
||||||
|
use PHPStan\Type\Php\RegexArrayShapeMatcher;
|
||||||
|
use PHPStan\Type\TypeTraverser;
|
||||||
|
use PHPStan\Type\UnionType;
|
||||||
|
|
||||||
|
final class PregMatchFlags
|
||||||
|
{
|
||||||
|
static public function getType(?Arg $flagsArg, Scope $scope): ?Type
|
||||||
|
{
|
||||||
|
if ($flagsArg === null) {
|
||||||
|
return new ConstantIntegerType(PREG_UNMATCHED_AS_NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
$flagsType = $scope->getType($flagsArg->value);
|
||||||
|
|
||||||
|
$constantScalars = $flagsType->getConstantScalarValues();
|
||||||
|
if ($constantScalars === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$internalFlagsTypes = [];
|
||||||
|
foreach ($flagsType->getConstantScalarValues() as $constantScalarValue) {
|
||||||
|
if (!is_int($constantScalarValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$internalFlagsTypes[] = new ConstantIntegerType($constantScalarValue | PREG_UNMATCHED_AS_NULL);
|
||||||
|
}
|
||||||
|
return TypeCombinator::union(...$internalFlagsTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function removeNullFromMatches(Type $matchesType): Type
|
||||||
|
{
|
||||||
|
return TypeTraverser::map($matchesType, static function (Type $type, callable $traverse): Type {
|
||||||
|
if ($type instanceof UnionType || $type instanceof IntersectionType) {
|
||||||
|
return $traverse($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type instanceof ConstantArrayType) {
|
||||||
|
return new ConstantArrayType(
|
||||||
|
$type->getKeyTypes(),
|
||||||
|
array_map(static function (Type $valueType) use ($traverse): Type {
|
||||||
|
return $traverse($valueType);
|
||||||
|
}, $type->getValueTypes()),
|
||||||
|
$type->getNextAutoIndexes(),
|
||||||
|
[],
|
||||||
|
$type->isList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type instanceof ArrayType) {
|
||||||
|
return new ArrayType($type->getKeyType(), $traverse($type->getItemType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return TypeCombinator::removeNull($type);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Composer\Pcre\PHPStan;
|
||||||
|
|
||||||
|
use Composer\Pcre\Preg;
|
||||||
|
use PhpParser\Node\Expr\StaticCall;
|
||||||
|
use PHPStan\Analyser\Scope;
|
||||||
|
use PHPStan\Reflection\MethodReflection;
|
||||||
|
use PHPStan\Reflection\ParameterReflection;
|
||||||
|
use PHPStan\TrinaryLogic;
|
||||||
|
use PHPStan\Type\Php\RegexArrayShapeMatcher;
|
||||||
|
use PHPStan\Type\StaticMethodParameterOutTypeExtension;
|
||||||
|
use PHPStan\Type\Type;
|
||||||
|
|
||||||
|
final class PregMatchParameterOutTypeExtension implements StaticMethodParameterOutTypeExtension
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var RegexArrayShapeMatcher
|
||||||
|
*/
|
||||||
|
private $regexShapeMatcher;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
RegexArrayShapeMatcher $regexShapeMatcher
|
||||||
|
)
|
||||||
|
{
|
||||||
|
$this->regexShapeMatcher = $regexShapeMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$methodReflection->getDeclaringClass()->getName() === Preg::class
|
||||||
|
&& in_array($methodReflection->getName(), [
|
||||||
|
'match', 'isMatch', 'matchStrictGroups', 'isMatchStrictGroups',
|
||||||
|
'matchAll', 'isMatchAll', 'matchAllStrictGroups', 'isMatchAllStrictGroups'
|
||||||
|
], true)
|
||||||
|
&& $parameter->getName() === 'matches';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParameterOutTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type
|
||||||
|
{
|
||||||
|
$args = $methodCall->getArgs();
|
||||||
|
$patternArg = $args[0] ?? null;
|
||||||
|
$matchesArg = $args[2] ?? null;
|
||||||
|
$flagsArg = $args[3] ?? null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
$patternArg === null || $matchesArg === null
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flagsType = PregMatchFlags::getType($flagsArg, $scope);
|
||||||
|
if ($flagsType === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripos($methodReflection->getName(), 'matchAll') !== false) {
|
||||||
|
return $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Composer\Pcre\PHPStan;
|
||||||
|
|
||||||
|
use Composer\Pcre\Preg;
|
||||||
|
use PhpParser\Node\Expr\StaticCall;
|
||||||
|
use PHPStan\Analyser\Scope;
|
||||||
|
use PHPStan\Analyser\SpecifiedTypes;
|
||||||
|
use PHPStan\Analyser\TypeSpecifier;
|
||||||
|
use PHPStan\Analyser\TypeSpecifierAwareExtension;
|
||||||
|
use PHPStan\Analyser\TypeSpecifierContext;
|
||||||
|
use PHPStan\Reflection\MethodReflection;
|
||||||
|
use PHPStan\TrinaryLogic;
|
||||||
|
use PHPStan\Type\Constant\ConstantArrayType;
|
||||||
|
use PHPStan\Type\Php\RegexArrayShapeMatcher;
|
||||||
|
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
|
||||||
|
use PHPStan\Type\TypeCombinator;
|
||||||
|
use PHPStan\Type\Type;
|
||||||
|
|
||||||
|
final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var TypeSpecifier
|
||||||
|
*/
|
||||||
|
private $typeSpecifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var RegexArrayShapeMatcher
|
||||||
|
*/
|
||||||
|
private $regexShapeMatcher;
|
||||||
|
|
||||||
|
public function __construct(RegexArrayShapeMatcher $regexShapeMatcher)
|
||||||
|
{
|
||||||
|
$this->regexShapeMatcher = $regexShapeMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
|
||||||
|
{
|
||||||
|
$this->typeSpecifier = $typeSpecifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClass(): string
|
||||||
|
{
|
||||||
|
return Preg::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStaticMethodSupported(MethodReflection $methodReflection, StaticCall $node, TypeSpecifierContext $context): bool
|
||||||
|
{
|
||||||
|
return in_array($methodReflection->getName(), [
|
||||||
|
'match', 'isMatch', 'matchStrictGroups', 'isMatchStrictGroups',
|
||||||
|
'matchAll', 'isMatchAll', 'matchAllStrictGroups', 'isMatchAllStrictGroups'
|
||||||
|
], true)
|
||||||
|
&& !$context->null();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function specifyTypes(MethodReflection $methodReflection, StaticCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
|
||||||
|
{
|
||||||
|
$args = $node->getArgs();
|
||||||
|
$patternArg = $args[0] ?? null;
|
||||||
|
$subjectArg = $args[1] ?? null;
|
||||||
|
$matchesArg = $args[2] ?? null;
|
||||||
|
$flagsArg = $args[3] ?? null;
|
||||||
|
|
||||||
|
$subjectTypes = new SpecifiedTypes();
|
||||||
|
if ($patternArg === null) {
|
||||||
|
return $subjectTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$subjectArg !== null
|
||||||
|
&& $context->true()
|
||||||
|
&& $scope->getType($subjectArg->value)->isString()->yes()
|
||||||
|
) {
|
||||||
|
$subjectType = $this->regexShapeMatcher->matchSubjectExpr($patternArg->value, $scope);
|
||||||
|
if ($subjectType !== null) {
|
||||||
|
$subjectTypes = $this->typeSpecifier->create(
|
||||||
|
$subjectArg->value,
|
||||||
|
$subjectType,
|
||||||
|
$context,
|
||||||
|
$scope,
|
||||||
|
)->setRootExpr($node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($matchesArg === null) {
|
||||||
|
return $subjectTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flagsType = PregMatchFlags::getType($flagsArg, $scope);
|
||||||
|
if ($flagsType === null) {
|
||||||
|
return $subjectTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripos($methodReflection->getName(), 'matchAll') !== false) {
|
||||||
|
$matchedType = $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope);
|
||||||
|
} else {
|
||||||
|
$matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($matchedType === null) {
|
||||||
|
return $subjectTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
in_array($methodReflection->getName(), ['matchStrictGroups', 'isMatchStrictGroups', 'matchAllStrictGroups', 'isMatchAllStrictGroups'], true)
|
||||||
|
) {
|
||||||
|
$matchedType = PregMatchFlags::removeNullFromMatches($matchedType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$overwrite = false;
|
||||||
|
if ($context->false()) {
|
||||||
|
$overwrite = true;
|
||||||
|
$context = $context->negate();
|
||||||
|
}
|
||||||
|
|
||||||
|
$specifiedTypes = $this->typeSpecifier->create(
|
||||||
|
$matchesArg->value,
|
||||||
|
$matchedType,
|
||||||
|
$context,
|
||||||
|
$scope
|
||||||
|
)->setRootExpr($node);
|
||||||
|
|
||||||
|
return $subjectTypes->unionWith($overwrite ? $specifiedTypes->setAlwaysOverwriteTypes() : $specifiedTypes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Composer\Pcre\PHPStan;
|
||||||
|
|
||||||
|
use Composer\Pcre\Preg;
|
||||||
|
use Composer\Pcre\Regex;
|
||||||
|
use PhpParser\Node\Expr\StaticCall;
|
||||||
|
use PHPStan\Analyser\Scope;
|
||||||
|
use PHPStan\Reflection\MethodReflection;
|
||||||
|
use PHPStan\Reflection\Native\NativeParameterReflection;
|
||||||
|
use PHPStan\Reflection\ParameterReflection;
|
||||||
|
use PHPStan\TrinaryLogic;
|
||||||
|
use PHPStan\Type\ClosureType;
|
||||||
|
use PHPStan\Type\Constant\ConstantArrayType;
|
||||||
|
use PHPStan\Type\Php\RegexArrayShapeMatcher;
|
||||||
|
use PHPStan\Type\StaticMethodParameterClosureTypeExtension;
|
||||||
|
use PHPStan\Type\StringType;
|
||||||
|
use PHPStan\Type\TypeCombinator;
|
||||||
|
use PHPStan\Type\Type;
|
||||||
|
|
||||||
|
final class PregReplaceCallbackClosureTypeExtension implements StaticMethodParameterClosureTypeExtension
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var RegexArrayShapeMatcher
|
||||||
|
*/
|
||||||
|
private $regexShapeMatcher;
|
||||||
|
|
||||||
|
public function __construct(RegexArrayShapeMatcher $regexShapeMatcher)
|
||||||
|
{
|
||||||
|
$this->regexShapeMatcher = $regexShapeMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
|
||||||
|
{
|
||||||
|
return in_array($methodReflection->getDeclaringClass()->getName(), [Preg::class, Regex::class], true)
|
||||||
|
&& in_array($methodReflection->getName(), ['replaceCallback', 'replaceCallbackStrictGroups'], true)
|
||||||
|
&& $parameter->getName() === 'replacement';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type
|
||||||
|
{
|
||||||
|
$args = $methodCall->getArgs();
|
||||||
|
$patternArg = $args[0] ?? null;
|
||||||
|
$flagsArg = $args[5] ?? null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
$patternArg === null
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flagsType = PregMatchFlags::getType($flagsArg, $scope);
|
||||||
|
|
||||||
|
$matchesType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope);
|
||||||
|
if ($matchesType === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($methodReflection->getName() === 'replaceCallbackStrictGroups' && count($matchesType->getConstantArrays()) === 1) {
|
||||||
|
$matchesType = $matchesType->getConstantArrays()[0];
|
||||||
|
$matchesType = new ConstantArrayType(
|
||||||
|
$matchesType->getKeyTypes(),
|
||||||
|
array_map(static function (Type $valueType): Type {
|
||||||
|
if (count($valueType->getConstantArrays()) === 1) {
|
||||||
|
$valueTypeArray = $valueType->getConstantArrays()[0];
|
||||||
|
return new ConstantArrayType(
|
||||||
|
$valueTypeArray->getKeyTypes(),
|
||||||
|
array_map(static function (Type $valueType): Type {
|
||||||
|
return TypeCombinator::removeNull($valueType);
|
||||||
|
}, $valueTypeArray->getValueTypes()),
|
||||||
|
$valueTypeArray->getNextAutoIndexes(),
|
||||||
|
[],
|
||||||
|
$valueTypeArray->isList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return TypeCombinator::removeNull($valueType);
|
||||||
|
}, $matchesType->getValueTypes()),
|
||||||
|
$matchesType->getNextAutoIndexes(),
|
||||||
|
[],
|
||||||
|
$matchesType->isList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ClosureType(
|
||||||
|
[
|
||||||
|
new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $matchesType, $parameter->passedByReference(), $parameter->isVariadic(), $parameter->getDefaultValue()),
|
||||||
|
],
|
||||||
|
new StringType()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Composer\Pcre\PHPStan;
|
||||||
|
|
||||||
|
use Composer\Pcre\Preg;
|
||||||
|
use Composer\Pcre\Regex;
|
||||||
|
use PhpParser\Node;
|
||||||
|
use PhpParser\Node\Expr\StaticCall;
|
||||||
|
use PhpParser\Node\Name\FullyQualified;
|
||||||
|
use PHPStan\Analyser\Scope;
|
||||||
|
use PHPStan\Analyser\SpecifiedTypes;
|
||||||
|
use PHPStan\Rules\Rule;
|
||||||
|
use PHPStan\Rules\RuleErrorBuilder;
|
||||||
|
use PHPStan\TrinaryLogic;
|
||||||
|
use PHPStan\Type\ObjectType;
|
||||||
|
use PHPStan\Type\Type;
|
||||||
|
use PHPStan\Type\TypeCombinator;
|
||||||
|
use PHPStan\Type\Php\RegexArrayShapeMatcher;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements Rule<StaticCall>
|
||||||
|
*/
|
||||||
|
final class UnsafeStrictGroupsCallRule implements Rule
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var RegexArrayShapeMatcher
|
||||||
|
*/
|
||||||
|
private $regexShapeMatcher;
|
||||||
|
|
||||||
|
public function __construct(RegexArrayShapeMatcher $regexShapeMatcher)
|
||||||
|
{
|
||||||
|
$this->regexShapeMatcher = $regexShapeMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNodeType(): string
|
||||||
|
{
|
||||||
|
return StaticCall::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processNode(Node $node, Scope $scope): array
|
||||||
|
{
|
||||||
|
if (!$node->class instanceof FullyQualified) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$isRegex = $node->class->toString() === Regex::class;
|
||||||
|
$isPreg = $node->class->toString() === Preg::class;
|
||||||
|
if (!$isRegex && !$isPreg) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!$node->name instanceof Node\Identifier || !in_array($node->name->name, ['matchStrictGroups', 'isMatchStrictGroups', 'matchAllStrictGroups', 'isMatchAllStrictGroups'], true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$args = $node->getArgs();
|
||||||
|
if (!isset($args[0])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$patternArg = $args[0] ?? null;
|
||||||
|
if ($isPreg) {
|
||||||
|
if (!isset($args[2])) { // no matches set, skip as the matches won't be used anyway
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$flagsArg = $args[3] ?? null;
|
||||||
|
} else {
|
||||||
|
$flagsArg = $args[2] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patternArg === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$flagsType = PregMatchFlags::getType($flagsArg, $scope);
|
||||||
|
if ($flagsType === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope);
|
||||||
|
if ($matchedType === null) {
|
||||||
|
return [
|
||||||
|
RuleErrorBuilder::message(sprintf('The %s call is potentially unsafe as $matches\' type could not be inferred.', $node->name->name))
|
||||||
|
->identifier('composerPcre.maybeUnsafeStrictGroups')
|
||||||
|
->build(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($matchedType->getConstantArrays()) === 1) {
|
||||||
|
$matchedType = $matchedType->getConstantArrays()[0];
|
||||||
|
$nullableGroups = [];
|
||||||
|
foreach ($matchedType->getValueTypes() as $index => $type) {
|
||||||
|
if (TypeCombinator::containsNull($type)) {
|
||||||
|
$nullableGroups[] = $matchedType->getKeyTypes()[$index]->getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\count($nullableGroups) > 0) {
|
||||||
|
return [
|
||||||
|
RuleErrorBuilder::message(sprintf(
|
||||||
|
'The %s call is unsafe as match group%s "%s" %s optional and may be null.',
|
||||||
|
$node->name->name,
|
||||||
|
\count($nullableGroups) > 1 ? 's' : '',
|
||||||
|
implode('", "', $nullableGroups),
|
||||||
|
\count($nullableGroups) > 1 ? 'are' : 'is'
|
||||||
|
))->identifier('composerPcre.unsafeStrictGroups')->build(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
class PcreException extends \RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $function
|
||||||
|
* @param string|string[] $pattern
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function fromFunction($function, $pattern)
|
||||||
|
{
|
||||||
|
$code = preg_last_error();
|
||||||
|
|
||||||
|
if (is_array($pattern)) {
|
||||||
|
$pattern = implode(', ', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PcreException($function.'(): failed executing "'.$pattern.'": '.self::pcreLastErrorMessage($code), $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $code
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function pcreLastErrorMessage($code)
|
||||||
|
{
|
||||||
|
if (function_exists('preg_last_error_msg')) {
|
||||||
|
return preg_last_error_msg();
|
||||||
|
}
|
||||||
|
|
||||||
|
$constants = get_defined_constants(true);
|
||||||
|
if (!isset($constants['pcre']) || !is_array($constants['pcre'])) {
|
||||||
|
return 'UNDEFINED_ERROR';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($constants['pcre'] as $const => $val) {
|
||||||
|
if ($val === $code && substr($const, -6) === '_ERROR') {
|
||||||
|
return $const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'UNDEFINED_ERROR';
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+430
@@ -0,0 +1,430 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
class Preg
|
||||||
|
{
|
||||||
|
/** @internal */
|
||||||
|
public const ARRAY_MSG = '$subject as an array is not supported. You can use \'foreach\' instead.';
|
||||||
|
/** @internal */
|
||||||
|
public const INVALID_TYPE_MSG = '$subject must be a string, %s given.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
* @return 0|1
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, string|null> $matches
|
||||||
|
*/
|
||||||
|
public static function match(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
|
||||||
|
{
|
||||||
|
self::checkOffsetCapture($flags, 'matchWithOffsets');
|
||||||
|
|
||||||
|
$result = preg_match($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL, $offset);
|
||||||
|
if ($result === false) {
|
||||||
|
throw PcreException::fromFunction('preg_match', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of `match()` which outputs non-null matches (or throws)
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
* @return 0|1
|
||||||
|
* @throws UnexpectedNullMatchException
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, string> $matches
|
||||||
|
*/
|
||||||
|
public static function matchStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
|
||||||
|
{
|
||||||
|
$result = self::match($pattern, $subject, $matchesInternal, $flags, $offset);
|
||||||
|
$matches = self::enforceNonNullMatches($pattern, $matchesInternal, 'match');
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs preg_match with PREG_OFFSET_CAPTURE
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_UNMATCHED_AS_NULL and PREG_OFFSET_CAPTURE are always set, no other flags are supported
|
||||||
|
* @return 0|1
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, array{string|null, int<-1, max>}> $matches
|
||||||
|
*/
|
||||||
|
public static function matchWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): int
|
||||||
|
{
|
||||||
|
$result = preg_match($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE, $offset);
|
||||||
|
if ($result === false) {
|
||||||
|
throw PcreException::fromFunction('preg_match', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
* @return 0|positive-int
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, list<string|null>> $matches
|
||||||
|
*/
|
||||||
|
public static function matchAll(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
|
||||||
|
{
|
||||||
|
self::checkOffsetCapture($flags, 'matchAllWithOffsets');
|
||||||
|
self::checkSetOrder($flags);
|
||||||
|
|
||||||
|
$result = preg_match_all($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL, $offset);
|
||||||
|
if (!is_int($result)) { // PHP < 8 may return null, 8+ returns int|false
|
||||||
|
throw PcreException::fromFunction('preg_match_all', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of `match()` which outputs non-null matches (or throws)
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
* @return 0|positive-int
|
||||||
|
* @throws UnexpectedNullMatchException
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, list<string>> $matches
|
||||||
|
*/
|
||||||
|
public static function matchAllStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
|
||||||
|
{
|
||||||
|
$result = self::matchAll($pattern, $subject, $matchesInternal, $flags, $offset);
|
||||||
|
$matches = self::enforceNonNullMatchAll($pattern, $matchesInternal, 'matchAll');
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs preg_match_all with PREG_OFFSET_CAPTURE
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_UNMATCHED_AS_NULL and PREG_MATCH_OFFSET are always set, no other flags are supported
|
||||||
|
* @return 0|positive-int
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, list<array{string|null, int<-1, max>}>> $matches
|
||||||
|
*/
|
||||||
|
public static function matchAllWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): int
|
||||||
|
{
|
||||||
|
self::checkSetOrder($flags);
|
||||||
|
|
||||||
|
$result = preg_match_all($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE, $offset);
|
||||||
|
if (!is_int($result)) { // PHP < 8 may return null, 8+ returns int|false
|
||||||
|
throw PcreException::fromFunction('preg_match_all', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|string[] $pattern
|
||||||
|
* @param string|string[] $replacement
|
||||||
|
* @param string $subject
|
||||||
|
* @param int $count Set by method
|
||||||
|
*
|
||||||
|
* @param-out int<0, max> $count
|
||||||
|
*/
|
||||||
|
public static function replace($pattern, $replacement, $subject, int $limit = -1, ?int &$count = null): string
|
||||||
|
{
|
||||||
|
if (!is_scalar($subject)) {
|
||||||
|
if (is_array($subject)) {
|
||||||
|
throw new \InvalidArgumentException(static::ARRAY_MSG);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \TypeError(sprintf(static::INVALID_TYPE_MSG, gettype($subject)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = preg_replace($pattern, $replacement, $subject, $limit, $count);
|
||||||
|
if ($result === null) {
|
||||||
|
throw PcreException::fromFunction('preg_replace', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|string[] $pattern
|
||||||
|
* @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array<int|string, array{string|null, int<-1, max>}>): string) : callable(array<int|string, string|null>): string) $replacement
|
||||||
|
* @param string $subject
|
||||||
|
* @param int $count Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set
|
||||||
|
*
|
||||||
|
* @param-out int<0, max> $count
|
||||||
|
*/
|
||||||
|
public static function replaceCallback($pattern, callable $replacement, $subject, int $limit = -1, ?int &$count = null, int $flags = 0): string
|
||||||
|
{
|
||||||
|
if (!is_scalar($subject)) {
|
||||||
|
if (is_array($subject)) {
|
||||||
|
throw new \InvalidArgumentException(static::ARRAY_MSG);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \TypeError(sprintf(static::INVALID_TYPE_MSG, gettype($subject)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = preg_replace_callback($pattern, $replacement, $subject, $limit, $count, $flags | PREG_UNMATCHED_AS_NULL);
|
||||||
|
if ($result === null) {
|
||||||
|
throw PcreException::fromFunction('preg_replace_callback', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of `replaceCallback()` which outputs non-null matches (or throws)
|
||||||
|
*
|
||||||
|
* @param string $pattern
|
||||||
|
* @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array<int|string, array{string, int<0, max>}>): string) : callable(array<int|string, string>): string) $replacement
|
||||||
|
* @param string $subject
|
||||||
|
* @param int $count Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set
|
||||||
|
*
|
||||||
|
* @param-out int<0, max> $count
|
||||||
|
*/
|
||||||
|
public static function replaceCallbackStrictGroups(string $pattern, callable $replacement, $subject, int $limit = -1, ?int &$count = null, int $flags = 0): string
|
||||||
|
{
|
||||||
|
return self::replaceCallback($pattern, function (array $matches) use ($pattern, $replacement) {
|
||||||
|
return $replacement(self::enforceNonNullMatches($pattern, $matches, 'replaceCallback'));
|
||||||
|
}, $subject, $limit, $count, $flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ($flags is PREG_OFFSET_CAPTURE ? (array<string, callable(array<int|string, array{string|null, int<-1, max>}>): string>) : array<string, callable(array<int|string, string|null>): string>) $pattern
|
||||||
|
* @param string $subject
|
||||||
|
* @param int $count Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set
|
||||||
|
*
|
||||||
|
* @param-out int<0, max> $count
|
||||||
|
*/
|
||||||
|
public static function replaceCallbackArray(array $pattern, $subject, int $limit = -1, ?int &$count = null, int $flags = 0): string
|
||||||
|
{
|
||||||
|
if (!is_scalar($subject)) {
|
||||||
|
if (is_array($subject)) {
|
||||||
|
throw new \InvalidArgumentException(static::ARRAY_MSG);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \TypeError(sprintf(static::INVALID_TYPE_MSG, gettype($subject)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = preg_replace_callback_array($pattern, $subject, $limit, $count, $flags | PREG_UNMATCHED_AS_NULL);
|
||||||
|
if ($result === null) {
|
||||||
|
$pattern = array_keys($pattern);
|
||||||
|
throw PcreException::fromFunction('preg_replace_callback_array', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int-mask<PREG_SPLIT_NO_EMPTY|PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_OFFSET_CAPTURE> $flags PREG_SPLIT_NO_EMPTY or PREG_SPLIT_DELIM_CAPTURE
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function split(string $pattern, string $subject, int $limit = -1, int $flags = 0): array
|
||||||
|
{
|
||||||
|
if (($flags & PREG_SPLIT_OFFSET_CAPTURE) !== 0) {
|
||||||
|
throw new \InvalidArgumentException('PREG_SPLIT_OFFSET_CAPTURE is not supported as it changes the type of $matches, use splitWithOffsets() instead');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = preg_split($pattern, $subject, $limit, $flags);
|
||||||
|
if ($result === false) {
|
||||||
|
throw PcreException::fromFunction('preg_split', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int-mask<PREG_SPLIT_NO_EMPTY|PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_OFFSET_CAPTURE> $flags PREG_SPLIT_NO_EMPTY or PREG_SPLIT_DELIM_CAPTURE, PREG_SPLIT_OFFSET_CAPTURE is always set
|
||||||
|
* @return list<array{string, int}>
|
||||||
|
* @phpstan-return list<array{string, int<0, max>}>
|
||||||
|
*/
|
||||||
|
public static function splitWithOffsets(string $pattern, string $subject, int $limit = -1, int $flags = 0): array
|
||||||
|
{
|
||||||
|
$result = preg_split($pattern, $subject, $limit, $flags | PREG_SPLIT_OFFSET_CAPTURE);
|
||||||
|
if ($result === false) {
|
||||||
|
throw PcreException::fromFunction('preg_split', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T of string|\Stringable
|
||||||
|
* @param string $pattern
|
||||||
|
* @param array<T> $array
|
||||||
|
* @param int-mask<PREG_GREP_INVERT> $flags PREG_GREP_INVERT
|
||||||
|
* @return array<T>
|
||||||
|
*/
|
||||||
|
public static function grep(string $pattern, array $array, int $flags = 0): array
|
||||||
|
{
|
||||||
|
$result = preg_grep($pattern, $array, $flags);
|
||||||
|
if ($result === false) {
|
||||||
|
throw PcreException::fromFunction('preg_grep', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of match() which returns a bool instead of int
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, string|null> $matches
|
||||||
|
*/
|
||||||
|
public static function isMatch(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
|
||||||
|
{
|
||||||
|
return (bool) static::match($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of `isMatch()` which outputs non-null matches (or throws)
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
* @throws UnexpectedNullMatchException
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, string> $matches
|
||||||
|
*/
|
||||||
|
public static function isMatchStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
|
||||||
|
{
|
||||||
|
return (bool) self::matchStrictGroups($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of matchAll() which returns a bool instead of int
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, list<string|null>> $matches
|
||||||
|
*/
|
||||||
|
public static function isMatchAll(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
|
||||||
|
{
|
||||||
|
return (bool) static::matchAll($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of `isMatchAll()` which outputs non-null matches (or throws)
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, list<string>> $matches
|
||||||
|
*/
|
||||||
|
public static function isMatchAllStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
|
||||||
|
{
|
||||||
|
return (bool) self::matchAllStrictGroups($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of matchWithOffsets() which returns a bool instead of int
|
||||||
|
*
|
||||||
|
* Runs preg_match with PREG_OFFSET_CAPTURE
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, array{string|null, int<-1, max>}> $matches
|
||||||
|
*/
|
||||||
|
public static function isMatchWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): bool
|
||||||
|
{
|
||||||
|
return (bool) static::matchWithOffsets($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of matchAllWithOffsets() which returns a bool instead of int
|
||||||
|
*
|
||||||
|
* Runs preg_match_all with PREG_OFFSET_CAPTURE
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param array<mixed> $matches Set by method
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
*
|
||||||
|
* @param-out array<int|string, list<array{string|null, int<-1, max>}>> $matches
|
||||||
|
*/
|
||||||
|
public static function isMatchAllWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): bool
|
||||||
|
{
|
||||||
|
return (bool) static::matchAllWithOffsets($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function checkOffsetCapture(int $flags, string $useFunctionName): void
|
||||||
|
{
|
||||||
|
if (($flags & PREG_OFFSET_CAPTURE) !== 0) {
|
||||||
|
throw new \InvalidArgumentException('PREG_OFFSET_CAPTURE is not supported as it changes the type of $matches, use ' . $useFunctionName . '() instead');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function checkSetOrder(int $flags): void
|
||||||
|
{
|
||||||
|
if (($flags & PREG_SET_ORDER) !== 0) {
|
||||||
|
throw new \InvalidArgumentException('PREG_SET_ORDER is not supported as it changes the type of $matches');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string, string|null|array{string|null, int}> $matches
|
||||||
|
* @return array<int|string, string>
|
||||||
|
* @throws UnexpectedNullMatchException
|
||||||
|
*/
|
||||||
|
private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod): array
|
||||||
|
{
|
||||||
|
foreach ($matches as $group => $match) {
|
||||||
|
if (is_string($match) || (is_array($match) && is_string($match[0]))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string> */
|
||||||
|
return $matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string, list<string|null>> $matches
|
||||||
|
* @return array<int|string, list<string>>
|
||||||
|
* @throws UnexpectedNullMatchException
|
||||||
|
*/
|
||||||
|
private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod): array
|
||||||
|
{
|
||||||
|
foreach ($matches as $group => $groupMatches) {
|
||||||
|
foreach ($groupMatches as $match) {
|
||||||
|
if (null === $match) {
|
||||||
|
throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int|string, list<string>> */
|
||||||
|
return $matches;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+176
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
class Regex
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
*/
|
||||||
|
public static function isMatch(string $pattern, string $subject, int $offset = 0): bool
|
||||||
|
{
|
||||||
|
return (bool) Preg::match($pattern, $subject, $matches, 0, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
*/
|
||||||
|
public static function match(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchResult
|
||||||
|
{
|
||||||
|
self::checkOffsetCapture($flags, 'matchWithOffsets');
|
||||||
|
|
||||||
|
$count = Preg::match($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
|
||||||
|
return new MatchResult($count, $matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of `match()` which returns non-null matches (or throws)
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
* @throws UnexpectedNullMatchException
|
||||||
|
*/
|
||||||
|
public static function matchStrictGroups(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchStrictGroupsResult
|
||||||
|
{
|
||||||
|
// @phpstan-ignore composerPcre.maybeUnsafeStrictGroups
|
||||||
|
$count = Preg::matchStrictGroups($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
|
||||||
|
return new MatchStrictGroupsResult($count, $matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs preg_match with PREG_OFFSET_CAPTURE
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_UNMATCHED_AS_NULL and PREG_MATCH_OFFSET are always set, no other flags are supported
|
||||||
|
*/
|
||||||
|
public static function matchWithOffsets(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchWithOffsetsResult
|
||||||
|
{
|
||||||
|
$count = Preg::matchWithOffsets($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
|
||||||
|
return new MatchWithOffsetsResult($count, $matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
*/
|
||||||
|
public static function matchAll(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchAllResult
|
||||||
|
{
|
||||||
|
self::checkOffsetCapture($flags, 'matchAllWithOffsets');
|
||||||
|
self::checkSetOrder($flags);
|
||||||
|
|
||||||
|
$count = Preg::matchAll($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
|
||||||
|
return new MatchAllResult($count, $matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of `matchAll()` which returns non-null matches (or throws)
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
|
||||||
|
* @throws UnexpectedNullMatchException
|
||||||
|
*/
|
||||||
|
public static function matchAllStrictGroups(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchAllStrictGroupsResult
|
||||||
|
{
|
||||||
|
self::checkOffsetCapture($flags, 'matchAllWithOffsets');
|
||||||
|
self::checkSetOrder($flags);
|
||||||
|
|
||||||
|
// @phpstan-ignore composerPcre.maybeUnsafeStrictGroups
|
||||||
|
$count = Preg::matchAllStrictGroups($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
|
||||||
|
return new MatchAllStrictGroupsResult($count, $matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs preg_match_all with PREG_OFFSET_CAPTURE
|
||||||
|
*
|
||||||
|
* @param non-empty-string $pattern
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_UNMATCHED_AS_NULL and PREG_MATCH_OFFSET are always set, no other flags are supported
|
||||||
|
*/
|
||||||
|
public static function matchAllWithOffsets(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchAllWithOffsetsResult
|
||||||
|
{
|
||||||
|
self::checkSetOrder($flags);
|
||||||
|
|
||||||
|
$count = Preg::matchAllWithOffsets($pattern, $subject, $matches, $flags, $offset);
|
||||||
|
|
||||||
|
return new MatchAllWithOffsetsResult($count, $matches);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param string|string[] $pattern
|
||||||
|
* @param string|string[] $replacement
|
||||||
|
* @param string $subject
|
||||||
|
*/
|
||||||
|
public static function replace($pattern, $replacement, $subject, int $limit = -1): ReplaceResult
|
||||||
|
{
|
||||||
|
$result = Preg::replace($pattern, $replacement, $subject, $limit, $count);
|
||||||
|
|
||||||
|
return new ReplaceResult($count, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|string[] $pattern
|
||||||
|
* @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array<int|string, array{string|null, int<-1, max>}>): string) : callable(array<int|string, string|null>): string) $replacement
|
||||||
|
* @param string $subject
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set
|
||||||
|
*/
|
||||||
|
public static function replaceCallback($pattern, callable $replacement, $subject, int $limit = -1, int $flags = 0): ReplaceResult
|
||||||
|
{
|
||||||
|
$result = Preg::replaceCallback($pattern, $replacement, $subject, $limit, $count, $flags);
|
||||||
|
|
||||||
|
return new ReplaceResult($count, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of `replaceCallback()` which outputs non-null matches (or throws)
|
||||||
|
*
|
||||||
|
* @param string $pattern
|
||||||
|
* @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array<int|string, array{string, int<0, max>}>): string) : callable(array<int|string, string>): string) $replacement
|
||||||
|
* @param string $subject
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set
|
||||||
|
*/
|
||||||
|
public static function replaceCallbackStrictGroups($pattern, callable $replacement, $subject, int $limit = -1, int $flags = 0): ReplaceResult
|
||||||
|
{
|
||||||
|
$result = Preg::replaceCallbackStrictGroups($pattern, $replacement, $subject, $limit, $count, $flags);
|
||||||
|
|
||||||
|
return new ReplaceResult($count, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ($flags is PREG_OFFSET_CAPTURE ? (array<string, callable(array<int|string, array{string|null, int<-1, max>}>): string>) : array<string, callable(array<int|string, string|null>): string>) $pattern
|
||||||
|
* @param string $subject
|
||||||
|
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE> $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set
|
||||||
|
*/
|
||||||
|
public static function replaceCallbackArray(array $pattern, $subject, int $limit = -1, int $flags = 0): ReplaceResult
|
||||||
|
{
|
||||||
|
$result = Preg::replaceCallbackArray($pattern, $subject, $limit, $count, $flags);
|
||||||
|
|
||||||
|
return new ReplaceResult($count, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function checkOffsetCapture(int $flags, string $useFunctionName): void
|
||||||
|
{
|
||||||
|
if (($flags & PREG_OFFSET_CAPTURE) !== 0) {
|
||||||
|
throw new \InvalidArgumentException('PREG_OFFSET_CAPTURE is not supported as it changes the return type, use '.$useFunctionName.'() instead');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function checkSetOrder(int $flags): void
|
||||||
|
{
|
||||||
|
if (($flags & PREG_SET_ORDER) !== 0) {
|
||||||
|
throw new \InvalidArgumentException('PREG_SET_ORDER is not supported as it changes the return type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
final class ReplaceResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public $result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var 0|positive-int
|
||||||
|
*/
|
||||||
|
public $count;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @readonly
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $matched;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param 0|positive-int $count
|
||||||
|
*/
|
||||||
|
public function __construct(int $count, string $result)
|
||||||
|
{
|
||||||
|
$this->count = $count;
|
||||||
|
$this->matched = (bool) $count;
|
||||||
|
$this->result = $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of composer/pcre.
|
||||||
|
*
|
||||||
|
* (c) Composer <https://github.com/composer>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Pcre;
|
||||||
|
|
||||||
|
class UnexpectedNullMatchException extends PcreException
|
||||||
|
{
|
||||||
|
public static function fromFunction($function, $pattern)
|
||||||
|
{
|
||||||
|
throw new \LogicException('fromFunction should not be called on '.self::class.', use '.PcreException::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// platform_check.php @generated by Composer
|
||||||
|
|
||||||
|
$issues = array();
|
||||||
|
|
||||||
|
if (!(PHP_VERSION_ID >= 80300)) {
|
||||||
|
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.3.0". You are running ' . PHP_VERSION . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PHP_INT_SIZE !== 8) {
|
||||||
|
$issues[] = 'Your Composer dependencies require a 64-bit build of PHP.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($issues) {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('HTTP/1.1 500 Internal Server Error');
|
||||||
|
}
|
||||||
|
if (!ini_get('display_errors')) {
|
||||||
|
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||||
|
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
|
||||||
|
} elseif (!headers_sent()) {
|
||||||
|
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Composer detected issues in your platform: ' . implode(' ', $issues)
|
||||||
|
);
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
|
[*.{yml,md,xml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{rst,php}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[composer.json]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[composer.lock]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phive xmlns="https://phar.io/phive">
|
||||||
|
<phar name="phpdocumentor" version="^3.3.1" installed="3.8.0" location="./tools/phpdocumentor" copy="false"/>
|
||||||
|
</phive>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHP-CS-Fixer config for ZipStream-PHP
|
||||||
|
* @author Nicolas CARPi <nico-git@deltablot.email>
|
||||||
|
* @copyright 2022 Nicolas CARPi
|
||||||
|
* @see https://github.com/maennchen/ZipStream-PHP
|
||||||
|
* @license MIT
|
||||||
|
* @package maennchen/ZipStream-PHP
|
||||||
|
*/
|
||||||
|
|
||||||
|
use PhpCsFixer\Config;
|
||||||
|
use PhpCsFixer\Finder;
|
||||||
|
use PhpCsFixer\Runner;
|
||||||
|
|
||||||
|
$finder = Finder::create()
|
||||||
|
->exclude('.github')
|
||||||
|
->exclude('.phpdoc')
|
||||||
|
->exclude('docs')
|
||||||
|
->exclude('tools')
|
||||||
|
->exclude('vendor')
|
||||||
|
->in(__DIR__);
|
||||||
|
|
||||||
|
$config = new Config();
|
||||||
|
return $config->setRules([
|
||||||
|
'@PER' => true,
|
||||||
|
'@PER:risky' => true,
|
||||||
|
'@PHP83Migration' => true,
|
||||||
|
// Enable once PHP 8.4 is the minimum version
|
||||||
|
// '@PHP84Migration' => true,
|
||||||
|
'@PHPUnit84Migration:risky' => true,
|
||||||
|
'array_syntax' => ['syntax' => 'short'],
|
||||||
|
'class_attributes_separation' => true,
|
||||||
|
'declare_strict_types' => true,
|
||||||
|
'dir_constant' => true,
|
||||||
|
'is_null' => true,
|
||||||
|
'no_homoglyph_names' => true,
|
||||||
|
'no_null_property_initialization' => true,
|
||||||
|
'no_php4_constructor' => true,
|
||||||
|
'no_unused_imports' => true,
|
||||||
|
'no_useless_else' => true,
|
||||||
|
'non_printable_character' => true,
|
||||||
|
'ordered_imports' => true,
|
||||||
|
'ordered_class_elements' => true,
|
||||||
|
'php_unit_construct' => true,
|
||||||
|
'pow_to_exponentiation' => true,
|
||||||
|
'psr_autoloading' => true,
|
||||||
|
'random_api_migration' => true,
|
||||||
|
'return_assignment' => true,
|
||||||
|
'self_accessor' => true,
|
||||||
|
'semicolon_after_instruction' => true,
|
||||||
|
'short_scalar_cast' => true,
|
||||||
|
'simplified_null_return' => true,
|
||||||
|
'single_class_element_per_statement' => true,
|
||||||
|
'single_line_comment_style' => true,
|
||||||
|
'single_quote' => true,
|
||||||
|
'space_after_semicolon' => true,
|
||||||
|
'standardize_not_equals' => true,
|
||||||
|
'strict_param' => true,
|
||||||
|
'ternary_operator_spaces' => true,
|
||||||
|
'trailing_comma_in_multiline' => true,
|
||||||
|
'trim_array_spaces' => true,
|
||||||
|
'unary_operator_spaces' => true,
|
||||||
|
'global_namespace_import' => [
|
||||||
|
'import_classes' => true,
|
||||||
|
'import_functions' => true,
|
||||||
|
'import_constants' => true,
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->setFinder($finder)
|
||||||
|
->setRiskyAllowed(true)
|
||||||
|
->setUnsupportedPhpVersionAllowed(true)
|
||||||
|
->setParallelConfig(Runner\Parallel\ParallelConfigFactory::detect());
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends 'layout.html.twig' %}
|
||||||
|
|
||||||
|
{% set topMenu = {
|
||||||
|
"menu": [
|
||||||
|
{ "name": "Guides", "url": "https://maennchen.dev/ZipStream-PHP/guide/index.html"},
|
||||||
|
{ "name": "API", "url": "https://maennchen.dev/ZipStream-PHP/classes/ZipStream-ZipStream.html"},
|
||||||
|
{ "name": "Issues", "url": "https://github.com/maennchen/ZipStream-PHP/issues"},
|
||||||
|
],
|
||||||
|
"social": [
|
||||||
|
{ "iconClass": "fab fa-github", "url": "https://github.com/maennchen/ZipStream-PHP"},
|
||||||
|
{ "iconClass": "fas fa-envelope-open-text", "url": "https://github.com/maennchen/ZipStream-PHP/discussions"},
|
||||||
|
{ "iconClass": "fas fa-money-bill", "url": "https://github.com/sponsors/maennchen"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
%}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
php 8.5.0
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (C) 2007-2009 Paul Duncan <pabs@pablotron.org>
|
||||||
|
Copyright (C) 2014 Jonatan Männchen <jonatan@maennchen.ch>
|
||||||
|
Copyright (C) 2014 Jesse G. Donat <donatj@gmail.com>
|
||||||
|
Copyright (C) 2018 Nicolas CARPi <nicolas.carpi@curie.fr>
|
||||||
|
|
||||||
|
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.
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
# ZipStream-PHP
|
||||||
|
|
||||||
|
[](https://github.com/maennchen/ZipStream-PHP/actions/workflows/branch_main.yml)
|
||||||
|
[](https://coveralls.io/github/maennchen/ZipStream-PHP?branch=main)
|
||||||
|
[](https://packagist.org/packages/maennchen/zipstream-php)
|
||||||
|
[](https://packagist.org/packages/maennchen/zipstream-php)
|
||||||
|
[](https://www.bestpractices.dev/projects/9524)
|
||||||
|
[](https://scorecard.dev/viewer/?uri=github.com/maennchen/ZipStream-PHP)
|
||||||
|
|
||||||
|
## Unstable Branch
|
||||||
|
|
||||||
|
The `main` branch is not stable. Please see the
|
||||||
|
[releases](https://github.com/maennchen/ZipStream-PHP/releases) for a stable
|
||||||
|
version.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A fast and simple streaming zip file downloader for PHP. Using this library will
|
||||||
|
save you from having to write the Zip to disk. You can directly send it to the
|
||||||
|
user, which is much faster. It can work with S3 buckets or any PSR7 Stream.
|
||||||
|
|
||||||
|
Please see the [LICENSE](LICENSE) file for licensing and warranty information.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Simply add a dependency on maennchen/zipstream-php to your project's
|
||||||
|
`composer.json` file if you use Composer to manage the dependencies of your
|
||||||
|
project. Use following command to add the package to your project's dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require maennchen/zipstream-php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
For detailed instructions, please check the
|
||||||
|
[Documentation](https://maennchen.github.io/ZipStream-PHP/).
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Autoload the dependencies
|
||||||
|
require 'vendor/autoload.php';
|
||||||
|
|
||||||
|
// create a new zipstream object
|
||||||
|
$zip = new ZipStream\ZipStream(
|
||||||
|
outputName: 'example.zip',
|
||||||
|
|
||||||
|
// enable output of HTTP headers
|
||||||
|
sendHttpHeaders: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// create a file named 'hello.txt'
|
||||||
|
$zip->addFile(
|
||||||
|
fileName: 'hello.txt',
|
||||||
|
data: 'This is the contents of hello.txt',
|
||||||
|
);
|
||||||
|
|
||||||
|
// add a file named 'some_image.jpg' from a local file 'path/to/image.jpg'
|
||||||
|
$zip->addFileFromPath(
|
||||||
|
fileName: 'some_image.jpg',
|
||||||
|
path: 'path/to/image.jpg',
|
||||||
|
);
|
||||||
|
|
||||||
|
// finish the zip stream
|
||||||
|
$zip->finish();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Callback Output
|
||||||
|
|
||||||
|
You can stream ZIP data to a custom callback function instead of directly to the browser:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use ZipStream\ZipStream;
|
||||||
|
use ZipStream\Stream\CallbackStreamWrapper;
|
||||||
|
|
||||||
|
// Stream to a callback function with proper file handling
|
||||||
|
$outputFile = fopen('output.zip', 'wb');
|
||||||
|
$backupFile = fopen('backup.zip', 'wb');
|
||||||
|
|
||||||
|
$zip = new ZipStream(
|
||||||
|
outputStream: CallbackStreamWrapper::open(function (string $data) use ($outputFile, $backupFile) {
|
||||||
|
// Handle ZIP data as it's generated
|
||||||
|
fwrite($outputFile, $data);
|
||||||
|
|
||||||
|
// Send to multiple destinations efficiently
|
||||||
|
echo $data; // Browser
|
||||||
|
fwrite($backupFile, $data); // Backup file
|
||||||
|
}),
|
||||||
|
sendHttpHeaders: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->addFile('hello.txt', 'Hello World!');
|
||||||
|
$zip->finish();
|
||||||
|
|
||||||
|
// Clean up resources
|
||||||
|
fclose($outputFile);
|
||||||
|
fclose($backupFile);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
**💬 Questions? Please Read This First!**
|
||||||
|
|
||||||
|
If you have a question about using this library, please *do not email the
|
||||||
|
authors directly*. Instead, head over to the
|
||||||
|
[GitHub Discussions](https://github.com/maennchen/ZipStream-PHP/discussions)
|
||||||
|
page — your question might already be answered there! Using Discussions helps
|
||||||
|
build a shared knowledge base, so others can also benefit from the answers. If
|
||||||
|
you need dedicated 1:1 support, check out the options available on
|
||||||
|
[@maennchen's sponsorship page](https://github.com/sponsors/maennchen?frequency=one-time&sponsor=maennchen).
|
||||||
|
|
||||||
|
## Upgrade to version 3.1.2
|
||||||
|
|
||||||
|
- Minimum PHP Version: `8.2`
|
||||||
|
|
||||||
|
## Upgrade to version 3.0.0
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- Minimum PHP Version: `8.1`
|
||||||
|
- Only 64bit Architecture is supported.
|
||||||
|
- The class `ZipStream\Option\Method` has been replaced with the enum
|
||||||
|
`ZipStream\CompressionMethod`.
|
||||||
|
- Most classes have been flagged as `@internal` and should not be used from the
|
||||||
|
outside.
|
||||||
|
If you're using internal resources to extend this library, please open an
|
||||||
|
issue so that a clean interface can be added & published.
|
||||||
|
The externally available classes & enums are:
|
||||||
|
- `ZipStream\CompressionMethod`
|
||||||
|
- `ZipStream\Exception*`
|
||||||
|
- `ZipStream\ZipStream`
|
||||||
|
|
||||||
|
### Archive Options
|
||||||
|
|
||||||
|
- The class `ZipStream\Option\Archive` has been replaced in favor of named
|
||||||
|
arguments in the `ZipStream\ZipStream` constructor.
|
||||||
|
- The archive options `largeFileSize` & `largeFileMethod` has been removed. If
|
||||||
|
you want different `compressionMethods` based on the file size, you'll have to
|
||||||
|
implement this yourself.
|
||||||
|
- The archive option `httpHeaderCallback` changed the type from `callable` to
|
||||||
|
`Closure`.
|
||||||
|
- The archive option `zeroHeader` has been replaced with the option
|
||||||
|
`defaultEnableZeroHeader` and can be overridden for every file. Its default
|
||||||
|
value changed from `false` to `true`.
|
||||||
|
- The archive option `statFiles` was removed since the library no longer checks
|
||||||
|
filesizes this way.
|
||||||
|
- The archive option `deflateLevel` has been replaced with the option
|
||||||
|
`defaultDeflateLevel` and can be overridden for every file.
|
||||||
|
- The first argument (`name`) of the `ZipStream\ZipStream` constructor has been
|
||||||
|
replaced with the named argument `outputName`.
|
||||||
|
- Headers are now also sent if the `outputName` is empty. If you do not want to
|
||||||
|
automatically send http headers, set `sendHttpHeaders` to `false`.
|
||||||
|
|
||||||
|
### File Options
|
||||||
|
|
||||||
|
- The class `ZipStream\Option\File` has been replaced in favor of named
|
||||||
|
arguments in the `ZipStream\ZipStream->addFile*` functions.
|
||||||
|
- The file option `method` has been renamed to `compressionMethod`.
|
||||||
|
- The file option `time` has been renamed to `lastModificationDateTime`.
|
||||||
|
- The file option `size` has been renamed to `maxSize`.
|
||||||
|
|
||||||
|
## Upgrade to version 2.0.0
|
||||||
|
|
||||||
|
https://github.com/maennchen/ZipStream-PHP/tree/2.0.0#upgrade-to-version-200
|
||||||
|
|
||||||
|
## Upgrade to version 1.0.0
|
||||||
|
|
||||||
|
https://github.com/maennchen/ZipStream-PHP/tree/2.0.0#upgrade-to-version-100
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
ZipStream-PHP is a collaborative project. Please take a look at the
|
||||||
|
[.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) file.
|
||||||
|
|
||||||
|
## Version Support
|
||||||
|
|
||||||
|
Versions are supported according to the table below.
|
||||||
|
|
||||||
|
Please do not open any pull requests contradicting the current version support
|
||||||
|
status.
|
||||||
|
|
||||||
|
Careful: Always check the `README` on `main` for up-to-date information.
|
||||||
|
|
||||||
|
| Version | New Features | Bugfixes | Security |
|
||||||
|
|---------|--------------|----------|----------|
|
||||||
|
| *3* | ✓ | ✓ | ✓ |
|
||||||
|
| *2* | ✗ | ✗ | ✓ |
|
||||||
|
| *1* | ✗ | ✗ | ✗ |
|
||||||
|
| *0* | ✗ | ✗ | ✗ |
|
||||||
|
|
||||||
|
This library aligns itself with the PHP core support. New features and bugfixes
|
||||||
|
will only target PHP versions according to their current status.
|
||||||
|
|
||||||
|
See: https://www.php.net/supported-versions.php
|
||||||
|
|
||||||
|
## About the Authors
|
||||||
|
|
||||||
|
- Paul Duncan <pabs@pablotron.org> - https://pablotron.org/
|
||||||
|
- Jonatan Männchen <jonatan@maennchen.ch> - https://maennchen.dev
|
||||||
|
- Jesse G. Donat <donatj@gmail.com> - https://donatstudios.com
|
||||||
|
- Nicolas CARPi <nico-git@deltablot.email> - https://www.deltablot.com
|
||||||
|
- Nik Barham <nik@brokencube.co.uk> - https://www.brokencube.co.uk
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"name": "maennchen/zipstream-php",
|
||||||
|
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||||
|
"keywords": ["zip", "stream"],
|
||||||
|
"type": "library",
|
||||||
|
"license": "MIT",
|
||||||
|
"authors": [{
|
||||||
|
"name": "Paul Duncan",
|
||||||
|
"email": "pabs@pablotron.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonatan Männchen",
|
||||||
|
"email": "jonatan@maennchen.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Donat",
|
||||||
|
"email": "donatj@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "András Kolesár",
|
||||||
|
"email": "kolesar@kolesar.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php-64bit": "^8.3",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zlib": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^12.0",
|
||||||
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"mikey179/vfsstream": "^1.6",
|
||||||
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
|
"vimeo/psalm": "^6.0",
|
||||||
|
"brianium/paratest": "^7.7"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"psr/http-message": "^2.0",
|
||||||
|
"guzzlehttp/psr7": "^2.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"format": "php-cs-fixer fix",
|
||||||
|
"test": [
|
||||||
|
"@test:unit",
|
||||||
|
"@test:formatted",
|
||||||
|
"@test:lint"
|
||||||
|
],
|
||||||
|
"test:unit:setup-cov": "@putenv XDEBUG_MODE=coverage",
|
||||||
|
"test:unit": "paratest --functional",
|
||||||
|
"test:unit:cov": ["@test:unit:setup-cov", "@test:unit --coverage-clover=coverage.clover.xml --coverage-html cov"],
|
||||||
|
"test:unit:slow": "@test:unit --group slow",
|
||||||
|
"test:unit:slow:cov": ["@test:unit:setup-cov", "@test:unit --coverage-clover=coverage.clover.xml --coverage-html cov --group slow"],
|
||||||
|
"test:unit:fast": "@test:unit --exclude-group slow",
|
||||||
|
"test:unit:fast:cov": ["@test:unit:setup-cov", "@test:unit --coverage-clover=coverage.clover.xml --coverage-html cov --exclude-group slow"],
|
||||||
|
"test:formatted": "@format --dry-run --stop-on-violation --using-cache=no",
|
||||||
|
"test:lint": "psalm --stats --show-info=true --find-unused-psalm-suppress",
|
||||||
|
"coverage:report": "php-coveralls --coverage_clover=coverage.clover.xml --json_path=coveralls-upload.json --insecure",
|
||||||
|
"install:tools": "phive install --trust-gpg-keys 0x67F861C3D889C656 --trust-gpg-keys 0x8AC0BAA79732DD42 --trust-gpg-keys 0x6DA3ACC4991FFAE5",
|
||||||
|
"docs:generate": "tools/phpdocumentor --sourcecode"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ZipStream\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": { "ZipStream\\Test\\": "test/" }
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"exclude": [
|
||||||
|
"/composer.lock",
|
||||||
|
"/docs",
|
||||||
|
"/.gitattributes",
|
||||||
|
"/.github",
|
||||||
|
"/.gitignore",
|
||||||
|
"/guides",
|
||||||
|
"/.phive",
|
||||||
|
"/.php-cs-fixer.cache",
|
||||||
|
"/.php-cs-fixer.dist.php",
|
||||||
|
"/.phpdoc",
|
||||||
|
"/phpdoc.dist.xml",
|
||||||
|
"/.phpunit.result.cache",
|
||||||
|
"/phpunit.xml.dist",
|
||||||
|
"/psalm.xml",
|
||||||
|
"/test",
|
||||||
|
"/tools",
|
||||||
|
"/.tool-versions",
|
||||||
|
"/vendor"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
Adding Content-Length header
|
||||||
|
=============
|
||||||
|
|
||||||
|
Adding a ``Content-Length`` header for ``ZipStream`` can be achieved by
|
||||||
|
using the options ``SIMULATION_STRICT`` or ``SIMULATION_LAX`` in the
|
||||||
|
``operationMode`` parameter.
|
||||||
|
|
||||||
|
In the ``SIMULATION_STRICT`` mode, ``ZipStream`` will not allow to calculate the
|
||||||
|
size based on reading the whole file. ``SIMULATION_LAX`` will read the whole
|
||||||
|
file if necessary.
|
||||||
|
|
||||||
|
``SIMULATION_STRICT`` is therefore useful to make sure that the size can be
|
||||||
|
calculated efficiently.
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
use ZipStream\OperationMode;
|
||||||
|
use ZipStream\ZipStream;
|
||||||
|
|
||||||
|
$zip = new ZipStream(
|
||||||
|
operationMode: OperationMode::SIMULATE_STRICT, // or SIMULATE_LAX
|
||||||
|
defaultEnableZeroHeader: false,
|
||||||
|
sendHttpHeaders: true,
|
||||||
|
outputStream: $stream,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Normally add files
|
||||||
|
$zip->addFile('sample.txt', 'Sample String Data');
|
||||||
|
|
||||||
|
// Use addFileFromCallback and exactSize if you want to defer opening of
|
||||||
|
// the file resource
|
||||||
|
$zip->addFileFromCallback(
|
||||||
|
'sample.txt',
|
||||||
|
exactSize: 18,
|
||||||
|
callback: function () {
|
||||||
|
return fopen('...');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read resulting file size
|
||||||
|
$size = $zip->finish();
|
||||||
|
|
||||||
|
// Tell it to the browser
|
||||||
|
header('Content-Length: '. $size);
|
||||||
|
|
||||||
|
// Execute the Simulation and stream the actual zip to the client
|
||||||
|
$zip->executeSimulation();
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
Usage with FlySystem
|
||||||
|
===============
|
||||||
|
|
||||||
|
For saving or uploading the generated zip, you can use the
|
||||||
|
`Flysystem <https://flysystem.thephpleague.com>`_ package, and its many
|
||||||
|
adapters.
|
||||||
|
|
||||||
|
For that you will need to provide another stream than the ``php://output``
|
||||||
|
default one, and pass it to Flysystem ``putStream`` method.
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
// Open Stream only once for read and write since it's a memory stream and
|
||||||
|
// the content is lost when closing the stream / opening another one
|
||||||
|
$tempStream = fopen('php://memory', 'w+');
|
||||||
|
|
||||||
|
// Create Zip Archive
|
||||||
|
$zipStream = new ZipStream(
|
||||||
|
outputStream: $tempStream,
|
||||||
|
outputName: 'test.zip',
|
||||||
|
);
|
||||||
|
$zipStream->addFile('test.txt', 'text');
|
||||||
|
$zipStream->finish();
|
||||||
|
|
||||||
|
// Store File
|
||||||
|
// (see Flysystem documentation, and all its framework integration)
|
||||||
|
// Can be any adapter (AWS, Google, Ftp, etc.)
|
||||||
|
$adapter = new Local(__DIR__.'/path/to/folder');
|
||||||
|
$filesystem = new Filesystem($adapter);
|
||||||
|
|
||||||
|
$filesystem->writeStream('test.zip', $tempStream)
|
||||||
|
|
||||||
|
// Close Stream
|
||||||
|
fclose($tempStream);
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
Compressing large files to S3 compatible storages
|
||||||
|
=============
|
||||||
|
|
||||||
|
S3 compatible storages usually have a limitation of 5 GiB when using single upload. When generating larger zips, the
|
||||||
|
solution is to use a `multi-part upload`_.
|
||||||
|
|
||||||
|
.. _multi-part upload: <https://github.com/awsdocs/aws-doc-sdk-examples/blob/75a8bf8536d436db91ec5de1ba0ed80fd258e904/php/example_code/s3/s3-multipart-upload-using-lowlevel-php-sdk-api.php>`
|
||||||
|
|
||||||
|
We can implement a `PSR-7 stream <https://www.php-fig.org/psr/psr-7/>`_ that buffers ZipStream's output and uploads
|
||||||
|
to S3 in chunks.
|
||||||
|
|
||||||
|
MultipartUploadBufferStream example
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Aws\S3\S3ClientInterface;
|
||||||
|
use Psr\Http\Message\StreamInterface;
|
||||||
|
|
||||||
|
use function strlen;
|
||||||
|
use function substr;
|
||||||
|
|
||||||
|
final class MultipartUploadBufferStream implements StreamInterface
|
||||||
|
{
|
||||||
|
private const int PART_SIZE = 5242880; // 5 MiB in bytes
|
||||||
|
|
||||||
|
private string $buffer = '';
|
||||||
|
private int $bufferSize = 0;
|
||||||
|
private int $partNumber = 1;
|
||||||
|
private array $parts = [];
|
||||||
|
|
||||||
|
private string $uploadId;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $destinationFileName,
|
||||||
|
private readonly string $bucket,
|
||||||
|
private readonly S3ClientInterface $client
|
||||||
|
) {
|
||||||
|
$result = $this->client->createMultipartUpload(
|
||||||
|
[
|
||||||
|
'Bucket' => $bucket,
|
||||||
|
'Key' => $destinationFileName,
|
||||||
|
'StorageClass' => 'REDUCED_REDUNDANCY',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->uploadId = $result['UploadId'];
|
||||||
|
|
||||||
|
$this->parts['Parts'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function write($string): int
|
||||||
|
{
|
||||||
|
$chunkSize = strlen($string);
|
||||||
|
|
||||||
|
$this->buffer .= $string;
|
||||||
|
$this->bufferSize += $chunkSize;
|
||||||
|
|
||||||
|
if ($this->bufferSize >= self::PART_SIZE) {
|
||||||
|
$this->uploadPart();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(): void
|
||||||
|
{
|
||||||
|
// Upload remaining closing bytes from zip
|
||||||
|
$this->uploadPart();
|
||||||
|
|
||||||
|
$this->client->completeMultipartUpload([
|
||||||
|
'Bucket' => $this->bucket,
|
||||||
|
'Key' => $this->destinationFileName,
|
||||||
|
'UploadId' => $this->uploadId,
|
||||||
|
'MultipartUpload' => $this->parts,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->bufferSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uploadPart(): void
|
||||||
|
{
|
||||||
|
$result = $this->client->uploadPart([
|
||||||
|
'Bucket' => $this->bucket,
|
||||||
|
'Key' => $this->destinationFileName,
|
||||||
|
'UploadId' => $this->uploadId,
|
||||||
|
'PartNumber' => $this->partNumber,
|
||||||
|
'Body' => $this->buffer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->bufferSize = 0;
|
||||||
|
|
||||||
|
$this->parts['Parts'][$this->partNumber] = [
|
||||||
|
'PartNumber' => $this->partNumber,
|
||||||
|
'ETag' => $result['ETag'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->partNumber++;
|
||||||
|
|
||||||
|
$result = null;
|
||||||
|
gc_collect_cycles(); // To avoid memory leaks. @see github.com/aws/aws-sdk-php/issues/1273
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->getContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContents(): string
|
||||||
|
{
|
||||||
|
$buffer = $this->buffer;
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->bufferSize = 0;
|
||||||
|
|
||||||
|
return $buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detach(): null
|
||||||
|
{
|
||||||
|
$this->close();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSize(): int
|
||||||
|
{
|
||||||
|
return $this->bufferSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isReadable(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isWritable(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSeekable(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rewind(): void
|
||||||
|
{
|
||||||
|
$this->seek(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function seek($offset, $whence = SEEK_SET): void
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('Cannot seek a BufferStream');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function eof(): bool
|
||||||
|
{
|
||||||
|
return $this->bufferSize === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tell(): int
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('Cannot determine the position of a BufferStream');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read($length): string
|
||||||
|
{
|
||||||
|
$currentLength = $this->bufferSize;
|
||||||
|
|
||||||
|
if ($length >= $currentLength) {
|
||||||
|
// No need to slice the buffer because we don't have enough data.
|
||||||
|
$result = $this->buffer;
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->bufferSize = 0;
|
||||||
|
} else {
|
||||||
|
// Slice up the result to provide a subset of the buffer.
|
||||||
|
$result = substr($this->buffer, 0, $length);
|
||||||
|
$this->buffer = substr($this->buffer, $length);
|
||||||
|
$this->bufferSize -= $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMetadata($key = null)
|
||||||
|
{
|
||||||
|
return $key ? null : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Then, we can use it with ZipStream to compress the files and upload the parts to the storage.
|
||||||
|
|
||||||
|
ZipStream usage
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Aws\S3\S3Client;
|
||||||
|
use Aws\Credentials\CredentialProvider;
|
||||||
|
use MultipartUploadBufferStream;
|
||||||
|
use ZipStream\ZipStream;
|
||||||
|
|
||||||
|
$bucket = 'your bucket name';
|
||||||
|
$client = new S3Client([
|
||||||
|
'region' => 'your region',
|
||||||
|
'version' => 'latest',
|
||||||
|
'bucketName' => $bucket,
|
||||||
|
'credentials' => CredentialProvider::defaultProvider(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bufferStream = new MultipartUploadBufferStream(
|
||||||
|
'destination-file.zip',
|
||||||
|
$bucket,
|
||||||
|
$client,
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip = new ZipStream(
|
||||||
|
outputStream: $destination,
|
||||||
|
defaultCompressionMethod: CompressionMethod::STORE,
|
||||||
|
defaultEnableZeroHeader: true,
|
||||||
|
sendHttpHeaders: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->addFile(
|
||||||
|
fileName: 'big-file-1.txt',
|
||||||
|
data: 'File1 data',
|
||||||
|
);
|
||||||
|
$zip->addFile(
|
||||||
|
fileName: 'big-file-2.txt',
|
||||||
|
data: 'File2 data',
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->finish();
|
||||||
|
$destination->close(); // Needed after $zip->finish() to upload the last remaining bytes to S3
|
||||||
|
|
||||||
|
You can read more about the logic behind this implementation in the `discussion`_.
|
||||||
|
|
||||||
|
.. _discussion: https://github.com/maennchen/ZipStream-PHP/discussions/402
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
Usage with nginx
|
||||||
|
=============
|
||||||
|
|
||||||
|
If you are using nginx as a webserver, it will try to buffer the response.
|
||||||
|
So you'll want to disable this with a custom header:
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
header('X-Accel-Buffering: no');
|
||||||
|
# or with the Response class from Symfony
|
||||||
|
$response->headers->set('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
Alternatively, you can tweak the
|
||||||
|
`fastcgi cache parameters <https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_buffers>`_
|
||||||
|
within nginx config.
|
||||||
|
|
||||||
|
See `original issue <https://github.com/maennchen/ZipStream-PHP/issues/77>`_.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
Available options
|
||||||
|
===============
|
||||||
|
|
||||||
|
Here is the full list of options available to you. You can also have a look at
|
||||||
|
``src/ZipStream.php`` file.
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
use ZipStream\ZipStream;
|
||||||
|
|
||||||
|
require_once 'vendor/autoload.php';
|
||||||
|
|
||||||
|
$zip = new ZipStream(
|
||||||
|
// Define output stream
|
||||||
|
// (argument is either a resource or implementing
|
||||||
|
// `Psr\Http\Message\StreamInterface`)
|
||||||
|
//
|
||||||
|
// Setup with `psr/http-message` & `guzzlehttp/psr7` dependencies
|
||||||
|
// required when using `Psr\Http\Message\StreamInterface`.
|
||||||
|
//
|
||||||
|
// Can also use CallbackStreamWrapper for custom output handling:
|
||||||
|
// outputStream: CallbackStreamWrapper::open(function($data) { /* handle data */ }),
|
||||||
|
outputStream: $filePointer,
|
||||||
|
|
||||||
|
// Set the deflate level (default is 6; use -1 to disable it)
|
||||||
|
defaultDeflateLevel: 6,
|
||||||
|
|
||||||
|
// Add a comment to the zip file
|
||||||
|
comment: 'This is a comment.',
|
||||||
|
|
||||||
|
// Send http headers (default is true)
|
||||||
|
sendHttpHeaders: false,
|
||||||
|
|
||||||
|
// HTTP Content-Disposition.
|
||||||
|
// Defaults to 'attachment', where FILENAME is the specified filename.
|
||||||
|
// Note that this does nothing if you are not sending HTTP headers.
|
||||||
|
contentDisposition: 'attachment',
|
||||||
|
|
||||||
|
// Output Name for HTTP Content-Disposition
|
||||||
|
// Defaults to no name
|
||||||
|
outputName: "example.zip",
|
||||||
|
|
||||||
|
// HTTP Content-Type.
|
||||||
|
// Defaults to 'application/x-zip'.
|
||||||
|
// Note that this does nothing if you are not sending HTTP headers.
|
||||||
|
contentType: 'application/x-zip',
|
||||||
|
|
||||||
|
// Set the function called for setting headers.
|
||||||
|
// Default is the `header()` of PHP
|
||||||
|
httpHeaderCallback: header(...),
|
||||||
|
|
||||||
|
// Enable streaming files with single read where general purpose bit 3
|
||||||
|
// indicates local file header contain zero values in crc and size
|
||||||
|
// fields, these appear only after file contents in data descriptor
|
||||||
|
// block.
|
||||||
|
// Set to true if your input stream is remote
|
||||||
|
// (used with addFileFromStream()).
|
||||||
|
// Default is false.
|
||||||
|
defaultEnableZeroHeader: false,
|
||||||
|
|
||||||
|
// Enable zip64 extension, allowing very large archives
|
||||||
|
// (> 4Gb or file count > 64k)
|
||||||
|
// Default is true
|
||||||
|
enableZip64: true,
|
||||||
|
|
||||||
|
// Flush output buffer after every write
|
||||||
|
// Default is false
|
||||||
|
flushOutput: true,
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
Usage with PSR 7 Streams
|
||||||
|
===============
|
||||||
|
|
||||||
|
PSR-7 streams are `standardized streams <https://www.php-fig.org/psr/psr-7/>`_.
|
||||||
|
|
||||||
|
ZipStream-PHP supports working with these streams with the function
|
||||||
|
``addFileFromPsr7Stream``.
|
||||||
|
|
||||||
|
For all parameters of the function see the API documentation.
|
||||||
|
|
||||||
|
Example
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
$stream = $response->getBody();
|
||||||
|
// add a file named 'streamfile.txt' from the content of the stream
|
||||||
|
$zip->addFileFromPsr7Stream(
|
||||||
|
fileName: 'streamfile.txt',
|
||||||
|
stream: $stream,
|
||||||
|
);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
Stream Output
|
||||||
|
===============
|
||||||
|
|
||||||
|
Stream to S3 Bucket
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
use Aws\S3\S3Client;
|
||||||
|
use Aws\Credentials\CredentialProvider;
|
||||||
|
use ZipStream\ZipStream;
|
||||||
|
|
||||||
|
$bucket = 'your bucket name';
|
||||||
|
$client = new S3Client([
|
||||||
|
'region' => 'your region',
|
||||||
|
'version' => 'latest',
|
||||||
|
'bucketName' => $bucket,
|
||||||
|
'credentials' => CredentialProvider::defaultProvider(),
|
||||||
|
]);
|
||||||
|
$client->registerStreamWrapper();
|
||||||
|
|
||||||
|
$zipFile = fopen("s3://$bucket/example.zip", 'w');
|
||||||
|
|
||||||
|
$zip = new ZipStream(
|
||||||
|
enableZip64: false,
|
||||||
|
outputStream: $zipFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->addFile(
|
||||||
|
fileName: 'file1.txt',
|
||||||
|
data: 'File1 data',
|
||||||
|
);
|
||||||
|
$zip->addFile(
|
||||||
|
fileName: 'file2.txt',
|
||||||
|
data: 'File2 data',
|
||||||
|
);
|
||||||
|
$zip->finish();
|
||||||
|
|
||||||
|
fclose($zipFile);
|
||||||
|
|
||||||
|
Stream to Callback Function
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
The CallbackStreamWrapper allows you to stream ZIP data to a custom callback function,
|
||||||
|
enabling flexible output handling such as streaming to multiple destinations,
|
||||||
|
progress tracking, or data transformation.
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
use ZipStream\ZipStream;
|
||||||
|
use ZipStream\Stream\CallbackStreamWrapper;
|
||||||
|
|
||||||
|
// Example 1: Stream to multiple destinations with proper file handling
|
||||||
|
$backupFile = fopen('backup.zip', 'wb');
|
||||||
|
$logFile = fopen('transfer.log', 'ab');
|
||||||
|
|
||||||
|
$zip = new ZipStream(
|
||||||
|
outputStream: CallbackStreamWrapper::open(function (string $data) use ($backupFile, $logFile) {
|
||||||
|
// Send to browser
|
||||||
|
echo $data;
|
||||||
|
|
||||||
|
// Save to file efficiently
|
||||||
|
fwrite($backupFile, $data);
|
||||||
|
|
||||||
|
// Log transfer progress
|
||||||
|
fwrite($logFile, "Transferred " . strlen($data) . " bytes\n");
|
||||||
|
}),
|
||||||
|
sendHttpHeaders: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->addFile('hello.txt', 'Hello World!');
|
||||||
|
$zip->finish();
|
||||||
|
|
||||||
|
// Clean up resources
|
||||||
|
fclose($backupFile);
|
||||||
|
fclose($logFile);
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
// Example 2: Progress tracking
|
||||||
|
$totalBytes = 0;
|
||||||
|
$zip = new ZipStream(
|
||||||
|
outputStream: CallbackStreamWrapper::open(function (string $data) use (&$totalBytes) {
|
||||||
|
$totalBytes += strlen($data);
|
||||||
|
reportProgress($totalBytes); // Report progress to your tracking system
|
||||||
|
|
||||||
|
// Your actual output handling
|
||||||
|
echo $data;
|
||||||
|
}),
|
||||||
|
sendHttpHeaders: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->addFile('large_file.txt', str_repeat('A', 10000));
|
||||||
|
$zip->finish();
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
// Example 3: Data transformation using PHP stream filters
|
||||||
|
// For data transformations, prefer PHP's built-in stream filters
|
||||||
|
$outputStream = fopen('php://output', 'w');
|
||||||
|
stream_filter_append($outputStream, 'convert.base64-encode');
|
||||||
|
|
||||||
|
$zip = new ZipStream(
|
||||||
|
outputStream: $outputStream,
|
||||||
|
sendHttpHeaders: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->addFile('secret.txt', 'Confidential data');
|
||||||
|
$zip->finish();
|
||||||
|
fclose($outputStream);
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
For data transformations, PHP's built-in stream filters are preferred over callback transformations. Stream filters operate at the stream level and maintain data integrity. You can register custom filters using ``stream_filter_register()`` for specialized transformations.
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
Usage with Symfony
|
||||||
|
===============
|
||||||
|
|
||||||
|
Overview for using ZipStream in Symfony
|
||||||
|
--------
|
||||||
|
|
||||||
|
Using ZipStream in Symfony requires use of Symfony's ``StreamedResponse`` when
|
||||||
|
used in controller actions.
|
||||||
|
|
||||||
|
Wrap your call to the relevant ``ZipStream`` stream method (i.e. ``addFile``,
|
||||||
|
``addFileFromPath``, ``addFileFromStream``) in Symfony's ``StreamedResponse``
|
||||||
|
function passing in any required arguments for your use case.
|
||||||
|
|
||||||
|
Using Symfony's ``StreamedResponse`` will allow Symfony to stream output from
|
||||||
|
ZipStream correctly to users' browsers and avoid a corrupted final zip landing
|
||||||
|
on the users' end.
|
||||||
|
|
||||||
|
Example for using ``ZipStream`` in a controller action to zip stream files
|
||||||
|
stored in an AWS S3 bucket by key:
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
use Aws\S3\S3Client;
|
||||||
|
use ZipStream;
|
||||||
|
|
||||||
|
//...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/zipstream", name="zipstream")
|
||||||
|
*/
|
||||||
|
public function zipStreamAction()
|
||||||
|
{
|
||||||
|
// sample test file on s3
|
||||||
|
$s3keys = array(
|
||||||
|
"ziptestfolder/file1.txt"
|
||||||
|
);
|
||||||
|
|
||||||
|
$s3Client = $this->get('app.amazon.s3'); //s3client service
|
||||||
|
$s3Client->registerStreamWrapper(); //required
|
||||||
|
|
||||||
|
// using StreamedResponse to wrap ZipStream functionality
|
||||||
|
// for files on AWS s3.
|
||||||
|
$response = new StreamedResponse(function() use($s3keys, $s3Client)
|
||||||
|
{
|
||||||
|
// Define suitable options for ZipStream Archive.
|
||||||
|
// this is needed to prevent issues with truncated zip files
|
||||||
|
//initialise zipstream with output zip filename and options.
|
||||||
|
$zip = new ZipStream\ZipStream(
|
||||||
|
outputName: 'test.zip',
|
||||||
|
defaultEnableZeroHeader: true,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
);
|
||||||
|
|
||||||
|
//loop keys - useful for multiple files
|
||||||
|
foreach ($s3keys as $key) {
|
||||||
|
// Get the file name in S3 key so we can save it to the zip
|
||||||
|
//file using the same name.
|
||||||
|
$fileName = basename($key);
|
||||||
|
|
||||||
|
// concatenate s3path.
|
||||||
|
// replace with your bucket name or get from parameters file.
|
||||||
|
$bucket = 'bucketname';
|
||||||
|
$s3path = "s3://" . $bucket . "/" . $key;
|
||||||
|
|
||||||
|
//addFileFromStream
|
||||||
|
if ($streamRead = fopen($s3path, 'r')) {
|
||||||
|
$zip->addFileFromStream(
|
||||||
|
fileName: $fileName,
|
||||||
|
stream: $streamRead,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
die('Could not open stream for reading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->finish();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
In the above example, files on AWS S3 are being streamed from S3 to the Symfon
|
||||||
|
application via ``fopen`` call when the s3Client has ``registerStreamWrapper``
|
||||||
|
applied. This stream is then passed to ``ZipStream`` via the
|
||||||
|
``addFileFromStream`` function, which ZipStream then streams as a zip to the
|
||||||
|
client browser via Symfony's ``StreamedResponse``. No Zip is created server
|
||||||
|
side, which makes this approach a more efficient solution for streaming zips to
|
||||||
|
the client browser especially for larger files.
|
||||||
|
|
||||||
|
For the above use case you will need to have installed
|
||||||
|
`aws/aws-sdk-php-symfony <https://github.com/aws/aws-sdk-php-symfony>`_ to
|
||||||
|
support accessing S3 objects in your Symfony web application. This is not
|
||||||
|
required for locally stored files on you server you intend to stream via
|
||||||
|
``ZipStream``.
|
||||||
|
|
||||||
|
See official Symfony documentation for details on
|
||||||
|
`Symfony's StreamedResponse <https://symfony.com/doc/current/components/http_foundation.html#streaming-a-response>`_
|
||||||
|
``Symfony\Component\HttpFoundation\StreamedResponse``.
|
||||||
|
|
||||||
|
Note from `S3 documentation <https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/s3-stream-wrapper.html>`_:
|
||||||
|
|
||||||
|
Streams opened in "r" mode only allow data to be read from the stream, and
|
||||||
|
are not seekable by default. This is so that data can be downloaded from
|
||||||
|
Amazon S3 in a truly streaming manner, where previously read bytes do not
|
||||||
|
need to be buffered into memory. If you need a stream to be seekable, you
|
||||||
|
can pass seekable into the stream context options of a function.
|
||||||
|
|
||||||
|
Make sure to configure your S3 context correctly!
|
||||||
|
|
||||||
|
Uploading a file
|
||||||
|
--------
|
||||||
|
|
||||||
|
You need to add correct permissions
|
||||||
|
(see `#120 <https://github.com/maennchen/ZipStream-PHP/issues/120>`_)
|
||||||
|
|
||||||
|
**example code**
|
||||||
|
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
$path = "s3://{$adapter->getBucket()}/{$this->getArchivePath()}";
|
||||||
|
|
||||||
|
// the important bit
|
||||||
|
$outputContext = stream_context_create([
|
||||||
|
's3' => ['ACL' => 'public-read'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
fopen($path, 'w', null, $outputContext);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
Usage with Varnish
|
||||||
|
=============
|
||||||
|
|
||||||
|
Serving a big zip with varnish in between can cause random stream close.
|
||||||
|
This can be solved by adding attached code to the vcl file.
|
||||||
|
|
||||||
|
To avoid the problem, add the following to your varnish config file:
|
||||||
|
|
||||||
|
.. code-block::
|
||||||
|
sub vcl_recv {
|
||||||
|
# Varnish can’t intercept the discussion anymore
|
||||||
|
# helps for streaming big zips
|
||||||
|
if (req.url ~ "\.(tar|gz|zip|7z|exe)$") {
|
||||||
|
return (pipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Varnish can’t intercept the discussion anymore
|
||||||
|
# helps for streaming big zips
|
||||||
|
sub vcl_pipe {
|
||||||
|
set bereq.http.connection = "close";
|
||||||
|
return (pipe);
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
ZipStream PHP
|
||||||
|
=============
|
||||||
|
|
||||||
|
A fast and simple streaming zip file downloader for PHP. Using this library will
|
||||||
|
save you from having to write the Zip to disk. You can directly send it to the
|
||||||
|
user, which is much faster. It can work with S3 buckets or any PSR7 Stream.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
|
||||||
|
index
|
||||||
|
Symfony
|
||||||
|
Options
|
||||||
|
StreamOutput
|
||||||
|
FlySystem
|
||||||
|
PSR7Streams
|
||||||
|
Nginx
|
||||||
|
Varnish
|
||||||
|
ContentLength
|
||||||
|
LargeFilesToS3
|
||||||
|
|
||||||
|
Installation
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Simply add a dependency on ``maennchen/zipstream-php`` to your project's
|
||||||
|
``composer.json`` file if you use Composer to manage the dependencies of your
|
||||||
|
project. Use following command to add the package to your project's
|
||||||
|
dependencies:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
composer require maennchen/zipstream-php
|
||||||
|
|
||||||
|
If you want to use``addFileFromPsr7Stream```
|
||||||
|
(``Psr\Http\Message\StreamInterface``) or use a stream instead of a
|
||||||
|
``resource`` as ``outputStream``, the following dependencies must be installed
|
||||||
|
as well:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
composer require psr/http-message guzzlehttp/psr7
|
||||||
|
|
||||||
|
If ``composer install`` yields the following error, your installation is missing
|
||||||
|
the `mbstring extension <https://www.php.net/manual/en/book.mbstring.php>`_,
|
||||||
|
either `install it <https://www.php.net/manual/en/mbstring.installation.php>`_
|
||||||
|
or run the following command:
|
||||||
|
|
||||||
|
.. code-block::
|
||||||
|
Your requirements could not be resolved to an installable set of packages.
|
||||||
|
|
||||||
|
Problem 1
|
||||||
|
- Root composer.json requires PHP extension ext-mbstring * but it is
|
||||||
|
missing from your system. Install or enable PHP's mbstrings extension.
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
composer require symfony/polyfill-mbstring
|
||||||
|
|
||||||
|
Usage Intro
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Here's a simple example:
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
|
||||||
|
// Autoload the dependencies
|
||||||
|
require 'vendor/autoload.php';
|
||||||
|
|
||||||
|
// create a new zipstream object
|
||||||
|
$zip = new ZipStream\ZipStream(
|
||||||
|
outputName: 'example.zip',
|
||||||
|
|
||||||
|
// enable output of HTTP headers
|
||||||
|
sendHttpHeaders: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// create a file named 'hello.txt'
|
||||||
|
$zip->addFile(
|
||||||
|
fileName: 'hello.txt',
|
||||||
|
data: 'This is the contents of hello.txt',
|
||||||
|
);
|
||||||
|
|
||||||
|
// add a file named 'some_image.jpg' from a local file 'path/to/image.jpg'
|
||||||
|
$zip->addFileFromPath(
|
||||||
|
fileName: 'some_image.jpg',
|
||||||
|
path: 'path/to/image.jpg',
|
||||||
|
);
|
||||||
|
|
||||||
|
// add a file named 'goodbye.txt' from an open stream resource
|
||||||
|
$filePointer = tmpfile();
|
||||||
|
fwrite($filePointer, 'The quick brown fox jumped over the lazy dog.');
|
||||||
|
rewind($filePointer);
|
||||||
|
$zip->addFileFromStream(
|
||||||
|
fileName: 'goodbye.txt',
|
||||||
|
stream: $filePointer,
|
||||||
|
);
|
||||||
|
fclose($filePointer);
|
||||||
|
|
||||||
|
// add a file named 'streamfile.txt' from the body of a `guzzle` response
|
||||||
|
// Setup with `psr/http-message` & `guzzlehttp/psr7` dependencies required.
|
||||||
|
$zip->addFileFromPsr7Stream(
|
||||||
|
fileName: 'streamfile.txt',
|
||||||
|
stream: $response->getBody(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// finish the zip stream
|
||||||
|
$zip->finish();
|
||||||
|
|
||||||
|
You can also add comments, modify file timestamps, and customize (or
|
||||||
|
disable) the HTTP headers. It is also possible to specify the storage method
|
||||||
|
when adding files, the current default storage method is ``DEFLATE``
|
||||||
|
i.e files are stored with Compression mode 0x08.
|
||||||
|
|
||||||
|
Known Issues
|
||||||
|
---------------
|
||||||
|
|
||||||
|
The native Mac OS archive extraction tool prior to macOS 10.15 might not open
|
||||||
|
archives in some conditions. A workaround is to disable the Zip64 feature with
|
||||||
|
the option ``enableZip64: false``. This limits the archive to 4 Gb and 64k files
|
||||||
|
but will allow users on macOS 10.14 and below to open them without issue.
|
||||||
|
See `#116 <https://github.com/maennchen/ZipStream-PHP/issues/116>`_.
|
||||||
|
|
||||||
|
The linux ``unzip`` utility might not handle properly unicode characters.
|
||||||
|
It is recommended to extract with another tool like
|
||||||
|
`7-zip <https://www.7-zip.org/>`_.
|
||||||
|
See `#146 <https://github.com/maennchen/ZipStream-PHP/issues/146>`_.
|
||||||
|
|
||||||
|
It is the responsibility of the client code to make sure that files are not
|
||||||
|
saved with the same path, as it is not possible for the library to figure it out
|
||||||
|
while streaming a zip.
|
||||||
|
See `#154 <https://github.com/maennchen/ZipStream-PHP/issues/154>`_.
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<phpdocumentor
|
||||||
|
configVersion="3"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns="https://www.phpdoc.org"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/phpDocumentor/phpDocumentor/master/data/xsd/phpdoc.xsd"
|
||||||
|
>
|
||||||
|
<title>💾 ZipStream-PHP</title>
|
||||||
|
<paths>
|
||||||
|
<output>docs</output>
|
||||||
|
</paths>
|
||||||
|
<version number="3.0.0">
|
||||||
|
<folder>latest</folder>
|
||||||
|
<api>
|
||||||
|
<source dsn=".">
|
||||||
|
<path>src</path>
|
||||||
|
</source>
|
||||||
|
<output>api</output>
|
||||||
|
<ignore hidden="true" symlinks="true">
|
||||||
|
<path>tests/**/*</path>
|
||||||
|
<path>vendor/**/*</path>
|
||||||
|
</ignore>
|
||||||
|
<extensions>
|
||||||
|
<extension>php</extension>
|
||||||
|
</extensions>
|
||||||
|
<visibility>public</visibility>
|
||||||
|
<default-package-name>ZipStream</default-package-name>
|
||||||
|
<include-source>true</include-source>
|
||||||
|
</api>
|
||||||
|
<guide>
|
||||||
|
<source dsn=".">
|
||||||
|
<path>guides</path>
|
||||||
|
</source>
|
||||||
|
<output>guide</output>
|
||||||
|
</guide>
|
||||||
|
</version>
|
||||||
|
<setting name="guides.enabled" value="true"/>
|
||||||
|
<template name="default" />
|
||||||
|
</phpdocumentor>
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="test/bootstrap.php" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd" cacheDirectory=".phpunit.cache">
|
||||||
|
<coverage/>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Application">
|
||||||
|
<directory>test</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<logging/>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<psalm
|
||||||
|
errorLevel="1"
|
||||||
|
resolveFromConfigFile="true"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns="https://getpsalm.org/schema/config"
|
||||||
|
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
|
||||||
|
findUnusedBaselineEntry="true"
|
||||||
|
findUnusedCode="true"
|
||||||
|
phpVersion="8.2.0"
|
||||||
|
>
|
||||||
|
<!-- TODO: Update phpVersion when raising the minimum supported version -->
|
||||||
|
<projectFiles>
|
||||||
|
<directory name="src" />
|
||||||
|
<ignoreFiles>
|
||||||
|
<directory name="vendor" />
|
||||||
|
</ignoreFiles>
|
||||||
|
</projectFiles>
|
||||||
|
<issueHandlers>
|
||||||
|
<!-- Turn off dead code warnings for externally called functions -->
|
||||||
|
<PossiblyUnusedProperty errorLevel="suppress" />
|
||||||
|
<PossiblyUnusedMethod errorLevel="suppress" />
|
||||||
|
<PossiblyUnusedReturnValue errorLevel="suppress" />
|
||||||
|
</issueHandlers>
|
||||||
|
</psalm>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
use DateTimeInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
abstract class CentralDirectoryFileHeader
|
||||||
|
{
|
||||||
|
private const SIGNATURE = 0x02014b50;
|
||||||
|
|
||||||
|
public static function generate(
|
||||||
|
int $versionMadeBy,
|
||||||
|
int $versionNeededToExtract,
|
||||||
|
int $generalPurposeBitFlag,
|
||||||
|
CompressionMethod $compressionMethod,
|
||||||
|
DateTimeInterface $lastModificationDateTime,
|
||||||
|
int $crc32,
|
||||||
|
int $compressedSize,
|
||||||
|
int $uncompressedSize,
|
||||||
|
string $fileName,
|
||||||
|
string $extraField,
|
||||||
|
string $fileComment,
|
||||||
|
int $diskNumberStart,
|
||||||
|
int $internalFileAttributes,
|
||||||
|
int $externalFileAttributes,
|
||||||
|
int $relativeOffsetOfLocalHeader,
|
||||||
|
): string {
|
||||||
|
return PackField::pack(
|
||||||
|
new PackField(format: 'V', value: self::SIGNATURE),
|
||||||
|
new PackField(format: 'v', value: $versionMadeBy),
|
||||||
|
new PackField(format: 'v', value: $versionNeededToExtract),
|
||||||
|
new PackField(format: 'v', value: $generalPurposeBitFlag),
|
||||||
|
new PackField(format: 'v', value: $compressionMethod->value),
|
||||||
|
new PackField(format: 'V', value: Time::dateTimeToDosTime($lastModificationDateTime)),
|
||||||
|
new PackField(format: 'V', value: $crc32),
|
||||||
|
new PackField(format: 'V', value: $compressedSize),
|
||||||
|
new PackField(format: 'V', value: $uncompressedSize),
|
||||||
|
new PackField(format: 'v', value: strlen($fileName)),
|
||||||
|
new PackField(format: 'v', value: strlen($extraField)),
|
||||||
|
new PackField(format: 'v', value: strlen($fileComment)),
|
||||||
|
new PackField(format: 'v', value: $diskNumberStart),
|
||||||
|
new PackField(format: 'v', value: $internalFileAttributes),
|
||||||
|
new PackField(format: 'V', value: $externalFileAttributes),
|
||||||
|
new PackField(format: 'V', value: $relativeOffsetOfLocalHeader),
|
||||||
|
) . $fileName . $extraField . $fileComment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
enum CompressionMethod: int
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The file is stored (no compression)
|
||||||
|
*/
|
||||||
|
case STORE = 0x00;
|
||||||
|
|
||||||
|
// 0x01: legacy algorithm - The file is Shrunk
|
||||||
|
// 0x02: legacy algorithm - The file is Reduced with compression factor 1
|
||||||
|
// 0x03: legacy algorithm - The file is Reduced with compression factor 2
|
||||||
|
// 0x04: legacy algorithm - The file is Reduced with compression factor 3
|
||||||
|
// 0x05: legacy algorithm - The file is Reduced with compression factor 4
|
||||||
|
// 0x06: legacy algorithm - The file is Imploded
|
||||||
|
// 0x07: Reserved for Tokenizing compression algorithm
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file is Deflated
|
||||||
|
*/
|
||||||
|
case DEFLATE = 0x08;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Enhanced Deflating using Deflate64(tm)
|
||||||
|
// */
|
||||||
|
// case DEFLATE_64 = 0x09;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * PKWARE Data Compression Library Imploding (old IBM TERSE)
|
||||||
|
// */
|
||||||
|
// case PKWARE = 0x0a;
|
||||||
|
|
||||||
|
// // 0x0b: Reserved by PKWARE
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * File is compressed using BZIP2 algorithm
|
||||||
|
// */
|
||||||
|
// case BZIP2 = 0x0c;
|
||||||
|
|
||||||
|
// // 0x0d: Reserved by PKWARE
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * LZMA
|
||||||
|
// */
|
||||||
|
// case LZMA = 0x0e;
|
||||||
|
|
||||||
|
// // 0x0f: Reserved by PKWARE
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * IBM z/OS CMPSC Compression
|
||||||
|
// */
|
||||||
|
// case IBM_ZOS_CMPSC = 0x10;
|
||||||
|
|
||||||
|
// // 0x11: Reserved by PKWARE
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * File is compressed using IBM TERSE
|
||||||
|
// */
|
||||||
|
// case IBM_TERSE = 0x12;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * IBM LZ77 z Architecture
|
||||||
|
// */
|
||||||
|
// case IBM_LZ77 = 0x13;
|
||||||
|
|
||||||
|
// // 0x14: deprecated (use method 93 for zstd)
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Zstandard (zstd) Compression
|
||||||
|
// */
|
||||||
|
// case ZSTD = 0x5d;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * MP3 Compression
|
||||||
|
// */
|
||||||
|
// case MP3 = 0x5e;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * XZ Compression
|
||||||
|
// */
|
||||||
|
// case XZ = 0x5f;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * JPEG variant
|
||||||
|
// */
|
||||||
|
// case JPEG = 0x60;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * WavPack compressed data
|
||||||
|
// */
|
||||||
|
// case WAV_PACK = 0x61;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * PPMd version I, Rev 1
|
||||||
|
// */
|
||||||
|
// case PPMD_1_1 = 0x62;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * AE-x encryption marker
|
||||||
|
// */
|
||||||
|
// case AE_X_ENCRYPTION = 0x63;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
abstract class DataDescriptor
|
||||||
|
{
|
||||||
|
private const SIGNATURE = 0x08074b50;
|
||||||
|
|
||||||
|
public static function generate(
|
||||||
|
int $crc32UncompressedData,
|
||||||
|
int $compressedSize,
|
||||||
|
int $uncompressedSize,
|
||||||
|
): string {
|
||||||
|
return PackField::pack(
|
||||||
|
new PackField(format: 'V', value: self::SIGNATURE),
|
||||||
|
new PackField(format: 'V', value: $crc32UncompressedData),
|
||||||
|
new PackField(format: 'V', value: $compressedSize),
|
||||||
|
new PackField(format: 'V', value: $uncompressedSize),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
abstract class EndOfCentralDirectory
|
||||||
|
{
|
||||||
|
private const SIGNATURE = 0x06054b50;
|
||||||
|
|
||||||
|
public static function generate(
|
||||||
|
int $numberOfThisDisk,
|
||||||
|
int $numberOfTheDiskWithCentralDirectoryStart,
|
||||||
|
int $numberOfCentralDirectoryEntriesOnThisDisk,
|
||||||
|
int $numberOfCentralDirectoryEntries,
|
||||||
|
int $sizeOfCentralDirectory,
|
||||||
|
int $centralDirectoryStartOffsetOnDisk,
|
||||||
|
string $zipFileComment,
|
||||||
|
): string {
|
||||||
|
/** @psalm-suppress MixedArgument */
|
||||||
|
return PackField::pack(
|
||||||
|
new PackField(format: 'V', value: static::SIGNATURE),
|
||||||
|
new PackField(format: 'v', value: $numberOfThisDisk),
|
||||||
|
new PackField(format: 'v', value: $numberOfTheDiskWithCentralDirectoryStart),
|
||||||
|
new PackField(format: 'v', value: $numberOfCentralDirectoryEntriesOnThisDisk),
|
||||||
|
new PackField(format: 'v', value: $numberOfCentralDirectoryEntries),
|
||||||
|
new PackField(format: 'V', value: $sizeOfCentralDirectory),
|
||||||
|
new PackField(format: 'V', value: $centralDirectoryStartOffsetOnDisk),
|
||||||
|
new PackField(format: 'v', value: strlen($zipFileComment)),
|
||||||
|
) . $zipFileComment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
abstract class Exception extends \Exception {}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Exception;
|
||||||
|
|
||||||
|
use DateTimeInterface;
|
||||||
|
use ZipStream\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Exception gets invoked if a DOS time is overflowing
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class DosTimeOverflowException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly DateTimeInterface $dateTime
|
||||||
|
) {
|
||||||
|
parent::__construct('The date ' . $dateTime->format(DateTimeInterface::ATOM) . " can't be represented as DOS time / date.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Exception;
|
||||||
|
|
||||||
|
use ZipStream\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Exception gets invoked if a file wasn't found
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class FileNotFoundException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $path
|
||||||
|
) {
|
||||||
|
parent::__construct("The file with the path $path wasn't found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Exception;
|
||||||
|
|
||||||
|
use ZipStream\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Exception gets invoked if a file isn't readable
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class FileNotReadableException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $path
|
||||||
|
) {
|
||||||
|
parent::__construct("The file with the path $path isn't readable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Exception;
|
||||||
|
|
||||||
|
use ZipStream\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Exception gets invoked if a file is not as large as it was specified.
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class FileSizeIncorrectException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $expectedSize,
|
||||||
|
public readonly int $actualSize
|
||||||
|
) {
|
||||||
|
parent::__construct("File is {$actualSize} instead of {$expectedSize} bytes large. Adjust `exactSize` parameter.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Exception;
|
||||||
|
|
||||||
|
use ZipStream\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Exception gets invoked if a counter value exceeds storage size
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class OverflowException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('File size exceeds limit of 32 bit integer. Please enable "zip64" option.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Exception;
|
||||||
|
|
||||||
|
use ZipStream\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Exception gets invoked if a resource like `fread` returns false
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class ResourceActionException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var ?resource
|
||||||
|
*/
|
||||||
|
public $resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $resource
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $function,
|
||||||
|
$resource = null,
|
||||||
|
) {
|
||||||
|
$this->resource = $resource;
|
||||||
|
parent::__construct('Function ' . $function . 'failed on resource.');
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Exception;
|
||||||
|
|
||||||
|
use ZipStream\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Exception gets invoked if a strict simulation is executed and the file
|
||||||
|
* information can't be determined without reading the entire file.
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class SimulationFileUnknownException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('The details of the strict simulation file could not be determined without reading the entire file.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Exception;
|
||||||
|
|
||||||
|
use ZipStream\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Exception gets invoked if a stream can't be read.
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class StreamNotReadableException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('The stream could not be read.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Exception;
|
||||||
|
|
||||||
|
use ZipStream\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This Exception gets invoked if a non seekable stream is
|
||||||
|
* provided and zero headers are disabled.
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class StreamNotSeekableException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('enableZeroHeader must be enable to add non seekable streams');
|
||||||
|
}
|
||||||
|
}
|
||||||
+430
@@ -0,0 +1,430 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use DeflateContext;
|
||||||
|
use RuntimeException;
|
||||||
|
use ZipStream\Exception\FileSizeIncorrectException;
|
||||||
|
use ZipStream\Exception\OverflowException;
|
||||||
|
use ZipStream\Exception\ResourceActionException;
|
||||||
|
use ZipStream\Exception\SimulationFileUnknownException;
|
||||||
|
use ZipStream\Exception\StreamNotReadableException;
|
||||||
|
use ZipStream\Exception\StreamNotSeekableException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class File
|
||||||
|
{
|
||||||
|
private const CHUNKED_READ_BLOCK_SIZE = 0x1000000;
|
||||||
|
|
||||||
|
private Version $version;
|
||||||
|
|
||||||
|
private int $compressedSize = 0;
|
||||||
|
|
||||||
|
private int $uncompressedSize = 0;
|
||||||
|
|
||||||
|
private int $crc = 0;
|
||||||
|
|
||||||
|
private int $generalPurposeBitFlag = 0;
|
||||||
|
|
||||||
|
private readonly string $fileName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var resource|null
|
||||||
|
*/
|
||||||
|
private $stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Closure $dataCallback
|
||||||
|
* @psalm-param Closure(): resource $dataCallback
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
string $fileName,
|
||||||
|
private readonly Closure $dataCallback,
|
||||||
|
private readonly OperationMode $operationMode,
|
||||||
|
private readonly int $startOffset,
|
||||||
|
private readonly CompressionMethod $compressionMethod,
|
||||||
|
private readonly string $comment,
|
||||||
|
private readonly DateTimeInterface $lastModificationDateTime,
|
||||||
|
private readonly int $deflateLevel,
|
||||||
|
private readonly ?int $maxSize,
|
||||||
|
private readonly ?int $exactSize,
|
||||||
|
private readonly bool $enableZip64,
|
||||||
|
private readonly bool $enableZeroHeader,
|
||||||
|
private readonly Closure $send,
|
||||||
|
private readonly Closure $recordSentBytes,
|
||||||
|
) {
|
||||||
|
$this->fileName = self::filterFilename($fileName);
|
||||||
|
$this->checkEncoding();
|
||||||
|
|
||||||
|
if ($this->enableZeroHeader) {
|
||||||
|
$this->generalPurposeBitFlag |= GeneralPurposeBitFlag::ZERO_HEADER;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->version = $this->compressionMethod === CompressionMethod::DEFLATE ? Version::DEFLATE : Version::STORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cloneSimulationExecution(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->fileName,
|
||||||
|
$this->dataCallback,
|
||||||
|
OperationMode::NORMAL,
|
||||||
|
$this->startOffset,
|
||||||
|
$this->compressionMethod,
|
||||||
|
$this->comment,
|
||||||
|
$this->lastModificationDateTime,
|
||||||
|
$this->deflateLevel,
|
||||||
|
$this->maxSize,
|
||||||
|
$this->exactSize,
|
||||||
|
$this->enableZip64,
|
||||||
|
$this->enableZeroHeader,
|
||||||
|
$this->send,
|
||||||
|
$this->recordSentBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(): string
|
||||||
|
{
|
||||||
|
$forecastSize = $this->forecastSize();
|
||||||
|
|
||||||
|
if ($this->enableZeroHeader) {
|
||||||
|
// No calculation required
|
||||||
|
} elseif ($this->isSimulation() && $forecastSize !== null) {
|
||||||
|
$this->uncompressedSize = $forecastSize;
|
||||||
|
$this->compressedSize = $forecastSize;
|
||||||
|
} else {
|
||||||
|
$this->readStream(send: false);
|
||||||
|
if (rewind($this->unpackStream()) === false) {
|
||||||
|
throw new ResourceActionException('rewind', $this->unpackStream());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addFileHeader();
|
||||||
|
|
||||||
|
$detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$this->isSimulation()
|
||||||
|
&& $detectedSize !== null
|
||||||
|
) {
|
||||||
|
$this->uncompressedSize = $detectedSize;
|
||||||
|
$this->compressedSize = $detectedSize;
|
||||||
|
($this->recordSentBytes)($detectedSize);
|
||||||
|
} else {
|
||||||
|
$this->readStream(send: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addFileFooter();
|
||||||
|
return $this->getCdrFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return resource
|
||||||
|
*/
|
||||||
|
private function unpackStream()
|
||||||
|
{
|
||||||
|
if ($this->stream) {
|
||||||
|
return $this->stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->operationMode === OperationMode::SIMULATE_STRICT) {
|
||||||
|
throw new SimulationFileUnknownException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stream = ($this->dataCallback)();
|
||||||
|
|
||||||
|
if (!$this->enableZeroHeader && !stream_get_meta_data($this->stream)['seekable']) {
|
||||||
|
throw new StreamNotSeekableException();
|
||||||
|
}
|
||||||
|
if (!(
|
||||||
|
str_contains(stream_get_meta_data($this->stream)['mode'], 'r')
|
||||||
|
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'w+')
|
||||||
|
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'a+')
|
||||||
|
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'x+')
|
||||||
|
|| str_contains(stream_get_meta_data($this->stream)['mode'], 'c+')
|
||||||
|
)) {
|
||||||
|
throw new StreamNotReadableException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function forecastSize(): ?int
|
||||||
|
{
|
||||||
|
if ($this->compressionMethod !== CompressionMethod::STORE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($this->exactSize !== null) {
|
||||||
|
return $this->exactSize;
|
||||||
|
}
|
||||||
|
$fstat = fstat($this->unpackStream());
|
||||||
|
if (!$fstat || !array_key_exists('size', $fstat) || $fstat['size'] < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->maxSize !== null && $this->maxSize < $fstat['size']) {
|
||||||
|
return $this->maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fstat['size'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and send zip header for this file.
|
||||||
|
*/
|
||||||
|
private function addFileHeader(): void
|
||||||
|
{
|
||||||
|
$forceEnableZip64 = $this->enableZeroHeader && $this->enableZip64;
|
||||||
|
|
||||||
|
$footer = $this->buildZip64ExtraBlock($forceEnableZip64);
|
||||||
|
|
||||||
|
$zip64Enabled = $footer !== '';
|
||||||
|
|
||||||
|
if ($zip64Enabled) {
|
||||||
|
$this->version = Version::ZIP64;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->generalPurposeBitFlag & GeneralPurposeBitFlag::EFS) {
|
||||||
|
// Put the tricky entry to
|
||||||
|
// force Linux unzip to lookup EFS flag.
|
||||||
|
$footer .= Zs\ExtendedInformationExtraField::generate();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = LocalFileHeader::generate(
|
||||||
|
versionNeededToExtract: $this->version->value,
|
||||||
|
generalPurposeBitFlag: $this->generalPurposeBitFlag,
|
||||||
|
compressionMethod: $this->compressionMethod,
|
||||||
|
lastModificationDateTime: $this->lastModificationDateTime,
|
||||||
|
crc32UncompressedData: $this->crc,
|
||||||
|
compressedSize: ($forceEnableZip64 || $this->compressedSize > 0xFFFFFFFF)
|
||||||
|
? 0xFFFFFFFF
|
||||||
|
: $this->compressedSize,
|
||||||
|
uncompressedSize: ($forceEnableZip64 || $this->uncompressedSize > 0xFFFFFFFF)
|
||||||
|
? 0xFFFFFFFF
|
||||||
|
: $this->uncompressedSize,
|
||||||
|
fileName: $this->fileName,
|
||||||
|
extraField: $footer,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
($this->send)($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip characters that are not legal in Windows filenames
|
||||||
|
* to prevent compatibility issues
|
||||||
|
*/
|
||||||
|
private static function filterFilename(
|
||||||
|
/**
|
||||||
|
* Unprocessed filename
|
||||||
|
*/
|
||||||
|
string $fileName
|
||||||
|
): string {
|
||||||
|
// strip leading slashes from file name
|
||||||
|
// (fixes bug in windows archive viewer)
|
||||||
|
$fileName = ltrim($fileName, '/');
|
||||||
|
|
||||||
|
return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkEncoding(): void
|
||||||
|
{
|
||||||
|
// Sets Bit 11: Language encoding flag (EFS). If this bit is set,
|
||||||
|
// the filename and comment fields for this file
|
||||||
|
// MUST be encoded using UTF-8. (see APPENDIX D)
|
||||||
|
if (mb_check_encoding($this->fileName, 'UTF-8')
|
||||||
|
&& mb_check_encoding($this->comment, 'UTF-8')) {
|
||||||
|
$this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildZip64ExtraBlock(bool $force = false): string
|
||||||
|
{
|
||||||
|
$outputZip64ExtraBlock = false;
|
||||||
|
|
||||||
|
$originalSize = null;
|
||||||
|
if ($force || $this->uncompressedSize > 0xFFFFFFFF) {
|
||||||
|
$outputZip64ExtraBlock = true;
|
||||||
|
$originalSize = $this->uncompressedSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
$compressedSize = null;
|
||||||
|
if ($force || $this->compressedSize > 0xFFFFFFFF) {
|
||||||
|
$outputZip64ExtraBlock = true;
|
||||||
|
$compressedSize = $this->compressedSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this file will start over 4GB limit in ZIP file,
|
||||||
|
// CDR record will have to use Zip64 extension to describe offset
|
||||||
|
// to keep consistency we use the same value here
|
||||||
|
$relativeHeaderOffset = null;
|
||||||
|
if ($this->startOffset > 0xFFFFFFFF) {
|
||||||
|
$outputZip64ExtraBlock = true;
|
||||||
|
$relativeHeaderOffset = $this->startOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$outputZip64ExtraBlock) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->enableZip64) {
|
||||||
|
throw new OverflowException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Zip64\ExtendedInformationExtraField::generate(
|
||||||
|
originalSize: $originalSize,
|
||||||
|
compressedSize: $compressedSize,
|
||||||
|
relativeHeaderOffset: $relativeHeaderOffset,
|
||||||
|
diskStartNumber: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addFileFooter(): void
|
||||||
|
{
|
||||||
|
if (($this->compressedSize > 0xFFFFFFFF || $this->uncompressedSize > 0xFFFFFFFF) && $this->version !== Version::ZIP64) {
|
||||||
|
throw new OverflowException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->enableZeroHeader) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->version === Version::ZIP64) {
|
||||||
|
$footer = Zip64\DataDescriptor::generate(
|
||||||
|
crc32UncompressedData: $this->crc,
|
||||||
|
compressedSize: $this->compressedSize,
|
||||||
|
uncompressedSize: $this->uncompressedSize,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$footer = DataDescriptor::generate(
|
||||||
|
crc32UncompressedData: $this->crc,
|
||||||
|
compressedSize: $this->compressedSize,
|
||||||
|
uncompressedSize: $this->uncompressedSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
($this->send)($footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readStream(bool $send): void
|
||||||
|
{
|
||||||
|
$this->compressedSize = 0;
|
||||||
|
$this->uncompressedSize = 0;
|
||||||
|
$hash = hash_init('crc32b');
|
||||||
|
|
||||||
|
$deflate = $this->compressionInit();
|
||||||
|
|
||||||
|
while (
|
||||||
|
!feof($this->unpackStream())
|
||||||
|
&& ($this->maxSize === null || $this->uncompressedSize < $this->maxSize)
|
||||||
|
&& ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
|
||||||
|
) {
|
||||||
|
$readLength = min(
|
||||||
|
($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
|
||||||
|
($this->exactSize ?? PHP_INT_MAX) - $this->uncompressedSize,
|
||||||
|
self::CHUNKED_READ_BLOCK_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = fread($this->unpackStream(), $readLength);
|
||||||
|
|
||||||
|
if ($data === false) {
|
||||||
|
throw new ResourceActionException('fread', $this->unpackStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
hash_update($hash, $data);
|
||||||
|
|
||||||
|
$this->uncompressedSize += strlen($data);
|
||||||
|
|
||||||
|
if ($deflate) {
|
||||||
|
$data = deflate_add(
|
||||||
|
$deflate,
|
||||||
|
$data,
|
||||||
|
feof($this->unpackStream()) ? ZLIB_FINISH : ZLIB_NO_FLUSH
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($data === false) {
|
||||||
|
throw new RuntimeException('deflate_add failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->compressedSize += strlen($data);
|
||||||
|
|
||||||
|
if ($send) {
|
||||||
|
($this->send)($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->exactSize !== null && $this->uncompressedSize !== $this->exactSize) {
|
||||||
|
throw new FileSizeIncorrectException(expectedSize: $this->exactSize, actualSize: $this->uncompressedSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->crc = hexdec(hash_final($hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compressionInit(): ?DeflateContext
|
||||||
|
{
|
||||||
|
switch ($this->compressionMethod) {
|
||||||
|
case CompressionMethod::STORE:
|
||||||
|
// Noting to do
|
||||||
|
return null;
|
||||||
|
case CompressionMethod::DEFLATE:
|
||||||
|
$deflateContext = deflate_init(
|
||||||
|
ZLIB_ENCODING_RAW,
|
||||||
|
['level' => $this->deflateLevel]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$deflateContext) {
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
throw new RuntimeException("Can't initialize deflate context.");
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// False positive, resource is no longer returned from this function
|
||||||
|
return $deflateContext;
|
||||||
|
default:
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
throw new RuntimeException('Unsupported Compression Method ' . print_r($this->compressionMethod, true));
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCdrFile(): string
|
||||||
|
{
|
||||||
|
$footer = $this->buildZip64ExtraBlock();
|
||||||
|
|
||||||
|
return CentralDirectoryFileHeader::generate(
|
||||||
|
versionMadeBy: ZipStream::ZIP_VERSION_MADE_BY,
|
||||||
|
versionNeededToExtract: $this->version->value,
|
||||||
|
generalPurposeBitFlag: $this->generalPurposeBitFlag,
|
||||||
|
compressionMethod: $this->compressionMethod,
|
||||||
|
lastModificationDateTime: $this->lastModificationDateTime,
|
||||||
|
crc32: $this->crc,
|
||||||
|
compressedSize: $this->compressedSize > 0xFFFFFFFF
|
||||||
|
? 0xFFFFFFFF
|
||||||
|
: $this->compressedSize,
|
||||||
|
uncompressedSize: $this->uncompressedSize > 0xFFFFFFFF
|
||||||
|
? 0xFFFFFFFF
|
||||||
|
: $this->uncompressedSize,
|
||||||
|
fileName: $this->fileName,
|
||||||
|
extraField: $footer,
|
||||||
|
fileComment: $this->comment,
|
||||||
|
diskNumberStart: 0,
|
||||||
|
internalFileAttributes: 0,
|
||||||
|
externalFileAttributes: 32,
|
||||||
|
relativeOffsetOfLocalHeader: $this->startOffset > 0xFFFFFFFF
|
||||||
|
? 0xFFFFFFFF
|
||||||
|
: $this->startOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSimulation(): bool
|
||||||
|
{
|
||||||
|
return $this->operationMode === OperationMode::SIMULATE_LAX || $this->operationMode === OperationMode::SIMULATE_STRICT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
abstract class GeneralPurposeBitFlag
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* If set, indicates that the file is encrypted.
|
||||||
|
*/
|
||||||
|
public const ENCRYPTED = 1 << 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (For Methods 8 and 9 - Deflating)
|
||||||
|
* Normal (-en) compression option was used.
|
||||||
|
*/
|
||||||
|
public const DEFLATE_COMPRESSION_NORMAL = 0 << 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (For Methods 8 and 9 - Deflating)
|
||||||
|
* Maximum (-exx/-ex) compression option was used.
|
||||||
|
*/
|
||||||
|
public const DEFLATE_COMPRESSION_MAXIMUM = 1 << 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (For Methods 8 and 9 - Deflating)
|
||||||
|
* Fast (-ef) compression option was used.
|
||||||
|
*/
|
||||||
|
public const DEFLATE_COMPRESSION_FAST = 10 << 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (For Methods 8 and 9 - Deflating)
|
||||||
|
* Super Fast (-es) compression option was used.
|
||||||
|
*/
|
||||||
|
public const DEFLATE_COMPRESSION_SUPERFAST = 11 << 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the compression method used was type 14,
|
||||||
|
* LZMA, then this bit, if set, indicates
|
||||||
|
* an end-of-stream (EOS) marker is used to
|
||||||
|
* mark the end of the compressed data stream.
|
||||||
|
* If clear, then an EOS marker is not present
|
||||||
|
* and the compressed data size must be known
|
||||||
|
* to extract.
|
||||||
|
*/
|
||||||
|
public const LZMA_EOS = 1 << 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If this bit is set, the fields crc-32, compressed
|
||||||
|
* size and uncompressed size are set to zero in the
|
||||||
|
* local header. The correct values are put in the
|
||||||
|
* data descriptor immediately following the compressed
|
||||||
|
* data.
|
||||||
|
*/
|
||||||
|
public const ZERO_HEADER = 1 << 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If this bit is set, this indicates that the file is
|
||||||
|
* compressed patched data.
|
||||||
|
*/
|
||||||
|
public const COMPRESSED_PATCHED_DATA = 1 << 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strong encryption. If this bit is set, you MUST
|
||||||
|
* set the version needed to extract value to at least
|
||||||
|
* 50 and you MUST also set bit 0. If AES encryption
|
||||||
|
* is used, the version needed to extract value MUST
|
||||||
|
* be at least 51.
|
||||||
|
*/
|
||||||
|
public const STRONG_ENCRYPTION = 1 << 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language encoding flag (EFS). If this bit is set,
|
||||||
|
* the filename and comment fields for this file
|
||||||
|
* MUST be encoded using UTF-8.
|
||||||
|
*/
|
||||||
|
public const EFS = 1 << 11;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when encrypting the Central Directory to indicate
|
||||||
|
* selected data values in the Local Header are masked to
|
||||||
|
* hide their actual values.
|
||||||
|
*/
|
||||||
|
public const ENCRYPT_CENTRAL_DIRECTORY = 1 << 13;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
use DateTimeInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
abstract class LocalFileHeader
|
||||||
|
{
|
||||||
|
private const SIGNATURE = 0x04034b50;
|
||||||
|
|
||||||
|
public static function generate(
|
||||||
|
int $versionNeededToExtract,
|
||||||
|
int $generalPurposeBitFlag,
|
||||||
|
CompressionMethod $compressionMethod,
|
||||||
|
DateTimeInterface $lastModificationDateTime,
|
||||||
|
int $crc32UncompressedData,
|
||||||
|
int $compressedSize,
|
||||||
|
int $uncompressedSize,
|
||||||
|
string $fileName,
|
||||||
|
string $extraField,
|
||||||
|
): string {
|
||||||
|
return PackField::pack(
|
||||||
|
new PackField(format: 'V', value: self::SIGNATURE),
|
||||||
|
new PackField(format: 'v', value: $versionNeededToExtract),
|
||||||
|
new PackField(format: 'v', value: $generalPurposeBitFlag),
|
||||||
|
new PackField(format: 'v', value: $compressionMethod->value),
|
||||||
|
new PackField(format: 'V', value: Time::dateTimeToDosTime($lastModificationDateTime)),
|
||||||
|
new PackField(format: 'V', value: $crc32UncompressedData),
|
||||||
|
new PackField(format: 'V', value: $compressedSize),
|
||||||
|
new PackField(format: 'V', value: $uncompressedSize),
|
||||||
|
new PackField(format: 'v', value: strlen($fileName)),
|
||||||
|
new PackField(format: 'v', value: strlen($extraField)),
|
||||||
|
) . $fileName . $extraField;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZipStream execution operation modes
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
enum OperationMode
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Stream file into output stream
|
||||||
|
*/
|
||||||
|
case NORMAL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the zip to figure out the resulting file size
|
||||||
|
*
|
||||||
|
* This only supports entries where the file size is known beforehand and
|
||||||
|
* deflation is disabled.
|
||||||
|
*/
|
||||||
|
case SIMULATE_STRICT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the zip to figure out the resulting file size
|
||||||
|
*
|
||||||
|
* If the file size is not known beforehand or deflation is enabled, the
|
||||||
|
* entry streams will be read and rewound.
|
||||||
|
*
|
||||||
|
* If the entry does not support rewinding either, you will not be able to
|
||||||
|
* use the same stream in a later operation mode like `NORMAL`.
|
||||||
|
*/
|
||||||
|
case SIMULATE_LAX;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
* TODO: Make class readonly when requiring PHP 8.2 exclusively
|
||||||
|
*/
|
||||||
|
final class PackField
|
||||||
|
{
|
||||||
|
public const MAX_V = 0xFFFFFFFF;
|
||||||
|
|
||||||
|
public const MAX_v = 0xFFFF;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $format,
|
||||||
|
public readonly int|string $value
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a format string and argument list for pack(), then call
|
||||||
|
* pack() and return the result.
|
||||||
|
*/
|
||||||
|
public static function pack(self ...$fields): string
|
||||||
|
{
|
||||||
|
$fmt = array_reduce($fields, function (string $acc, self $field) {
|
||||||
|
return $acc . $field->format;
|
||||||
|
}, '');
|
||||||
|
|
||||||
|
$args = array_map(function (self $field) {
|
||||||
|
switch ($field->format) {
|
||||||
|
case 'V':
|
||||||
|
if ($field->value > self::MAX_V) {
|
||||||
|
throw new RuntimeException(print_r($field->value, true) . ' is larger than 32 bits');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'v':
|
||||||
|
if ($field->value > self::MAX_v) {
|
||||||
|
throw new RuntimeException(print_r($field->value, true) . ' is larger than 16 bits');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'P': break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $field->value;
|
||||||
|
}, $fields);
|
||||||
|
|
||||||
|
return pack($fmt, ...$args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ZipStream\Stream;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream wrapper that allows writing data to a callback function.
|
||||||
|
*
|
||||||
|
* This wrapper creates a virtual stream that forwards all written data
|
||||||
|
* to a provided callback function, enabling custom output handling
|
||||||
|
* such as streaming to HTTP responses, files, or other destinations.
|
||||||
|
*
|
||||||
|
* @psalm-suppress UnusedClass Used dynamically through stream_wrapper_register
|
||||||
|
*/
|
||||||
|
final class CallbackStreamWrapper
|
||||||
|
{
|
||||||
|
public const PROTOCOL = 'zipcb';
|
||||||
|
|
||||||
|
/** @var resource|null */
|
||||||
|
public $context;
|
||||||
|
|
||||||
|
/** @var array<string, callable(string):void> Map of stream IDs to callback functions */
|
||||||
|
private static array $callbacks = [];
|
||||||
|
|
||||||
|
/** @var string|null Unique identifier for this stream instance */
|
||||||
|
private ?string $id = null;
|
||||||
|
|
||||||
|
/** @var int Current position in the stream */
|
||||||
|
private int $pos = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destructor - ensures cleanup even if stream_close() isn't called.
|
||||||
|
* Prevents memory leaks in long-running processes.
|
||||||
|
*/
|
||||||
|
public function __destruct()
|
||||||
|
{
|
||||||
|
$this->stream_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new callback stream.
|
||||||
|
*
|
||||||
|
* @param callable(string):void $callback Function to call with written data
|
||||||
|
* @return resource|false Stream resource or false on failure
|
||||||
|
*/
|
||||||
|
public static function open(callable $callback)
|
||||||
|
{
|
||||||
|
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
|
||||||
|
if (!stream_wrapper_register(self::PROTOCOL, self::class)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate cryptographically secure unique ID to prevent collisions
|
||||||
|
$id = 'cb_' . bin2hex(random_bytes(16));
|
||||||
|
self::$callbacks[$id] = $callback;
|
||||||
|
|
||||||
|
return fopen(self::PROTOCOL . "://{$id}", 'wb');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all registered callbacks (useful for testing).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public static function cleanup(): void
|
||||||
|
{
|
||||||
|
self::$callbacks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the stream.
|
||||||
|
*
|
||||||
|
* @param string $path Stream path containing the callback ID
|
||||||
|
* @param string $mode File mode (must contain 'w' for writing)
|
||||||
|
* @param int $options Stream options (required by interface, unused)
|
||||||
|
* @param string|null $opened_path Opened path reference (required by interface, unused)
|
||||||
|
* @return bool True if stream opened successfully
|
||||||
|
* @psalm-suppress UnusedParam $options and $opened_path are required by the stream wrapper interface
|
||||||
|
*/
|
||||||
|
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
|
||||||
|
{
|
||||||
|
if (!str_contains($mode, 'w')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = parse_url($path, PHP_URL_HOST);
|
||||||
|
if ($host === false || $host === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->id = $host;
|
||||||
|
return isset(self::$callbacks[$this->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write data to the callback.
|
||||||
|
*
|
||||||
|
* @param string $data Data to write
|
||||||
|
* @return int Number of bytes written
|
||||||
|
* @throws RuntimeException If callback execution fails
|
||||||
|
*/
|
||||||
|
public function stream_write(string $data): int
|
||||||
|
{
|
||||||
|
if ($this->id === null) {
|
||||||
|
trigger_error('Stream not properly initialized', E_USER_WARNING);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$callback = self::$callbacks[$this->id] ?? null;
|
||||||
|
if ($callback === null) {
|
||||||
|
trigger_error('Callback not found for stream', E_USER_WARNING);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$callback($data);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Callback function failed during stream write: ' . $e->getMessage(),
|
||||||
|
0,
|
||||||
|
$e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$length = strlen($data);
|
||||||
|
$this->pos += $length;
|
||||||
|
return $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current position in stream.
|
||||||
|
*
|
||||||
|
* @return int Current position
|
||||||
|
*/
|
||||||
|
public function stream_tell(): int
|
||||||
|
{
|
||||||
|
return $this->pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if stream has reached end of file.
|
||||||
|
*
|
||||||
|
* @return bool Always false for write-only streams
|
||||||
|
*/
|
||||||
|
public function stream_eof(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush stream buffers.
|
||||||
|
*
|
||||||
|
* @return bool Always true (no buffering)
|
||||||
|
*/
|
||||||
|
public function stream_flush(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the stream and clean up callback.
|
||||||
|
*/
|
||||||
|
public function stream_close(): void
|
||||||
|
{
|
||||||
|
if ($this->id !== null) {
|
||||||
|
unset(self::$callbacks[$this->id]);
|
||||||
|
$this->id = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stream statistics.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Stream statistics
|
||||||
|
*/
|
||||||
|
public function stream_stat(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'dev' => 0,
|
||||||
|
'ino' => 0,
|
||||||
|
'mode' => 0o100666, // Regular file, read/write permissions
|
||||||
|
'nlink' => 1,
|
||||||
|
'uid' => 0,
|
||||||
|
'gid' => 0,
|
||||||
|
'rdev' => 0,
|
||||||
|
'size' => $this->pos,
|
||||||
|
'atime' => time(),
|
||||||
|
'mtime' => time(),
|
||||||
|
'ctime' => time(),
|
||||||
|
'blksize' => 4096,
|
||||||
|
'blocks' => ceil($this->pos / 4096),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read data from stream (not supported - write-only stream).
|
||||||
|
*
|
||||||
|
* @param int $count Number of bytes to read (required by interface, unused)
|
||||||
|
* @return string Always empty string
|
||||||
|
* @psalm-suppress UnusedParam $count is required by the stream wrapper interface
|
||||||
|
*/
|
||||||
|
public function stream_read(int $count): string
|
||||||
|
{
|
||||||
|
trigger_error('Read operations not supported on callback streams', E_USER_WARNING);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to position in stream (not supported).
|
||||||
|
*
|
||||||
|
* @param int $offset Offset to seek to (required by interface, unused)
|
||||||
|
* @param int $whence Seek mode (required by interface, unused)
|
||||||
|
* @return bool Always false
|
||||||
|
* @psalm-suppress UnusedParam $offset and $whence are required by the stream wrapper interface
|
||||||
|
*/
|
||||||
|
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
|
||||||
|
{
|
||||||
|
trigger_error('Seek operations not supported on callback streams', E_USER_WARNING);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set options on stream (not supported).
|
||||||
|
*
|
||||||
|
* @param int $option Option to set (required by interface, unused)
|
||||||
|
* @param int $arg1 First argument (required by interface, unused)
|
||||||
|
* @param int $arg2 Second argument (required by interface, unused)
|
||||||
|
* @return bool Always false
|
||||||
|
* @psalm-suppress UnusedParam All parameters are required by the stream wrapper interface
|
||||||
|
*/
|
||||||
|
public function stream_set_option(int $option, int $arg1, int $arg2): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate stream (not supported).
|
||||||
|
*
|
||||||
|
* @param int $new_size New size (required by interface, unused)
|
||||||
|
* @return bool Always false
|
||||||
|
* @psalm-suppress UnusedParam $new_size is required by the stream wrapper interface
|
||||||
|
*/
|
||||||
|
public function stream_truncate(int $new_size): bool
|
||||||
|
{
|
||||||
|
trigger_error('Truncate operations not supported on callback streams', E_USER_WARNING);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user