first commit.

This commit is contained in:
kj
2026-10-05 15:08:39 -03:00
commit b7799f9fff
93 changed files with 8966 additions and 0 deletions
View File
+11
View File
@@ -0,0 +1,11 @@
{
"name": "cache",
"version": "0.1.0",
"description": "Caché APCu (APCache)",
"core": { "requires": ">=0.1", "tested": "0.1" },
"php": ">=8.1",
"components": {
"apcache": { "files": ["src/Libs/APCache.php"] }
},
"default": ["apcache"]
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Libs;
/**
* DuckBrain - Microframework
*
* APCache is a simple library that makes use of APC cache.
* Data is stored in memory and does not disappear until
* it expires or is explicitly deleted.
*
* @author: KJ
* @web: https://kj2.me
* @license: MIT
*/
class APCache
{
/**
* Get an item from the cache.
*
* @param string $key
* The key to retrieve.
* @return mixed
* The stored variable or an array of variables on success; FALSE on failure.
*/
public static function get(string $key): mixed
{
return apcu_fetch($key);
}
/**
* Stores or overwrites a key in the cache.
*
* @param string $key
* The key to store.
* @param mixed $data
* The variable to store under the key.
* @param int $expire
* Expiration time for the key in seconds.
* @return bool | array
* Returns TRUE on success or FALSE on failure. An array of error keys on failure.
*/
public static function save(string $key, mixed $data, int $expire = 0): bool|array
{
return apcu_store($key, $data, $expire);
}
/**
* Deletes an item from the cache.
*
* @param string $key
* The key to delete.
* @return boolean | array
* Returns TRUE on success or FALSE on failure. An array of error keys on failure.
*/
public static function delete(string $key): bool|array
{
return apcu_delete($key);
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env php
<?php
// CLI del proyecto Duckbrain. Requiere el autoloader del core (proyecto ya
// armado). Delega en el motor `Toolbox\` si el componente opcional
// `commands:toolbox` está instalado; en caso contrario, sólo ofrece los
// comandos propios del proyecto (p. ej. migraciones).
require_once('./autoload.php');
$command = $argv[1] ?? null;
$toolboxCommands = [
'add', 'remove', 'update', 'list', 'doctor', 'catalog', 'adopt',
'help', '--help', '-h', '--version', '-V',
];
if ($command === null || in_array($command, $toolboxCommands, true)) {
if (!class_exists('Toolbox\\Cli')) {
fwrite(STDERR, "El componente 'commands:toolbox' no está instalado.\n");
fwrite(STDERR, "Añádelo con el instalador global: duckbrain add commands:toolbox\n");
exit(1);
}
exit((new Toolbox\Cli())->run($argv));
}
// Comandos propios del proyecto, despachados desde ROOT_CORE/Commands.
$coreCommand = 'Commands\\' . ucfirst($command);
if (!class_exists($coreCommand) && !file_exists(ROOT_CORE . '/Commands/' . ucfirst($command) . '.php')) {
exit("Invalid command.\n");
}
$cli = new $coreCommand();
if ($cli instanceof Interfaces\CommandInterface) {
call_user_func_array([$cli, 'run'], array_slice($argv, 2));
} else {
exit("Invalid command.\n");
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "commands",
"version": "0.1.0",
"description": "CLI y migraciones para Duckbrain",
"components": {
"cli": {
"files": [
"duckbrain",
"src/Interfaces/CommandInterface.php"
]
},
"migration": {
"files": [
"src/Commands/Migration.php",
"src/Libs/Migration.php"
],
"require": {
"commands:cli": "*"
}
},
"toolbox": {
"files": [
"src/Toolbox"
]
}
},
"default": [
"cli",
"migration"
]
}
@@ -0,0 +1,259 @@
<?php
namespace Commands;
use Interfaces\CommandInterface;
use Libs\Database;
use Libs\Migration as LibsMigration;
use Override;
use PDO;
class Migration implements CommandInterface
{
private const MIGRATIONS_PATH = ROOT_DIR . '/migrations/';
private string $migratonsDatabaseFile;
private PDO $db;
public function __construct(public bool $verbose = true)
{
$key = md5(DB_TYPE . '/' . DB_HOST . '/' . DB_NAME . '/' . DB_USER);
$this->migratonsDatabaseFile = "migrations-{$key}.sqlite";
$this->db = Database::getInstance(
type: 'sqlite',
name: static::MIGRATIONS_PATH . $this->migratonsDatabaseFile,
);
}
#[Override]
public function run(): void
{
$args = func_get_args();
$this->ensureDatabaseExists();
$command = $args[0] ?? '';
switch ($command) {
case 'migrate':
$this->migrate();
break;
case 'rollback':
$this->rollback((int)($args[1] ?? 0));
break;
case 'create':
$this->create((string)($args[1] ?? 'migration'));
break;
case 'fresh':
$this->fresh();
break;
case 'clear':
$this->dropAllTables();
break;
default:
$this->log('Invalid migration command.');
break;
}
}
/**
* Ensures that the migrations table exists in the database.
*
* @return void
*/
public function ensureDatabaseExists(): void
{
$this->db->query(
"CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY, migration TEXT, batch INTEGER)"
);
}
/**
* Runs all pending migrations.
*
* @return void
*/
public function migrate(): void
{
$files = glob(rtrim(static::MIGRATIONS_PATH, '/') . '/*.php');
$quantity = 1;
foreach ($files as $file) {
// Verificamos si la migración ya ha sigo realizada
$filename = basename($file);
$prepared = $this->db->prepare("SELECT id FROM migrations WHERE migration = ?");
$prepared->execute([$filename]);
if ((bool) $prepared->fetch()) {
continue;
}
// Obtener la migración
$migration = require $file;
if ($migration instanceof LibsMigration) {
if ($this->verbose) {
$this->log('Running migration: %s', $filename);
}
$migration->up();
// agregar la migracion a la base de datos
$stmt = $this->db->prepare("INSERT INTO migrations (migration, batch) VALUES (?, ?)");
$stmt->execute([$filename, $quantity]);
$quantity++;
}
}
$this->log("Migrations completed successfully.");
}
/**
* Rolls back a specified number of migrations or by default rollback lates batch of it.
*
* @param int $steps The number of migrations to rollback.
*
* @return void
*/
public function rollback(int $steps = 0): void
{
if ($steps <= 0) {
$steps = $this->db->query(
"SELECT batch FROM migrations ORDER BY id DESC LIMIT 1"
)->fetchColumn();
if ($steps === false) {
$this->log("No migrations to rollback.");
return;
}
}
$stmt = $this->db->query("SELECT migration FROM migrations ORDER BY id DESC LIMIT $steps");
$migrations = $stmt->fetchAll(PDO::FETCH_COLUMN);
foreach ($migrations as $filename) {
$file = static::MIGRATIONS_PATH . $filename;
if (file_exists($file)) {
$migration = require $file;
if ($migration instanceof LibsMigration) {
$this->log('Rolling back migration: %s', $filename);
$migration->down();
}
}
$this->db->prepare("DELETE FROM migrations WHERE migration = ?")->execute([$filename]);
}
$this->log('Rollback completed successfully.');
}
/**
* Creates a new migration file.
*
* @param string $name
*
* @return void
*/
public function create(string $name = 'migration'): void
{
$filename = date('Y_m_d_His') . '_' . $name . '.php';
$filepath = static::MIGRATIONS_PATH . $filename;
$template = <<<'TEMPLATE'
<?php
use Libs\Migration;
return new class () extends Migration {
#[Override]
public function up(): void
{
}
#[Override]
public function down(): void
{
}
};
TEMPLATE;
file_put_contents($filepath, $template);
$this->log('%s migration created.', $filename);
}
/**
* Refreshes the database by deleting the database and running all migrations.
*
* @return void
*/
public function fresh(): void
{
$this->dropAllTables();
$this->migrate();
$this->log("Database refreshed successfully.");
}
/**
* Drops all tables from the main database and clears the migrations control table.
*
* @return void
* @throws \Exception If the database driver is not supported.
*/
private function dropAllTables(): void
{
if (DB_TYPE == 'sqlite') {
$mainDatabase = Database::getInstance(
type: DB_TYPE,
name: DB_NAME
);
} else {
$mainDatabase = Database::getInstance(
DB_TYPE,
DB_HOST,
DB_NAME,
DB_USER,
DB_PASS
);
}
$driver = $mainDatabase->getAttribute(PDO::ATTR_DRIVER_NAME);
// Query to get all tables based on the driver
$query = match ($driver) {
'mysql' => "SHOW TABLES",
'pgsql' => "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema'",
'sqlite' => "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
default => throw new \Exception("Driver $driver not supported for fresh command"),
};
$stmt = $mainDatabase->query($query);
$tables = $stmt->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
// Disable foreign key checks to avoid constraint errors
if ($driver === 'mysql') {
$mainDatabase->exec("SET FOREIGN_KEY_CHECKS = 0");
}
if ($driver === 'pgsql') {
$mainDatabase->exec("SET session_replication_role = 'replica'");
}
$quotedTable = match ($driver) {
'mysql' => "`$table`",
'pgsql', 'sqlite' => "\"$table\"",
default => $table,
};
$cascade = $driver === 'sqlite' ? '' : ' CASCADE';
$mainDatabase->exec("DROP TABLE IF EXISTS $quotedTable$cascade");
}
if ($driver === 'mysql') {
$mainDatabase->exec("SET FOREIGN_KEY_CHECKS = 1");
}
if ($driver === 'pgsql') {
$mainDatabase->exec("SET session_replication_role = 'origin'");
}
// Clear the migration control table in the SQLite DB
$this->db->exec("DELETE FROM migrations");
$this->log("Database schema cleared. Ready for new migrations.");
}
private function log(string $msg, mixed ...$args): void
{
if ($this->verbose) {
printf("$msg\n", ...$args);
}
}
}
@@ -0,0 +1,13 @@
<?php
namespace Interfaces;
interface CommandInterface
{
/**
* Corre el comando CLI
*
* @return void
*/
public function run(): void;
}
+986
View File
@@ -0,0 +1,986 @@
<?php
namespace Libs;
use PDO;
/**
* Migration - DuckBrain
*
* Migration base class.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
abstract class Migration
{
public PDO $database;
public function __construct()
{
if (DB_TYPE == 'sqlite') {
$this->database = Database::getInstance(
type: DB_TYPE,
name: DB_NAME
);
} else {
$this->database = Database::getInstance(
DB_TYPE,
DB_HOST,
DB_NAME,
DB_USER,
DB_PASS
);
}
}
/**
* Run the migration.
*/
public function up(): void
{
}
/**
* Reverse the migration.
*/
public function down(): void
{
}
/**
* Create a table.
*
* @param string $dbTableName The name of the table to create
* @param array<string> $colums The columns to create
*
* @return void
*/
public function create(string $dbTableName, array $colums = []): void
{
$columns = implode(', ', $colums);
$sql = "CREATE TABLE IF NOT EXISTS " . $this->quoteIdentifier($dbTableName) . " ($columns)";
$this->database->exec($sql);
}
/**
* Drop a table.
*
* @param string $tableName The name of the table to drop
*
* @return void
*/
public function dropTable(string $tableName): void
{
$this->database->exec("DROP TABLE IF EXISTS " . $this->quoteIdentifier($tableName));
}
/**
* Add a column to an existing table.
*
* @param string $tableName The name of the table
* @param string $columnDefinition The column definition (output of string(), integer(), etc.)
*
* @return void
*/
public function addColumn(string $tableName, string $columnDefinition): void
{
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($tableName) . " ADD COLUMN $columnDefinition");
}
/**
* Add multiple columns to an existing table.
*
* @param string $tableName The name of the table
* @param array<string> $columns The column definitions (output of string(), integer(), etc.)
*
* @return void
*/
public function addColumns(string $tableName, array $columns = []): void
{
foreach ($columns as $column) {
$this->addColumn($tableName, $column);
}
}
/**
* Drop a column from a table.
*
* @param string $tableName The name of the table
* @param string $columnName The name of the column to drop
*
* @return void
*/
public function dropColumn(string $tableName, string $columnName): void
{
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($tableName) . " DROP COLUMN " . $this->quoteIdentifier($columnName));
}
/**
* Drop multiple columns from a table.
*
* @param string $tableName The name of the table
* @param string[] $columnNames The names of the columns to drop
*
* @return void
*/
public function dropColumns(string $tableName, array $columnNames): void
{
foreach ($columnNames as $columnName) {
$this->dropColumn($tableName, $columnName);
}
}
/**
* Rename a table.
*
* @param string $from The current name of the table
* @param string $to The new name of the table
*
* @return void
*/
public function renameTable(string $from, string $to): void
{
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($from) . " RENAME TO " . $this->quoteIdentifier($to));
}
/**
* Rename a column.
*
* @param string $tableName The name of the table
* @param string $from The current name of the column
* @param string $to The new name of the column
*
* @return void
*/
public function renameColumn(string $tableName, string $from, string $to): void
{
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($tableName) . " RENAME COLUMN " . $this->quoteIdentifier($from) . " TO " . $this->quoteIdentifier($to));
}
/**
* Modify a column definition.
*
* @param string $tableName The name of the table
* @param string $columnDefinition The new column definition (output of string(), integer(), etc.)
*
* @return void
*/
public function modifyColumn(string $tableName, string $columnDefinition): void
{
$sql = match (DB_TYPE) {
'mysql' => "ALTER TABLE " . $this->quoteIdentifier($tableName) . " MODIFY COLUMN $columnDefinition",
'pgsql' => $this->pgModifyColumn($tableName, $columnDefinition),
default => throw new \RuntimeException('modifyColumn is not supported on SQLite'),
};
$this->database->exec($sql);
}
/**
* Helper method to handle PostgreSQL column modification.
*
* @param string $tableName The name of the table
* @param string $columnDefinition The new column definition
*
* @return string
*/
private function pgModifyColumn(string $tableName, string $columnDefinition): string
{
preg_match('/^[`"](\w+)[`"]\s+(.+)$/', $columnDefinition, $m);
$column = $m[1] ?? '';
$rest = $m[2] ?? '';
$type = preg_replace('/\s+(NOT\s+NULL|DEFAULT\s+.+)$/i', '', $rest);
$sql = "ALTER TABLE \"$tableName\" ALTER COLUMN \"$column\" TYPE $type USING \"$column\"::$type";
if (preg_match('/NOT\s+NULL/i', $rest)) {
$sql .= "; ALTER TABLE \"$tableName\" ALTER COLUMN \"$column\" SET NOT NULL";
}
if (preg_match('/DEFAULT\s+(.+)$/i', $rest, $d)) {
$sql .= "; ALTER TABLE \"$tableName\" ALTER COLUMN \"$column\" SET DEFAULT {$d[1]}";
}
return $sql;
}
/**
* Empty a table.
*
* @param string $tableName The name of the table to truncate
*
* @return void
*/
public function truncate(string $tableName): void
{
if (DB_TYPE === 'sqlite') {
$this->database->exec("DELETE FROM " . $this->quoteIdentifier($tableName));
} else {
$this->database->exec("TRUNCATE TABLE " . $this->quoteIdentifier($tableName));
}
}
/**
* Check if a table exists.
*
* @param string $tableName The name of the table
*
* @return bool
*/
public function hasTable(string $tableName): bool
{
$sql = match (DB_TYPE) {
'mysql' => "SHOW TABLES LIKE '$tableName'",
'pgsql' => "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = '$tableName'",
default => "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$tableName'",
};
return (bool) $this->database->query($sql)->fetchColumn();
}
/**
* Check if a column exists in a table.
*
* @param string $tableName The name of the table
* @param string $columnName The name of the column
*
* @return bool
*/
public function hasColumn(string $tableName, string $columnName): bool
{
if (DB_TYPE === 'sqlite') {
$stmt = $this->database->query("PRAGMA table_info('$tableName')");
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
return in_array($columnName, array_column($columns, 'name'));
}
// phpcs:disable Generic.Files.LineLength.TooLong
$sql = match (DB_TYPE) {
'mysql' => "SHOW COLUMNS FROM " . $this->quoteIdentifier($tableName) . " LIKE '$columnName'",
'pgsql' => "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '$tableName' AND column_name = '$columnName'",
};
// phpcs:disable Generic.Files.LineLength.TooLong
return (bool) $this->database->query($sql)->fetchColumn();
}
/**
* Add an index to a table.
*
* @param string $tableName The name of the table
* @param string|array<string> $columns The column(s) to index
* @param string|null $name The index name (auto-generated if null)
* @param bool $unique Whether the index is unique
*
* @return void
*/
public function addIndex(
string $tableName,
string|array $columns,
?string $name = null,
bool $unique = false
): void {
$columns = (array) $columns;
$colList = implode(', ', array_map([$this, 'qi'], $columns));
$name ??= 'idx_' . $tableName . '_' . implode('_', $columns);
$unique = $unique ? 'UNIQUE' : '';
$this->database->exec("CREATE $unique INDEX IF NOT EXISTS " . $this->quoteIdentifier($name) . " ON " . $this->quoteIdentifier($tableName) . " ($colList)");
}
/**
* Add a unique index to a table.
*
* @param string $tableName The name of the table
* @param string|array<string> $columns The column(s) to index
* @param string|null $name The index name (auto-generated if null)
*
* @return void
*/
public function addUnique(
string $tableName,
string|array $columns,
?string $name = null
): void {
$this->addIndex($tableName, $columns, $name, unique: true);
}
/**
* Drop an index.
*
* @param string $tableName The name of the table
* @param string $name The index name
*
* @return void
*/
public function dropIndex(string $tableName, string $name): void
{
if (DB_TYPE === 'mysql') {
$this->database->exec("DROP INDEX IF EXISTS " . $this->quoteIdentifier($name) . " ON " . $this->quoteIdentifier($tableName));
} else {
$this->database->exec("DROP INDEX IF EXISTS " . $this->quoteIdentifier($name));
}
}
/**
* Add a foreign key constraint.
*
* @param string $tableName The name of the table
* @param string $column The column that references another table
* @param string $refTable The referenced table
* @param string $refColumn The referenced column
* @param string|null $name The constraint name (auto-generated if null)
*
* @return void
*/
public function addForeign(
string $tableName,
string $column,
string $refTable,
string $refColumn,
?string $name = null
): void {
if (DB_TYPE === 'sqlite') {
//throw new \RuntimeException('addForeign is not supported on SQLite');
return;
}
$name ??= "fk_{$tableName}_{$column}";
$sql = "ALTER TABLE " . $this->quoteIdentifier($tableName) . " ADD CONSTRAINT " . $this->quoteIdentifier($name) . " FOREIGN KEY (" . $this->quoteIdentifier($column) . ") REFERENCES " . $this->quoteIdentifier($refTable) . " (" . $this->quoteIdentifier($refColumn) . ")";
$this->database->exec($sql);
}
/**
* Drop a foreign key constraint.
*
* @param string $tableName The name of the table
* @param string $name The constraint name
*
* @return void
*/
public function dropForeign(string $tableName, string $name): void
{
if (DB_TYPE === 'sqlite') {
throw new \RuntimeException('dropForeign is not supported on SQLite');
}
$clause = match (DB_TYPE) {
'mysql' => "DROP FOREIGN KEY " . $this->quoteIdentifier($name),
'pgsql' => "DROP CONSTRAINT " . $this->quoteIdentifier($name),
default => "DROP CONSTRAINT " . $this->quoteIdentifier($name),
};
$this->database->exec("ALTER TABLE " . $this->quoteIdentifier($tableName) . " $clause");
}
/**
* Create a string column (VARCHAR).
*
* @param string $name The name of the column
* @param int $chars The maximum length of the string
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function string(
string $name,
int $chars = 255,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => "VARCHAR($chars)",
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a char column.
*
* @param string $name The name of the column
* @param int $chars The fixed length
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function char(
string $name,
int $chars = 255,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => "CHAR($chars)",
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a tiny integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function tinyInteger(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'TINYINT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'pgsql' => 'SMALLINT',
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a small integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function smallInteger(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'SMALLINT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a medium integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function mediumInteger(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'MEDIUMINT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'pgsql' => 'INTEGER',
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create an integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function integer(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'INT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'pgsql' => 'INTEGER',
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a big integer column.
*
* @param string $name The name of the column
* @param int|null $default The default value
* @param bool $notNull Whether the column is not null
* @param bool $unsigned Whether the column is unsigned (MySQL only)
*
* @return string
*/
public function bigInteger(
string $name,
?int $default = null,
bool $notNull = true,
bool $unsigned = false
): string {
$type = 'BIGINT';
if (DB_TYPE === 'mysql' && $unsigned) {
$type .= ' UNSIGNED';
}
$type = match (DB_TYPE) {
'sqlite' => 'INTEGER',
default => $type,
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a float column.
*
* @param string $name The name of the column
* @param int $precision The total number of digits
* @param int $scale The number of digits after the decimal point
* @param float|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function float(
string $name,
int $precision = 10,
int $scale = 2,
?float $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => "FLOAT($precision, $scale)",
'pgsql' => 'REAL',
default => 'REAL',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a double column.
*
* @param string $name The name of the column
* @param int $precision The total number of digits
* @param int $scale The number of digits after the decimal point
* @param float|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function double(
string $name,
int $precision = 10,
int $scale = 2,
?float $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => "DOUBLE($precision, $scale)",
'pgsql' => 'DOUBLE PRECISION',
default => 'REAL',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a decimal column.
*
* @param string $name The name of the column
* @param int $precision The total number of digits
* @param int $scale The number of digits after the decimal point
* @param float|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function decimal(
string $name,
int $precision = 10,
int $scale = 2,
?float $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => "DECIMAL($precision, $scale)",
'pgsql' => "NUMERIC($precision, $scale)",
default => 'NUMERIC',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? (string) $default : null);
}
/**
* Create a boolean column.
*
* @param string $name The name of the column
* @param bool|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function boolean(
string $name,
?bool $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'TINYINT(1)',
'pgsql' => 'BOOLEAN',
default => 'INTEGER',
};
$sql = $this->quoteIdentifier($name) . " $type";
$defaultSql = null;
if ($default !== null) {
$defaultSql = match (DB_TYPE) {
'pgsql' => $default ? "'t'" : "'f'",
default => $default ? '1' : '0',
};
}
return $this->suffix($sql, $notNull, $defaultSql);
}
/**
* Create a date column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function date(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => 'DATE',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a time column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function time(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => 'TIME',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a datetime column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function dateTime(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'pgsql' => 'TIMESTAMP',
'sqlite' => 'TEXT',
default => 'DATETIME',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a timestamp column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function timestamp(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'pgsql' => 'TIMESTAMPTZ',
'sqlite' => 'TEXT',
default => 'TIMESTAMP',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a text column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function text(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'sqlite' => 'TEXT',
default => 'TEXT',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a medium text column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function mediumText(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'MEDIUMTEXT',
default => 'TEXT',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a long text column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function longText(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'LONGTEXT',
default => 'TEXT',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a JSON column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function json(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'JSON',
'pgsql' => 'JSONB',
default => 'TEXT',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Create a binary column.
*
* @param string $name The name of the column
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function binary(
string $name,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => 'BLOB',
'pgsql' => 'BYTEA',
default => 'BLOB',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, null);
}
/**
* Create an enum column.
*
* @param string $name The name of the column
* @param array<string> $values The allowed values
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function enum(
string $name,
array $values,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'mysql' => "ENUM('" . implode("', '", $values) . "')",
default => "VARCHAR(255)",
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Make a column a primary key (auto-increment for integer types).
*
* @param string $columnDefinition The column definition (output of bigInteger(), uuid(), etc.)
*
* @return string
*/
public function primary(string $columnDefinition): string
{
preg_match('/^[`"](\w+)[`"]\s+(\w+)/', $columnDefinition, $m);
$name = $m[1];
$type = strtoupper($m[2]);
$isInteger = in_array($type, ['TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'INTEGER', 'BIGINT']);
if ($isInteger) {
return match (DB_TYPE) {
'pgsql' => match ($type) {
'SMALLINT' => $this->quoteIdentifier($name) . " SMALLSERIAL PRIMARY KEY",
'INT', 'INTEGER' => $this->quoteIdentifier($name) . " SERIAL PRIMARY KEY",
default => $this->quoteIdentifier($name) . " BIGSERIAL PRIMARY KEY",
},
'sqlite' => $this->quoteIdentifier($name) . " INTEGER PRIMARY KEY AUTOINCREMENT",
default => $this->quoteIdentifier($name) . " {$type} UNSIGNED AUTO_INCREMENT PRIMARY KEY",
};
}
$clean = preg_replace('/\s+(NOT\s+NULL)?(\s+DEFAULT\s+\S+)?\s*$/', '', $columnDefinition);
return "$clean PRIMARY KEY";
}
/**
* Create a UUID column.
*
* @param string $name The name of the column
* @param string|null $default The default value
* @param bool $notNull Whether the column is not null
*
* @return string
*/
public function uuid(
string $name,
?string $default = null,
bool $notNull = true
): string {
$type = match (DB_TYPE) {
'pgsql' => 'UUID',
'sqlite' => 'TEXT',
default => 'CHAR(36)',
};
$sql = $this->quoteIdentifier($name) . " $type";
return $this->suffix($sql, $notNull, $default !== null ? "'$default'" : null);
}
/**
* Quote identifier by database type.
*
* @param string $value
*
* @return string
*/
private function quoteIdentifier(string $value): string
{
return match (DB_TYPE) {
'pgsql' => "\"$value\"",
default => "`$value`",
};
}
/**
* Build the column suffix (NOT NULL, DEFAULT).
*
* @param string $sql The column definition without suffix
* @param bool $notNull Whether the column is not null
* @param string|null $default The default value clause (already formatted), or null for no default
*
* @return string
*/
private function suffix(
string $sql,
bool $notNull,
?string $default = null
): string {
if ($notNull) {
$sql .= ' NOT NULL';
}
if ($default !== null) {
$sql .= " DEFAULT $default";
}
return $sql;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Toolbox;
/**
* Catálogo de paquetes descubiertos en un directorio `packages/`.
*/
final class Catalog
{
/** @var array<string, Manifest>|null */
private ?array $cache = null;
public function __construct(private readonly string $packagesDir)
{
}
public static function fromRoot(string $root): self
{
return new self(rtrim($root, '/') . '/packages');
}
/** @return array<string, Manifest> */
public function packages(): array
{
if ($this->cache !== null) {
return $this->cache;
}
$packages = [];
foreach (glob($this->packagesDir . '/*/duckbrain.json') ?: [] as $manifestPath) {
$manifest = Manifest::fromFile($manifestPath);
$packages[$manifest->name] = $manifest;
}
ksort($packages);
return $this->cache = $packages;
}
public function has(string $name): bool
{
return isset($this->packages()[$name]);
}
public function get(string $name): Manifest
{
if (!$this->has($name)) {
throw new ToolboxException("Paquete desconocido en el catálogo: '{$name}'");
}
return $this->packages()[$name];
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Toolbox;
use Toolbox\Commands\AddCommand;
use Toolbox\Commands\AdoptCommand;
use Toolbox\Commands\CatalogCommand;
use Toolbox\Commands\DoctorCommand;
use Toolbox\Commands\InstallCommand;
use Toolbox\Commands\ListCommand;
use Toolbox\Commands\RemoveCommand;
use Toolbox\Commands\SelfUpdateCommand;
use Toolbox\Commands\UpdateCommand;
final class Cli
{
/** @var array<string, class-string> */
private const COMMANDS = [
'install' => InstallCommand::class,
'add' => AddCommand::class,
'remove' => RemoveCommand::class,
'update' => UpdateCommand::class,
'list' => ListCommand::class,
'doctor' => DoctorCommand::class,
'catalog' => CatalogCommand::class,
'adopt' => AdoptCommand::class,
'self-update' => SelfUpdateCommand::class,
];
public function __construct(private ?Console $console = null)
{
$this->console ??= new Console();
}
/**
* @param list<string> $argv
*/
public function run(array $argv): int
{
$args = array_slice($argv, 1);
$command = $args[0] ?? null;
if ($command === null || in_array($command, ['help', '--help', '-h'], true)) {
$this->printHelp();
return 0;
}
if (in_array($command, ['--version', '-V'], true)) {
$this->console->out('duckbrain-toolbox ' . Config::VERSION);
return 0;
}
if (!isset(self::COMMANDS[$command])) {
$this->console->error("Comando desconocido: {$command}");
$this->printHelp();
return 1;
}
$class = self::COMMANDS[$command];
if (!class_exists($class)) {
$this->console->error("El comando '{$command}' no está disponible en esta instalación.");
return 1;
}
return (new $class($this->console))->run(array_slice($args, 1));
}
public function printHelp(): void
{
$this->console->out('duckbrain-toolbox ' . Config::VERSION);
$this->console->out('');
$this->console->out('Uso: duckbrain <comando> [opciones]');
$this->console->out('');
$this->console->out('Comandos:');
foreach (self::COMMANDS as $name => $class) {
if (!class_exists($class)) {
continue;
}
$description = method_exists($class, 'describe') ? $class::describe() : '';
$this->console->out(sprintf(' %-9s %s', $name, $description));
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class AddCommand extends Command
{
public static function describe(): string
{
return 'Instala componentes o paquetes (alta con copia real)';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
if ($parsed['args'] === []) {
$this->console->error('Indica al menos un componente o paquete.');
return 1;
}
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
try {
$result = $installer->add($parsed['args'], $this->behaviorOptions($parsed['options']));
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
foreach ($result['installed'] as $id) {
$this->console->ok($id);
}
foreach ($result['warnings'] as $warning) {
$this->console->warn($warning);
}
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado: ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
}
@@ -0,0 +1,152 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Destination;
use Toolbox\DirectorySource;
use Toolbox\Drift;
use Toolbox\FileExpander;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class AdoptCommand extends Command
{
public static function describe(): string
{
return 'Adopta el core y las copias existentes y las registra en el lock';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$rootCore = $this->rootCore($parsed['options'], $root) ?? $root . '/src';
$dryRun = $this->flag($parsed['options'], 'dry-run');
$installer = new Installer($root, $toolbox, $this->console, $rootCore);
$catalog = $installer->catalog();
$lock = $installer->lock();
$source = new DirectorySource(rtrim($toolbox, '/') . '/packages');
$adopted = [];
$divergent = [];
foreach ($catalog->packages() as $package) {
foreach ($package->components() as $name => $component) {
$id = $package->name . ':' . $name;
if ($lock->has($id)) {
continue;
}
$files = [];
$present = 0;
$hasLocal = false;
foreach (FileExpander::expand($source, $package->name, $component->files) as $file) {
$relative = Destination::relative($file, $root, $rootCore, $component->destinations);
$absolute = $root . '/' . $relative;
if (!is_file($absolute)) {
continue;
}
$present++;
$canonical = $source->read($package->name, $file);
if ($canonical !== null) {
// Se registra el hash CANÓNICO: si la copia local difiere,
// queda marcada como edición local (drift) y `update`
// preguntará antes de pisarla.
$files[$relative] = 'sha256:' . hash('sha256', $canonical);
if (file_get_contents($absolute) !== $canonical) {
$hasLocal = true;
}
} else {
$files[$relative] = Drift::hash($absolute);
$hasLocal = true;
}
}
if ($present === 0) {
continue;
}
$lock->setComponent($id, [
'package' => $package->name,
'version' => $package->version,
'toolbox_commit' => null,
'mode' => 'copy',
'origin' => $hasLocal ? 'local' : 'adopted',
'files' => $files,
]);
$adopted[] = $id;
if ($hasLocal) {
$divergent[] = $id;
}
}
}
$coreAdopted = $this->adoptCore($parsed['options'], $root, $lock, $dryRun);
if (!$dryRun) {
$lock->save($root);
}
foreach ($adopted as $id) {
$mark = in_array($id, $divergent, true) ? ' (con ediciones locales)' : '';
$this->console->ok('adoptado ' . $id . $mark);
}
if ($coreAdopted > 0) {
$this->console->ok("adoptado el core ({$coreAdopted} archivos)");
}
if ($adopted === [] && $coreAdopted === 0) {
$this->console->out('No se encontró nada que adoptar.');
}
return 0;
}
/**
* Registra el core existente: sólo los archivos que también existen en el
* core de referencia, con su hash actual. No toca archivos.
*
* @param array<string, string|bool> $options
*/
private function adoptCore(array $options, string $root, $lock, bool $dryRun): int
{
if ($lock->core() !== null) {
return 0;
}
try {
$provider = $this->coreProvider($options);
} catch (ToolboxException $e) {
$this->console->warn('no se pudo resolver el core para adoptar: ' . $e->getMessage());
return 0;
}
$files = [];
foreach ($provider->files() as $relative) {
$absolute = $root . '/' . $relative;
if (is_file($absolute)) {
$files[$relative] = Drift::hash($absolute);
}
}
if ($files === []) {
return 0;
}
if (!$dryRun) {
$lock->setCore([
'version' => $provider->version(),
'commit' => $provider->commit(),
'files' => $files,
]);
}
return count($files);
}
}
@@ -0,0 +1,44 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Catalog;
use Toolbox\Lock;
final class CatalogCommand extends Command
{
public static function describe(): string
{
return 'Lista los paquetes y componentes disponibles';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$catalog = Catalog::fromRoot($toolbox);
$lock = Lock::load($root);
$rows = [['PAQUETE', 'COMPONENTE', 'ESTADO', 'DESCRIPCIÓN']];
foreach ($catalog->packages() as $package) {
$first = true;
foreach ($package->components() as $name => $component) {
$id = $package->name . ':' . $name;
$installed = $lock->has($id) ? 'instalado' : '-';
$rows[] = [
$first ? $package->name . ' (' . $package->version . ')' : '',
$name,
$installed,
$first ? $package->description : '',
];
$first = false;
}
}
$this->console->table($rows);
return 0;
}
}
@@ -0,0 +1,107 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Config;
use Toolbox\Console;
use Toolbox\CoreProvider;
abstract class Command implements CommandInterface
{
public function __construct(protected readonly Console $console)
{
}
/**
* @param list<string> $args
* @return array{options: array<string, string|bool>, args: list<string>}
*/
protected function parse(array $args): array
{
$options = [];
$positional = [];
foreach ($args as $arg) {
if (str_starts_with($arg, '--')) {
$token = substr($arg, 2);
if (str_contains($token, '=')) {
[$key, $value] = explode('=', $token, 2);
$options[$key] = $value;
} else {
$options[$token] = true;
}
continue;
}
$positional[] = $arg;
}
return ['options' => $options, 'args' => $positional];
}
/**
* @param array<string, string|bool> $options
*/
protected function option(array $options, string $key, ?string $default = null): ?string
{
$value = $options[$key] ?? null;
if ($value === true || $value === null) {
return $default;
}
return (string) $value;
}
/**
* @param array<string, string|bool> $options
*/
protected function flag(array $options, string $key): bool
{
return array_key_exists($key, $options);
}
protected function root(array $options): string
{
return rtrim($this->option($options, 'root', getcwd() ?: '.') ?? '.', '/');
}
protected function toolboxDir(array $options): string
{
return rtrim($this->option($options, 'toolbox', getcwd() ?: '.') ?? '.', '/');
}
protected function rootCore(array $options, string $root): ?string
{
$core = $this->option($options, 'core');
return $core === null ? null : rtrim($core, '/');
}
protected function coreProvider(array $options): CoreProvider
{
$dir = $this->option($options, 'core-dir');
if ($dir !== null) {
return CoreProvider::fromDirectory($dir);
}
return CoreProvider::fromUrl(Config::coreUrl(), Config::cacheDir(), $this->console);
}
/**
* @param array<string, string|bool> $options
* @return array<string, bool>
*/
protected function behaviorOptions(array $options): array
{
return [
'force' => $this->flag($options, 'force'),
'backup' => $this->flag($options, 'backup'),
'keepLocal' => $this->flag($options, 'keep-local'),
'dryRun' => $this->flag($options, 'dry-run'),
'yes' => $this->flag($options, 'yes') || $this->flag($options, 'y'),
'link' => $this->flag($options, 'link'),
];
}
}
@@ -0,0 +1,13 @@
<?php
namespace Toolbox\Commands;
interface CommandInterface
{
/**
* @param list<string> $args
*/
public function run(array $args): int;
public static function describe(): string;
}
@@ -0,0 +1,62 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Catalog;
use Toolbox\Compatibility;
use Toolbox\Lock;
final class DoctorCommand extends Command
{
public static function describe(): string
{
return 'Matriz de compatibilidad de los componentes instalados con el core';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$lock = Lock::load($root);
$catalog = Catalog::fromRoot($toolbox);
$coreVersion = $lock->core()['version'] ?? null;
if ($lock->components() === []) {
$this->console->out('No hay componentes instalados.');
return 0;
}
$rows = [['COMPONENTE', 'VER', 'CORE REQ', 'CORE PROBADO', 'CORE ACTUAL', 'ESTADO']];
foreach ($lock->components() as $id => $entry) {
[$package, $component] = array_pad(explode(':', $id, 2), 2, null);
$requires = null;
$tested = null;
if ($component !== null && $package !== null && $catalog->has($package)) {
$manifest = $catalog->get($package);
if ($manifest->hasComponent($component)) {
$requires = $manifest->coreRequirement($component);
$tested = $manifest->testedCore($component);
}
}
$result = Compatibility::evaluateCore($coreVersion, $requires, $tested);
$rows[] = [
$id,
(string) ($entry['version'] ?? ''),
$requires ?? '—',
$tested ?? '—',
$coreVersion ?? '—',
Compatibility::label($result['status']),
];
}
$this->console->table($rows);
return 0;
}
}
@@ -0,0 +1,43 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class InstallCommand extends Command
{
public static function describe(): string
{
return 'Descarga el core Duckbrain en un directorio (bootstrap)';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$dir = rtrim($parsed['args'][0] ?? '.', '/');
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
$this->console->error('No se pudo crear el directorio: ' . $dir);
return 1;
}
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($dir, $toolbox, $this->console, $this->rootCore($parsed['options'], $dir));
try {
$provider = $this->coreProvider($parsed['options']);
$result = $installer->installCore($provider, $this->behaviorOptions($parsed['options']));
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
$this->console->ok('core ' . ($result['version'] ?? '?') . ' en ' . $dir);
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado: ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
}
@@ -0,0 +1,42 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class ListCommand extends Command
{
public static function describe(): string
{
return 'Lista el core y los componentes instalados';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
$rows = [['TIPO', 'ID', 'VERSIÓN', 'MODO']];
$core = $installer->lock()->core();
if ($core !== null) {
$rows[] = ['core', 'core', (string) ($core['version'] ?? '?'), 'copy'];
}
foreach ($installer->lock()->components() as $id => $entry) {
$rows[] = [$entry['package'], $id, $entry['version'], $entry['mode']];
}
if (count($rows) === 1) {
$this->console->out('No hay nada instalado.');
return 0;
}
$this->console->table($rows);
return 0;
}
}
@@ -0,0 +1,44 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class RemoveCommand extends Command
{
public static function describe(): string
{
return 'Quita componentes instalados (sólo sus archivos)';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
if ($parsed['args'] === []) {
$this->console->error('Indica al menos un componente (paquete:componente).');
return 1;
}
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
try {
$result = $installer->remove($parsed['args'], $this->behaviorOptions($parsed['options']));
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
foreach ($result['removed'] as $id) {
$this->console->ok('quitado ' . $id);
}
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado (edición local): ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
}
@@ -0,0 +1,37 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Config;
use Toolbox\SelfUpdater;
use Toolbox\ToolboxException;
final class SelfUpdateCommand extends Command
{
public static function describe(): string
{
return 'Actualiza el instalador global (script/phar)';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$from = $this->option($parsed['options'], 'from', Config::updateUrl());
$target = $this->option($parsed['options'], 'target', Config::binPath());
$temp = $this->option($parsed['options'], 'temp', sys_get_temp_dir());
if ($from === null || $target === null) {
$this->console->error('Faltan --from o --target.');
return 1;
}
try {
(new SelfUpdater($this->console))->update($from, $target, $temp);
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
return 0;
}
}
@@ -0,0 +1,62 @@
<?php
namespace Toolbox\Commands;
use Toolbox\Installer;
use Toolbox\ToolboxException;
final class UpdateCommand extends Command
{
public static function describe(): string
{
return 'Actualiza el core, componentes o todo el proyecto';
}
public function run(array $args): int
{
$parsed = $this->parse($args);
$options = $this->behaviorOptions($parsed['options']);
$root = $this->root($parsed['options']);
$toolbox = $this->toolboxDir($parsed['options']);
$installer = new Installer($root, $toolbox, $this->console, $this->rootCore($parsed['options'], $root));
try {
if (($parsed['args'][0] ?? null) === 'core') {
$provider = $this->coreProvider($parsed['options']);
$result = $installer->updateCore($provider, $options);
$this->console->ok('core ' . ($result['version'] ?? '?'));
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado: ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
$ids = $parsed['args'] !== []
? $parsed['args']
: array_keys($installer->lock()->components());
if ($ids === []) {
$this->console->out('No hay componentes instalados que actualizar.');
return 0;
}
$result = $installer->update($ids, $options);
} catch (ToolboxException $e) {
$this->console->error($e->getMessage());
return 1;
}
foreach ($result['installed'] as $id) {
$this->console->ok('actualizado ' . $id);
}
foreach ($result['warnings'] as $warning) {
$this->console->warn($warning);
}
foreach ($result['aborted'] as $aborted) {
$this->console->error('abortado: ' . $aborted);
}
return $result['aborted'] === [] ? 0 : 1;
}
}
@@ -0,0 +1,77 @@
<?php
namespace Toolbox;
/**
* Modelo de compatibilidad estilo WordPress: un rango mínimo requerido y uno
* máximo probado contra el core, más el requisito de PHP. Sólo produce avisos.
*/
final class Compatibility
{
public const OK = 'ok';
public const UNTESTED = 'untested';
public const INSUFFICIENT = 'insufficient';
public const UNKNOWN = 'unknown';
/**
* @return array{status:string, message:string}
*/
public static function evaluateCore(
?string $coreVersion,
?string $requires,
?string $tested
): array {
if ($coreVersion === null) {
return ['status' => self::UNKNOWN, 'message' => 'versión del core desconocida (sin tags)'];
}
if ($requires === null && $tested === null) {
return ['status' => self::UNKNOWN, 'message' => 'sin datos de compatibilidad'];
}
if ($requires !== null && !Semver::satisfies($coreVersion, $requires)) {
return [
'status' => self::INSUFFICIENT,
'message' => "requiere core {$requires} (tienes {$coreVersion})",
];
}
if ($tested !== null && Semver::compare($coreVersion, $tested) > 0) {
return [
'status' => self::UNTESTED,
'message' => "probado hasta {$tested} (tienes {$coreVersion})",
];
}
return ['status' => self::OK, 'message' => 'compatible'];
}
/**
* @return array{status:string, message:string}
*/
public static function evaluatePhp(?string $requires, string $phpVersion): array
{
if ($requires === null) {
return ['status' => self::UNKNOWN, 'message' => 'sin datos de PHP'];
}
if (!Semver::satisfies($phpVersion, $requires)) {
return [
'status' => self::INSUFFICIENT,
'message' => "requiere PHP {$requires} (tienes {$phpVersion})",
];
}
return ['status' => self::OK, 'message' => 'compatible'];
}
public static function label(string $status): string
{
return match ($status) {
self::OK => 'OK',
self::UNTESTED => 'NO PROBADO',
self::INSUFFICIENT => 'INSUFICIENTE',
default => 'SIN DATOS',
};
}
}
@@ -0,0 +1,64 @@
<?php
namespace Toolbox;
final class Component
{
/**
* @param list<string> $files
* @param array<string, string> $requires
* @param array<string, string>|null $destinations
*/
public function __construct(
public readonly string $name,
public readonly array $files,
public readonly array $requires = [],
public readonly ?string $coreRequires = null,
public readonly ?string $coreTested = null,
public readonly ?array $destinations = null,
public readonly bool $override = false,
) {
}
public static function fromArray(string $name, array $data): self
{
if (!isset($data['files']) || !is_array($data['files']) || $data['files'] === []) {
throw new ToolboxException("El componente '{$name}' debe declarar 'files' no vacío");
}
foreach ($data['files'] as $file) {
if (!is_string($file) || $file === '') {
throw new ToolboxException("El componente '{$name}' tiene un archivo inválido");
}
}
$requires = [];
foreach (($data['require'] ?? []) as $id => $constraint) {
if (!is_string($id) || !is_string($constraint)) {
throw new ToolboxException("El componente '{$name}' tiene un 'require' inválido");
}
$requires[$id] = $constraint;
}
$destinations = null;
if (isset($data['destinations'])) {
if (!is_array($data['destinations'])) {
throw new ToolboxException("El componente '{$name}' tiene 'destinations' inválido");
}
$destinations = [];
foreach ($data['destinations'] as $from => $to) {
$destinations[$from] = $to;
}
}
return new self(
name: $name,
files: array_values($data['files']),
requires: $requires,
coreRequires: $data['core']['requires'] ?? null,
coreTested: $data['core']['tested'] ?? null,
destinations: $destinations,
override: (bool) ($data['override'] ?? false),
);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Toolbox;
/**
* Constantes y ubicaciones del toolbox. Las URLs de origen van embebidas y sólo
* pueden sobreescribirse mediante variables de entorno, pensadas para pruebas.
*/
final class Config
{
public const VERSION = '0.1.0';
public static function toolboxUrl(): string
{
return self::env('DUCKBRAIN_TOOLBOX_URL')
?: 'git@git.kj2.me:kj/duckbrain-toolbox.git';
}
public static function coreUrl(): string
{
return self::env('DUCKBRAIN_CORE_URL')
?: 'git@git.kj2.me:kj/duckbrain.git';
}
public static function updateUrl(): string
{
return self::env('DUCKBRAIN_UPDATE_URL')
?: 'https://git.kj2.me/kj/duckbrain-toolbox/releases/download/latest';
}
public static function binPath(): string
{
$running = \Phar::running(false);
if ($running !== '') {
return $running;
}
$script = $_SERVER['argv'][0] ?? '';
return $script === '' ? '' : (realpath($script) ?: $script);
}
public static function cacheDir(): string
{
$override = self::env('DUCKBRAIN_CACHE_DIR');
if ($override !== '') {
return rtrim($override, '/') . '/toolbox';
}
$home = self::env('HOME') ?: sys_get_temp_dir();
$xdg = self::env('XDG_CACHE_HOME') ?: $home . '/.cache';
return rtrim($xdg, '/') . '/duckbrain/toolbox';
}
private static function env(string $name): string
{
$value = getenv($name);
return $value === false ? '' : $value;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Toolbox;
final class Console
{
/** @var resource */
private $outStream;
/** @var resource */
private $errStream;
/**
* @param resource|null $out
* @param resource|null $err
*/
public function __construct($out = null, $err = null)
{
$this->outStream = $out ?? STDOUT;
$this->errStream = $err ?? STDERR;
}
public function out(string $message = ''): void
{
fwrite($this->outStream, $message . PHP_EOL);
}
public function warn(string $message): void
{
fwrite($this->errStream, 'WARN: ' . $message . PHP_EOL);
}
public function error(string $message): void
{
fwrite($this->errStream, 'ERROR: ' . $message . PHP_EOL);
}
public function ok(string $message): void
{
fwrite($this->outStream, ' OK: ' . $message . PHP_EOL);
}
public function confirm(string $question, bool $assumeYes = false): bool
{
if ($assumeYes) {
return true;
}
fwrite($this->outStream, $question . ' [y/N] ');
$answer = trim((string) fgets(STDIN));
return in_array(strtolower($answer), ['y', 'yes', 's', 'si', 'sí'], true);
}
/**
* @param array<int, array<int, string>> $rows
*/
public function table(array $rows): void
{
if ($rows === []) {
return;
}
$widths = [];
foreach ($rows as $row) {
foreach ($row as $i => $cell) {
$widths[$i] = max($widths[$i] ?? 0, strlen($cell));
}
}
foreach ($rows as $row) {
$cells = [];
foreach ($row as $i => $cell) {
$cells[] = str_pad($cell, $widths[$i]);
}
$this->out(' ' . rtrim(implode(' ', $cells)));
}
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace Toolbox;
/**
* Copia (o enlaza) los archivos de un componente al proyecto respetando la
* regla de destino, el modo y el drift.
*/
final class Copier
{
public function __construct(private readonly FileSync $sync = new FileSync())
{
}
/**
* @param list<string> $files rutas relativas al paquete
* @param array<string, string> $expectedFiles rel -> hash previo (lock)
* @param array<string, string>|null $destinations
* @param array{force?:bool, backup?:bool, keepLocal?:bool, dryRun?:bool} $options
* @return array{files: array<string,string>, actions: array<string,string>, aborted: list<string>}
*/
public function install(
array $files,
string $sourceDir,
string $rootDir,
string $rootCore,
?array $destinations,
string $mode,
array $options = [],
array $expectedFiles = []
): array {
$installed = [];
$actions = [];
$aborted = [];
foreach ($files as $file) {
$relative = Destination::relative($file, $rootDir, $rootCore, $destinations);
$source = rtrim($sourceDir, '/') . '/' . ltrim($file, '/');
$target = rtrim($rootDir, '/') . '/' . $relative;
$expected = $expectedFiles[$relative] ?? null;
if (($options['dryRun'] ?? false)) {
$actions[$relative] = 'planned';
continue;
}
if ($mode === 'link') {
$action = $this->link($source, $target, $options);
} else {
$contents = file_get_contents($source);
if ($contents === false) {
throw new ToolboxException("No se pudo leer el archivo de origen: {$source}");
}
$action = $this->sync->write($target, $contents, $expected, $options);
}
$actions[$relative] = $action;
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
continue;
}
$installed[$relative] = is_file($source) ? Drift::hash($source) : '';
}
return ['files' => $installed, 'actions' => $actions, 'aborted' => $aborted];
}
/**
* @param array{force?:bool, keepLocal?:bool} $options
*/
private function link(string $source, string $target, array $options): string
{
if (is_link($target)) {
if (readlink($target) === $source) {
return FileSync::WRITTEN;
}
unlink($target);
} elseif (is_file($target)) {
if ($options['keepLocal'] ?? false) {
return FileSync::SKIPPED;
}
if (!($options['force'] ?? false)) {
return FileSync::ABORTED;
}
unlink($target);
}
if (!is_dir(dirname($target))) {
mkdir(dirname($target), 0777, true);
}
if (!symlink($source, $target)) {
throw new ToolboxException("No se pudo enlazar {$target} -> {$source}");
}
return FileSync::WRITTEN;
}
}
@@ -0,0 +1,187 @@
<?php
namespace Toolbox;
/**
* Provee los archivos del core. La versión se resuelve por tags semver
* (`vX.Y.Z`); no hay archivo VERSION. Si no hay tags, la versión es desconocida
* y se trabaja sobre la rama por defecto, fijando el commit en el lock.
*/
final class CoreProvider
{
private function __construct(private readonly string $dir)
{
}
public static function fromDirectory(string $dir): self
{
if (!is_dir($dir)) {
throw new ToolboxException("El directorio del core no existe: {$dir}");
}
return new self(rtrim($dir, '/'));
}
public static function fromUrl(string $url, string $cacheDir, Console $console): self
{
$dir = rtrim($cacheDir, '/') . '/core';
if (!is_dir($dir . '/.git')) {
if (!is_dir(dirname($dir))) {
mkdir(dirname($dir), 0777, true);
}
$console->out(' clonando core desde ' . $url);
exec('git clone -q --tags ' . escapeshellarg($url) . ' ' . escapeshellarg($dir) . ' 2>&1', $out, $code);
if ($code !== 0) {
throw new ToolboxException('No se pudo clonar el core: ' . implode("\n", $out));
}
} else {
exec('git -C ' . escapeshellarg($dir) . ' fetch -q --tags 2>&1');
}
$provider = new self($dir);
$version = $provider->version();
// Tags opcionales: si hay, se usa la release más alta; si no, la rama por defecto.
if ($version !== null) {
exec('git -C ' . escapeshellarg($dir) . ' checkout -q ' . escapeshellarg('v' . $version) . ' 2>&1');
} else {
exec('git -C ' . escapeshellarg($dir) . ' checkout -q 2>&1');
exec('git -C ' . escapeshellarg($dir) . ' pull -q --ff-only 2>/dev/null');
}
return $provider;
}
public function dir(): string
{
return $this->dir;
}
public function isGit(): bool
{
return is_dir($this->dir . '/.git');
}
/**
* Versión más alta entre los tags `vX.Y.Z`, o null si no hay tags.
*/
public function version(): ?string
{
if (!$this->isGit()) {
return null;
}
exec('git -C ' . escapeshellarg($this->dir) . ' tag -l ' . escapeshellarg('v*') . ' 2>/dev/null', $tags, $code);
if ($code !== 0) {
return null;
}
$best = null;
foreach ($tags as $tag) {
$candidate = ltrim(trim($tag), 'vV');
try {
Semver::parse($candidate);
} catch (ToolboxException) {
continue;
}
if ($best === null || Semver::compare($candidate, $best) > 0) {
$best = $candidate;
}
}
return $best;
}
public function commit(): ?string
{
if (!$this->isGit()) {
return null;
}
exec('git -C ' . escapeshellarg($this->dir) . ' rev-parse HEAD 2>/dev/null', $out, $code);
$commit = trim($out[0] ?? '');
return $code === 0 && $commit !== '' ? $commit : null;
}
/**
* Prefijos/exactos que no forman parte del core distribuible: herramientas
* del repositorio, control de versiones y documentación. `.htaccess` NO se
* excluye (sí es del core).
*/
private const EXCLUDED = [
'.git/', '.github/', '.opencode/', '.openspec/', '.idea/', '.vscode/',
'node_modules/', 'vendor/', 'dist/',
'.gitignore', '.gitattributes', '.editorconfig', '.DS_Store', 'Thumbs.db',
'readme.org', 'README.org', 'readme.md', 'README.md', 'readme.txt',
];
/**
* Archivos del core distribuible. Si la fuente es un repositorio git se
* parte de sus archivos RASTREADOS (`git ls-files`, los mismos del remoto);
* si no, se recorre el directorio. En ambos casos se filtra la lista negra
* (herramientas y documentación).
*
* @return list<string>
*/
public function files(): array
{
$files = $this->isGit() ? $this->trackedFiles() : $this->walkFiles();
$files = array_values(array_filter($files, fn (string $file) => !$this->isExcluded($file)));
sort($files);
return $files;
}
/**
* @return list<string>
*/
private function trackedFiles(): array
{
exec('git -C ' . escapeshellarg($this->dir) . ' ls-files 2>/dev/null', $out, $code);
if ($code !== 0) {
return $this->walkFiles();
}
return array_values(array_filter($out, fn (string $file) => $file !== ''));
}
/**
* @return list<string>
*/
private function walkFiles(): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$files[] = substr($item->getPathname(), strlen($this->dir) + 1);
}
return $files;
}
private function isExcluded(string $relative): bool
{
foreach (self::EXCLUDED as $excluded) {
if ($relative === $excluded || str_starts_with($relative, $excluded)) {
return true;
}
}
return false;
}
public function read(string $relative): string
{
$path = $this->dir . '/' . ltrim($relative, '/');
return (string) file_get_contents($path);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Toolbox;
/**
* Regla de destino: `src/**` -> ROOT_CORE (sin el prefijo `src/`); el resto ->
* ROOT_DIR. Admite un mapa explícito por componente.
*/
final class Destination
{
public static function resolve(
string $source,
string $rootDir,
string $rootCore,
?array $explicit = null
): string {
$target = self::relative($source, $rootDir, $rootCore, $explicit);
return rtrim($rootDir, '/') . '/' . $target;
}
public static function relative(
string $source,
string $rootDir,
string $rootCore,
?array $explicit = null
): string {
if ($explicit !== null && isset($explicit[$source])) {
return ltrim($explicit[$source], '/');
}
$source = ltrim($source, '/');
if (str_starts_with($source, 'src/')) {
$inCore = substr($source, 4);
$prefix = rtrim(str_replace(rtrim($rootDir, '/') . '/', '', $rootCore), '/');
return ($prefix === '' ? '' : $prefix . '/') . $inCore;
}
return $source;
}
}
@@ -0,0 +1,25 @@
<?php
namespace Toolbox;
/**
* Fuente de paquetes en disco: `packages/<pkg>/<archivo>`.
*/
final class DirectorySource implements PackageSource
{
public function __construct(private readonly string $packagesDir)
{
}
public function baseDir(string $package): string
{
return rtrim($this->packagesDir, '/') . '/' . $package;
}
public function read(string $package, string $file): ?string
{
$path = $this->baseDir($package) . '/' . ltrim($file, '/');
return is_file($path) ? (string) file_get_contents($path) : null;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Toolbox;
/**
* Detección de ediciones locales comparando hashes contra el lock.
*/
final class Drift
{
public const MATCH = 'match';
public const DRIFTED = 'drifted';
public const MISSING = 'missing';
public static function hash(string $absolutePath): string
{
return 'sha256:' . hash_file('sha256', $absolutePath);
}
public static function status(string $absolutePath, ?string $expected): string
{
if (!is_file($absolutePath)) {
return self::MISSING;
}
if ($expected === null) {
return self::MATCH;
}
return self::hash($absolutePath) === $expected ? self::MATCH : self::DRIFTED;
}
public static function isDrifted(string $absolutePath, ?string $expected): bool
{
return self::status($absolutePath, $expected) === self::DRIFTED;
}
}
@@ -0,0 +1,43 @@
<?php
namespace Toolbox;
/**
* Expande las entradas de `files` de un componente: una entrada que apunta a un
* directorio se convierte en la lista de sus archivos.
*/
final class FileExpander
{
/**
* @param list<string> $files
* @return list<string>
*/
public static function expand(PackageSource $source, string $package, array $files): array
{
$base = rtrim($source->baseDir($package), '/');
$out = [];
foreach ($files as $entry) {
$entry = ltrim($entry, '/');
$abs = $base . '/' . $entry;
if (is_dir($abs)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$out[] = substr($item->getPathname(), strlen($base) + 1);
}
} else {
$out[] = $entry;
}
}
sort($out);
return array_values(array_unique($out));
}
}
@@ -0,0 +1,92 @@
<?php
namespace Toolbox;
/**
* Escribe archivos respetando el drift: sin edición local escribe; con edición
* decide según `--force`, `--backup`, `--keep-local` o aborta.
*/
final class FileSync
{
public const WRITTEN = 'written';
public const OVERWRITTEN = 'overwritten';
public const BACKED_UP = 'backed_up';
public const SKIPPED = 'skipped';
public const ABORTED = 'aborted';
/**
* @param array{force?:bool, backup?:bool, keepLocal?:bool} $options
*/
public function write(string $path, string $contents, ?string $expected, array $options = []): string
{
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0777, true);
}
$existed = is_file($path);
$drifted = $existed && Drift::isDrifted($path, $expected);
if (!$drifted) {
// Sin hash previo (p. ej. primera canonicalización de un proyecto
// heredado) un archivo existente también puede respaldarse.
$changed = $existed && (string) file_get_contents($path) !== $contents;
if ($changed && ($options['backup'] ?? false)) {
copy($path, $path . '.bak');
file_put_contents($path, $contents);
return self::BACKED_UP;
}
file_put_contents($path, $contents);
return $existed ? self::OVERWRITTEN : self::WRITTEN;
}
if ($options['force'] ?? false) {
file_put_contents($path, $contents);
return self::OVERWRITTEN;
}
if ($options['backup'] ?? false) {
copy($path, $path . '.bak');
file_put_contents($path, $contents);
return self::BACKED_UP;
}
if ($options['keepLocal'] ?? false) {
return self::SKIPPED;
}
return self::ABORTED;
}
/**
* @param array{force?:bool, backup?:bool, keepLocal?:bool} $options
*/
public function remove(string $path, ?string $expected, array $options = []): string
{
if (!is_file($path)) {
return self::SKIPPED;
}
$drifted = Drift::isDrifted($path, $expected);
if ($drifted && !($options['force'] ?? false)) {
if ($options['keepLocal'] ?? false) {
return self::SKIPPED;
}
if ($options['backup'] ?? false) {
copy($path, $path . '.bak');
} else {
return self::ABORTED;
}
}
unlink($path);
return self::OVERWRITTEN;
}
}
+367
View File
@@ -0,0 +1,367 @@
<?php
namespace Toolbox;
/**
* Motor de gestión compartido por el instalador global y el CLI del proyecto:
* alta, baja, actualización de core/componentes, listado y diagnóstico.
*/
final class Installer
{
private readonly Catalog $catalog;
private readonly Resolver $resolver;
private readonly PackageSource $source;
private readonly FileSync $sync;
private readonly Copier $copier;
private ProjectManifest $manifest;
private Lock $lock;
private readonly string $root;
private readonly string $rootCore;
public function __construct(
string $root,
private readonly string $toolboxDir,
private readonly Console $console,
?string $rootCore = null,
) {
$this->root = rtrim($root, '/');
$this->rootCore = $rootCore !== null ? rtrim($rootCore, '/') : $this->root . '/src';
$this->catalog = Catalog::fromRoot($this->toolboxDir);
$this->resolver = new Resolver($this->catalog);
$this->source = new DirectorySource(rtrim($this->toolboxDir, '/') . '/packages');
$this->sync = new FileSync();
$this->copier = new Copier($this->sync);
$this->manifest = ProjectManifest::load($this->root);
$this->lock = Lock::load($this->root);
}
public function catalog(): Catalog
{
return $this->catalog;
}
public function lock(): Lock
{
return $this->lock;
}
public function manifest(): ProjectManifest
{
return $this->manifest;
}
public function coreVersion(): ?string
{
return $this->lock->core()['version'] ?? null;
}
/**
* @param list<string> $requests
* @param array<string, bool|string> $options
* @return array{installed:list<string>, warnings:list<string>, aborted:list<string>, actions:array<string,string>}
*/
public function add(array $requests, array $options): array
{
return $this->installRequests($requests, $options, false);
}
/**
* @param list<string> $requests
* @param array<string, bool|string> $options
* @return array{installed:list<string>, warnings:list<string>, aborted:list<string>, actions:array<string,string>}
*/
public function update(array $requests, array $options): array
{
return $this->installRequests($requests, $options, true);
}
/**
* @param list<string> $requests
* @param array<string, bool|string> $options
* @return array{installed:list<string>, warnings:list<string>, aborted:list<string>, actions:array<string,string>}
*/
private function installRequests(array $requests, array $options, bool $isUpdate): array
{
$clean = [];
$constraints = [];
foreach ($requests as $request) {
[$id, $constraint] = array_pad(explode('@', $request, 2), 2, null);
$clean[] = $id;
if ($constraint !== null) {
$constraints[$id] = $constraint;
}
}
$resolved = $this->resolver->resolve($clean);
$warnings = [];
$installed = [];
$aborted = [];
$actions = [];
$post = [];
foreach ($resolved as $entry) {
$package = $entry['package'];
$componentName = $entry['component'];
$manifest = $this->catalog->get($package);
$component = $manifest->component($componentName);
$id = $package . ':' . $componentName;
[$version, $commit] = $this->resolveVersion($package);
$compat = Compatibility::evaluateCore(
$this->coreVersion(),
$manifest->coreRequirement($componentName),
$manifest->testedCore($componentName)
);
if ($compat['status'] !== Compatibility::OK) {
$warnings[] = $id . ': ' . $compat['message'];
$assumeYes = (bool) ($options['yes'] ?? false);
if (!$assumeYes && !$this->console->confirm(" {$id}: {$compat['message']}. ¿Continuar?", false)) {
$aborted[] = $id;
continue;
}
}
$mode = ($options['link'] ?? false) ? 'link' : 'copy';
$expected = $this->lock->component($id)['files'] ?? [];
$files = FileExpander::expand($this->source, $package, $component->files);
$result = $this->copier->install(
$files,
$this->source->baseDir($package),
$this->root,
$this->rootCore,
$component->destinations,
$mode,
$options,
$expected
);
foreach ($result['actions'] as $rel => $action) {
$actions[$rel] = $action;
}
if ($result['aborted'] !== []) {
foreach ($result['aborted'] as $rel) {
$aborted[] = $rel;
}
continue;
}
if (!($options['dryRun'] ?? false)) {
$this->lock->setComponent($id, [
'package' => $package,
'version' => $version,
'toolbox_commit' => $commit,
'mode' => $mode,
'files' => $result['files'],
]);
}
$installed[] = $id;
$post[$package] = $manifest;
}
foreach ($constraints as $key => $constraint) {
if (str_contains($key, ':')) {
if (!$isUpdate) {
$this->manifest->setConstraint($key, $constraint);
}
continue;
}
foreach ($installed as $id) {
if (str_starts_with($id, $key . ':') && !$isUpdate) {
$this->manifest->setConstraint($id, $constraint);
}
}
}
Ownership::fromLock($this->lock)->assertNoCollisions();
if (!($options['dryRun'] ?? false)) {
$this->lock->save($this->root);
$this->manifest->save($this->root);
foreach ($post as $manifest) {
if ($manifest->postInstall !== []) {
PostInstall::run(
$manifest->postInstall,
$this->console,
(bool) ($options['yes'] ?? false),
$options['postRunner'] ?? null
);
}
}
}
return ['installed' => $installed, 'warnings' => $warnings, 'aborted' => $aborted, 'actions' => $actions];
}
/**
* @param list<string> $ids
* @param array<string, bool|string> $options
* @return array{removed:list<string>, aborted:list<string>}
*/
public function remove(array $ids, array $options): array
{
$removed = [];
$aborted = [];
foreach ($ids as $id) {
$entry = $this->lock->component($id);
if ($entry === null) {
$aborted[] = $id;
continue;
}
$componentAbort = false;
foreach ($entry['files'] as $relative => $hash) {
$action = $this->sync->remove($this->root . '/' . $relative, $hash, $options);
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
$componentAbort = true;
}
}
if ($componentAbort) {
continue;
}
$this->lock->removeComponent($id);
$this->manifest->removeConstraint($id);
$removed[] = $id;
}
if (!($options['dryRun'] ?? false)) {
$this->lock->save($this->root);
$this->manifest->save($this->root);
}
return ['removed' => $removed, 'aborted' => $aborted];
}
/**
* @param array<string, bool|string> $options
* @return array{files:array<string,string>, actions:array<string,string>, aborted:list<string>, version:?string}
*/
public function installCore(CoreProvider $provider, array $options): array
{
return $this->writeCore($provider, $options, false);
}
/**
* @param array<string, bool|string> $options
* @return array{files:array<string,string>, actions:array<string,string>, aborted:list<string>, version:?string}
*/
public function updateCore(CoreProvider $provider, array $options): array
{
return $this->writeCore($provider, $options, true);
}
/**
* @param array<string, bool|string> $options
* @return array{files:array<string,string>, actions:array<string,string>, aborted:list<string>, version:?string}
*/
private function writeCore(CoreProvider $provider, array $options, bool $removeStale): array
{
$protected = $this->componentOwnedPaths();
$expected = $this->lock->core()['files'] ?? [];
$files = $provider->files();
$installed = [];
$actions = [];
$aborted = [];
foreach ($files as $relative) {
if (isset($protected[$relative])) {
continue;
}
$absolute = $this->root . '/' . $relative;
$action = $this->sync->write($absolute, $provider->read($relative), $expected[$relative] ?? null, $options);
$actions[$relative] = $action;
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
continue;
}
$installed[$relative] = Drift::hash($absolute);
}
if ($removeStale) {
foreach ($expected as $relative => $hash) {
if (isset($installed[$relative]) || isset($protected[$relative]) || in_array($relative, $files, true)) {
continue;
}
$action = $this->sync->remove($this->root . '/' . $relative, $hash, $options);
if ($action === FileSync::ABORTED) {
$aborted[] = $relative;
}
}
}
if ($aborted === [] && !($options['dryRun'] ?? false)) {
$this->lock->setCore([
'version' => $provider->version() ?? $this->coreVersion(),
'commit' => $provider->commit() ?? ($this->lock->core()['commit'] ?? null),
'files' => $installed,
]);
$this->lock->save($this->root);
}
return ['files' => $installed, 'actions' => $actions, 'aborted' => $aborted, 'version' => $provider->version()];
}
/** @return array<string, true> */
private function componentOwnedPaths(): array
{
$paths = [];
foreach ($this->lock->components() as $entry) {
foreach (array_keys($entry['files'] ?? []) as $relative) {
$paths[$relative] = true;
}
}
return $paths;
}
/**
* @return array{0:string, 1:?string}
*/
private function resolveVersion(string $package): array
{
if (is_dir($this->toolboxDir . '/.git')) {
$resolver = new TagResolver(new ToolboxRepo($this->toolboxDir, ''), $this->yanked($package));
$latest = $resolver->latest($package);
if ($latest !== null) {
return [$latest['version'], $latest['commit']];
}
}
return [$this->catalog->get($package)->version, null];
}
/**
* @return list<string>
*/
private function yanked(string $package): array
{
$file = $this->toolboxDir . '/yanked.json';
if (!is_file($file)) {
return [];
}
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) {
return [];
}
$list = $data[$package] ?? [];
return is_array($list) ? array_values($list) : [];
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace Toolbox;
/**
* `.duckbrain/lock.json`: estado resuelto (core + componentes con hashes).
*/
final class Lock
{
private const DIR = '.duckbrain';
private const FILE = 'lock.json';
/**
* @param array{version?:string, commit?:string, files?:array<string,string>}|null $core
* @param array<string, array{package:string, version:string, toolbox_commit:?string, mode:string, files:array<string,string>}> $components
*/
private function __construct(
private ?array $core,
private array $components,
) {
}
public static function path(string $root): string
{
return rtrim($root, '/') . '/' . self::DIR . '/' . self::FILE;
}
public static function empty(): self
{
return new self(null, []);
}
public static function load(string $root): self
{
$path = self::path($root);
if (!is_file($path)) {
return self::empty();
}
$data = json_decode((string) file_get_contents($path), true);
if (!is_array($data)) {
throw new ToolboxException("Lock inválido: {$path}");
}
$components = [];
foreach (($data['components'] ?? []) as $id => $entry) {
$components[$id] = [
'package' => (string) ($entry['package'] ?? ''),
'version' => (string) ($entry['version'] ?? ''),
'toolbox_commit' => $entry['toolbox_commit'] ?? null,
'mode' => (string) ($entry['mode'] ?? 'copy'),
'origin' => (string) ($entry['origin'] ?? 'toolbox'),
'files' => is_array($entry['files'] ?? null) ? $entry['files'] : [],
];
}
return new self($data['core'] ?? null, $components);
}
/** @return array{version?:string, commit?:string, files?:array<string,string>}|null */
public function core(): ?array
{
return $this->core;
}
/** @return array<string, array{package:string, version:string, toolbox_commit:?string, mode:string, files:array<string,string>}> */
public function components(): array
{
return $this->components;
}
public function has(string $id): bool
{
return isset($this->components[$id]);
}
public function component(string $id): ?array
{
return $this->components[$id] ?? null;
}
/** @param array{version?:string, commit?:string, files?:array<string,string>} $core */
public function setCore(array $core): void
{
$this->core = $core;
}
/** @param array{package:string, version:string, toolbox_commit:?string, mode:string, files:array<string,string>} $entry */
public function setComponent(string $id, array $entry): void
{
$this->components[$id] = $entry;
}
public function removeComponent(string $id): void
{
unset($this->components[$id]);
}
public function save(string $root): void
{
$dir = rtrim($root, '/') . '/' . self::DIR;
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
$data = ['core' => $this->core, 'components' => $this->components];
file_put_contents(
self::path($root),
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace Toolbox;
final class Manifest
{
/**
* @param array<string, Component> $components
* @param list<string> $default
*/
private function __construct(
public readonly string $name,
public readonly string $version,
public readonly string $description,
private readonly array $components,
private readonly ?array $default,
public readonly ?string $coreRequires,
public readonly ?string $coreTested,
public readonly ?string $php,
public readonly array $postInstall,
) {
}
public static function fromFile(string $path): self
{
if (!is_file($path)) {
throw new ToolboxException("No se encontró el manifiesto: {$path}");
}
$data = json_decode((string) file_get_contents($path), true);
if (!is_array($data)) {
throw new ToolboxException("Manifiesto JSON inválido: {$path}");
}
return self::fromArray($data, $path);
}
public static function fromArray(array $data, string $origin = 'duckbrain.json'): self
{
$name = $data['name'] ?? null;
$version = $data['version'] ?? null;
if (!is_string($name) || $name === '') {
throw new ToolboxException("El manifiesto '{$origin}' no declara 'name'");
}
if (!is_string($version) || $version === '') {
throw new ToolboxException("El manifiesto '{$origin}' no declara 'version'");
}
Semver::parse($version);
if (!isset($data['components']) || !is_array($data['components']) || $data['components'] === []) {
throw new ToolboxException("El manifiesto '{$origin}' no declara 'components'");
}
$components = [];
foreach ($data['components'] as $componentName => $componentData) {
if (!is_string($componentName) || !is_array($componentData)) {
throw new ToolboxException("Componente inválido en '{$origin}'");
}
$components[$componentName] = Component::fromArray($componentName, $componentData);
}
$default = null;
if (array_key_exists('default', $data)) {
if (!is_array($data['default'])) {
throw new ToolboxException("El manifiesto '{$origin}' tiene 'default' inválido");
}
$default = array_values($data['default']);
foreach ($default as $componentName) {
if (!is_string($componentName) || !isset($components[$componentName])) {
throw new ToolboxException("'default' referencia un componente inexistente en '{$origin}'");
}
}
}
$postInstall = [];
foreach (($data['post-install'] ?? []) as $command) {
if (!is_string($command)) {
throw new ToolboxException("'post-install' inválido en '{$origin}'");
}
$postInstall[] = $command;
}
return new self(
name: $name,
version: $version,
description: is_string($data['description'] ?? null) ? $data['description'] : '',
components: $components,
default: $default,
coreRequires: $data['core']['requires'] ?? null,
coreTested: $data['core']['tested'] ?? null,
php: is_string($data['php'] ?? null) ? $data['php'] : null,
postInstall: $postInstall,
);
}
/** @return array<string, Component> */
public function components(): array
{
return $this->components;
}
public function hasComponent(string $name): bool
{
return isset($this->components[$name]);
}
public function component(string $name): Component
{
if (!isset($this->components[$name])) {
throw new ToolboxException("El paquete '{$this->name}' no tiene el componente '{$name}'");
}
return $this->components[$name];
}
/**
* Expande una petición (componente o conjunto por defecto) a nombres.
*
* @return list<string>
*/
public function expand(?string $component): array
{
if ($component !== null) {
if (!$this->hasComponent($component)) {
throw new ToolboxException("El paquete '{$this->name}' no tiene el componente '{$component}'");
}
return [$component];
}
if ($this->default === null) {
throw new ToolboxException(
"El paquete '{$this->name}' no declara 'default'; indica un componente explícito"
);
}
return $this->default;
}
public function coreRequirement(?string $component = null): ?string
{
if ($component !== null) {
return $this->component($component)->coreRequires ?? $this->coreRequires;
}
return $this->coreRequires;
}
public function testedCore(?string $component = null): ?string
{
if ($component !== null) {
return $this->component($component)->coreTested ?? $this->coreTested;
}
return $this->coreTested;
}
}
@@ -0,0 +1,86 @@
<?php
namespace Toolbox;
/**
* Mapa de propiedad: cada ruta instalada pertenece al core o a un componente.
*/
final class Ownership
{
/** @var array<string, string> path => owner id ('core' o 'pkg:comp') */
private array $owners = [];
/** @var array<string, list<string>> path => owners en conflicto */
private array $collisions = [];
public static function fromLock(Lock $lock): self
{
$ownership = new self();
$core = $lock->core();
foreach (($core['files'] ?? []) as $path => $hash) {
$ownership->claim('core', $path);
}
foreach ($lock->components() as $id => $entry) {
foreach (($entry['files'] ?? []) as $path => $hash) {
$ownership->claim($id, $path);
}
}
return $ownership;
}
private function claim(string $owner, string $path): void
{
if (!isset($this->owners[$path])) {
$this->owners[$path] = $owner;
return;
}
if ($this->owners[$path] !== $owner) {
$this->collisions[$path][] = $this->owners[$path];
$this->collisions[$path][] = $owner;
}
}
public function ownerOf(string $path): ?string
{
return $this->owners[$path] ?? null;
}
/** @return list<string> */
public function pathsOf(string $owner): array
{
return array_keys(array_filter($this->owners, fn ($o) => $o === $owner));
}
/**
* @return array<string, list<string>>
*/
public function collisions(): array
{
$result = [];
foreach ($this->collisions as $path => $owners) {
$result[$path] = array_values(array_unique($owners));
}
return $result;
}
public function assertNoCollisions(): void
{
$collisions = $this->collisions();
if ($collisions === []) {
return;
}
$parts = [];
foreach ($collisions as $path => $owners) {
$parts[] = $path . ' (' . implode(', ', $owners) . ')';
}
throw new ToolboxException('Colisión de propiedad: ' . implode('; ', $parts));
}
}
@@ -0,0 +1,10 @@
<?php
namespace Toolbox;
interface PackageSource
{
public function baseDir(string $package): string;
public function read(string $package, string $file): ?string;
}
@@ -0,0 +1,47 @@
<?php
namespace Toolbox;
/**
* Ejecuta los comandos `post-install` de un paquete, mostrándolos y pidiendo
* confirmación salvo en modo `--yes`.
*/
final class PostInstall
{
/**
* @param list<string> $commands
* @param callable(string):int|null $runner
* @return list<string> comandos ejecutados
*/
public static function run(
array $commands,
Console $console,
bool $assumeYes = false,
?callable $runner = null
): array {
$executed = [];
foreach ($commands as $command) {
$console->out(' post-install: ' . $command);
if (!$console->confirm(' ¿Ejecutar?', $assumeYes)) {
$console->warn('post-install omitido: ' . $command);
continue;
}
if ($runner !== null) {
$runner($command);
} else {
passthru($command, $code);
if (($code ?? 0) !== 0) {
throw new ToolboxException("post-install falló: {$command}");
}
}
$executed[] = $command;
}
return $executed;
}
}
@@ -0,0 +1,95 @@
<?php
namespace Toolbox;
/**
* `.duckbrain/manifest.json`: estado deseado (restricciones de versión).
*/
final class ProjectManifest
{
private const DIR = '.duckbrain';
private const FILE = 'manifest.json';
/**
* @param array<string, string> $constraints
*/
private function __construct(
public ?string $coreConstraint,
private array $constraints,
) {
}
public static function path(string $root): string
{
return rtrim($root, '/') . '/' . self::DIR . '/' . self::FILE;
}
public static function load(string $root): self
{
$path = self::path($root);
if (!is_file($path)) {
return new self(null, []);
}
$data = json_decode((string) file_get_contents($path), true);
if (!is_array($data)) {
throw new ToolboxException("Manifest de proyecto inválido: {$path}");
}
$constraints = [];
foreach (($data['components'] ?? []) as $id => $constraint) {
$constraints[$id] = (string) $constraint;
}
return new self(
isset($data['core']['constraint']) ? (string) $data['core']['constraint'] : null,
$constraints
);
}
/** @return array<string, string> */
public function constraints(): array
{
return $this->constraints;
}
public function constraint(string $id): ?string
{
return $this->constraints[$id] ?? null;
}
public function setConstraint(string $id, ?string $constraint): void
{
if ($constraint === null) {
unset($this->constraints[$id]);
return;
}
$this->constraints[$id] = $constraint;
}
public function removeConstraint(string $id): void
{
unset($this->constraints[$id]);
}
public function save(string $root): void
{
$dir = rtrim($root, '/') . '/' . self::DIR;
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
$data = ['core' => [], 'components' => []];
if ($this->coreConstraint !== null) {
$data['core']['constraint'] = $this->coreConstraint;
}
$data['components'] = $this->constraints;
file_put_contents(
self::path($root),
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
}
@@ -0,0 +1,99 @@
<?php
namespace Toolbox;
/**
* Resuelve peticiones (`paquete` o `paquete:componente`) expandiendo el
* conjunto por defecto y las dependencias entre componentes, en orden de
* dependencia (las dependencias primero).
*/
final class Resolver
{
/** @var array<string, true> */
private array $visiting = [];
/** @var array<string, array{package:string, component:string}> */
private array $resolved = [];
public function __construct(private readonly Catalog $catalog)
{
}
/**
* @param list<string> $requests
* @return list<array{package:string, component:string}>
*/
public function resolve(array $requests): array
{
$this->visiting = [];
$this->resolved = [];
foreach ($requests as $request) {
[$packageName, $component] = self::parseRequest($request);
$manifest = $this->catalog->get($packageName);
foreach ($manifest->expand($component) as $componentName) {
$this->resolveComponent($packageName, $componentName);
}
}
return array_values($this->resolved);
}
private function resolveComponent(string $packageName, string $componentName): void
{
$id = $packageName . ':' . $componentName;
if (isset($this->resolved[$id])) {
return;
}
if (isset($this->visiting[$id])) {
throw new ToolboxException("Ciclo de dependencias detectado en '{$id}'");
}
$manifest = $this->catalog->get($packageName);
$component = $manifest->component($componentName);
$this->visiting[$id] = true;
foreach ($component->requires as $requiredId => $constraint) {
[$requiredPackage, $requiredComponent] = self::parseRequest($requiredId, $packageName);
$requiredManifest = $this->catalog->get($requiredPackage);
if (!$requiredManifest->hasComponent($requiredComponent)) {
throw new ToolboxException(
"'{$id}' requiere '{$requiredPackage}:{$requiredComponent}', que no existe"
);
}
if (!Semver::satisfies($requiredManifest->version, $constraint)) {
throw new ToolboxException(sprintf(
"'%s' requiere '%s' %s, pero la versión disponible es %s",
$id,
$requiredId,
$constraint,
$requiredManifest->version
));
}
$this->resolveComponent($requiredPackage, $requiredComponent);
}
unset($this->visiting[$id]);
$this->resolved[$id] = ['package' => $packageName, 'component' => $componentName];
}
/**
* @return array{0:string, 1:?string}
*/
public static function parseRequest(string $request, ?string $defaultPackage = null): array
{
if (str_contains($request, ':')) {
[$package, $component] = explode(':', $request, 2);
return [$package, $component];
}
return [$defaultPackage ?? $request, null];
}
}
@@ -0,0 +1,51 @@
<?php
namespace Toolbox;
/**
* Autoactualización del instalador global: descarga el phar, verifica su
* sha256 contra el publicado y reemplaza el binario de forma atómica.
*/
final class SelfUpdater
{
public function __construct(private readonly Console $console)
{
}
public function update(string $from, string $target, string $tempDir): void
{
$source = rtrim($from, '/') . '/duckbrain.phar';
$sumUrl = $source . '.sha256';
$this->console->out(' descargando ' . $source);
$data = @file_get_contents($source);
if ($data === false) {
throw new ToolboxException("No se pudo descargar el instalador: {$source}");
}
$expected = trim((string) @file_get_contents($sumUrl));
if ($expected === '') {
throw new ToolboxException("No se pudo leer el sha256 publicado: {$sumUrl}");
}
$actual = hash('sha256', $data);
if (!hash_equals($expected, $actual)) {
throw new ToolboxException('sha256 inválido; actualización abortada sin tocar el binario');
}
if (!is_dir($tempDir)) {
mkdir($tempDir, 0777, true);
}
$temp = rtrim($tempDir, '/') . '/duckbrain.phar.' . bin2hex(random_bytes(4));
file_put_contents($temp, $data);
chmod($temp, 0755);
if (!rename($temp, $target)) {
@unlink($temp);
throw new ToolboxException("No se pudo reemplazar el instalador: {$target}");
}
$this->console->ok('instalador actualizado (' . $target . ')');
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace Toolbox;
/**
* Comparación y evaluación de rangos semver, sin dependencias externas.
*/
final class Semver
{
/**
* @return array{major:int, minor:int, patch:int, pre:string, meta:string}
*/
public static function parse(string $version): array
{
$version = ltrim(trim($version), 'vV');
$pattern = '/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.\-]+))?(?:\+([0-9A-Za-z.\-]+))?$/';
if (!preg_match($pattern, $version, $m)) {
throw new ToolboxException("Versión semver inválida: '{$version}'");
}
return [
'major' => (int) $m[1],
'minor' => (int) $m[2],
'patch' => (int) $m[3],
'pre' => $m[4] ?? '',
'meta' => $m[5] ?? '',
];
}
/**
* Completa versiones parciales (`1`, `1.2`) a `MAJOR.MINOR.PATCH`.
*/
public static function normalize(string $version): string
{
$version = ltrim(trim($version), 'vV');
if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?([-+].*)?$/', $version, $m) === 1) {
$core = $m[1] . '.' . ($m[2] ?? '0') . '.' . ($m[3] ?? '0');
return $core . ($m[4] ?? '');
}
return $version;
}
public static function compare(string $a, string $b): int
{
$pa = self::parse(self::normalize($a));
$pb = self::parse(self::normalize($b));
foreach (['major', 'minor', 'patch'] as $part) {
if ($pa[$part] !== $pb[$part]) {
return $pa[$part] <=> $pb[$part];
}
}
return self::comparePre($pa['pre'], $pb['pre']);
}
/**
* ¿La versión satisface la restricción? Soportado: *, vacío, exacta,
* =, ==, !=, >, >=, <, <=, ^ y ~. La restricción puede venir con el prefijo
* de paquete ya removido.
*/
public static function satisfies(string $version, string $constraint): bool
{
$constraint = trim($constraint);
if ($constraint === '' || $constraint === '*') {
return true;
}
if (str_contains($constraint, '||')) {
foreach (explode('||', $constraint) as $alternative) {
if (self::satisfies($version, trim($alternative))) {
return true;
}
}
return false;
}
if (preg_match('/^(>=|<=|!=|==|>|<|=)?\s*(.+)$/', $constraint, $m) !== 1) {
throw new ToolboxException("Restricción inválida: '{$constraint}'");
}
$operator = $m[1] ?: '=';
$target = trim($m[2]);
if ($target === '' || $target === '*') {
return true;
}
if ($target[0] === '^' || $target[0] === '~') {
return self::satisfiesRange($version, $target);
}
$cmp = self::compare($version, self::normalize($target));
return match ($operator) {
'>' => $cmp > 0,
'>=' => $cmp >= 0,
'<' => $cmp < 0,
'<=' => $cmp <= 0,
'!=' => $cmp !== 0,
default => $cmp === 0,
};
}
private static function satisfiesRange(string $version, string $range): bool
{
$operator = $range[0];
$lower = self::normalize(substr($range, 1));
$p = self::parse($lower);
if (self::compare($version, $lower) < 0) {
return false;
}
if ($operator === '~') {
$upper = $p['major'] . '.' . ($p['minor'] + 1) . '.0';
} else {
$upper = $p['major'] > 0
? ($p['major'] + 1) . '.0.0'
: '0.' . ($p['minor'] + 1) . '.0';
}
return self::compare($version, $upper) < 0;
}
private static function comparePre(string $a, string $b): int
{
if ($a === '' && $b === '') {
return 0;
}
if ($a === '') {
return 1;
}
if ($b === '') {
return -1;
}
return strcmp($a, $b) <=> 0;
}
}
@@ -0,0 +1,106 @@
<?php
namespace Toolbox;
/**
* Resuelve versiones de paquete a partir de tags prefijados del repositorio,
* verificando que el tag coincida con la versión del manifiesto en ese commit.
*/
final class TagResolver
{
/**
* @param list<string> $yanked versiones retiradas
*/
public function __construct(
private readonly ToolboxRepo $repo,
private readonly array $yanked = [],
) {
}
/**
* @return list<array{version:string, tag:string, commit:string, valid:bool}>
*/
public function versions(string $package): array
{
$prefix = $package . '-v';
$candidates = [];
foreach ($this->repo->tags($prefix) as $tag) {
$version = substr($tag, strlen($prefix));
try {
Semver::parse($version);
} catch (ToolboxException) {
continue;
}
$commit = $this->repo->commitOfTag($tag);
if ($commit === null) {
continue;
}
$candidates[] = [
'version' => $version,
'tag' => $tag,
'commit' => $commit,
'valid' => $this->isConsistent($package, $version, $commit),
];
}
usort($candidates, fn ($a, $b) => Semver::compare($b['version'], $a['version']));
return $candidates;
}
/**
* @return array{version:string, tag:string, commit:string}|null
*/
public function latest(
string $package,
?string $constraint = null,
bool $includePre = false
): ?array {
foreach ($this->versions($package) as $candidate) {
if (!$candidate['valid']) {
continue;
}
if (!$includePre && self::isPre($candidate['version'])) {
continue;
}
if (in_array($candidate['version'], $this->yanked, true)) {
continue;
}
if ($constraint !== null && !Semver::satisfies($candidate['version'], $constraint)) {
continue;
}
return [
'version' => $candidate['version'],
'tag' => $candidate['tag'],
'commit' => $candidate['commit'],
];
}
return null;
}
private function isConsistent(string $package, string $version, string $commit): bool
{
$raw = $this->repo->fileAt($commit, "packages/{$package}/duckbrain.json");
if ($raw === null) {
return false;
}
$data = json_decode($raw, true);
return is_array($data) && ($data['version'] ?? null) === $version;
}
private static function isPre(string $version): bool
{
return str_contains($version, '-');
}
}
@@ -0,0 +1,7 @@
<?php
namespace Toolbox;
class ToolboxException extends \RuntimeException
{
}
@@ -0,0 +1,107 @@
<?php
namespace Toolbox;
/**
* Caché clonada del toolbox. Mantiene historial completo (no superficial) para
* poder resolver cualquier commit registrado en un lock.
*/
final class ToolboxRepo
{
public function __construct(
private readonly string $dir,
private readonly string $url,
) {
}
public function dir(): string
{
return $this->dir;
}
public function isCloned(): bool
{
return is_dir($this->dir . '/.git');
}
public function ensure(): void
{
if (!$this->isCloned()) {
$parent = dirname($this->dir);
if (!is_dir($parent)) {
mkdir($parent, 0777, true);
}
$this->git(['clone', '--tags', $this->url, $this->dir]);
return;
}
$this->ensureFull();
$this->fetch();
}
public function fetch(): void
{
$this->git(['fetch', '--tags', '--force', 'origin']);
}
/**
* Si la caché es superficial, la completa.
*/
public function ensureFull(): void
{
if (is_file($this->dir . '/.git/shallow')) {
$this->git(['fetch', '--unshallow', '--tags']);
}
}
/**
* @return list<string>
*/
public function tags(?string $prefix = null): array
{
$tags = array_filter(explode("\n", trim($this->git(['tag', '-l'])[1])));
if ($prefix === null) {
return array_values($tags);
}
return array_values(array_filter($tags, fn ($tag) => str_starts_with($tag, $prefix)));
}
public function commitOfTag(string $tag): ?string
{
$result = $this->git(['rev-list', '-n', '1', $tag]);
return $result[0] === 0 && trim($result[1]) !== '' ? trim($result[1]) : null;
}
public function hasCommit(string $commit): bool
{
$result = $this->git(['cat-file', '-e', $commit . '^{commit}']);
return $result[0] === 0;
}
public function fileAt(string $ref, string $path): ?string
{
$result = $this->git(['show', $ref . ':' . $path]);
return $result[0] === 0 ? $result[1] : null;
}
/**
* @param list<string> $args
* @return array{0:int, 1:string, 2:string}
*/
public function git(array $args): array
{
$command = 'git -C ' . escapeshellarg($this->dir) . ' ' . implode(' ', array_map('escapeshellarg', $args)) . ' 2>&1';
$output = [];
$code = 0;
exec($command, $output, $code);
return [$code, implode("\n", $output), ''];
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "crypto",
"version": "0.1.0",
"description": "Criptografía y segundo factor (Crypto, Nonce, TwoFactorAuthenticator)",
"core": { "requires": ">=0.1", "tested": "0.1" },
"php": ">=8.1",
"components": {
"crypto": { "files": ["src/Libs/Crypto.php"] },
"nonce": { "files": ["src/Libs/Nonce.php"] },
"twoFactor": { "files": ["src/Libs/TwoFactorAuthenticator.php"] }
},
"default": ["crypto", "nonce", "twoFactor"]
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace Libs;
/**
* Crypto - DuckBrain
*
* Class created for encrypting/decrypting
* using openssl with the AES 256 CBC algorithm.
*
* Optionally depends on the PRIVATE_KEY constant.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Crypto
{
/**
* Encrypts using openssl with the AES 256 CBC algorithm
* and returns the result in hexadecimal.
*
* @param string $data
* @param string $password
*
* @return string
*/
public static function encryptHex(string $data, string $password = PRIVATE_KEY): string
{
return bin2hex(static::encrypt($data, $password));
}
/**
* Decrypts a string from hexadecimal
* and then using openssl with the AES 256 CBC algorithm.
*
* @param string $data Hexadecimal string
* @param string $password
*
* @return string
*/
public static function decryptHex(string $data, string $password = PRIVATE_KEY): string
{
return static::decrypt(hex2bin($data), $password);
}
/**
* Encrypts using openssl with the AES 256 CBC algorithm
* and returns the result in base64.
*
* @param string $data
* @param string $password
*
* @return string
*/
public static function encrypt64(string $data, string $password = PRIVATE_KEY): string
{
return base64_encode(static::encrypt($data, $password));
}
/**
* Decrypts a string from base64
* and then using openssl with the AES 256 CBC algorithm.
*
* @param string $data
* @param string $password
*
* @return string
*/
public static function decrypt64(string $data, string $password = PRIVATE_KEY): string
{
return static::decrypt(base64_decode($data), $password);
}
/**
* Encrypts using openssl with the AES 256 CBC algorithm.
*
* @param string $data
* @param string $password
*
* @return string
*/
public static function encrypt(string $data, string $password = PRIVATE_KEY): string
{
return openssl_encrypt(
$data,
'AES-256-CBC',
$password,
OPENSSL_RAW_DATA,
substr($password, 0, 16)
);
}
/**
* Decrypts using openssl with the AES 256 CBC algorithm.
*
* @param string $data
* @param string $password
*
* @return string
*/
public static function decrypt(string $data, string $password = PRIVATE_KEY): string
{
return openssl_decrypt(
$data,
'AES-256-CBC',
$password,
OPENSSL_RAW_DATA,
substr($password, 0, 16)
);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Libs;
/**
* Nonce - DuckBrain
*
* Simple class to create and verify nonces.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Nonce
{
/**
* Returns a nonce code.
*
* @param string $passphrase
*
* @return string
*/
public static function get(string $passphrase = ''): string
{
return bin2hex(
openssl_encrypt(
time(),
'aes-128-ecb',
$passphrase,
OPENSSL_RAW_DATA
)
);
}
/**
* Verifies a nonce code.
*
* @param string $nonce
* @param string $passphrase
*
* @return bool
*/
public static function check(string $nonce, string $passphrase = ''): bool
{
$decripted = openssl_decrypt(
hex2bin($nonce),
'aes-128-ecb',
$passphrase,
OPENSSL_RAW_DATA
);
return $decripted > (time() - 1800);
}
}
@@ -0,0 +1,256 @@
<?php
namespace Libs;
use Exception;
/**
* PHP Class for handling Google Authenticator 2-factor authentication.
*
* @author Michael Kliewe
* @copyright 2012 Michael Kliewe
* @license http://www.opensource.org/licenses/bsd-license.php BSD License
*
* @link http://www.phpgangsta.de/
*/
class TwoFactorAuthenticator // Name changed from PHPGangsta_GoogleAuthenticator to TwoFactorAuthenticator
{
protected $_codeLength = 6;
/**
* Create new secret.
* 16 characters, randomly chosen from the allowed base32 characters.
*
* @param int $secretLength
*
* @return string
*/
public function createSecret(int $secretLength = 16): string
{
$validChars = $this->_getBase32LookupTable();
// Valid secret lengths are 80 to 640 bits
if ($secretLength < 16 || $secretLength > 128) {
throw new Exception('Bad secret length');
}
$secret = '';
$rnd = false;
if (function_exists('random_bytes')) {
$rnd = random_bytes($secretLength);
} elseif (function_exists('mcrypt_create_iv')) {
$rnd = mcrypt_create_iv($secretLength, MCRYPT_DEV_URANDOM);
} elseif (function_exists('openssl_random_pseudo_bytes')) {
$rnd = openssl_random_pseudo_bytes($secretLength, $cryptoStrong);
if (!$cryptoStrong) {
$rnd = false;
}
}
if ($rnd !== false) {
for ($i = 0; $i < $secretLength; ++$i) {
$secret .= $validChars[ord($rnd[$i]) & 31];
}
} else {
throw new Exception('No source of secure random');
}
return $secret;
}
/**
* Calculate the code, with given secret and point in time.
*
* @param string $secret
* @param int|null $timeSlice
*
* @return string
*/
public function getCode(string $secret, ?int $timeSlice = null): string
{
if ($timeSlice === null) {
$timeSlice = floor(time() / 30);
}
$secretkey = $this->_base32Decode($secret);
// Pack time into binary string
$time = chr(0) . chr(0) . chr(0) . chr(0) . pack('N*', $timeSlice);
// Hash it with users secret key
$hm = hash_hmac('SHA1', $time, $secretkey, true);
// Use last nipple of result as index/offset
$offset = ord(substr($hm, -1)) & 0x0F;
// grab 4 bytes of the result
$hashpart = substr($hm, $offset, 4);
// Unpak binary value
$value = unpack('N', $hashpart);
$value = $value[1];
// Only 32 bits
$value = $value & 0x7FFFFFFF;
$modulo = pow(10, $this->_codeLength);
return str_pad($value % $modulo, $this->_codeLength, '0', STR_PAD_LEFT);
}
/**
* Get QR-Code URL for image, from google charts.
*
* @param string $name
* @param string $secret
* @param string|null $title
* @param array $params
*
* @return string
*/
public function getQRCodeGoogleUrl(string $name, string $secret, ?string $title = null, array $params = array()): string
{
$width = !empty($params['width']) && (int) $params['width'] > 0 ? (int) $params['width'] : 200;
$height = !empty($params['height']) && (int) $params['height'] > 0 ? (int) $params['height'] : 200;
$level = !empty($params['level']) && array_search($params['level'], array('L', 'M', 'Q', 'H')) !== false ? $params['level'] : 'M';
$urlencoded = urlencode('otpauth://totp/' . $name . '?secret=' . $secret . '');
if (isset($title)) {
$urlencoded .= urlencode('&issuer=' . urlencode($title));
}
return "https://api.qrserver.com/v1/create-qr-code/?data=$urlencoded&size={$width}x{$height}&ecc=$level";
}
/**
* Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now.
*
* @param string $secret
* @param string $code
* @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after)
* @param int|null $currentTimeSlice time slice if we want use other that time()
*
* @return bool
*/
public function verifyCode($secret, $code, $discrepancy = 1, $currentTimeSlice = null): bool
{
if ($currentTimeSlice === null) {
$currentTimeSlice = floor(time() / 30);
}
if (strlen($code) != 6) {
return false;
}
for ($i = -$discrepancy; $i <= $discrepancy; ++$i) {
$calculatedCode = $this->getCode($secret, $currentTimeSlice + $i);
if ($this->timingSafeEquals($calculatedCode, $code)) {
return true;
}
}
return false;
}
/**
* Set the code length, should be >=6.
*
* @param int $length
*
* @return PHPGangsta_GoogleAuthenticator
*/
public function setCodeLength(string $length): TwoFactorAuthenticator
{
$this->_codeLength = $length;
return $this;
}
/**
* Helper class to decode base32.
*
* @param $secret
*
* @return bool|string
*/
protected function _base32Decode($secret): string|bool
{
if (empty($secret)) {
return '';
}
$base32chars = $this->_getBase32LookupTable();
$base32charsFlipped = array_flip($base32chars);
$paddingCharCount = substr_count($secret, $base32chars[32]);
$allowedValues = array(6, 4, 3, 1, 0);
if (!in_array($paddingCharCount, $allowedValues)) {
return false;
}
for ($i = 0; $i < 4; ++$i) {
if ($paddingCharCount == $allowedValues[$i] &&
substr($secret, -($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])) {
return false;
}
}
$secret = str_replace('=', '', $secret);
$secret = str_split($secret);
$binaryString = '';
for ($i = 0; $i < count($secret); $i = $i + 8) {
$x = '';
if (!in_array($secret[$i], $base32chars)) {
return false;
}
for ($j = 0; $j < 8; ++$j) {
$x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT);
}
$eightBits = str_split($x, 8);
for ($z = 0; $z < count($eightBits); ++$z) {
$binaryString .= (($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48) ? $y : '';
}
}
return $binaryString;
}
/**
* Get array with all 32 characters for decoding from/encoding to base32.
*
* @return array
*/
protected function _getBase32LookupTable(): array
{
return array(
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7
'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
'=', // padding char
);
}
/**
* A timing safe equals comparison
* more info here: http://blog.ircmaxell.com/2014/11/its-all-about-time.html.
*
* @param string $safeString The internal (safe) value to be checked
* @param string $userString The user submitted (unsafe) value
*
* @return bool True if the two strings are identical
*/
private function timingSafeEquals(string $safeString, string $userString): bool
{
if (function_exists('hash_equals')) {
return hash_equals($safeString, $userString);
}
$safeLen = strlen($safeString);
$userLen = strlen($userString);
if ($userLen != $safeLen) {
return false;
}
$result = 0;
for ($i = 0; $i < $userLen; ++$i) {
$result |= (ord($safeString[$i]) ^ ord($userString[$i]));
}
// They are only identical strings if $result is exactly 0...
return $result === 0;
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "htmx",
"version": "0.1.0",
"description": "Motor de vistas basado en componentes y HTMX",
"core": { "requires": ">=0.1", "tested": "0.1" },
"php": ">=8.1",
"components": {
"htmlComponent": {
"files": ["src/Libs/HTMLComponent.php", "src/Libs/Sanitizer"]
},
"htmx": {
"files": ["src/Libs/HTMX.php"],
"require": { "htmx:htmlComponent": ">=0.1" }
}
},
"default": ["htmlComponent", "htmx"]
}
+470
View File
@@ -0,0 +1,470 @@
<?php
namespace Libs;
use Libs\Sanitizer\SanitizerProxy;
/**
* HTMLComponent - DuckBrain
*
* Duckbrain Library for HTMX using components.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class HTMLComponent
{
/**
* __construct
*
* @param array $properties
* @param string $content
* @param string $viewPath
* @param string $extension
*/
public function __construct(
protected array $properties = [],
protected string $content = '',
protected ?string $viewPath = null,
protected string $extension = '.php',
) {
if (is_null($this->viewPath)) {
if (defined('VIEWS_DIR')) {
$this->viewPath = rtrim(VIEWS_DIR, '/') . '/';
} else {
$this->viewPath = ROOT_CORE . '/Views/';
}
} else {
$this->viewPath = rtrim($this->viewPath, '/') . '/';
}
}
/**
* Loads the component from a file.
*
* @param string $component
*
* @return static
*/
public function load(string $component): static
{
$componentRealName = trim(str_replace(':', '/', $component), ' :');
if (file_exists($this->viewPath . $componentRealName . $this->extension)) {
ob_start();
include($this->viewPath . $componentRealName . $this->extension);
$this->content = ob_get_clean();
$this->parse();
} else {
throw new \Exception(
'"' . $component . '" component not exists.'
);
}
return $this;
}
/**
* Component tag token grammar. Matches an opening tag `<Name attrs>` or a
* closing tag `</Name>` where Name is an optional leading-colon namespace
* followed by an uppercase letter and at least one word/digit/colon char
* (so plain lowercase HTML and single-letter tags are never treated as
* components). Mirrors the discovery grammar the previous regex used.
*/
private const TAG_RE = '/<(\/)?((?::+)?[A-Z][\w1-9:]+)([^>]*)>/';
/**
* Parses and processes the content in search of more components.
*
* Uses a single left-to-right pass over all component tags with an
* explicit stack, so it correctly resolves balanced, same-type nested
* components at any depth (which the previous per-name lazy regex could
* not) and costs one pass regardless of how many distinct component
* types the document uses.
*
* @return void
*/
protected function parse(): void
{
$this->content = $this->parseComponents($this->content, $this->properties);
}
/**
* Resolves every component tag found in $content, rendering each with the
* properties inherited from its ancestors merged with its own, and returns
* the fully rendered string. Nested components are rendered bottom-up (the
* stack assembles each frame's inner content before the frame is rendered).
*
* @param string $content Content that may contain component tags.
* @param array $inherited Properties inherited from ancestor components.
*
* @return string
*/
private function parseComponents(string $content, array $inherited): string
{
preg_match_all(self::TAG_RE, $content, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
if (!$tokens) {
return $content;
}
$segments = [];
$cursor = 0;
/** @var array<int, array{name: string, props: array, seg: array<int,string>, rawStart: int}> $stack */
$stack = [];
$top = -1;
$length = strlen($content);
foreach ($tokens as $token) {
$offset = $token[0][1];
$raw = $token[0][0];
if ($offset > $cursor) {
$literal = substr($content, $cursor, $offset - $cursor);
if ($top >= 0) {
$stack[$top]['seg'][] = $literal;
} else {
$segments[] = $literal;
}
}
$cursor = $offset + strlen($raw);
$isClose = $token[1][0] === '/';
$name = $token[2][0];
if (!$isClose) {
$parentProperties = $top >= 0 ? $stack[$top]['props'] : $inherited;
$ownProperties = static::parseProperties($token[3][0]);
$stack[] = [
'name' => $name,
'props' => array_merge($parentProperties, $ownProperties),
'seg' => [],
'rawStart' => $offset,
];
$top++;
continue;
}
if ($top >= 0 && $stack[$top]['name'] === $name) {
$frame = array_pop($stack);
$top--;
$inner = implode('', $frame['seg']);
$rendered = $this->renderComponent($name, $frame['props'], $inner);
if ($top >= 0) {
$stack[$top]['seg'][] = $rendered;
} else {
$segments[] = $rendered;
}
} else {
// stray or mis-nested closing tag: keep it verbatim (malformed
// markup is left unparsed rather than crashing).
if ($top >= 0) {
$stack[$top]['seg'][] = $raw;
} else {
$segments[] = $raw;
}
}
}
if ($top >= 0) {
// unbalanced opening tags: emit the remainder verbatim from the
// first unclosed component onward.
$segments[] = substr($content, $stack[0]['rawStart']);
} elseif ($cursor < $length) {
$segments[] = substr($content, $cursor);
}
return implode('', $segments);
}
/**
* Renders a single component with the given (already inherited+merged)
* properties and inner content. The inner content is fully resolved before
* it reaches here; load() re-parses the view's own output so components
* declared inside a view file keep working.
*
* @param string $component
* @param array $properties
* @param string $inner
*
* @return string
*/
private function renderComponent(string $component, array $properties, string $inner): string
{
$instance = new static($properties);
$instance->content = $inner;
$instance->load($component);
return $instance->getContent();
}
/**
* Converts a string in "key='value' key2='value2'" format into an array.
*
* @param string $propertiesString
*
* @return array
*/
protected static function parseProperties(string $propertiesString): array
{
preg_match_all('/([\w]+)=[\'"](.+)?[\'"]/sU', $propertiesString, $matches, PREG_PATTERN_ORDER);
$result = [];
foreach ($matches[1] as $index => $property) {
$result[$property] = $matches[2][$index];
}
preg_match_all('/([\w]+) /si', $propertiesString . ' ', $matches, PREG_PATTERN_ORDER);
foreach ($matches[1] as $property) {
$result[$property] = $property;
}
return $result;
}
/**
* Prints the component.
*
* @return void
*/
public function print(): void
{
echo $this->content;
}
/**
* Renders a component.
*
* @param string $component
* @param array $properties
*
* @return void
*/
public static function render(string $component, array $properties = []): void
{
$instance = new static($properties);
$instance->load($component);
$instance->print();
}
/**
* Returns the component as a string.
*
* @return string
*/
public function getContent(): string
{
return $this->content;
}
/**
* Returns the value of a property.
*
* @param string $key
* @param mixed $default
*
* @return mixed
*/
public function property(string $key, mixed $default = null): mixed
{
return $this->properties[$key] ?? $default;
}
/**
* Returns the value of a property escaped with htmlspecialchars.
*
* @param string $key
* @param string $default
*
* @return string
*/
public function escapedProperty(string $key, string $default = ''): string
{
if (isset($this->properties[$key])) {
return SanitizerProxy::apply($this->properties[$key] ?? null);
} else {
return htmlspecialchars($default);
}
}
/**
* Alias function for the property method.
*
* @param string $key
* @param mixed $default
*
* @return mixed
*/
public function p(string $key, mixed $default = null): mixed
{
return $this->property($key, $default);
}
/**
* Alias function for the escapedProperty method.
*
* @param string $key
* @param string $default
*
* @return string
*/
public function e(string $key, string $default = ''): string
{
return $this->escapedProperty($key, $default);
}
/**
* Based on the received array of property names, it returns them
* with their values as an associative array.
* If no properties are received as arguments, all existing ones are returned.
*
* @param array $properties
*
* @return array
*/
public function propsToArray(...$properties): array
{
if (empty($properties)) {
return $this->properties;
}
$result = [];
foreach ($properties as $property) {
$result[$property] = $this->properties[$property] ?? null;
}
return $result;
}
/**
* Based on the received array of property names, it returns them
* as HTML attributes in property="value" format.
*
* @param array $properties
*
* @return string
*/
public function propsToAtts(...$properties): string
{
$attributes = [];
foreach ($properties as $property) {
$attributes[] = $property . '="' .
htmlspecialchars($this->property($property, '')) . '"';
}
return ' ' . implode(' ', $attributes);
}
/**
* Based on the received array of property names, it returns them
* as unary HTML attributes such as checked, required, etc.
*
* @param array $properties
*
* @return string
*/
public function propsToUnary(...$properties): string
{
$result = ' ';
foreach ($properties as $property) {
if (isset($this->properties[$property])) {
$result .= $property . ' ';
}
}
return $result;
}
/**
* Treats a property as a definition of a unary HTML property.
*
* @param string $property Unary property to look for.
* @param mixed $result Value to return if the property is defined. If null, returns $property.
*
* @return mixed
*/
public function unary(string $property, mixed $result = null): mixed
{
if (isset($this->properties[$property])) {
if (isset($result)) {
return $result;
} else {
return $property;
}
}
return null;
}
/**
* Adds a new property/attribute.
* If a property already exists, it replaces it.
*
* @param string $key
* @param mixed $value
*
* @return void
*/
public function addProperty(string $key, mixed $value): void
{
$this->properties = array_merge($this->properties, [$key => $value]);
}
/**
* Adds new properties in bulk.
* If a property already exists, it replaces it.
*
* @param array $properties
*
* @return void
*/
public function addProperties(array $properties): void
{
$this->properties = array_merge($this->properties, $properties);
}
/**
* Attempts to return the absolute URL from a relative path.
*
* @param string $path
*
* @return string
*/
public static function route(string $path = '/'): string
{
if (defined('SITE_URL') && !empty(SITE_URL)) {
return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/');
}
return $path;
}
/**
* Returns the request path.
*
* @return string
*/
public static function path(): string
{
return Router::currentPath();
}
/**
* __get
*
* @param string $index
* @return mixed
*/
public function __get(string $index): mixed
{
return SanitizerProxy::apply($this->property($index));
}
/**
* __isset
*
* @param string $key
*
* @return bool
*/
public function __isset(string $key): bool
{
return isset($this->properties[$key]);
}
}
+447
View File
@@ -0,0 +1,447 @@
<?php
namespace Libs;
/**
* HTMX - DuckBrain
*
* HTMX component library for DuckBrain.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class HTMX extends HTMLComponent
{
/**
* Valid HTMX swap styles.
*/
public const SWAP_STYLES = [
'innerHTML', 'outerHTML', 'outerSync',
'beforebegin', 'before',
'afterbegin', 'prepend',
'beforeend', 'append',
'afterend', 'after',
'delete', 'none',
'innerMorph', 'outerMorph',
'textContent',
];
/**
* Valid HTMX swap option keys (used as key:value after the style).
*/
public const SWAP_OPTIONS = [
'swap', 'settle', 'transition', 'ignoreTitle', 'strip',
'focusScroll', 'swapEmpty', 'scroll', 'show', 'showTarget',
'scrollTarget', 'target',
];
/**
* Renders a component
*
* @param string $component
* @param array $properties
*
* @return void
*/
public static function render(
string $component,
array $properties = []
): void {
$instance = new static($properties);
$instance->load($component);
$instance->print();
}
/**
* Parses and processes the content in search of more components
*
* @return void
*/
protected function parse(): void
{
$this->parseHTMX();
parent::parse();
}
/**
* Removes or displays sections based on comment tags and
* HTMX request states.
*
* Possible comments are:
*
* | Block | normal | full | partial | Shown if the request is |
* |--------------|--------|------+---------+--------------------------------------|
* | hx | - | + | + | any HTMX request |
* | !hx | + | - | - | not HTMX (plain navigation) |
* | hx-full | - | + | - | full page HTMX request |
* | !hx-full | + | - | + | not a full HTMX request (plain + swap)|
* | hx-partial | - | - | + | partial (targeted) HTMX request |
* | !hx-partial | + | + | - | not a partial request (plain + full) |
*
* "full" includes boosted requests, which are no longer a separate
* state (legacy): distinguish them with HTMX::isBoosted() in PHP if
* needed.
*
* "!" negates a block. Nest blocks for AND, and use sibling blocks
* with the same content for OR. Nesting two blocks with the same
* name is not supported.
*
* @return void
*/
protected function parseHTMX(): void
{
if (!static::isHtmx()) {
$replacement = ['$3', '$3', '$3', '', '', ''];
} elseif (static::isPartialRequest()) {
$replacement = ['', '$3', '', '$3', '', '$3'];
} else {
$replacement = ['', '$3', '$3', '$3', '$3', ''];
}
$this->content = trim(preg_replace(
[
'/([\t \r\n]+)?<!-- !hx -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/!hx -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- !hx-full -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/!hx-full -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- !hx-partial -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/!hx-partial -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- hx -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/hx -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- hx-full -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/hx-full -->([\t \r\n]+)?/siU',
'/([\t \r\n]+)?<!-- hx-partial -->([\t \r\n]+)?(.+)?([\t \r\n]+)?<!-- \/hx-partial -->([\t \r\n]+)?/siU',
],
$replacement,
$this->content
));
}
/**
* Deletes empty lines.
*
* @return void
*/
public function deleteEmptyLines(): void
{
$this->content = trim(preg_replace(
'/^[ \t]*[\r\n]+/m',
'',
$this->content
));
}
/**
* Checks if the request is htmx or not.
*
* @return bool
*/
public static function isHtmx(): bool
{
return isset($_SERVER['HTTP_HX_REQUEST']);
}
/**
* Returns the request type of the HTMX request.
*
* "partial" for targeted swaps, "full" for body-level or
* hx-select requests. Empty string if the request is not HTMX.
*
* @return string
*/
public static function requestType(): string
{
return $_SERVER['HTTP_HX_REQUEST_TYPE'] ?? '';
}
/**
* Checks if the request is a full page HTMX request.
*
* @return bool
*/
public static function isFullRequest(): bool
{
return static::requestType() === 'full';
}
/**
* Checks if the request is a partial (targeted) HTMX request.
*
* @return bool
*/
public static function isPartialRequest(): bool
{
return static::requestType() === 'partial';
}
/**
* Redirects to an internal relative path by sending the appropriate header
* if it's a normal or HTMX request.
*
* @param string $path
* The path relative to the base path.
*
* @return void
*/
public static function redirect(string $path): void
{
if (static::isHtmx()) {
header('HX-Redirect: ' . Router::basePath() . ltrim($path, '/'));
} else {
Router::redirect($path);
}
}
/**
* Checks if the request is Boosted or not.
*
* @return bool
*/
public static function isBoosted(): bool
{
return isset($_SERVER['HTTP_HX_BOOSTED']) && boolval($_SERVER['HTTP_HX_BOOSTED']);
}
/**
* Returns the current browser URL when the HTMX request is made.
*
* @return string
*/
public static function currentURL(): string
{
return $_SERVER['HTTP_HX_CURRENT_URL'] ?? '';
}
/**
* Returns the HTMX target (if it exists).
*
* The HX-Target header uses the "tagName#id" format.
*
* @return string
*/
public static function target(): string
{
return $_SERVER['HTTP_HX_TARGET'] ?? '';
}
/**
* Returns the HX-Source header: the element that triggered the request,
* in "tagName#id" format.
*
* @return string
*/
public static function source(): string
{
return $_SERVER['HTTP_HX_SOURCE'] ?? '';
}
/**
* The tag name of the element that triggered the request (if it exists).
*
* @return string
*/
public static function sourceTag(): string
{
$source = static::source();
$hash = strpos($source, '#');
return $hash === false ? $source : substr($source, 0, $hash);
}
/**
* The ID of the element that triggered the request (if it exists).
*
* @return string
*/
public static function sourceId(): string
{
$source = static::source();
$hash = strpos($source, '#');
return $hash === false ? '' : substr($source, $hash + 1);
}
/**
* Only when it is an HTMX request, it returns the HTMX hx-swap-oob property.
*
* @param string $value Swap value: "true" for the default swap,
* or a swap style with optional options
* (e.g. "beforeend" or "outerHTML").
* @param bool $excludeBoosted Exclude if the request is boosted (default: false)
*
* @return string
*
* @throws \InvalidArgumentException On an unknown style or option.
*/
public static function swapOob(string $value = 'true', bool $excludeBoosted = false): string
{
if ($excludeBoosted && static::isBoosted()) {
return '';
}
if ($value !== 'true') {
$value = static::validateSwap($value);
}
return static::isHtmx() ? 'hx-swap-oob="' . $value . '"' : '';
}
/**
* Only when it is an HTMX request, it returns the HTMX hx-select-oob property.
*
* @param string $selector
*
* @return string
*/
public static function selectOob(string $selector): string
{
return static::isHtmx() ? 'hx-select-oob="' . $selector . '"' : '';
}
/**
* Wraps the given content in an HTMX <hx-partial> element, which is
* swapped into its own target alongside the main response.
*
* Returns an empty string on non-HTMX requests, since partials are only
* meaningful in a swap response. Note that, if the response contains
* only partials, the main swap is skipped unless the triggering element
* uses the "swapEmpty:true" swap option.
*
* @param string $content HTML content to deliver to the target.
* @param string $target CSS selector of the target element.
* @param string $swap Swap style (default: innerHTML).
*
* @return string
*/
public static function partial(
string $content,
string $target,
string $swap = 'innerHTML'
): string {
if (!static::isHtmx()) {
return '';
}
$swap = static::validateSwap($swap);
return '<hx-partial hx-target="' . htmlspecialchars($target)
. '" hx-swap="' . htmlspecialchars($swap) . '">'
. $content
. '</hx-partial>';
}
/**
* Forces the browser's URL to change and adds it to the history.
*
* Equivalent to using the htmx hx-push-url property. When the user
* navigates back, htmx re-requests the URL from the server and swaps it
* into <body> (or the [hx-history-elt] element if present).
*
* @param string $url Relative path.
*
* @return void
*/
public static function pushUrl(string $url): void
{
if (static::isHtmx()) {
header('HX-Push-Url: ' . static::route($url));
}
}
/**
* Forces the browser's URL to change without adding it to the history.
*
* Equivalent to using the htmx hx-replace-url property.
*
* @param string $url Relative path.
*
* @return void
*/
public static function replaceUrl(string $url): void
{
if (static::isHtmx()) {
header('HX-Replace-Url: ' . static::route($url));
}
}
/**
* Changes the HTMX swap target.
*
* @param string $selector
*
* @return void
*/
public static function retarget(string $selector): void
{
header('HX-Retarget: ' . $selector);
}
/**
* Changes the HTMX swap rule.
*
* The value is a swap style optionally followed by swap options,
* e.g. "outerHTML transition:true" or "delete".
*
* @param string $rule
*
* @return void
*
* @throws \InvalidArgumentException On an unknown style or option.
*/
public static function reswap(string $rule = 'innerHTML'): void
{
header('HX-Reswap: ' . static::validateSwap($rule));
}
/**
* Validates a HTMX swap value ("style [key:value]...").
*
* @param string $value
*
* @return string The normalized value.
*
* @throws \InvalidArgumentException On an unknown style or option.
*/
public static function validateSwap(string $value): string
{
$parts = preg_split(
'/\s+/',
trim($value),
-1,
PREG_SPLIT_NO_EMPTY
);
if (empty($parts)) {
throw new \InvalidArgumentException('Empty swap value.');
}
if (!in_array($parts[0], static::SWAP_STYLES, true)) {
throw new \InvalidArgumentException(
'Invalid swap style: ' . $parts[0]
);
}
foreach (array_slice($parts, 1) as $option) {
$key = explode(':', $option)[0];
if (!in_array($key, static::SWAP_OPTIONS, true)) {
throw new \InvalidArgumentException(
'Invalid swap option: ' . $option
);
}
}
return implode(' ', $parts);
}
/**
* Triggers an HTMX event on the frontend upon response.
*
* @param Neuron|string $event
*
* @return void
*/
public static function trigger(Neuron|string $event): void
{
if (is_string($event)) {
header('HX-Trigger: ' . $event);
} else {
header('HX-Trigger: ' . json_encode($event));
}
}
}
@@ -0,0 +1,73 @@
<?php
namespace Libs\Sanitizer;
use ArrayAccess;
use Countable;
use Iterator;
/**
* Library for HTML sanitization of arrays.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
class SanitizerArrayProxy implements ArrayAccess, Iterator, Countable
{
public function __construct(
protected array &$data
) {
}
public function offsetExists(mixed $offset): bool
{
return isset($this->data[$offset]);
}
public function offsetGet(mixed $offset): mixed
{
return SanitizerProxy::apply($this->data[$offset] ?? null);
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->data[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->data[$offset]);
}
public function current(): mixed
{
return SanitizerProxy::apply(current($this->data));
}
public function key(): mixed
{
return key($this->data);
}
public function next(): void
{
next($this->data);
}
public function rewind(): void
{
reset($this->data);
}
public function valid(): bool
{
$key = key($this->data);
return $key !== null && isset($this->data[$key]);
}
public function count(): int
{
return count($this->data);
}
}
@@ -0,0 +1,67 @@
<?php
namespace Libs\Sanitizer;
use UnitEnum;
/**
* HTML sanitization library.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
class SanitizerProxy
{
public function __construct(
protected mixed &$data
) {
}
/**
* Try to sanitize if is a string, ignore if is a enum, number boolean or null
* or in other case, create a proxy.
*
* @param mixed $data
*
* @return mixed
*/
public static function apply(mixed $data = null): mixed
{
if ($data instanceof UnitEnum) {
return $data;
}
return match (gettype($data)) {
'string' => htmlspecialchars($data),
'integer', 'double', 'boolean', 'NULL' => $data,
'array' => new SanitizerArrayProxy($data),
default => new static($data)
};
}
public function __set(string $name, mixed $value): void
{
$this->data->{$name} = $value;
}
public function __unset(string $name): void
{
unset($this->data->{$name});
}
public function __get(string $key): mixed
{
return $this->apply($this->data->{$key});
}
public function __isset(string $key): bool
{
return isset($this->data->{$key});
}
public function __call(string $name, array $arguments): mixed
{
return $this->apply($this->data->{$name}(...$arguments));
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "http",
"version": "0.1.0",
"description": "Utilidades HTTP (CurlRequest, Aria2, Proxy, CompreFace)",
"core": { "requires": ">=0.1", "tested": "0.1" },
"php": ">=8.1",
"components": {
"curlRequest": { "files": ["src/Libs/CurlRequest.php"] },
"aria2": { "files": ["src/Libs/Aria2.php"] },
"proxy": { "files": ["src/Libs/Proxy.php"] },
"compreFace": {
"files": ["src/Libs/CompreFace.php"],
"require": { "http:curlRequest": ">=0.1" }
}
},
"default": ["curlRequest", "aria2", "proxy", "compreFace"]
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace Libs;
use CurlHandle;
/**
* Aria2
*
* Library to control aria2c from PHP
* using its jsonrpc API.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
class Aria2
{
protected CurlHandle $ch;
protected ?string $token;
protected bool $batch = false;
protected array $batch_list = [];
/**
* Constructor
*
* @param string $server
* Path to aria2's jsonrpc server.
* @param string|null $token
* (optional) Access token for aria2c API.
*/
public function __construct(string $server = 'http://127.0.0.1:6800/jsonrpc', ?string $token = null)
{
$this->ch = curl_init($server);
$this->token = $token;
curl_setopt_array($this->ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
]);
}
/**
* Destructor
*
* @return void
*/
public function __destruct()
{
curl_close($this->ch);
}
/**
* Allows starting a batch call.
*
* @return static
*/
public function batch(): static
{
$this->batch = true;
return $this;
}
/**
* Returns whether it is in a batch call or not.
*
* @return bool
*/
public function inBatch(): bool
{
return $this->batch;
}
/**
* Sends a batch request.
*
* @return mixed
*/
public function commit(): mixed
{
$data = json_encode($this->batch_list);
$this->batch = false;
$this->batch_list = [];
$result = $this->send($data);
return json_decode($result);
}
/**
* Sends a request to aria2c via jsonrpc.
*
* @param string $data
* @return string|bool
*/
protected function send(string $data): string|bool
{
curl_setopt($this->ch, CURLOPT_POSTFIELDS, $data);
return curl_exec($this->ch);
}
/**
* Magic to simplify work :).
*
* PHP methods are automatically converted into aria2 methods.
*
* aria2.addUri => Aria2::addUri
* aria2.addTorrent => Aria2::addTorrent
* aria2.getPeers => Aria2::getPeers
* aria2.addMetalink => Aria2::addMetalink
* aria2.remove => Aria2::remove
* aria2.pause => Aria2::pause
* aria2.forcePause => Aria2::forcePause
* aria2.pauseAll => Aria2::pauseAll
* aria2.forcePauseAll => Aria2::forcePauseAll
* aria2.unpause => Aria2::unpause
* aria2.unpauseAll => Aria2::unpauseAll
* aria2.forceRemove => Aria2::forceRemove
* aria2.changePosition => Aria2::changePosition
* aria2.tellStatus => Aria2::tellStatus
* aria2.getUris => Aria2::getUris
* aria2.getFiles => Aria2::getFiles
* aria2.getServers => Aria2::getServers
* aria2.tellActive => Aria2::tellActive
* aria2.tellWaiting => Aria2::tellWaiting
* aria2.tellStopped => Aria2::tellStopped
* aria2.getOption => Aria2::getOption
* aria2.changeUri => Aria2::changeUri
* aria2.changeOption => Aria2::changeOption
* aria2.getGlobalOption => Aria2::getGlobalOption
* aria2.changeGlobalOption => Aria2::changeGlobalOption
* aria2.purgeDownloadResult => Aria2::purgeDownloadResult
* aria2.removeDownloadResult => Aria2::removeDownloadResult
* aria2.getVersion => Aria2::getVersion
* aria2.getSessionInfo => Aria2::getSessionInfo
* aria2.shutdown => Aria2::shutdown
* aria2.forceShutdown => Aria2::forceShutdown
* aria2.getGlobalStat => Aria2::getGlobalStat
* aria2.saveSession => Aria2::saveSession
* system.multicall => Aria2::system_multicall
* system.listMethods => Aria2::system_listMethods
* system.listNotifications => Aria2::system_listNotifications
*
* @param array<int,mixed> $arg
* @return mixed
*/
public function __call(string $name, array $arg): mixed
{
if (!is_null($this->token)) {
array_unshift($arg, $this->token);
}
// Allow system methods (the last 3 on the list)
if (strpos($name, '_') === false) { // if it doesn't contain underscores, it's an aria2 method command.
$name = 'aria2.' . $name;
} else {
$name = str_replace('_', '.', $name);
}
$data = [
'jsonrpc' => '2.0',
'id' => '1',
'method' => $name,
'params' => $arg,
];
// Apply batch call if we are in one
if ($this->batch) {
$this->batch_list[] = $data;
return $this;
}
$data = json_encode($data);
$response = $this->send($data);
if ($response === false) {
trigger_error(curl_error($this->ch));
}
return json_decode($response);
}
}
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace Libs;
/**
* CompreFase - DuckBrain
*
* Library for CompreFace (https://github.com/exadel-inc/CompreFace/).
* Can use the constants COMPREFACE_API and COMPREFACE_KEY.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class CompreFace
{
private string $apiUrl;
private CurlRequest $request;
/**
* __construct
*
* @param string $apiKey
* @param string $apiUrl
*/
public function __construct(string $apiKey = '', string $apiUrl = '')
{
if (!defined('COMPREFACE_KEY') && empty($apiKey)) {
throw new \Exception('CompreFace API Key is not defined.');
}
if (empty($apiKey)) {
$apiKey = COMPREFACE_KEY;
}
if (!empty($apiUrl)) {
$this->apiUrl = $apiUrl;
} elseif (defined('COMPREFACE_API')) {
$this->apiUrl = COMPREFACE_API;
} else {
$this->apiUrl = 'http://localhost:8000/api/v1/';
}
$this->request = new CurlRequest();
$this->request->headers['x-api-key'] = $apiKey;
$this->request->headers['Content-Type'] = 'application/json';
}
/**
* Parses the request's JSON and returns it as Neuron.
*
* @param mixed $default
* Default value to return if the result is not JSON.
*/
private function parseJSON(mixed $default = [])
{
$json = json_decode($this->request->body());
if (json_last_error() === JSON_ERROR_NONE) {
return $json;
}
return $default;
}
/**
* Returns the list of subjects registered in CompreFace.
*/
public function subjects()
{
$this->request->get($this->apiUrl . 'recognition/subjects');
$response = $this->parseJSON(['subjects' => []]);
if (isset($response->subjects)) {
return $response->subjects;
}
return [];
}
/**
* Adds a subject.
*
* @param string $name
*/
public function addSubject(string $name)
{
$this->request->post(
$this->apiUrl . 'recognition/subjects',
json_encode(['subject' => $name])
);
return $this->parseJSON();
}
/**
* Renames a subject.
*
* @param string $name Current name
* @param string $newName New name
*/
public function renameSubject(string $name, string $newName)
{
$this->request->put(
$this->apiUrl . 'recognition/subjects/' . rawurlencode($name),
json_encode(['subject' => $newName])
);
return $this->parseJSON();
}
/**
* Deletes a subject.
*
* @param string $name
*/
public function deleteSubject(string $name)
{
$this->request->delete(
$this->apiUrl . 'recognition/subjects/' . rawurlencode($name)
);
return $this->parseJSON();
}
/**
* Deletes all subjects from CompreFace.
*/
public function deleteAllSubjects()
{
$this->request->delete(
$this->apiUrl . 'recognition/subjects'
);
return $this->parseJSON();
}
/**
* Returns the subject's images.
*
* @param string $subjectName
*/
public function subjectImages(string $subjectName)
{
$this->request->get(
$this->apiUrl . 'recognition/faces',
['size' => 100, 'subject' => $subjectName]
);
return $this->parseJSON();
}
/**
* Adds an image to the subject.
*
* @param string $subjectName
* @param string $base64_image
*/
public function addSubjectImage(string $subjectName, string $base64_image)
{
$this->request->post(
$this->apiUrl . 'recognition/faces?subject=' . rawurlencode($subjectName),
json_encode(['file' => $base64_image])
);
return $this->parseJSON();
}
/**
* Deletes an image from the subject.
*
* @param string $imageId
*/
public function deteleSubjectImage(string $imageId)
{
// CompreFace also allows deleting multiple or deleting all,
// but for now I won't add it to this library, as it is not necessary.
$this->request->delete(
$this->apiUrl . 'recognition/faces/' . rawurlencode($imageId)
);
return $this->parseJSON();
}
/**
* Facial recognition of the sent image.
*
* @param string $base64_image
*/
public function recognize(string $base64_image)
{
$this->request->post(
$this->apiUrl . 'recognition/recognize',
json_encode(['file' => $base64_image])
);
return $this->parseJSON();
}
/**
* Checks if the subject exists in CompreFace.
*
* @param string $name
*/
public function subjectExist(string $name)
{
$subject = $this->subjects();
return in_array($name, $subject);
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace Libs;
/**
* CurlRequest - DuckBrain
*
* Facade for CURL.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
class CurlRequest
{
public array $headers = [];
public array $options = [
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_RETURNTRANSFER => true,
CURLINFO_HEADER_OUT => true,
];
public int $statusCode = 0;
protected string $responseHeaders = '';
protected string $responseBody = '';
/**
* Sends the request.
*
* @param array $options
* @return void
*/
protected function send(array $options): void
{
$handler = curl_init();
$headers = [];
foreach ($this->headers as $index => $value) {
$headers[] = "$index: $value";
}
$options[CURLOPT_HTTPHEADER] = $headers;
foreach ($this->options as $index => $option) {
if (!isset($options[$index])) {
$options[$index] = $option;
}
}
curl_setopt_array($handler, $options);
$this->responseBody = curl_exec($handler);
$this->responseHeaders = curl_getinfo($handler, CURLINFO_HEADER_OUT);
$this->statusCode = curl_getinfo($handler, CURLINFO_HTTP_CODE);
// throw new Exception('Error al realizar la solicitud. '. curl_error($handler));
curl_close($handler);
}
/**
* Sends the request using the GET method.
*
* @param string $url
* @param array $args
*
* @return CurlRequest
*/
public function get(string $url, array $args = []): CurlRequest
{
$this->send([
CURLOPT_URL => $url . '?' . http_build_query($args),
CURLOPT_POST => false,
CURLOPT_PUT => false,
]);
return $this;
}
/**
* Sends the request using the POST method.
*
* @param string $url
* @param string $fields
*
* @return CurlRequest
*/
public function post(string $url, string $fields = ''): CurlRequest
{
$this->send([
CURLOPT_URL => $url,
CURLOPT_POSTFIELDS => $fields,
]);
return $this;
}
/**
* Sends the request using the PUT method.
*
* @param string $url
* @param string $fields
*
* @return CurlRequest
*/
public function put(string $url, string $fields = ''): CurlRequest
{
$this->send([
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $fields,
]);
return $this;
}
/**
* Sends the request using the PATCH method.
*
* @param string $url
* @param string $fields
*
* @return CurlRequest
*/
public function patch(string $url, string $fields = ''): CurlRequest
{
$this->send([
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_POSTFIELDS => $fields,
]);
return $this;
}
/**
* Sends the request using the DELETE method.
*
* @param string $url
* @param string $fields
*
* @return CurlRequest
*/
public function delete(string $url, string $fields = ''): CurlRequest
{
$this->send([
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_POSTFIELDS => $fields,
]);
return $this;
}
/**
* Returns the response headers.
*
* @return string
*/
public function headers(): string
{
return $this->responseHeaders;
}
/**
* Returns the response body.
*
* @return string
*/
public function body(): string
{
return $this->responseBody;
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace Libs;
/**
* PHP Proxy - DuckBrain
*
* Proxy is a library to create a PHP proxy.
* It allows controlling the headers that will be returned to the client,
* in addition to placing filters on the resulting HTML code.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
class Proxy
{
public static $proxied_headers = [
'Set-Cookie',
'Content-Type',
'Cookie',
'Location',
];
public static $proxy_base_url_canonical = '/';
public static $destination_host = SOURCE_URL;
protected static $filters = [];
/**
* Adds a callable (AKA filter) that will receive the content
* and should return a string.
*
* @param callable $cb
* Function that will modify the content.
*
* @return void
*/
public static function add_filter(callable $cb): void
{
static::$filters[] = $cb;
}
/**
* Applies the filters configured with add_filter.
*
* @param string $content
* Content on which the filters will be applied.
*
* @return mixed
* It would be correct to return a string, but
* since the filters might not return correctly,
* to avoid a design error, we specify that it returns mixed.
*/
protected static function apply_filters(string $content): mixed
{
foreach (static::$filters as $filter) {
$content = call_user_func($filter, $content);
}
return $content;
}
/**
* Starts the proxy process.
*
* @param string|null $proxy_request_uri
* The address to which the proxy will be applied.
*
* @return void
*/
public static function run(?string $proxy_request_uri = null): void
{
// Get the URI if not defined
if (is_null($proxy_request_uri)) {
$proxy_request_uri = substr($_SERVER['REQUEST_URI'], strlen(static::$proxy_base_url_canonical));
} elseif (substr($proxy_request_uri, 0, 1) == '/') {
$proxy_request_uri = substr($proxy_request_uri, 1);
}
// Generate the final URL
$proxy_request_url = static::$destination_host . $proxy_request_uri;
// cURL initialization
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $proxy_request_url);
curl_setopt($ch, CURLOPT_AUTOREFERER, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT']);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Expect:'));
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
// Capture client cookies
$hdrs = [];
if (isset($_SERVER['HTTP_COOKIE'])) {
$hdrs[] = "Cookie: " . $_SERVER['HTTP_COOKIE'];
}
// Capture client browser (User-Agent)
if (isset($_SERVER['HTTP_USER_AGENT'])) {
$hdrs[] = "User-Agent: " . $_SERVER['HTTP_USER_AGENT'];
}
// Add captured headers
curl_setopt($ch, CURLOPT_HTTPHEADER, $hdrs);
// Capture all client headers
$hdrs = [];
foreach (\getallheaders() as $name => $value) {
$hdrs[] = "$name: $value";
}
// Add captured headers to the request
curl_setopt($ch, CURLOPT_HTTPHEADER, $hdrs);
// Add POST parameters
if (sizeof($_POST) > 0) {
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($_POST));
}
// Execute the request
$res = curl_exec($ch);
curl_close($ch);
// Parse the response
list($headers, $body) = explode("\r\n\r\n", $res, 2);
$headers = str_replace(static::$destination_host, '/', $headers);
$headers = explode("\r\n", $headers);
$hs = [];
foreach ($headers as $header) {
if (false !== strpos($header, ': ')) {
list($h, $v) = explode(': ', $header);
$hs[$h][] = $v;
} else {
$header1 = $header;
}
}
// Define headers in the client
list($proto, $code, $text) = explode(' ', $header1);
header($_SERVER['SERVER_PROTOCOL'] . ' ' . $code . ' ' . $text);
foreach (static::$proxied_headers as $hname) {
if (isset($hs[$hname])) {
foreach ($hs[$hname] as $v) {
if ($hname === 'Set-Cookie') {
header($hname . ": " . $v, false);
} else {
header($hname . ": " . $v);
}
}
}
}
die(static::apply_filters($body));
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "id",
"version": "0.1.0",
"description": "Identificadores ULID (Ulid)",
"core": { "requires": ">=0.1", "tested": "0.1" },
"php": ">=8.1",
"components": {
"ulid": { "files": ["src/Libs/Ulid.php"] }
},
"default": ["ulid"]
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Libs;
/**
* ULID (Universally Unique Lexicographically Sortable Identifier) implementation.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Ulid
{
public const ENCODING_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
public const ENCODING_LENGTH = 32;
public const RANDOM_LENGTH = 16;
public const TIME_LENGTH = 10;
/**
* @var int
*/
private static $lastGenTime = 0;
/**
* @var array
*/
private static $lastRandChars = [];
/**
* Generates a unique ULID.
*
* Borrowed from robinvdvleuten/ulid
*
* @return string
*/
public static function generate(): string
{
$timestamp = (int) (microtime(true) * 1000);
$duplicateTime = $timestamp === static::$lastGenTime;
static::$lastGenTime = $timestamp;
$timeChars = '';
$randChars = '';
for ($i = static::TIME_LENGTH - 1; $i >= 0; $i--) {
$mod = $timestamp % static::ENCODING_LENGTH;
$timeChars = static::ENCODING_CHARS[$mod] . $timeChars;
$timestamp = ($timestamp - $mod) / static::ENCODING_LENGTH;
}
if (!$duplicateTime) {
for ($i = 0; $i < static::RANDOM_LENGTH; $i++) {
static::$lastRandChars[$i] = random_int(0, 31);
}
} else {
for ($i = static::RANDOM_LENGTH - 1; $i >= 0 && static::$lastRandChars[$i] === 31; $i--) {
static::$lastRandChars[$i] = 0;
}
static::$lastRandChars[$i]++;
}
for ($i = 0; $i < static::RANDOM_LENGTH; $i++) {
$randChars .= static::ENCODING_CHARS[static::$lastRandChars[$i]];
}
return $timeChars . $randChars;
}
/**
* Validates a ULID.
*
* @param string $ulid
*
* @return bool
*/
public static function validate(string $ulid): bool
{
return (bool) preg_match('/^[0-9A-HJKMNPQRSTVWXYZ]{26}$/', strtoupper($ulid));
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "test",
"version": "0.1.0",
"description": "Kit de pruebas PHPUnit para Duckbrain",
"core": { "requires": ">=0.1", "tested": "0.1" },
"php": ">=8.1",
"components": {
"harness": {
"files": ["phpunit.xml", "tests"],
"require": { "commands:cli": ">=0.1" }
}
},
"default": ["harness"],
"post-install": [
"composer require --dev phpunit/phpunit:^11.0 fakerphp/faker",
"composer dump-autoload"
]
}
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
<extensions>
</extensions>
</phpunit>
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Tests;
use Libs\Neuron;
use Libs\Router;
use Tests\Support\HttpStubs;
use Tests\Support\Response;
/**
* Base para tests de endpoint: despacha peticiones al router in-process
* (sin servidor HTTP) y expone helpers de aserción.
*
* No incluye helpers de autenticación: Duckbrain no define login por defecto,
* así que simular una sesión es responsabilidad de cada proyecto. Extiende esta
* clase (o crea tu propia subclase) y añade tu helper, p. ej. `loggedInAs()`.
*
* Nota: `#[RunTestsInSeparateProcesses]` NO se hereda en PHPUnit 11, así que
* cada clase concreta de endpoint debe declarar el atributo para aislar el
* estado estático (Synapsis, Router::$params, superglobales, http_response_code).
*/
abstract class EndpointTestCase extends TestCase
{
protected function dispatch(
string $method,
string $path,
array $data = [],
array $headers = []
): Response {
$method = strtoupper($method);
$_SERVER['REQUEST_METHOD'] = $method;
$_SERVER['REQUEST_URI'] = $path;
$_SERVER['HTTP_ACCEPT'] = $headers['Accept'] ?? 'text/html';
$_GET = [];
$_POST = [];
$query = parse_url($path, PHP_URL_QUERY);
if (is_string($query)) {
parse_str($query, $_GET);
}
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
$_POST = $data;
}
foreach ($headers as $name => $value) {
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
$_SERVER[$key] = $value;
}
Router::$params = new Neuron();
HttpStubs::reset();
http_response_code(200);
ob_start();
try {
Router::apply(parse_url($path, PHP_URL_PATH) ?: $path);
} finally {
$body = (string) ob_get_clean();
}
return new Response(
http_response_code(),
$body,
HttpStubs::$headers,
HttpStubs::$cookies
);
}
protected function get(string $path, array $headers = []): Response
{
return $this->dispatch('GET', $path, [], $headers);
}
protected function post(string $path, array $data = [], array $headers = []): Response
{
return $this->dispatch('POST', $path, $data, $headers);
}
protected function patch(string $path, array $data = [], array $headers = []): Response
{
return $this->dispatch('PATCH', $path, $data, $headers);
}
protected function delete(string $path, array $data = [], array $headers = []): Response
{
return $this->dispatch('DELETE', $path, $data, $headers);
}
protected function assertStatus(int $expected, Response $response): void
{
$this->assertSame(
$expected,
$response->status,
"Expected status {$expected}, got {$response->status}. Body: {$response->body}"
);
}
protected function assertRedirect(string $expectedPath, Response $response): void
{
$target = $response->redirectTarget();
$this->assertNotNull($target, 'Expected a redirect header (HX-Redirect or Location).');
$this->assertStringEndsWith($expectedPath, $target);
}
protected function assertHtmxTrigger(string $event, Response $response): void
{
$this->assertSame($event, $response->trigger());
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Tests\Factories;
use Faker\Factory as FakerFactory;
use Faker\Generator;
/**
* Factory - DuckBrain testing
*
* Clase base para crear fábricas de datos de prueba. Cada subclase
* define los atributos por defecto de un modelo y construye su
* instancia.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
abstract class Factory
{
protected static ?Generator $fake = null;
/**
* Devuelve una instancia de Faker (singleton por fábrica).
*/
protected static function faker(): Generator
{
return static::$fake ??= FakerFactory::create();
}
/**
* Fusiona los atributos por defecto con los indicados.
*
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
protected static function attributes(array $attributes = []): array
{
return array_merge(static::definition(), $attributes);
}
/**
* Atributos por defecto de la fábrica.
*
* @return array<string, mixed>
*/
abstract protected static function definition(): array;
/**
* Crea una instancia del modelo con los atributos dados.
*
* @param array<string, mixed> $attributes
*/
abstract public static function create(array $attributes = []): object;
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Tests\Support;
/**
* Colector estático de headers y cookies emitidos durante los tests.
*
* En el SAPI CLI, `header()` y `setcookie()` son no-op y `headers_list()`
* queda vacío; las funciones de `tests/Support/http_stubs.php` registran
* aquí lo que la aplicación intenta emitir.
*/
class HttpStubs
{
public static array $headers = [];
public static array $cookies = [];
public static function header(string $value, bool $replace = true, int $responseCode = 0): void
{
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $value, $matches)) {
http_response_code((int) $matches[1]);
}
if ($responseCode > 0) {
http_response_code($responseCode);
}
static::$headers[] = $value;
}
public static function setcookie(
string $name,
string $value = '',
int $expires = 0,
string $path = '',
string $domain = '',
bool $secure = false,
bool $httponly = false
): void {
static::$cookies[$name] = $value;
}
public static function reset(): void
{
static::$headers = [];
static::$cookies = [];
}
public static function headerValue(string $prefix): ?string
{
foreach (static::$headers as $header) {
if (stripos($header, $prefix) === 0) {
return substr($header, strlen($prefix));
}
}
return null;
}
public static function hasHeader(string $prefix): bool
{
return static::headerValue($prefix) !== null;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Tests\Support;
/**
* Respuesta capturada de una petición despachada in-process.
*/
class Response
{
public function __construct(
public int $status,
public string $body,
public array $headers = [],
public array $cookies = [],
) {
}
public function header(string $name): ?string
{
foreach ($this->headers as $header) {
if (stripos($header, $name . ':') === 0) {
return trim(substr($header, strlen($name) + 1));
}
}
return null;
}
public function hasHeader(string $name): bool
{
return $this->header($name) !== null;
}
public function redirectTarget(): ?string
{
return $this->header('HX-Redirect') ?? $this->header('Location');
}
public function trigger(): ?string
{
return $this->header('HX-Trigger');
}
}
@@ -0,0 +1,27 @@
<?php
/**
* Stubs por namespace de `header()` y `setcookie()`.
*
* Como el SAPI CLI no emite headers, estas funciones interceptan las llamadas
* no cualificadas de los namespaces de la aplicación y las registran en
* {@see \Tests\Support\HttpStubs}.
*
* La plantilla cubre únicamente el namespace del núcleo (`Libs`), que es el
* único que emite cabeceras en Duckbrain. Si tu proyecto emite headers o
* cookies desde otro namespace (`Models`, `Handlers`, `Controllers\...`, etc.),
* copia el bloque de `Libs` cambiando el nombre del namespace. Las llamadas
* desde namespaces no registrados aquí se ignorarán en los tests.
*/
namespace Libs {
function header(string $header, bool $replace = true, int $response_code = 0): void
{
\Tests\Support\HttpStubs::header($header, $replace, $response_code);
}
function setcookie(string $name, string $value = '', int $expires = 0, string $path = '', string $domain = '', bool $secure = false, bool $httponly = false): void
{
\Tests\Support\HttpStubs::setcookie($name, $value, $expires, $path, $domain, $secure, $httponly);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Tests;
use Commands\Migration;
use Override;
use PHPUnit\Framework\TestCase as PHPunitTestCase;
abstract class TestCase extends PHPunitTestCase
{
private static Migration $db_migration;
#[Override]
public static function setUpBeforeClass(): void
{
// Garantiza que ningún test corra contra la base de datos real.
self::assertSame('sqlite', DB_TYPE, 'Tests must run on SQLite.');
self::assertSame(':memory:', DB_NAME, 'Tests must run on an in-memory database.');
static::$db_migration = new Migration(verbose: false);
static::$db_migration->ensureDatabaseExists();
// fresh() limpia el historial de migraciones y vuelve a aplicarlas: en
// modo aislado, el proceso padre también ejecuta este hook y deja el
// archivo de control poblado, por lo que migrate() se saltaría el
// esquema sobre la base :memory: vacía del hijo.
static::$db_migration->fresh();
}
#[Override]
public static function tearDownAfterClass(): void
{
// Ejecutar el rollback de las migraciones
static::$db_migration->rollback();
}
}
@@ -0,0 +1,42 @@
<?php
namespace Tests\Unit;
use Libs\Database;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Prueba de humo: verifica que el entorno de pruebas quedó bien instalado.
*/
class BootstrapTest extends TestCase
{
#[Test]
public function coreClassesAreAutoloadable(): void
{
$this->assertTrue(class_exists(Database::class));
$this->assertTrue(class_exists(\Libs\Loader::class));
$this->assertTrue(class_exists(\Libs\Router::class));
}
#[Test]
public function databaseConstantsAreDefined(): void
{
$this->assertNotEmpty(DB_TYPE);
$this->assertNotEmpty(DB_NAME);
}
#[Test]
public function testDatabaseIsReachable(): void
{
$db = Database::getInstance(
type: DB_TYPE,
host: DB_HOST,
name: DB_NAME,
user: DB_USER,
pass: DB_PASS,
);
$this->assertSame(1, (int) $db->query('SELECT 1')->fetchColumn());
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
// Bootstrap para tests - carga el autoload de Composer
use Libs\Loader;
require_once __DIR__ . '/../vendor/autoload.php';
// ============================================
// IMPORTANT: Definir constantes ANTES de cargar config.php
// para sobreescribir la configuración para definir una
// base de datos para sólo los test
// ============================================
define('DB_TYPE', 'sqlite');
define('DB_HOST', 'localhost');
define('DB_NAME', ':memory:');
define('DB_USER', '');
define('DB_PASS', '');
// Incluir config.php con el autoloader
@require_once __DIR__ . '/../autoload.php';
// Router autoload
Loader::load(ROOT_CORE . '/Routers/');
// Other autoloads
Loader::load(ROOT_CORE . '/Autoloads/');
// Stubs de header()/setcookie() por namespace
require_once __DIR__ . '/Support/http_stubs.php';
+12
View File
@@ -0,0 +1,12 @@
{
"name": "text",
"version": "0.1.0",
"description": "Filtros y reemplazos de texto (Filter, Replacement)",
"core": { "requires": ">=0.1", "tested": "0.1" },
"php": ">=8.1",
"components": {
"filter": { "files": ["src/Libs/Filter.php"] },
"replacement": { "files": ["src/Libs/Replacement.php"] }
},
"default": ["filter", "replacement"]
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace Libs;
/**
* Filter - DuckBrain
*
* Library for text filters.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
class Filter
{
protected static array $filters = [];
/**
* Adds a filter.
*
* @param callable $filterCallback
* Function that performs the filter, receives the content and returns it filtered.
* @param string $context
* Context of the filter.
* @param int $priority
* Priority of the filter, by default 10. Lower values are executed first.
*
* @return void
*/
public static function add(callable $filterCallback, string $context = 'content', int $priority = 10): void
{
if (empty(static::$filters[$context])) {
static::$filters[$context] = [];
}
static::$filters[$context][] = [
'priority' => $priority,
'callback' => $filterCallback,
];
}
/**
* Applies the configured filters.
*
* @param string $content
* Content to which the filter will be applied.
* @param string $context
* Context of the filter.
*
* @return string
*/
public static function apply(string $content, string $context = 'content'): string
{
if (empty(static::$filters[$context])) {
return $content;
}
// Sort filters in ascending order according to their priority.
usort(static::$filters[$context], function ($a, $b) {
return $a['priority'] - $b['priority'];
});
foreach (static::$filters[$context] as $filter) {
$content = call_user_func($filter['callback'], $content);
}
return $content;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Libs;
/**
* Replacement - DuckBrain
*
* Library for batch replacements using regex.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Replacement
{
protected static array $replace = [
'patern' => [],
'replacement' => [],
];
protected static array $replaceCallback = [];
/**
* Adds a replacement configuration.
*
* @param string $patern
* Replacement pattern (Regular expression).
*
* @param string|callable $replacementOrCallback
* Replacement string or callback function for replacement.
*
* @return void
*/
public static function add(string $patern, string|callable $replacementOrCallback): void
{
if (is_callable($replacementOrCallback)) {
static::$replaceCallback[$patern] = $replacementOrCallback;
return;
}
static::$replace['patern'][] = $patern;
static::$replace['replacement'][] = $replacementOrCallback;
}
/**
* Deletes a replacement configuration.
*
* @param string $patern
* Replacement pattern (Regular expression).
*
* @return void
*/
public static function delete(string $patern): void
{
$index = array_search($patern, static::$replace['patern']);
if ($index !== false) {
unset(static::$replace['patern'][$index]);
unset(static::$replace['replacement'][$index]);
}
unset(static::$replaceCallback[$patern]);
}
/**
* Applies the configured replacements.
*
* @param string $content
* Content to which batch replacements will be applied.
*
* @return string
*/
public static function apply(string $content): string
{
$content = preg_replace(
static::$replace['patern'],
static::$replace['replacement'],
$content
);
$content = preg_replace_callback_array(
static::$replaceCallback,
$content
);
return $content;
}
}