diff --git a/.github/workflows/integrations-sync.yml b/.github/workflows/integrations-sync.yml new file mode 100644 index 00000000..d739b0c5 --- /dev/null +++ b/.github/workflows/integrations-sync.yml @@ -0,0 +1,237 @@ +name: integrations sync + +# Copies a TableProApp/integrations release into resources/data/integrations.json +# and public/images/integrations, then opens a pull request as the web bot +# (docs/deployment.md, "The web bot"). +# +# `fetch` parses the download and holds no secret. `pull-request` holds the +# App token, runs nothing from the release and copies only those two paths. +on: + schedule: + - cron: "17 2 * * *" + workflow_dispatch: + inputs: + tag: + description: "Registry release, v1.YYYYMMDD.N (empty for the latest)" + required: false + type: string + +permissions: {} + +concurrency: + group: integrations-sync + cancel-in-progress: false + +env: + REGISTRY: TableProApp/integrations + +jobs: + fetch: + runs-on: ubuntu-26.04 + timeout-minutes: 15 + permissions: + contents: read + outputs: + changed: ${{ steps.place.outputs.changed }} + tag: ${{ steps.release.outputs.tag }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve the release + id: release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + if [ -z "$TAG" ]; then + TAG=$(gh release list --repo "$REGISTRY" --json tagName,isLatest --jq '.[] | select(.isLatest) | .tagName') + fi + if [ -z "$TAG" ]; then + echo "The registry has no release yet." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if ! [[ "$TAG" =~ ^v1\.[0-9]{8}\.[0-9]+$ ]]; then + echo "::error::The tag is not a v1 registry release (v1.YYYYMMDD.N)." + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Download and verify the release + if: steps.release.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + RELEASE: ${{ runner.temp }}/release + run: | + gh release download "$TAG" --repo "$REGISTRY" --dir "$RELEASE" \ + --pattern index.json --pattern SHA256SUMS --pattern '*.png' + cd "$RELEASE" + for file in *; do + if ! [[ "$file" =~ ^(index\.json|SHA256SUMS|[a-z0-9]+(-[a-z0-9]+)*-[0-9a-f]{12}\.png)$ ]]; then + echo "::error::The release has an asset name outside the index contract." + exit 1 + fi + done + gh release verify "$TAG" --repo "$REGISTRY" + for file in *; do + gh release verify-asset "$TAG" "./$file" --repo "$REGISTRY" + done + sha256sum --check --strict SHA256SUMS + listed=$(awk '{ print $2 }' SHA256SUMS | sort) + downloaded=$(find . -maxdepth 1 -type f ! -name SHA256SUMS | sed 's|^\./||' | sort) + if [ "$listed" != "$downloaded" ]; then + echo "::error::SHA256SUMS and the downloaded assets differ." + exit 1 + fi + + - name: Place the files + id: place + if: steps.release.outputs.tag != '' + env: + RELEASE: ${{ runner.temp }}/release + run: | + cp "$RELEASE/index.json" resources/data/integrations.json + rm -rf public/images/integrations + mkdir -p public/images/integrations + find "$RELEASE" -maxdepth 1 -type f -name '*.png' -exec cp {} public/images/integrations/ \; + if [ -n "$(git status --porcelain -- resources/data/integrations.json public/images/integrations)" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "The site already has this release." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Setup PHP + if: steps.place.outputs.changed == 'true' + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: "8.5" + tools: composer:v2 + coverage: none + + - name: Resolve the Composer cache directory + id: composer-cache + if: steps.place.outputs.changed == 'true' + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + if: steps.place.outputs.changed == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ runner.os }}- + + - name: Test the data + if: steps.place.outputs.changed == 'true' + run: | + composer install --no-interaction --prefer-dist + cp .env.example .env + php artisan key:generate + php artisan test --compact tests/Feature/Data/IntegrationsDataTest.php + + # A non-English hub lists an active entry once every locale has its + # tagline and the English tagline still equals the registry summary. + - name: Write the pull request body + if: steps.place.outputs.changed == 'true' + env: + TAG: ${{ steps.release.outputs.tag }} + SYNC: ${{ runner.temp }}/sync + run: | + mkdir -p "$SYNC/images" + git show HEAD:resources/data/integrations.json > "$RUNNER_TEMP/before.json" 2>/dev/null \ + || echo '{"integrations": []}' > "$RUNNER_TEMP/before.json" + for locale in $(jq -r '.supported | keys[]' resources/data/locales.json); do + file="resources/data/content/$locale/integrations/index.json" + if [ -f "$file" ]; then + jq -c --arg locale "$locale" '{($locale): (.taglines // {})}' "$file" + else + jq -nc --arg locale "$locale" '{($locale): {}}' + fi + done | jq -s 'add' > "$RUNNER_TEMP/taglines.json" + jq -nr --arg tag "$TAG" --arg registry "$REGISTRY" \ + --slurpfile before "$RUNNER_TEMP/before.json" \ + --slurpfile after resources/data/integrations.json \ + --slurpfile taglines "$RUNNER_TEMP/taglines.json" ' + def bySlug: .integrations | map({key: .slug, value: .}) | from_entries; + def list: if length == 0 then "- None" else map("- `\(.)`") | join("\n") end; + ($before[0] | bySlug) as $old + | ($after[0] | bySlug) as $new + | $taglines[0] as $t + | [$new | keys[] | select($old[.] == null)] as $added + | [$old | keys[] | select($new[.] == null)] as $removed + | [$new | keys[] | select($old[.] != null and $old[.] != $new[.])] as $changed + | [$new[] | select(.status.state == "active") | . as $entry + | select($t.en[$entry.slug] != $entry.summary + or any($t | to_entries[] | select(.key != "en"); .value[$entry.slug] == null)) + | $entry.slug] as $untranslated + | "Registry release [\($tag)](https://github.com/\($registry)/releases/tag/\($tag)), copied by `integrations-sync.yml`.", + "", "**Added**", ($added | list), + "", "**Changed**", ($changed | list), + "", "**Removed**", ($removed | list), + "", "**English only until the tagline is translated**", ($untranslated | list) + ' > "$SYNC/pr-body.md" + cp resources/data/integrations.json "$SYNC/integrations.json" + find public/images/integrations -maxdepth 1 -type f -name '*.png' -exec cp {} "$SYNC/images/" \; + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.place.outputs.changed == 'true' + with: + name: integrations-sync + path: ${{ runner.temp }}/sync/ + if-no-files-found: error + retention-days: 1 + + pull-request: + needs: fetch + if: needs.fetch.outputs.changed == 'true' + runs-on: ubuntu-26.04 + timeout-minutes: 10 + environment: web-bot + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: integrations-sync + path: ${{ runner.temp }}/sync + + - name: Place the files + env: + SYNC: ${{ runner.temp }}/sync + run: | + cp "$SYNC/integrations.json" resources/data/integrations.json + rm -rf public/images/integrations + mkdir -p public/images/integrations + find "$SYNC/images" -maxdepth 1 -type f -name '*.png' -exec cp {} public/images/integrations/ \; + + - name: Mint a web bot token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.WEB_BOT_CLIENT_ID }} + private-key: ${{ secrets.WEB_BOT_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Open a pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + sign-commits: true + branch: bot/integrations-sync + delete-branch: true + commit-message: "chore(integrations): sync registry ${{ needs.fetch.outputs.tag }}" + title: "chore(integrations): sync registry ${{ needs.fetch.outputs.tag }}" + body-path: ${{ runner.temp }}/sync/pr-body.md + # A directory, not a glob: git add accepts it when a release has no images. + add-paths: | + resources/data/integrations.json + public/images/integrations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1570dfd2..d966a53c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,3 +66,7 @@ screenshots and stand up a competing commercial site with them. Do not commit images you do not have the rights to. Third-party logos under `public/images/sponsors/` belong to their owners and are used with permission; neither licence above extends to them. + +`resources/data/integrations.json` and `public/images/integrations/` are copied +from [TableProApp/integrations](https://github.com/TableProApp/integrations) +under its terms, and the next sync replaces them. Change an entry there. diff --git a/LICENSE-CONTENT b/LICENSE-CONTENT index b52dfbc4..65671cfb 100644 --- a/LICENSE-CONTENT +++ b/LICENSE-CONTENT @@ -13,9 +13,11 @@ This licence covers the EDITORIAL CONTENT of this repository: The source code in this repository is licensed separately under the MIT licence. See LICENSE. -Excluded from this licence: third-party logos under public/images/sponsors, -which are the property of their respective owners, and the TablePro name and -logo, which are trademarks. +Excluded from this licence: third-party logos under public/images/sponsors and +the icons and screenshots under public/images/integrations, which are the +property of their respective owners; resources/data/integrations.json, which is +copied from TableProApp/integrations, where it is CC0-1.0; and the TablePro +name and logo, which are trademarks. YOU ARE FREE TO: diff --git a/README.md b/README.md index bf376dcc..efd53e59 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ If any instruction ever tells you otherwise, that is a bug in this README. | `resources/js/i18n/` | UI strings per language, and the locale helpers. | | `app/Http/Controllers/` | One controller per page family. | | `public/og/` | Pre-rendered Open Graph cards, committed. | +| `resources/data/integrations.json`, `public/images/integrations/` | Copied from a [TableProApp/integrations](https://github.com/TableProApp/integrations) release by `.github/workflows/integrations-sync.yml`. Change entries there. | ## Writing a blog post @@ -129,6 +130,13 @@ Two licences, because code and writing want different terms: the images under `public/images` and `public/og`. Share and adapt it with attribution, but not commercially. -Neither licence covers the third-party logos under `public/images/sponsors`, -which belong to their owners, or the TablePro name and logo, which are -trademarks. See [CONTRIBUTING.md](CONTRIBUTING.md#licensing). +Neither licence covers: + +- the third-party logos under `public/images/sponsors` and the integration + icons and screenshots under `public/images/integrations`, which belong to + their owners; +- `resources/data/integrations.json`, which is CC0-1.0 in + [TableProApp/integrations](https://github.com/TableProApp/integrations); +- the TablePro name and logo, which are trademarks. + +See [CONTRIBUTING.md](CONTRIBUTING.md#licensing). diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fb4ab006..44a317ff 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,6 +6,7 @@ use App\Services\Og\BrowsershotOgImageRenderer; use App\Services\Og\OgImageRenderer; use App\Support\Content\ContentRepository; +use App\Support\Content\IntegrationCatalog; use App\Support\Content\MarkdownRenderer; use Illuminate\Support\ServiceProvider; @@ -31,5 +32,9 @@ public function register(): void )); $this->app->singleton(MarkdownRenderer::class); + + $this->app->singleton(IntegrationCatalog::class, fn(): IntegrationCatalog => new IntegrationCatalog( + path: resource_path('data/integrations.json'), + )); } } diff --git a/app/Support/Content/IntegrationCatalog.php b/app/Support/Content/IntegrationCatalog.php new file mode 100644 index 00000000..3dac27ae --- /dev/null +++ b/app/Support/Content/IntegrationCatalog.php @@ -0,0 +1,96 @@ +, + * platforms: list, + * categories: list, + * surfaces: list, + * disclosures: array, + * links: array, + * icon: array{512: Image, 128: Image}, + * screenshots: list, + * keywords?: list, + * addedAt: string, + * lastVerifiedAt: string, + * } + */ +final class IntegrationCatalog +{ + public const int SCHEMA_VERSION = 1; + + /** + * @var array|null + */ + private ?array $integrations = null; + + public function __construct( + private readonly string $path, + ) {} + + /** + * @return list + */ + public function all(): array + { + return array_values($this->integrations()); + } + + /** + * @return Integration|null + */ + public function find(string $slug): ?array + { + return $this->integrations()[$slug] ?? null; + } + + /** + * @return array + */ + private function integrations(): array + { + if ($this->integrations !== null) { + return $this->integrations; + } + + $data = File::json($this->path, JSON_THROW_ON_ERROR); + + if (! is_array($data) || ($data['schemaVersion'] ?? null) !== self::SCHEMA_VERSION || ! is_array($data['integrations'] ?? null) || ! array_is_list($data['integrations'])) { + throw new UnexpectedValueException("{$this->path} is not a v" . self::SCHEMA_VERSION . ' integrations index.'); + } + + $integrations = []; + + foreach ($data['integrations'] as $integration) { + $slug = $integration['slug'] ?? null; + + if (! is_string($slug) || isset($integrations[$slug])) { + throw new UnexpectedValueException("{$this->path} has a missing or repeated slug."); + } + + $integrations[$slug] = $integration; + } + + return $this->integrations = $integrations; + } +} diff --git a/composer.json b/composer.json index cd115413..9172dfb4 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "laravel/pint": "^1.32.1", "mockery/mockery": "^1.6.15", "nunomaduro/collision": "^8.9.5", + "opis/json-schema": "^2.6.0", "pestphp/pest": "^5.3", "pestphp/pest-plugin-laravel": "^5.0.1" }, diff --git a/composer.lock b/composer.lock index 1fb20db9..93300497 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": "680f792681663efbeb17d03fada55a26", + "content-hash": "16ccd9b7f4b48d45c778b6e04b8fd66d", "packages": [ { "name": "brick/math", @@ -7579,6 +7579,196 @@ ], "time": "2026-07-15T19:09:14+00:00" }, + { + "name": "opis/json-schema", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.1", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.6.0" + }, + "time": "2025-10-17T12:46:48+00:00" + }, + { + "name": "opis/string", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.1.0" + }, + "time": "2025-10-17T12:38:41+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, { "name": "pestphp/pest", "version": "v5.3.0", diff --git a/docs/deployment.md b/docs/deployment.md index c7a20252..8c1cc68f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -415,8 +415,8 @@ cache, it is used on the runner, and it is never sent to the server. ## The web bot `main` takes changes only through a pull request with green checks (the -repository rulesets), so `og.yml` cannot push to it. It opens a pull request -as the GitHub App `tablepro-web-bot` instead. +repository rulesets), so `og.yml` and `integrations-sync.yml` cannot push to +it. They open pull requests as the GitHub App `tablepro-web-bot` instead. - **What it can do.** The App is installed on this repository only, with Contents and Pull requests read and write, and no Workflows permission. A run diff --git a/resources/data/integrations.json b/resources/data/integrations.json new file mode 100644 index 00000000..ebd6c6a2 --- /dev/null +++ b/resources/data/integrations.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-10-10T07:41:01Z", + "commit": "f6c6d0ffd22190ac19e148d86ba1b26058851de7", + "contentSha256": "9230f56066af08026e1500e27f6e2ccc4ab6a26e6fa2b5040c8412dc77411b4a", + "integrations": [], + "removed": [] +} diff --git a/tests/Feature/Data/IntegrationsDataTest.php b/tests/Feature/Data/IntegrationsDataTest.php new file mode 100644 index 00000000..b482cfbf --- /dev/null +++ b/tests/Feature/Data/IntegrationsDataTest.php @@ -0,0 +1,124 @@ + ['resources/data/integrations.json', 'public/images/integrations'], + 'a two-entry release' => ['tests/Fixtures/integrations/index.json', 'tests/Fixtures/integrations/images'], +]); + +/** + * @return array{integrations: list>} + */ +function integrationsIndex(string $index): array +{ + return json_decode(File::get(base_path($index)), true, 512, JSON_THROW_ON_ERROR); +} + +it('matches the index schema the registry publishes', function (string $index, string $images): void { + $validator = new Validator(); + $validator->setMaxErrors(20); + + $result = $validator->validate( + json_decode(File::get(base_path($index)), false, 512, JSON_THROW_ON_ERROR), + File::get(base_path(INTEGRATIONS_SCHEMA)), + ); + $errors = $result->hasError() ? (new ErrorFormatter())->format($result->error()) : []; + + expect($errors)->toBe([]); +})->with('integration indexes'); + +it('gives every integration its own slug, safe in a URL', function (string $index, string $images): void { + $slugs = array_column(integrationsIndex($index)['integrations'], 'slug'); + + foreach ($slugs as $slug) { + expect($slug)->toMatch('/^[a-z0-9]+(?:-[a-z0-9]+)*$/'); + } + + expect($slugs)->toBe(array_values(array_unique($slugs))); +})->with('integration indexes'); + +it('ships every image the index names, as the PNG it describes, and no other file', function (string $index, string $images): void { + $named = []; + + foreach (integrationsIndex($index)['integrations'] as $integration) { + foreach ([...array_values($integration['icon']), ...$integration['screenshots']] as $image) { + $path = base_path("{$images}/{$image['file']}"); + $named[] = $image['file']; + + expect(is_file($path))->toBeTrue("{$integration['slug']}: {$image['file']} is missing"); + expect(hash_file('sha256', $path))->toBe($image['sha256'], $image['file']); + expect(array_slice(getimagesize($path) ?: [], 0, 3))->toBe([$image['width'], $image['height'], IMAGETYPE_PNG], $image['file']); + } + } + + $directory = base_path($images); + $files = is_dir($directory) ? array_values(array_diff(scandir($directory) ?: [], ['.', '..'])) : []; + $named = array_values(array_unique($named)); + sort($files); + sort($named); + + expect($files)->toBe($named); +})->with('integration indexes'); + +it('links only to https addresses', function (string $index, string $images): void { + $wrong = []; + + foreach (integrationsIndex($index)['integrations'] as $integration) { + $urls = array_filter([ + $integration['install']['url'], + $integration['source']['url'] ?? null, + $integration['publisher']['url'] ?? null, + $integration['host']['url'] ?? null, + $integration['status']['link'] ?? null, + ...array_values($integration['links']), + ], fn(mixed $url): bool => $url !== null); + + foreach ($urls as $url) { + $https = is_string($url) + && filter_var($url, FILTER_VALIDATE_URL) !== false + && parse_url($url, PHP_URL_SCHEME) === 'https' + && parse_url($url, PHP_URL_USER) === null; + + if (! $https) { + $wrong[] = "{$integration['slug']}: " . json_encode($url); + } + } + } + + expect($wrong)->toBe([]); +})->with('integration indexes'); + +it('reads the site data through the container', function (): void { + $slugs = array_column(integrationsIndex('resources/data/integrations.json')['integrations'], 'slug'); + + expect(array_column(app(IntegrationCatalog::class)->all(), 'slug'))->toBe($slugs); +}); + +it('finds an integration by its slug', function (): void { + $catalog = new IntegrationCatalog(base_path('tests/Fixtures/integrations/index.json')); + + expect(array_column($catalog->all(), 'slug'))->toBe(['command-line', 'shortcuts']); + expect($catalog->find('shortcuts')['name'] ?? null)->toBe('Shortcuts'); + expect($catalog->find('raycast'))->toBeNull(); +}); + +it('refuses an index it cannot read safely', function (array $index): void { + $path = tempnam(sys_get_temp_dir(), 'integrations') ?: throw new RuntimeException('No temp file.'); + File::put($path, json_encode($index, JSON_THROW_ON_ERROR)); + + try { + expect(fn(): array => (new IntegrationCatalog($path))->all())->toThrow(UnexpectedValueException::class); + } finally { + File::delete($path); + } +})->with([ + 'a later schema version' => [['schemaVersion' => 2, 'integrations' => []]], + 'a repeated slug' => [['schemaVersion' => 1, 'integrations' => [['slug' => 'raycast'], ['slug' => 'raycast']]]], +]); diff --git a/tests/Fixtures/integrations/images/command-line-icon-128-c39d875c6357.png b/tests/Fixtures/integrations/images/command-line-icon-128-c39d875c6357.png new file mode 100644 index 00000000..cba9b2b8 Binary files /dev/null and b/tests/Fixtures/integrations/images/command-line-icon-128-c39d875c6357.png differ diff --git a/tests/Fixtures/integrations/images/command-line-icon-512-ee2ee8eecb33.png b/tests/Fixtures/integrations/images/command-line-icon-512-ee2ee8eecb33.png new file mode 100644 index 00000000..33699679 Binary files /dev/null and b/tests/Fixtures/integrations/images/command-line-icon-512-ee2ee8eecb33.png differ diff --git a/tests/Fixtures/integrations/images/shortcuts-icon-128-005e96c58ac1.png b/tests/Fixtures/integrations/images/shortcuts-icon-128-005e96c58ac1.png new file mode 100644 index 00000000..46f9f555 Binary files /dev/null and b/tests/Fixtures/integrations/images/shortcuts-icon-128-005e96c58ac1.png differ diff --git a/tests/Fixtures/integrations/images/shortcuts-icon-512-4e4cc25aa23d.png b/tests/Fixtures/integrations/images/shortcuts-icon-512-4e4cc25aa23d.png new file mode 100644 index 00000000..83d103cf Binary files /dev/null and b/tests/Fixtures/integrations/images/shortcuts-icon-512-4e4cc25aa23d.png differ diff --git a/tests/Fixtures/integrations/index.json b/tests/Fixtures/integrations/index.json new file mode 100644 index 00000000..135e7f92 --- /dev/null +++ b/tests/Fixtures/integrations/index.json @@ -0,0 +1,180 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-10-10T10:19:30Z", + "commit": "416ffad0c54749fed4de3ab60ca61bb3061349c7", + "release": "v1.20261010.1", + "contentSha256": "9a643efdd4d011bbaad102e8fdcacc1608de24691c7627e4eae6f9d2e8f32d1a", + "integrations": [ + { + "slug": "command-line", + "name": "Command line", + "summary": "Open database URLs and SQL files in TablePro from Terminal with the tablepro command.", + "description": "The tablepro command opens a connection URL, a database file or a SQL file in TablePro from Terminal or a script. tablepro \"postgresql://user@127.0.0.1:5432/app\" opens that database, and tablepro schema.sql opens the file in a query tab.\n\nA URL that matches a saved connection uses it. Any other URL opens a temporary session that is not saved. Scripts can also run open -b com.TablePro with the same URL.", + "tier": "official", + "status": { + "state": "active" + }, + "publisher": { + "name": "TablePro", + "github": "TableProApp", + "githubId": 260450047, + "url": "https://tablepro.app" + }, + "source": { + "type": "github", + "repo": "TableProApp/TablePro", + "repoId": 1117891044, + "path": "TablePro/Core/Services/Infrastructure", + "url": "https://github.com/TableProApp/TablePro/tree/HEAD/TablePro/Core/Services/Infrastructure" + }, + "license": "AGPL-3.0-only", + "install": { + "type": "built-in", + "docs": "https://docs.tablepro.app/integrations/terminal", + "url": "https://docs.tablepro.app/integrations/terminal" + }, + "minTableProVersion": { + "macos": "0.57.0" + }, + "platforms": [ + "macos" + ], + "categories": [ + "command-line" + ], + "surfaces": [ + "cli", + "database-url" + ], + "disclosures": { + "reads": [], + "writes": [], + "lowersSafeMode": false, + "network": "none", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://docs.tablepro.app/integrations/terminal", + "docs": "https://docs.tablepro.app/connections/urls", + "issues": "https://github.com/TableProApp/TablePro/issues" + }, + "icon": { + "512": { + "file": "command-line-icon-512-ee2ee8eecb33.png", + "width": 512, + "height": 512, + "sha256": "ee2ee8eecb33b49d9fa995e8a3faed66a140dbab7313713e4961a3bb6cdd7dc0" + }, + "128": { + "file": "command-line-icon-128-c39d875c6357.png", + "width": 128, + "height": 128, + "sha256": "c39d875c6357430c83de5685d6d58da6ae44dd87a1d3f4852b5f73d7f39b6c7a" + } + }, + "keywords": [ + "terminal", + "shell", + "cli", + "database url" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10", + "summarySha256": "b9a57ebb1c91b32cb7be94df126462c870c388e590398cdf0993119db0305b2f", + "closedSource": false, + "screenshots": [] + }, + { + "slug": "shortcuts", + "name": "Shortcuts", + "summary": "Add rows to a table and open a connection from Shortcuts on iPhone and iPad.", + "description": "The TablePro app for iPhone and iPad adds three actions to Shortcuts, Siri and the Share Sheet: Add Row to Table, Add Rows to Table and Open Connection.\n\nThe two insert actions take JSON or CSV, insert up to 10,000 rows per run, and run in the background without opening the app.", + "tier": "official", + "status": { + "state": "active" + }, + "publisher": { + "name": "TablePro", + "github": "TableProApp", + "githubId": 260450047, + "url": "https://tablepro.app" + }, + "source": { + "type": "github", + "repo": "TableProApp/TablePro", + "repoId": 1117891044, + "path": "TableProMobile/TableProMobile/Intents", + "url": "https://github.com/TableProApp/TablePro/tree/HEAD/TableProMobile/TableProMobile/Intents" + }, + "license": "AGPL-3.0-only", + "install": { + "type": "built-in", + "docs": "https://docs.tablepro.app/integrations/ios-shortcuts", + "url": "https://docs.tablepro.app/integrations/ios-shortcuts" + }, + "host": { + "name": "Shortcuts", + "url": "https://support.apple.com/guide/shortcuts/welcome/ios" + }, + "minTableProVersion": { + "ios": "1.0", + "ipados": "1.0" + }, + "platforms": [ + "ios", + "ipados" + ], + "categories": [ + "automation" + ], + "surfaces": [ + "shortcuts" + ], + "disclosures": { + "reads": [ + "connections", + "schema" + ], + "writes": [ + "data" + ], + "lowersSafeMode": false, + "network": "none", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://docs.tablepro.app/integrations/ios-shortcuts", + "issues": "https://github.com/TableProApp/TablePro/issues" + }, + "icon": { + "512": { + "file": "shortcuts-icon-512-4e4cc25aa23d.png", + "width": 512, + "height": 512, + "sha256": "4e4cc25aa23d475eeec1315ab0099c21cc64cacce796cc92e31314c596482ebe" + }, + "128": { + "file": "shortcuts-icon-128-005e96c58ac1.png", + "width": 128, + "height": 128, + "sha256": "005e96c58ac156191391d3e3e25c014e00c5e0efa4a67322b36a43cec4b950c8" + } + }, + "keywords": [ + "ios", + "ipad", + "siri", + "app intents", + "automation" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10", + "summarySha256": "9208fcf6688e6cec5ad0dbaaeb1b4510590ce02416855aecca7aa8bed6ef3b49", + "closedSource": false, + "screenshots": [] + } + ], + "removed": [] +} diff --git a/tests/Support/integrations-index.v1.schema.json b/tests/Support/integrations-index.v1.schema.json new file mode 100644 index 00000000..5e968a55 --- /dev/null +++ b/tests/Support/integrations-index.v1.schema.json @@ -0,0 +1,270 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://integrations.tablepro.app/schema/v1/index.schema.json", + "title": "TablePro integrations index, v1", + "description": "The published index, at https://integrations.tablepro.app/v1/index.json and as the index.json asset of each release. A release holds index.json, SHA256SUMS and every image this file names, each as a flat asset listed in SHA256SUMS. Consumers ignore unknown properties and treat unknown enum-like values (tier, category, surface, install type, platform) as absent or as other. New optional properties and new values do not change v1. A removed or renamed property, a new required property or a type change ships as v2 beside v1.", + "type": "object", + "required": [ + "schemaVersion", + "generatedAt", + "commit", + "contentSha256", + "integrations", + "removed" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "release": { + "description": "Release tag this file was attached to. Absent on Pages builds made before the release exists.", + "type": "string", + "pattern": "^v1\\.[0-9]{8}\\.[0-9]+$" + }, + "contentSha256": { + "description": "SHA-256 over everything except generatedAt, commit and release. Equal values mean the same data.", + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "integrations": { + "type": "array", + "items": { + "$ref": "#/$defs/entry" + } + }, + "removed": { + "description": "Tombstones. A consumer answers 410 Gone for these slugs.", + "type": "array", + "items": { + "type": "object", + "required": [ + "slug", + "removedAt", + "reason" + ], + "properties": { + "slug": { + "type": "string" + }, + "removedAt": { + "type": "string", + "format": "date" + }, + "reason": { + "type": "string" + } + } + } + } + }, + "$defs": { + "image": { + "type": "object", + "required": [ + "file", + "width", + "height", + "sha256" + ], + "properties": { + "file": { + "description": "Flat file name ending in the first 12 hex digits of its SHA-256, for example raycast-icon-512-0123456789ab.png. It resolves relative to index.json: a release asset of the same name, or the file beside index.json on Pages. Safe to cache forever.", + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{12}\\.png$" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "entry": { + "type": "object", + "required": [ + "slug", + "name", + "summary", + "summarySha256", + "description", + "tier", + "status", + "publisher", + "source", + "closedSource", + "install", + "platforms", + "categories", + "surfaces", + "disclosures", + "links", + "icon", + "screenshots", + "addedAt", + "lastVerifiedAt" + ], + "properties": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "summarySha256": { + "description": "SHA-256 of summary. A consumer that stores translations keeps this beside each one to spot a changed English source.", + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "description": { + "type": "string" + }, + "tier": { + "type": "string" + }, + "status": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "publisher": { + "type": "object", + "required": [ + "name", + "github", + "githubId" + ] + }, + "source": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "url": { + "description": "Browsable source URL, derived from repo and path.", + "type": "string" + } + } + }, + "closedSource": { + "type": "boolean" + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "install": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string" + }, + "url": { + "description": "Resolved destination for the install button, for every install type.", + "type": "string" + } + } + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "disclosures": { + "type": "object" + }, + "links": { + "type": "object", + "required": [ + "homepage" + ] + }, + "icon": { + "type": "object", + "required": [ + "512", + "128" + ], + "properties": { + "512": { + "$ref": "#/$defs/image" + }, + "128": { + "$ref": "#/$defs/image" + } + } + }, + "screenshots": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/$defs/image" + } + ], + "required": [ + "alt" + ], + "properties": { + "alt": { + "type": "string" + } + } + } + }, + "addedAt": { + "type": "string", + "format": "date" + }, + "lastVerifiedAt": { + "type": "string", + "format": "date" + } + } + } + } +}