diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 480c2b3..e341f6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,8 +30,8 @@ composer test ``` Commit both `composer.lock` and `package-lock.json` for reproducible builds. -`migrate` and `seed` are unavailable until M2 (#12); they exit nonzero and leave -#10 open. Never remove volumes with `down -v`; the wrapper forbids it. +`migrate` and `seed` are available via `./dev.sh` (M2 #12). Never remove volumes +with `down -v`; the wrapper forbids it. `composer lint` enforces PSR-12 on PHP in `src`, `public`, `config`, `bin`, `templates`, `db/migrations` and `tests`. `composer test` runs foundation checks diff --git a/README.md b/README.md index 8b722f6..7c433f2 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,8 @@ and Node tooling runs inside containers. ``` `composer test` and `composer lint` also work on a host with PHP 8.3+ and Composer 2. -`composer audit` checks locked PHP dependencies. `migrate` and `seed` are unavailable -until M2 (#12); they exit nonzero and leave #10 open. +`composer audit` checks locked PHP dependencies. `migrate` and `seed` are available +via `./dev.sh` (M2 #12). Asset builds use a locked `package-lock.json` inside a container; host Node is not required. The generated `public/build/app.css` is served statically by nginx. diff --git a/composer.json b/composer.json index 3ab2879..2eba134 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ }, "require-dev": { "phpunit/phpunit": "^12.0", + "robmorgan/phinx": "^0.16.12", "squizlabs/php_codesniffer": "^3.13.5" }, "autoload": { diff --git a/composer.lock b/composer.lock index 1d8b856..baac573 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4651e99c6ead8679c8fa5e59ddb8d69f", + "content-hash": "9c13b36d41dcba4dd2c6a4d791712670", "packages": [ { "name": "fig/http-message-util", @@ -971,6 +971,472 @@ } ], "packages-dev": [ + { + "name": "cakephp/chronos", + "version": "3.5.1", + "source": { + "type": "git", + "url": "https://github.com/cakephp/chronos.git", + "reference": "00c16f165a762226d58f9cf88f43e54e41093f84" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/chronos/zipball/00c16f165a762226d58f9cf88f43e54e41093f84", + "reference": "00c16f165a762226d58f9cf88f43e54e41093f84", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^5.0", + "phpunit/phpunit": "^10.5.58 || ^11.5.3 || ^12.1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cake\\Chronos\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "http://nesbot.com" + }, + { + "name": "The CakePHP Team", + "homepage": "https://cakephp.org" + } + ], + "description": "A simple API extension for DateTime.", + "homepage": "https://cakephp.org", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "issues": "https://github.com/cakephp/chronos/issues", + "source": "https://github.com/cakephp/chronos" + }, + "time": "2026-09-06T02:30:12+00:00" + }, + { + "name": "cakephp/core", + "version": "5.4.3", + "source": { + "type": "git", + "url": "https://github.com/cakephp/core.git", + "reference": "55c9f6ec8a11f6c946cad5bc6e8da18d6d98ae06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/core/zipball/55c9f6ec8a11f6c946cad5bc6e8da18d6d98ae06", + "reference": "55c9f6ec8a11f6c946cad5bc6e8da18d6d98ae06", + "shasum": "" + }, + "require": { + "cakephp/utility": "^5.4.0", + "league/container": "^5.1", + "php": ">=8.2", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^2.0" + }, + "suggest": { + "cakephp/cache": "To use Configure::store() and restore().", + "cakephp/event": "To use PluginApplicationInterface or plugin applications.", + "league/container": "To use Container and ServiceProvider classes" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php" + ], + "psr-4": { + "Cake\\Core\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/core/graphs/contributors" + } + ], + "description": "CakePHP Framework Core classes", + "homepage": "https://cakephp.org", + "keywords": [ + "cakephp", + "core", + "framework" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/cakephp/issues", + "source": "https://github.com/cakephp/core" + }, + "time": "2026-08-24T11:32:41+00:00" + }, + { + "name": "cakephp/database", + "version": "5.4.3", + "source": { + "type": "git", + "url": "https://github.com/cakephp/database.git", + "reference": "25abb44fdbb80b0700ae0a8649a19653253940ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/database/zipball/25abb44fdbb80b0700ae0a8649a19653253940ff", + "reference": "25abb44fdbb80b0700ae0a8649a19653253940ff", + "shasum": "" + }, + "require": { + "cakephp/chronos": "^3.3", + "cakephp/core": "^5.4.0", + "cakephp/datasource": "^5.4.0", + "cakephp/event": "^5.4.0", + "php": ">=8.2", + "psr/log": "^3.0" + }, + "require-dev": { + "cakephp/i18n": "^5.4.0", + "cakephp/log": "^5.4.0", + "cakephp/utility": "^5.4.0" + }, + "suggest": { + "cakephp/i18n": "If you are using locale-aware datetime formats.", + "cakephp/log": "If you want to use query logging without providing a logger yourself.", + "cakephp/utility": "If you want to use EnumLabelTrait." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cake\\Database\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/database/graphs/contributors" + } + ], + "description": "Flexible and powerful Database abstraction library with a familiar PDO-like API", + "homepage": "https://cakephp.org", + "keywords": [ + "abstraction", + "cakephp", + "database", + "database abstraction", + "pdo" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/cakephp/issues", + "source": "https://github.com/cakephp/database" + }, + "time": "2026-10-03T03:38:06+00:00" + }, + { + "name": "cakephp/datasource", + "version": "5.4.3", + "source": { + "type": "git", + "url": "https://github.com/cakephp/datasource.git", + "reference": "cec80f34828d4aeaf24f75097055cd1466e1aec7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/datasource/zipball/cec80f34828d4aeaf24f75097055cd1466e1aec7", + "reference": "cec80f34828d4aeaf24f75097055cd1466e1aec7", + "shasum": "" + }, + "require": { + "cakephp/core": "^5.4.0", + "php": ">=8.2", + "psr/simple-cache": "^2.0 || ^3.0" + }, + "require-dev": { + "cakephp/cache": "^5.4.0", + "cakephp/collection": "^5.4.0", + "cakephp/utility": "^5.4.0" + }, + "suggest": { + "cakephp/cache": "If you decide to use Query caching.", + "cakephp/collection": "If you decide to use ResultSetInterface.", + "cakephp/utility": "If you decide to use EntityTrait." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cake\\Datasource\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/datasource/graphs/contributors" + } + ], + "description": "Provides connection managing and traits for Entities and Queries that can be reused for different datastores", + "homepage": "https://cakephp.org", + "keywords": [ + "cakephp", + "connection management", + "datasource", + "entity", + "query" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/cakephp/issues", + "source": "https://github.com/cakephp/datasource" + }, + "time": "2026-08-24T11:32:41+00:00" + }, + { + "name": "cakephp/event", + "version": "5.4.3", + "source": { + "type": "git", + "url": "https://github.com/cakephp/event.git", + "reference": "28e2797bda517cbac7fa683415f7bbc2774de103" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/event/zipball/28e2797bda517cbac7fa683415f7bbc2774de103", + "reference": "28e2797bda517cbac7fa683415f7bbc2774de103", + "shasum": "" + }, + "require": { + "cakephp/core": "^5.4.0", + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cake\\Event\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/event/graphs/contributors" + } + ], + "description": "CakePHP event dispatcher library that helps implementing the observer pattern", + "homepage": "https://cakephp.org", + "keywords": [ + "cakephp", + "dispatcher", + "event", + "observer pattern" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/cakephp/issues", + "source": "https://github.com/cakephp/event" + }, + "time": "2026-08-24T11:32:41+00:00" + }, + { + "name": "cakephp/utility", + "version": "5.4.3", + "source": { + "type": "git", + "url": "https://github.com/cakephp/utility.git", + "reference": "20f1749f19ed59b5fbc900e683acc72fa2938c17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/utility/zipball/20f1749f19ed59b5fbc900e683acc72fa2938c17", + "reference": "20f1749f19ed59b5fbc900e683acc72fa2938c17", + "shasum": "" + }, + "require": { + "cakephp/core": "^5.4.0", + "php": ">=8.2" + }, + "suggest": { + "ext-intl": "To use Text::transliterate() or Text::slug()", + "lib-ICU": "To use Text::transliterate() or Text::slug()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Cake\\Utility\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/utility/graphs/contributors" + } + ], + "description": "CakePHP Utility classes such as Inflector, String, Hash, and Security", + "homepage": "https://cakephp.org", + "keywords": [ + "cakephp", + "hash", + "inflector", + "security", + "string", + "utility" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/cakephp/issues", + "source": "https://github.com/cakephp/utility" + }, + "time": "2026-09-26T06:05:31+00:00" + }, + { + "name": "league/container", + "version": "5.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/container.git", + "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/container/zipball/58accbc032f0090a9bd08326f93062c5a658b2c5", + "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5", + "shasum": "" + }, + "require": { + "php": "^8.1", + "psr/container": "^2.0.2", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "replace": { + "orno/di": "~2.0" + }, + "require-dev": { + "nette/php-generator": "^4.1", + "nikic/php-parser": "^5.0", + "phpstan/phpstan": "^2.1.11", + "phpunit/phpunit": "^10.5.45|^11.5.15|^12.0", + "roave/security-advisories": "dev-latest", + "scrutinizer/ocular": "^1.9", + "squizlabs/php_codesniffer": "^3.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev", + "dev-3.x": "3.x-dev", + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev", + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Container\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Phil Bennett", + "email": "mail@philbennett.co.uk", + "role": "Developer" + } + ], + "description": "A fast and intuitive dependency injection container.", + "homepage": "https://github.com/thephpleague/container", + "keywords": [ + "container", + "dependency", + "di", + "injection", + "league", + "provider", + "service" + ], + "support": { + "issues": "https://github.com/thephpleague/container/issues", + "source": "https://github.com/thephpleague/container/tree/5.2.0" + }, + "funding": [ + { + "url": "https://github.com/philipobenito", + "type": "github" + } + ], + "time": "2026-03-19T18:52:39+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.14.0", @@ -1653,6 +2119,242 @@ ], "time": "2026-10-05T05:49:30+00:00" }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "robmorgan/phinx", + "version": "0.16.12", + "source": { + "type": "git", + "url": "https://github.com/cakephp/phinx.git", + "reference": "1cce44387a9146dc04c312e6a231b17fb30385cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/phinx/zipball/1cce44387a9146dc04c312e6a231b17fb30385cd", + "reference": "1cce44387a9146dc04c312e6a231b17fb30385cd", + "shasum": "" + }, + "require": { + "cakephp/database": "^5.0.2", + "composer-runtime-api": "^2.0", + "php-64bit": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/config": "^4.0|^5.0|^6.0|^7.0|^8.0", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^5.0", + "cakephp/i18n": "^5.0", + "ext-json": "*", + "ext-pdo": "*", + "phpunit/phpunit": "^10.5", + "symfony/yaml": "^4.0|^5.0|^6.0|^7.0|^8.0" + }, + "suggest": { + "ext-json": "Install if using JSON configuration format", + "ext-pdo": "PDO extension is needed", + "symfony/yaml": "Install if using YAML configuration format" + }, + "bin": [ + "bin/phinx" + ], + "type": "library", + "autoload": { + "psr-4": { + "Phinx\\": "src/Phinx/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rob Morgan", + "email": "robbym@gmail.com", + "homepage": "https://robmorgan.id.au", + "role": "Lead Developer" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com", + "homepage": "https://shadowhand.me", + "role": "Developer" + }, + { + "name": "Richard Quadling", + "email": "rquadling@gmail.com", + "role": "Developer" + }, + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/phinx/graphs/contributors", + "role": "Developer" + } + ], + "description": "Phinx makes it ridiculously easy to manage the database migrations for your PHP app.", + "homepage": "https://phinx.org", + "keywords": [ + "database", + "database migrations", + "db", + "migrations", + "phinx" + ], + "support": { + "issues": "https://github.com/cakephp/phinx/issues", + "source": "https://github.com/cakephp/phinx/tree/0.16.12" + }, + "time": "2026-07-03T07:27:17+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.1", @@ -2717,6 +3419,837 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/config", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/696e12da8eea497a1a3808d714b43ff67a7df43e", + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T09:55:18+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-18T15:55:00+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T15:19:04+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/02731ce2bd26514bec9efc0baef2cd0b0cf67f46", + "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-11T14:46:58+00:00" + }, { "name": "theseer/tokenizer", "version": "2.0.1", diff --git a/db/migrations/20261011120001_create_folders.php b/db/migrations/20261011120001_create_folders.php new file mode 100644 index 0000000..0af99fa --- /dev/null +++ b/db/migrations/20261011120001_create_folders.php @@ -0,0 +1,31 @@ +table('folders', ['id' => 'id', 'primary_key' => 'id']) + ->addColumn('parent_id', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('name', 'string', ['limit' => 255]) + ->addColumn('path', 'text', ['null' => false]) + ->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true]) + ->addForeignKey('parent_id', 'folders', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->addIndex(['parent_id', 'name'], ['unique' => true, 'name' => 'idx_folders_parent_name']) + ->addIndex(['path'], ['unique' => true, 'name' => 'idx_folders_path']) + ->create(); + + // Root folder entry (will be created by application on first run) + // Application ensures root folder exists with path = '/' and parent_id = NULL + } +} diff --git a/db/migrations/20261011120002_create_models.php b/db/migrations/20261011120002_create_models.php new file mode 100644 index 0000000..a54c153 --- /dev/null +++ b/db/migrations/20261011120002_create_models.php @@ -0,0 +1,30 @@ +table('models', ['id' => 'id', 'primary_key' => 'id']) + ->addColumn('folder_id', 'integer', ['null' => false, 'signed' => false]) + ->addColumn('name', 'string', ['limit' => 255]) + ->addColumn('notes', 'text', ['null' => true]) + ->addColumn('source_url', 'string', ['limit' => 2048, 'null' => true]) + ->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true]) + ->addColumn('updated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true]) + ->addForeignKey('folder_id', 'folders', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION']) + ->addIndex(['folder_id', 'name'], ['unique' => true, 'name' => 'idx_models_folder_name']) + ->addIndex(['added_at'], ['name' => 'idx_models_added_at']) + ->create(); + } +} diff --git a/db/migrations/20261011120003_create_files.php b/db/migrations/20261011120003_create_files.php new file mode 100644 index 0000000..ca54542 --- /dev/null +++ b/db/migrations/20261011120003_create_files.php @@ -0,0 +1,38 @@ +table('files', ['id' => 'id', 'primary_key' => 'id']) + ->addColumn('model_id', 'integer', ['null' => false, 'signed' => false]) + ->addColumn('folder_id', 'integer', ['null' => false, 'signed' => false]) + ->addColumn('name', 'string', ['limit' => 255]) + ->addColumn('managed_path', 'text', ['null' => false]) + ->addColumn('hash', 'string', ['limit' => 64, 'null' => true]) // SHA-256 hex + ->addColumn('size', 'biginteger', ['null' => true, 'signed' => false]) + ->addColumn('mime_type', 'string', ['limit' => 128, 'null' => true]) + ->addColumn('status', 'string', ['limit' => 32, 'default' => 'Present']) + ->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true]) + ->addColumn('updated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true]) + ->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->addForeignKey('folder_id', 'folders', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION']) + ->addIndex(['model_id', 'managed_path'], ['unique' => true, 'name' => 'idx_files_model_path']) + ->addIndex(['hash'], ['name' => 'idx_files_hash']) + ->addIndex(['status'], ['name' => 'idx_files_status']) + ->addIndex(['folder_id'], ['name' => 'idx_files_folder_id']) + ->create(); + } +} diff --git a/db/migrations/20261011120004_create_categories_tags.php b/db/migrations/20261011120004_create_categories_tags.php new file mode 100644 index 0000000..a6f1a0d --- /dev/null +++ b/db/migrations/20261011120004_create_categories_tags.php @@ -0,0 +1,41 @@ +table('categories', ['id' => 'id', 'primary_key' => 'id']) + ->addColumn('parent_id', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('name', 'string', ['limit' => 128]) + ->addColumn('description', 'text', ['null' => true]) + ->addForeignKey('parent_id', 'categories', 'id', ['delete' => 'SET NULL', 'update' => 'NO_ACTION']) + ->addIndex(['parent_id', 'name'], ['unique' => true, 'name' => 'idx_categories_parent_name']) + ->create(); + + // Tags - flat + $this->table('tags', ['id' => 'id', 'primary_key' => 'id']) + ->addColumn('name', 'string', ['limit' => 128]) + ->addIndex(['name'], ['unique' => true, 'name' => 'idx_tags_name']) + ->create(); + + // Model-Tag many-to-many + $this->table('model_tags', ['id' => false, 'primary_key' => ['model_id', 'tag_id']]) + ->addColumn('model_id', 'integer', ['null' => false, 'signed' => false]) + ->addColumn('tag_id', 'integer', ['null' => false, 'signed' => false]) + ->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->addForeignKey('tag_id', 'tags', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->create(); + } +} diff --git a/db/migrations/20261011120005_create_thumbnails.php b/db/migrations/20261011120005_create_thumbnails.php new file mode 100644 index 0000000..587d69a --- /dev/null +++ b/db/migrations/20261011120005_create_thumbnails.php @@ -0,0 +1,32 @@ +table('thumbnails', ['id' => 'id', 'primary_key' => 'id']) + ->addColumn('model_id', 'integer', ['null' => false, 'signed' => false]) + ->addColumn('managed_path', 'text', ['null' => false]) + ->addColumn('hash', 'string', ['limit' => 64, 'null' => false]) // SHA-256 hex + ->addColumn('size', 'biginteger', ['null' => false, 'signed' => false]) + ->addColumn('mime_type', 'string', ['limit' => 128, 'null' => false]) + ->addColumn('width', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('height', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('uploaded_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true]) + ->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->addIndex(['model_id'], ['name' => 'idx_thumbnails_model_id']) + ->addIndex(['hash'], ['unique' => true, 'name' => 'idx_thumbnails_hash']) + ->create(); + } +} diff --git a/db/migrations/20261011120006_create_import_jobs.php b/db/migrations/20261011120006_create_import_jobs.php new file mode 100644 index 0000000..13529cf --- /dev/null +++ b/db/migrations/20261011120006_create_import_jobs.php @@ -0,0 +1,54 @@ +table('import_jobs', ['id' => 'id', 'primary_key' => 'id']) + ->addColumn('source_path', 'text', ['null' => false]) + // Status: Pending, Scanning, Copying, Completed, Failed, Cancelled + ->addColumn('status', 'string', ['limit' => 32, 'default' => 'Pending']) + ->addColumn('total_files', 'integer', ['null' => false, 'default' => 0, 'signed' => false]) + ->addColumn('processed_files', 'integer', ['null' => false, 'default' => 0, 'signed' => false]) + ->addColumn('total_bytes', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false]) + ->addColumn('copied_bytes', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false]) + ->addColumn('started_at', 'timestamp', ['null' => true, 'timezone' => true]) + ->addColumn('completed_at', 'timestamp', ['null' => true, 'timezone' => true]) + ->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true]) + ->addIndex(['status'], ['name' => 'idx_import_jobs_status']) + ->addIndex(['created_at'], ['name' => 'idx_import_jobs_created_at']) + ->create(); + + // Import items - per-file tracking + $this->table('import_items', ['id' => 'id', 'primary_key' => 'id']) + ->addColumn('job_id', 'integer', ['null' => false, 'signed' => false]) + ->addColumn('source_path', 'text', ['null' => false]) + ->addColumn('managed_path', 'text', ['null' => true]) + ->addColumn('model_id', 'integer', ['null' => true, 'signed' => false]) + ->addColumn('hash', 'string', ['limit' => 64, 'null' => true]) // SHA-256 hex + ->addColumn('size', 'biginteger', ['null' => true, 'signed' => false]) + // Status: Pending, Copying, Completed, Failed, Skipped + ->addColumn('status', 'string', ['limit' => 32, 'default' => 'Pending']) + ->addColumn('failure_reason', 'text', ['null' => true]) + ->addColumn('started_at', 'timestamp', ['null' => true, 'timezone' => true]) + ->addColumn('completed_at', 'timestamp', ['null' => true, 'timezone' => true]) + ->addForeignKey('job_id', 'import_jobs', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->addForeignKey('model_id', 'models', 'id', ['delete' => 'SET NULL', 'update' => 'NO_ACTION']) + ->addIndex(['job_id', 'status'], ['name' => 'idx_import_items_job_status']) + ->addIndex(['job_id', 'managed_path'], ['name' => 'idx_import_items_job_path']) + ->addIndex(['hash'], ['name' => 'idx_import_items_hash']) + ->create(); + } +} diff --git a/db/seeds/DefaultDataSeed.php b/db/seeds/DefaultDataSeed.php new file mode 100644 index 0000000..b760b36 --- /dev/null +++ b/db/seeds/DefaultDataSeed.php @@ -0,0 +1,84 @@ + 'Aircraft', + 'description' => 'Planes, helicopters, and drones', + ], + [ + 'name' => 'Automotive', + 'description' => 'Cars, trucks, and vehicles', + ], + [ + 'name' => 'Characters', + 'description' => 'Humanoid and creature models', + ], + [ + 'name' => 'Props', + 'description' => 'Objects and accessories', + ], + [ + 'name' => 'Terrain', + 'description' => 'Landscapes, buildings, and scenery', + ], + [ + 'name' => 'Tools', + 'description' => 'Functional items and gadgets', + ], + ]; + + foreach ($categories as $category) { + $exists = $this->getAdapter()->fetchRow( + "SELECT id FROM categories WHERE name = '" + . $category['name'] . "'" + ); + if (!$exists) { + $this->getAdapter()->execute( + "INSERT INTO categories (name, description) VALUES ('" + . $category['name'] . "', '" + . $category['description'] . "')" + ); + } + } + + // Seed default tags + $tags = [ + ['name' => 'wip'], + ['name' => 'completed'], + ['name' => 'high-poly'], + ['name' => 'low-poly'], + ['name' => 'print-ready'], + ['name' => 'needs-supports'], + ['name' => 'articulated'], + ['name' => 'modular'], + ['name' => 'multi-part'], + ['name' => 'single-piece'], + ]; + + foreach ($tags as $tag) { + $exists = $this->getAdapter()->fetchRow( + "SELECT id FROM tags WHERE name = '" . $tag['name'] . "'" + ); + if (!$exists) { + $this->getAdapter()->execute( + "INSERT INTO tags (name) VALUES ('" . $tag['name'] . "')" + ); + } + } + } +} + diff --git a/dev.ps1 b/dev.ps1 index b2b5765..4d6da61 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -4,9 +4,11 @@ $Command = if ($args.Count) { $args[0] } else { '' } $Forward = @($args | Select-Object -Skip 1) try { switch ($Command) { - { $_ -in 'migrate', 'seed' } { - [Console]::Error.WriteLine("$Command unavailable until M2 (#12); #10 remains open") - exit 1 + 'migrate' { + $ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'app', '-c', "vendor/bin/phinx migrate $($Forward -join ' ')") + } + 'seed' { + $ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'app', '-c', "vendor/bin/phinx seed:run $($Forward -join ' ')") } 'up' { $ComposeArgs = @('up', '-d', '--build') } 'down' { diff --git a/dev.sh b/dev.sh index 362e7b7..1becd61 100644 --- a/dev.sh +++ b/dev.sh @@ -4,7 +4,10 @@ cd -- "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" cmd=${1:-} if [ "$#" -gt 0 ]; then shift; fi case "$cmd" in - migrate|seed) echo "$cmd unavailable until M2 (#12); #10 remains open" >&2; exit 1 ;; + migrate) + exec docker compose run --rm --no-deps -T --entrypoint sh app -c "vendor/bin/phinx migrate $*" ;; + seed) + exec docker compose run --rm --no-deps -T --entrypoint sh app -c "vendor/bin/phinx seed:run $*" ;; down) for arg do case "$arg" in diff --git a/docker-compose.yml b/docker-compose.yml index 1c39f57..87b7817 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,7 @@ services: - ./docker-compose.yml:/var/www/html/docker-compose.yml:ro - ./docker:/var/www/html/docker:ro - ./assets:/var/www/html/assets:ro + - ./phinx.php:/var/www/html/phinx.php:ro - type: bind source: ${MESHVAULT_LIBRARY_HOST_DIR:-./library} target: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library} diff --git a/docker/README.md b/docker/README.md index e6db2fe..d89b1fc 100644 --- a/docker/README.md +++ b/docker/README.md @@ -53,8 +53,8 @@ independent of your current working directory and propagate exit codes. ./dev.sh lint # run composer lint inside the app container ./dev.sh assets # build minified CSS inside the assets container ./dev.sh watch # start Tailwind watch inside the assets container -./dev.sh migrate # unavailable until M2 (#12); exits nonzero -./dev.sh seed # unavailable until M2 (#12); exits nonzero +./dev.sh migrate # run database migrations (M2 #12) +./dev.sh seed # seed default reference data (M2 #12) ``` `down --volumes` and `down -v` are blocked by the wrapper. Never remove the named diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index c9399c7..6fd8bd4 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -1,7 +1,7 @@ FROM php:8.3-fpm-bookworm -RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev unzip \ - && docker-php-ext-install mbstring dom \ +RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev libpq-dev unzip \ + && docker-php-ext-install mbstring dom pdo_pgsql \ && rm -rf /var/lib/apt/lists/* COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer WORKDIR /var/www/html diff --git a/phinx.php b/phinx.php new file mode 100644 index 0000000..b7fb2cb --- /dev/null +++ b/phinx.php @@ -0,0 +1,41 @@ + [ + 'migrations' => 'db/migrations', + 'seeds' => 'db/seeds', + ], + 'environments' => [ + 'default_migration_table' => 'phinxlog', + 'default_database' => 'development', + 'development' => [ + 'adapter' => 'pgsql', + 'host' => $dbHost, + 'port' => $dbPort, + 'name' => $dbName, + 'user' => $dbUser, + 'pass' => $dbPass, + 'charset' => 'utf8', + ], + ], + 'version_order' => 'creation', + 'migration_base_class' => 'Phinx\Migration\AbstractMigration', +]; diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 77f5207..6be2124 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -10,5 +10,12 @@ bin templates db/migrations + db/seeds tests + + + db/migrations/* + db/seeds/* + 0 + diff --git a/tests/Integration/MigrationTest.php b/tests/Integration/MigrationTest.php new file mode 100644 index 0000000..02e98bd --- /dev/null +++ b/tests/Integration/MigrationTest.php @@ -0,0 +1,82 @@ + \PDO::ERRMODE_EXCEPTION, + ]); + } catch (\PDOException $e) { + self::markTestSkipped('Database connection failed: ' . $e->getMessage()); + } + } + + public function testMigrationTablesExist(): void + { + if (self::$pdo === null) { + $this->markTestSkipped('Database connection not available.'); + } + + $expectedTables = [ + 'categories', + 'files', + 'folders', + 'import_items', + 'import_jobs', + 'model_tags', + 'models', + 'tags', + 'thumbnails', + 'phinxlog', + ]; + + foreach ($expectedTables as $table) { + $stmt = self::$pdo->query( + "SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{$table}'" + ); + $this->assertNotFalse($stmt, "Query failed for table {$table}"); + $this->assertNotEmpty($stmt->fetch(), "Table {$table} does not exist"); + } + } + + public function testSeedDataExists(): void + { + if (self::$pdo === null) { + $this->markTestSkipped('Database connection not available.'); + } + + $categories = self::$pdo->query("SELECT name FROM categories ORDER BY name")->fetchAll(\PDO::FETCH_COLUMN); + $this->assertContains('Aircraft', $categories); + $this->assertContains('Automotive', $categories); + $this->assertContains('Tools', $categories); + + $tags = self::$pdo->query("SELECT name FROM tags ORDER BY name")->fetchAll(\PDO::FETCH_COLUMN); + $this->assertContains('wip', $tags); + $this->assertContains('completed', $tags); + $this->assertContains('print-ready', $tags); + } +} \ No newline at end of file diff --git a/tests/Integration/TaskRunnerTest.php b/tests/Integration/TaskRunnerTest.php index 648648f..dcf3739 100644 --- a/tests/Integration/TaskRunnerTest.php +++ b/tests/Integration/TaskRunnerTest.php @@ -37,7 +37,13 @@ final class TaskRunnerTest extends TestCase self::assertStringContainsString('two words', $output); self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output)); } - foreach (['unknown', 'migrate', 'seed'] as $command) { + foreach (['migrate', 'seed'] as $command) { + [$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment); + self::assertSame(37, $code, $command . ': ' . $output); + self::assertStringContainsString('compose', $output); + self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output)); + } + foreach (['unknown'] as $command) { [$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment); self::assertNotSame(0, $code); self::assertStringNotContainsString('cwd=', $output);