From 6a1ebe583e9b30ea6a4a0229f95d4f6cb70d0f6c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 10 Oct 2026 18:04:05 +0700 Subject: [PATCH 1/2] feat(integrations): sync the integrations registry into the site --- .github/workflows/integrations-sync.yml | 237 +++++++++++++++ CONTRIBUTING.md | 4 + LICENSE-CONTENT | 8 +- README.md | 14 +- app/Providers/AppServiceProvider.php | 5 + app/Support/Content/IntegrationCatalog.php | 96 +++++++ composer.json | 1 + composer.lock | 192 ++++++++++++- docs/deployment.md | 4 +- resources/data/integrations.json | 8 + tests/Feature/Data/IntegrationsDataTest.php | 124 ++++++++ .../command-line-icon-128-c39d875c6357.png | Bin 0 -> 4515 bytes .../command-line-icon-512-ee2ee8eecb33.png | Bin 0 -> 18542 bytes .../shortcuts-icon-128-005e96c58ac1.png | Bin 0 -> 4154 bytes .../shortcuts-icon-512-4e4cc25aa23d.png | Bin 0 -> 15860 bytes tests/Fixtures/integrations/index.json | 180 ++++++++++++ .../Support/integrations-index.v1.schema.json | 270 ++++++++++++++++++ 17 files changed, 1134 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/integrations-sync.yml create mode 100644 app/Support/Content/IntegrationCatalog.php create mode 100644 resources/data/integrations.json create mode 100644 tests/Feature/Data/IntegrationsDataTest.php create mode 100644 tests/Fixtures/integrations/images/command-line-icon-128-c39d875c6357.png create mode 100644 tests/Fixtures/integrations/images/command-line-icon-512-ee2ee8eecb33.png create mode 100644 tests/Fixtures/integrations/images/shortcuts-icon-128-005e96c58ac1.png create mode 100644 tests/Fixtures/integrations/images/shortcuts-icon-512-4e4cc25aa23d.png create mode 100644 tests/Fixtures/integrations/index.json create mode 100644 tests/Support/integrations-index.v1.schema.json 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 0000000000000000000000000000000000000000..cba9b2b8664c5663020731c4b1802200aa4e7893 GIT binary patch literal 4515 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..46f9f55570ffd3287345661c13e04939f6c05b4a GIT binary patch literal 4154 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 Ef^JI7AOHXW literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..83d103cfccb36bb4e6e40f4e5639552c7c0e864c GIT binary patch literal 15860 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 ij{pDi?$^Qd9OtmEd literal 0 HcmV?d00001 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" + } + } + } + } +} From fed67a3babd87cba2eea8faa4b38e46c860abce7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 10 Oct 2026 19:28:54 +0700 Subject: [PATCH 2/2] feat(integrations): add the integrations directory pages --- .../Controllers/IntegrationController.php | 337 ++++++++++++++++++ app/Providers/SeoServiceProvider.php | 3 + app/Support/Localization/LocaleSwitcher.php | 1 + app/Support/Seo/ContentCollection.php | 3 +- app/Support/Seo/IntegrationPages.php | 63 ++++ app/Support/Seo/PageEntry.php | 2 +- docs/architecture.md | 32 +- docs/rebuild/design/design-system.md | 33 ++ docs/rebuild/design/positioning.md | 3 +- docs/rebuild/design/sitemap.md | 2 +- .../data/content/de/features/ai-mcp.json | 6 + .../data/content/de/integrations/index.json | 166 +++++++++ .../data/content/en/features/ai-mcp.json | 3 +- .../data/content/en/integrations/index.json | 166 +++++++++ .../data/content/es/features/ai-mcp.json | 6 + .../data/content/es/integrations/index.json | 166 +++++++++ .../data/content/fr/features/ai-mcp.json | 6 + .../data/content/fr/integrations/index.json | 166 +++++++++ .../data/content/id/features/ai-mcp.json | 6 + .../data/content/id/integrations/index.json | 166 +++++++++ .../data/content/it/features/ai-mcp.json | 6 + .../data/content/it/integrations/index.json | 166 +++++++++ .../data/content/ja/features/ai-mcp.json | 3 +- .../data/content/ja/integrations/index.json | 166 +++++++++ .../data/content/ko/features/ai-mcp.json | 3 +- .../data/content/ko/integrations/index.json | 166 +++++++++ .../data/content/pt-BR/features/ai-mcp.json | 6 + .../content/pt-BR/integrations/index.json | 166 +++++++++ .../data/content/vi/features/ai-mcp.json | 6 + .../data/content/vi/integrations/index.json | 166 +++++++++ .../data/content/zh-Hans/features/ai-mcp.json | 3 +- .../content/zh-Hans/integrations/index.json | 166 +++++++++ .../data/content/zh-Hant/features/ai-mcp.json | 3 +- .../content/zh-Hant/integrations/index.json | 166 +++++++++ resources/data/facts.json | 1 + .../js/components/integrations/README.md | 28 ++ .../integrations/install-action.tsx | 31 ++ .../integrations/integration-filters.tsx | 82 +++++ .../integrations/integration-list.tsx | 47 +++ resources/js/components/integrations/model.ts | 76 ++++ resources/js/components/integrations/types.ts | 91 +++++ resources/js/components/site/site-footer.tsx | 5 + resources/js/i18n/messages/de/footer.ts | 1 + resources/js/i18n/messages/de/seo.ts | 1 + resources/js/i18n/messages/en/footer.ts | 1 + resources/js/i18n/messages/en/seo.ts | 1 + resources/js/i18n/messages/es/footer.ts | 1 + resources/js/i18n/messages/es/seo.ts | 1 + resources/js/i18n/messages/fr/footer.ts | 1 + resources/js/i18n/messages/fr/seo.ts | 1 + resources/js/i18n/messages/id/footer.ts | 1 + resources/js/i18n/messages/id/seo.ts | 1 + resources/js/i18n/messages/it/footer.ts | 1 + resources/js/i18n/messages/it/seo.ts | 1 + resources/js/i18n/messages/ja/footer.ts | 1 + resources/js/i18n/messages/ja/seo.ts | 1 + resources/js/i18n/messages/ko/footer.ts | 1 + resources/js/i18n/messages/ko/seo.ts | 1 + resources/js/i18n/messages/pt-BR/footer.ts | 1 + resources/js/i18n/messages/pt-BR/seo.ts | 1 + resources/js/i18n/messages/vi/footer.ts | 1 + resources/js/i18n/messages/vi/seo.ts | 1 + resources/js/i18n/messages/zh-Hans/footer.ts | 1 + resources/js/i18n/messages/zh-Hans/seo.ts | 1 + resources/js/i18n/messages/zh-Hant/footer.ts | 1 + resources/js/i18n/messages/zh-Hant/seo.ts | 1 + resources/js/lib/data/facts.ts | 1 + resources/js/pages/Integrations/Index.tsx | 141 ++++++++ resources/js/pages/Integrations/Show.tsx | 263 ++++++++++++++ routes/localized.php | 16 +- tests/Feature/Chrome/SiteChromeTest.php | 2 +- tests/Feature/Content/helpers.php | 2 +- .../Integrations/IntegrationPagesTest.php | 178 +++++++++ tests/Feature/Landing/LandingTest.php | 13 +- .../Localization/ContentParityTest.php | 6 +- .../Localization/LocaleRoutingTest.php | 2 +- tests/Feature/Seo/PageFamiliesTest.php | 48 ++- tests/Feature/Seo/SeoLengthsTest.php | 19 + tests/Feature/Seo/helpers.php | 22 +- tests/js/integrations-model.test.ts | 92 +++++ 80 files changed, 3691 insertions(+), 27 deletions(-) create mode 100644 app/Http/Controllers/IntegrationController.php create mode 100644 app/Support/Seo/IntegrationPages.php create mode 100644 resources/data/content/de/integrations/index.json create mode 100644 resources/data/content/en/integrations/index.json create mode 100644 resources/data/content/es/integrations/index.json create mode 100644 resources/data/content/fr/integrations/index.json create mode 100644 resources/data/content/id/integrations/index.json create mode 100644 resources/data/content/it/integrations/index.json create mode 100644 resources/data/content/ja/integrations/index.json create mode 100644 resources/data/content/ko/integrations/index.json create mode 100644 resources/data/content/pt-BR/integrations/index.json create mode 100644 resources/data/content/vi/integrations/index.json create mode 100644 resources/data/content/zh-Hans/integrations/index.json create mode 100644 resources/data/content/zh-Hant/integrations/index.json create mode 100644 resources/js/components/integrations/README.md create mode 100644 resources/js/components/integrations/install-action.tsx create mode 100644 resources/js/components/integrations/integration-filters.tsx create mode 100644 resources/js/components/integrations/integration-list.tsx create mode 100644 resources/js/components/integrations/model.ts create mode 100644 resources/js/components/integrations/types.ts create mode 100644 resources/js/pages/Integrations/Index.tsx create mode 100644 resources/js/pages/Integrations/Show.tsx create mode 100644 tests/Feature/Integrations/IntegrationPagesTest.php create mode 100644 tests/js/integrations-model.test.ts diff --git a/app/Http/Controllers/IntegrationController.php b/app/Http/Controllers/IntegrationController.php new file mode 100644 index 00000000..4d2079b3 --- /dev/null +++ b/app/Http/Controllers/IntegrationController.php @@ -0,0 +1,337 @@ +, platforms: list, closedSource: bool, tagline: string, keywords: list, icon: Icon|null} + * @phpstan-type Filters array{q: string|null, category: string|null, platform: string|null, tier: string|null} + */ +class IntegrationController extends Controller +{ + private const array TIERS = ['official', 'partner', 'community']; + + // The registry names the OS a TablePro app runs on; the site names the app. + private const array PLATFORMS = ['macos' => 'mac', 'ios' => 'ios', 'ipados' => 'ios']; + + private const array NETWORK = ['none', 'local', 'internet']; + + private const array PAYMENT = ['free', 'optional', 'paid']; + + private const int QUERY_LENGTH = 100; + + private const string IMAGES = '/images/integrations/'; + + public function index(Request $request, ContentRepository $content, IntegrationCatalog $catalog): Response + { + $locale = App::getLocale(); + $page = $content->page('integrations/index', $locale); + $listed = $this->listed($catalog, $page, $content, $locale); + + return Inertia::render('Integrations/Index', [ + 'content' => Arr::except($page, ['taglines', 'show']), + 'integrations' => $listed, + 'filters' => $this->filters($request, $listed), + ]); + } + + public function show(ContentRepository $content, IntegrationCatalog $catalog, string $slug): Response + { + $integration = $catalog->find($slug); + + abort_if($integration === null, 404); + + $locale = App::getLocale(); + $page = $content->page('integrations/index', $locale); + + return Inertia::render('Integrations/Show', [ + 'content' => Arr::only($page, ['labels', 'show']), + 'integration' => $this->detail($integration, $page, $catalog), + 'dates' => [ + 'added' => $this->date($integration['addedAt'], $locale), + 'verified' => $this->date($integration['lastVerifiedAt'], $locale), + 'archived' => $this->date($integration['status']['since'] ?? null, $locale), + ], + ]); + } + + /** + * @param array $page + * @return list + */ + private function listed(IntegrationCatalog $catalog, array $page, ContentRepository $content, string $locale): array + { + $default = $locale === Locales::default(); + $taglines = $this->taglines($page); + $sources = $default ? [] : $this->taglines($content->page('integrations/index', Locales::default())); + $categories = $this->labelled($page, 'labels.categories'); + $listed = []; + + foreach ($catalog->all() as $integration) { + $slug = $integration['slug']; + + if (($integration['status']['state'] ?? null) !== 'active') { + continue; + } + + // Another language lists an entry only while its tagline there was translated from the current summary. + if ($default) { + $tagline = $integration['summary']; + } elseif (($sources[$slug] ?? null) === $integration['summary'] && isset($taglines[$slug])) { + $tagline = $taglines[$slug]; + } else { + continue; + } + + $listed[] = [ + 'slug' => $slug, + 'name' => $integration['name'], + 'publisher' => $integration['publisher']['name'], + 'tier' => $this->tier($integration), + 'categories' => $this->known($integration['categories'], $categories), + 'platforms' => $this->platforms($integration['platforms']), + 'closedSource' => $integration['closedSource'] === true, + 'tagline' => $tagline, + 'keywords' => array_values(array_filter($integration['keywords'] ?? [], 'is_string')), + 'icon' => $this->image($integration['icon']['128'] ?? null), + ]; + } + + $rank = array_flip(self::TIERS); + + usort($listed, fn(array $a, array $b): int => [$rank[$a['tier']], strtolower($a['name'])] <=> [$rank[$b['tier']], strtolower($b['name'])]); + + return $listed; + } + + /** + * @param array $page + * @return array + */ + private function taglines(array $page): array + { + $taglines = is_array($page['taglines'] ?? null) ? $page['taglines'] : []; + + return array_filter($taglines, fn(mixed $tagline): bool => is_string($tagline) && trim($tagline) !== ''); + } + + /** + * @param list $listed + * @return Filters + */ + private function filters(Request $request, array $listed): array + { + $value = function (string $key, string $field) use ($request, $listed): ?string { + $value = $request->query($key); + $present = array_merge(...array_map(fn(array $entry): array => (array) $entry[$field], $listed)); + + return is_string($value) && in_array($value, $present, true) ? $value : null; + }; + + $q = $request->query('q'); + $q = is_string($q) ? trim(mb_substr(trim($q), 0, self::QUERY_LENGTH)) : ''; + + return [ + 'q' => $q !== '' ? $q : null, + 'category' => $value('category', 'categories'), + 'platform' => $value('platform', 'platforms'), + 'tier' => $value('tier', 'tier'), + ]; + } + + /** + * @param array $integration + * @param array $page + * @return array + */ + private function detail(array $integration, array $page, IntegrationCatalog $catalog): array + { + $status = $integration['status']; + $disclosures = $integration['disclosures']; + $install = $integration['install']; + $host = $integration['host'] ?? null; + $versions = $integration['minTableProVersion'] ?? []; + $replacement = is_string($status['replacement'] ?? null) ? $catalog->find($status['replacement']) : null; + $type = in_array($install['type'], $this->labelled($page, 'show.install'), true) ? $install['type'] : 'page'; + $data = $this->labelled($page, 'show.uses.data'); + + return [ + 'slug' => $integration['slug'], + 'name' => $integration['name'], + 'summary' => $integration['summary'], + 'description' => array_values(array_filter(array_map('trim', preg_split('/\n\s*\n/', $integration['description']) ?: []))), + 'tier' => $this->tier($integration), + 'archived' => $status['state'] !== 'active' ? [ + 'reason' => in_array($status['reason'] ?? null, $this->labelled($page, 'show.archived.reasons'), true) ? $status['reason'] : null, + 'note' => $this->text($status['note'] ?? null), + 'replacement' => $replacement !== null ? ['slug' => $replacement['slug'], 'name' => $replacement['name']] : null, + ] : null, + 'publisher' => ['name' => $integration['publisher']['name'], 'url' => $this->https($integration['publisher']['url'] ?? null)], + 'categories' => $this->known($integration['categories'], $this->labelled($page, 'labels.categories')), + 'platforms' => $this->platforms($integration['platforms']), + 'versions' => [ + 'mac' => $this->text($versions['macos'] ?? null), + 'ios' => $this->text($versions['ios'] ?? $versions['ipados'] ?? null), + ], + 'host' => is_array($host) ? [ + 'name' => $host['name'], + 'url' => $this->https($host['url'] ?? null), + 'minVersion' => $this->text($host['minVersion'] ?? null), + 'note' => $this->text($host['note'] ?? null), + ] : null, + 'closedSource' => $integration['closedSource'] === true, + 'license' => $this->text($integration['license'] ?? null), + 'source' => $this->https($integration['source']['url'] ?? null), + 'install' => [ + 'type' => $type, + 'url' => $this->https($install['url']), + 'command' => $type === 'homebrew' ? $this->brew($install) : null, + ], + 'surfaces' => $this->known($integration['surfaces'], $this->labelled($page, 'show.uses.surface')), + 'reads' => $this->known($disclosures['reads'] ?? [], $data), + 'writes' => $this->known($disclosures['writes'] ?? [], $data), + 'accessNote' => $this->text($disclosures['accessNote'] ?? null), + 'lowersSafeMode' => ($disclosures['lowersSafeMode'] ?? false) === true, + 'safeModeNote' => $this->text($disclosures['safeModeNote'] ?? null), + 'network' => in_array($disclosures['network'] ?? null, self::NETWORK, true) ? $disclosures['network'] : null, + 'networkNote' => $this->text($disclosures['networkNote'] ?? null), + 'account' => ($disclosures['account'] ?? false) === true, + 'payment' => in_array($disclosures['payment'] ?? null, self::PAYMENT, true) ? $disclosures['payment'] : null, + 'paymentNote' => $this->text($disclosures['paymentNote'] ?? null), + 'links' => [ + 'docs' => $this->https($integration['links']['docs'] ?? null), + 'issues' => $this->https($integration['links']['issues'] ?? $integration['links']['support'] ?? null), + 'privacy' => $this->https($integration['links']['privacy'] ?? null), + ], + 'icon' => $this->image($integration['icon']['128'] ?? null), + 'screenshots' => $this->screenshots($integration['screenshots']), + ]; + } + + /** + * @param list $screenshots + * @return list + */ + private function screenshots(array $screenshots): array + { + $shots = []; + + foreach ($screenshots as $shot) { + $image = $this->image($shot); + + if ($image !== null && is_string($shot['alt'] ?? null)) { + $shots[] = [...$image, 'alt' => $shot['alt']]; + } + } + + return $shots; + } + + /** + * @param array $integration + */ + private function tier(array $integration): string + { + // An unknown tier gets the most cautious treatment: the disclaimer and rel="ugc nofollow". + return in_array($integration['tier'], self::TIERS, true) ? $integration['tier'] : 'community'; + } + + /** + * @param list $platforms + * @return list + */ + private function platforms(array $platforms): array + { + $apps = array_map(fn(mixed $platform): ?string => is_string($platform) ? (self::PLATFORMS[$platform] ?? null) : null, $platforms); + + return array_values(array_intersect(array_unique(self::PLATFORMS), $apps)); + } + + /** + * @param array $page + * @return list + */ + private function labelled(array $page, string $key): array + { + // A value the registry adds later stays off the page until the content file labels it. + $labels = Arr::get($page, $key); + + return is_array($labels) ? array_map('strval', array_keys($labels)) : []; + } + + /** + * @param list $values + * @param list $known + * @return list + */ + private function known(array $values, array $known): array + { + return array_values(array_intersect($known, $values)); + } + + /** + * @return Icon|null + */ + private function image(mixed $image): ?array + { + if (! is_array($image) || ! is_string($image['file'] ?? null) || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{12}\.png$/', $image['file']) !== 1) { + return null; + } + + return ['src' => self::IMAGES . $image['file'], 'width' => (int) $image['width'], 'height' => (int) $image['height']]; + } + + /** + * @param array $install + */ + private function brew(array $install): ?string + { + $token = $install['token'] ?? null; + + if (! is_string($token) || preg_match('/^[a-z0-9][a-z0-9@+._-]{0,99}$/', $token) !== 1) { + return null; + } + + return ($install['kind'] ?? null) === 'cask' ? "brew install --cask {$token}" : "brew install {$token}"; + } + + private function https(mixed $url): ?string + { + if (! is_string($url) || ! str_starts_with($url, 'https://') || filter_var($url, FILTER_VALIDATE_URL) === false) { + return null; + } + + return parse_url($url, PHP_URL_USER) === null ? $url : null; + } + + private function text(mixed $value): ?string + { + return is_string($value) && trim($value) !== '' ? $value : null; + } + + private function date(mixed $value, string $locale): ?string + { + if (! is_string($value) || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { + return null; + } + + try { + $date = Carbon::createFromFormat('!Y-m-d', $value); + } catch (InvalidArgumentException) { + return null; + } + + return $date !== null && $date->format('Y-m-d') === $value ? $date->locale($locale)->isoFormat('LL') : null; + } +} diff --git a/app/Providers/SeoServiceProvider.php b/app/Providers/SeoServiceProvider.php index ae0e7ef7..36eff356 100644 --- a/app/Providers/SeoServiceProvider.php +++ b/app/Providers/SeoServiceProvider.php @@ -5,6 +5,7 @@ use App\Support\Content\ContentRepository; use App\Support\Seo\BlogPosts; use App\Support\Seo\ContentCollection; +use App\Support\Seo\IntegrationPages; use App\Support\Seo\LastModified; use App\Support\Seo\LegalPages; use App\Support\Seo\OgFonts; @@ -29,6 +30,7 @@ * 2. `ContentCollection`: features, databases and comparisons, with their hubs. * 3. `LegalPages`: privacy, terms, refund policy. * 4. `BlogPosts`: one page per post, per language it is written in. + * 5. `IntegrationPages`: one English page per entry in `integrations.json`. * * Every family is wrapped in `WithoutRetiredPaths`, so a URL the redirect map * answers is never a page, whichever family still lists it. @@ -69,6 +71,7 @@ public function register(): void new ContentCollection($content), $app->make(LegalPages::class), $app->make(BlogPosts::class), + $app->make(IntegrationPages::class), ]; return new PageRegistry(array_map( diff --git a/app/Support/Localization/LocaleSwitcher.php b/app/Support/Localization/LocaleSwitcher.php index 09f47473..ee5df7bc 100644 --- a/app/Support/Localization/LocaleSwitcher.php +++ b/app/Support/Localization/LocaleSwitcher.php @@ -36,6 +36,7 @@ final class LocaleSwitcher 'compare' => 'landing.compare.index', 'database' => 'landing.databases.index', 'feature' => 'landing.features.index', + 'integration' => 'landing.integrations.index', ]; public function __construct( diff --git a/app/Support/Seo/ContentCollection.php b/app/Support/Seo/ContentCollection.php index 582f6a0c..e0b5a2d9 100644 --- a/app/Support/Seo/ContentCollection.php +++ b/app/Support/Seo/ContentCollection.php @@ -10,7 +10,7 @@ /** * The page families built from content collections: features, databases and - * comparisons, each with its hub. + * comparisons, each with its hub, and the integrations hub. * * A page renders in a locale when `resources/data/content/{locale}/{family}/{slug}.json` * exists (`index.json` for a hub), and is indexable there unless that file @@ -37,6 +37,7 @@ final class ContentCollection implements PageFamily 'landing.features.index' => ['features', 'feature', ['resources/data/paid-features.json', 'resources/data/facts.json']], 'landing.databases.index' => ['databases', 'database', ['resources/data/engines.json']], 'landing.compare.index' => ['compare', 'compare', ['resources/data/comparisons.json']], + 'landing.integrations.index' => ['integrations', 'integration', ['resources/data/integrations.json']], ]; /** diff --git a/app/Support/Seo/IntegrationPages.php b/app/Support/Seo/IntegrationPages.php new file mode 100644 index 00000000..938e62df --- /dev/null +++ b/app/Support/Seo/IntegrationPages.php @@ -0,0 +1,63 @@ + + */ + public function entries(): array + { + $entries = []; + + foreach ($this->catalog->all() as $integration) { + $entry = $this->find(self::ROUTE, ['slug' => $integration['slug']]); + + if ($entry !== null) { + $entries[] = $entry; + } + } + + return $entries; + } + + /** + * @param array $params + */ + public function find(string $route, array $params): ?PageEntry + { + $slug = $params['slug'] ?? null; + + if ($route !== self::ROUTE || ! is_string($slug) || count($params) !== 1 || ! BlogPosts::isSlug($slug)) { + return null; + } + + $integration = $this->catalog->find($slug); + + if ($integration === null) { + return null; + } + + $locale = Locales::default(); + + return new PageEntry( + route: self::ROUTE, + params: ['slug' => $slug], + renderLocales: [$locale], + indexableLocales: ($integration['status']['state'] ?? null) === 'active' ? [$locale] : [], + sources: ['resources/data/integrations.json', "resources/data/content/{$locale}/integrations/index.json"], + ogFamily: 'integration', + ogSlug: null, + ); + } +} diff --git a/app/Support/Seo/PageEntry.php b/app/Support/Seo/PageEntry.php index e5185cd2..617cb068 100644 --- a/app/Support/Seo/PageEntry.php +++ b/app/Support/Seo/PageEntry.php @@ -22,7 +22,7 @@ * @param list $renderLocales locales in which the URL answers 200 * @param list $indexableLocales subset of $renderLocales: index, sitemap, hreflang * @param list $sources repository paths whose last change is the page's lastmod - * @param string $ogFamily site | blog | database | compare | feature + * @param string $ogFamily site | blog | database | compare | feature | integration */ public function __construct( public string $route, diff --git a/docs/architecture.md b/docs/architecture.md index da4e8603..6d9f4a25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,7 @@ database and no credentials: every page is built from markdown in /features /features/{slug} /databases /{database}-client /{database}-gui /compare /compare/{slug} +/integrations /integrations/{slug} from resources/data/integrations.json; detail pages in English only /privacy /terms /refund-policy /blog /blog/{slug} markdown in resources/blog /blog/feed.xml the English posts as Atom, linked from the head of blog pages @@ -102,10 +103,11 @@ the two copies are compared by its `LanguageRegionsConfigTest`. ``` resources/data/*.json locale-neutral facts: locales, platforms, engines, pricing, paid-features, facts, sponsors, comparisons, - assets, redirects + assets, redirects, integrations resources/data/content/{locale}/ page copy: home, download, ios, pricing, faq, blog, - legal, engines, paid-features, and one file per - page under features/, databases/, compare/ + legal, engines, paid-features, one file per page + under features/, databases/, compare/, and + integrations/index.json resources/data/legal/{locale}/*.md privacy, terms, refund policy resources/blog/*.md posts (English); resources/blog/vi/*.md their translations lang/{locale}/*.php OG card labels and the static error pages @@ -124,6 +126,30 @@ A database, comparison or feature page exists when its slug is in the constant class in `app/Support/Content/Slugs/` (the route constraint) and its content file exists in a locale. `LocaleRoutingTest` holds the two lists together. +An integration page is the exception: its route takes any slug-shaped value, +and it exists when `resources/data/integrations.json` has that slug, so a sync +adds or removes pages without a PHP change or a route cache rebuild. + +### Integrations + +`resources/data/integrations.json` and the PNGs in `public/images/integrations/` +are a byte copy of a [TableProApp/integrations](https://github.com/TableProApp/integrations) +release, brought in by the pull request `integrations-sync.yml` opens. Nothing +here edits them. Icons and screenshots are PNG only, so no third-party SVG is +served from this origin, and every text field renders as React text. + +- `/integrations` renders in every locale. English lists every active entry + with the registry's `summary`. Another locale lists an entry only while + `content/{locale}/integrations/index.json` → `taglines` has it and + `content/en/…` → `taglines` still equals the summary, so a changed summary + hides the old translation until it is updated. +- `/integrations/{slug}` renders in English only (`IntegrationPages`), and + archived entries keep their page with `noindex`. Other locales answer 404 + with a link to the English page. +- The hub filters with one function in the server render and the browser + (`components/integrations/model.ts`), so the GET form works without + JavaScript and the canonical stays `/integrations`. + ### Retired URLs Redirects and 410s are not routes. `App\Http\Middleware\CanonicalizeRequest` diff --git a/docs/rebuild/design/design-system.md b/docs/rebuild/design/design-system.md index 579b092e..3d430c63 100644 --- a/docs/rebuild/design/design-system.md +++ b/docs/rebuild/design/design-system.md @@ -594,6 +594,8 @@ Repo key: | `ComparisonTable` | P | **Replace** | `landing/compare-table.tsx`, Compare page tables | One competitor per page, sourced and dated. Removed from the homepage | | `EngineTable` | P | **Replace** | `landing/database-grid.tsx` (tiles, filter, 2-D arrow keys) | Grouped table on the hub | | `EngineList` | P | **New** | — | Compact named list on the homepage | +| `IntegrationFilters` | P | **New** | — | `/integrations` only: a `role="search"` GET form of native controls (search, category, TablePro app, type). Results update as you type once the page has hydrated | +| `IntegrationList` | P | **New** | — | Integrations as ruled rows, rail to rail: mark, name, publisher, type, tagline. Not a card grid | | `DatabaseMark` | P | **Refactor** | `ui/database-mark.tsx` | Original colours, no `grayscale` / `opacity-40` / `invert`. Uses `iconDark` from data or a light tile | | `SponsorList` | P | **Refactor** | `landing/sponsor-row.tsx` | The 4 verified sponsors only. Each link named | | `ProseArticle` | P | **Refactor** | `.blog-article` CSS, `ui/prose-block.tsx` | Blog and legal. Body in `--foreground`. Permalink glyph kept out of the heading's accessible name | @@ -658,6 +660,7 @@ Repo key: - Opens in the same tab by default and shows `↗` (`aria-hidden`). - Docs links on Vietnamese pages read "Tài liệu (tiếng Anh)" because the docs are English-only. - When `target="_blank"` is unavoidable, add an sr-only "(opens in a new tab)" / "(mở trong thẻ mới)". + - A link from a Community integration's data (install, source, publisher, help) carries `rel="ugc nofollow"`. Official and Partner links carry none. - **Cross-app** (public ↔ `/account`, `/checkout`, `/thank-you`): - Always a plain ``, never an Inertia ``, because the other app answers with its own HTML and asset version. - Locale-aware: `/vi/...` public targets from Vietnamese account screens. @@ -984,6 +987,7 @@ One shared file, `components/shared/theme-control.tsx` (architecture §1.10 and - In dark mode use `iconDark` from data when the vendor publishes an official variant. Otherwise render the mark on a 32px `#ffffff` tile with 6px radius. - Two-letter monogram fallback: `mono` 13 on `--surface`, 1px `--rule`. - Always next to the visible name; the mark is `alt=""`. +- Integration icons follow the same rules, as PNG only: no third-party SVG is served from the site's origin. **SponsorList:** @@ -1767,6 +1771,35 @@ Card (surface) Newsletter Checkbox consent · [Subscribe] secondary · inline re None of these pages is indexed (`noindex`). The single robots tag comes from the page. +### 8.21 Integrations hub (`/integrations`, every locale) + +``` +PageHeader H1 · lead · Build an integration ↗ (docs) + IntegrationFilters: Search · Category · TablePro app · Type · [Show results] +p role=status "{count} integrations" +IntegrationList one row per entry, Official first; or EmptyState "No integrations match" · Clear filters → +#publish How to submit ↗ · Listing policy ↗ (the registry on GitHub) · owners' names and logos line +``` + +- **The one page with search and filters**, by the owner's decision of 2026-10-10. §8.9's "no filter" rule still holds for the blog. +- One filter function serves the server render and the browser. Without JavaScript the form reloads the page with the query; with it, results follow each change and the address bar is replaced, never pushed. The canonical stays `/integrations`. +- Another language lists an entry only once its tagline there is translated from the current English summary. Each row links the English detail page with "(English)". + +### 8.22 Integration page (`/integrations/{slug}`, English only) + +``` +Breadcrumbs Integrations / {name} +cols 1-8 mark 40 · H1 {name} · lead {summary} · By {publisher} · type Badge · Closed source · Archived + Callout warning when archived · install action by type (button ↗, or a Homebrew CodeBlock) · Source code ↗ +cols 9-12 Card "Details": Publisher · Category · Works with · TablePro app · License · Listed · Last checked +#about description paragraphs, as text +#screenshots CellGrid, 2 columns from 768 +#uses "What it uses": TablePro features (docs ↗) · Reads · Changes · Callouts for lowered Safe Mode, internet, account, cost +#support Report a problem ↗ (registry issue form) · Get help from {publisher} ↗ · Documentation ↗ · Callout when not Official +``` + +`/vi/integrations/{slug}` and the other languages answer 404 with a link to the English page. + --- ## 9. Sharing between the two repositories diff --git a/docs/rebuild/design/positioning.md b/docs/rebuild/design/positioning.md index 170cd3e0..7a0b7ce8 100644 --- a/docs/rebuild/design/positioning.md +++ b/docs/rebuild/design/positioning.md @@ -531,7 +531,7 @@ move to their hubs, so no page becomes undiscoverable. There is no footer blurb. | Group EN / VI | Links EN | Links VI | Targets | |---|---|---|---| -| Product / Sản phẩm | Features · Databases · iPhone & iPad · Pricing · Download · Compare | Tính năng · Cơ sở dữ liệu · iPhone và iPad · Bảng giá · Tải về · So sánh | `/features`, `/databases`, the platform link (§10.1), `/pricing`, `/download`, `/compare` | +| Product / Sản phẩm | Features · Databases · Integrations · iPhone & iPad · Pricing · Download · Compare | Tính năng · Cơ sở dữ liệu · Tích hợp · iPhone và iPad · Bảng giá · Tải về · So sánh | `/features`, `/databases`, `/integrations`, the platform link (§10.1), `/pricing`, `/download`, `/compare` | | Resources / Tài nguyên | Documentation ↗ · Changelog ↗ · Blog · FAQ · About · Source code ↗ · Report a bug ↗ | Tài liệu (tiếng Anh) ↗ · Changelog (tiếng Anh) ↗ · Blog · Câu hỏi thường gặp · Giới thiệu · Mã nguồn ↗ · Báo lỗi ↗ | Docs, docs `/changelog`, `/blog`, `/faq`, `/about`, the GitHub repository, GitHub issues | | Support / Hỗ trợ | Account · Troubleshooting ↗ · Email support · Live chat | Tài khoản · Khắc phục sự cố (tiếng Anh) ↗ · Gửi email hỗ trợ · Chat trực tuyến | `/account?locale=`; the docs troubleshooting page; `mailto:` the support address in `facts.json`; a button that loads Crisp only when clicked | | Community / Cộng đồng | GitHub Discussions · Discord · X · Sponsor TablePro ↗ | GitHub Discussions · Discord · X · Telegram · Tài trợ TablePro ↗ | `facts.json` `links`. Brand names stay as they are. Telegram is a Vietnamese group, so only Vietnamese pages link it. "Sponsor TablePro" goes to GitHub Sponsors | @@ -643,6 +643,7 @@ toàn" appear. Prose keeps "Safe Mode". |---|---| | Features · Databases · Pricing · Docs · Documentation · Blog · Account · Download | Tính năng · Cơ sở dữ liệu · Bảng giá · Tài liệu · Tài liệu · Blog · Tài khoản · Tải về | | FAQ · Comparisons · Support · Changelog | Câu hỏi thường gặp · So sánh · Hỗ trợ · Changelog | +| Integrations | Tích hợp | | Language · Theme · Light · Dark · System | Ngôn ngữ · Giao diện · Sáng · Tối · Theo hệ thống | | Privacy · Terms · Refund policy · Cookie settings | Quyền riêng tư · Điều khoản sử dụng · Chính sách hoàn tiền · Cài đặt cookie | | (English) label for English-only content | (tiếng Anh) | diff --git a/docs/rebuild/design/sitemap.md b/docs/rebuild/design/sitemap.md index 1b284629..cd3cf69a 100644 --- a/docs/rebuild/design/sitemap.md +++ b/docs/rebuild/design/sitemap.md @@ -305,7 +305,7 @@ The footer replaces the 11 comparison links and 26 database links with hubs, and | Group (EN / VI) | Links | |---|---| -| Product / Sản phẩm | Features · Databases · iPhone & iPad · Pricing · Download · Compare | +| Product / Sản phẩm | Features · Databases · Integrations · iPhone & iPad · Pricing · Download · Compare | | Resources / Tài nguyên | Documentation ↗ (VI: "Tài liệu (tiếng Anh)") · Changelog ↗ (docs `/changelog`) · Blog · FAQ · About · Source code ↗ (GitHub) · Report a bug ↗ (GitHub issues) | | Support / Hỗ trợ | Account (`/account?locale=`) · Troubleshooting ↗ (docs `/troubleshooting`) · Email (`hello@tablepro.app`) · Live chat (a button that loads Crisp only on click) | | Community / Cộng đồng | GitHub Discussions · Discord · X · Telegram (Vietnamese pages only: the group is in Vietnamese) · Sponsor TablePro (GitHub Sponsors). The repository is linked once, as "Source code" under Resources | diff --git a/resources/data/content/de/features/ai-mcp.json b/resources/data/content/de/features/ai-mcp.json index b97c93d4..3557c3c1 100644 --- a/resources/data/content/de/features/ai-mcp.json +++ b/resources/data/content/de/features/ai-mcp.json @@ -91,6 +91,12 @@ "paragraphs": [ "AppleScript kann sich verbinden, eine Abfrage ausführen und eine Tabelle öffnen, im Rahmen der Stufe für External Clients und des Safe Mode der Verbindung. tablepro://-Links öffnen eine Verbindung, eine Tabelle oder einen Abfrage-Tab; eine Abfrage in einem Link wird nach deiner Bestätigung eingefügt und nie von selbst ausgeführt. Datenbank-URLs wie postgresql:// öffnen sich als Verbindungen.", "Der Shell-Befehl tablepro öffnet diese URLs und Dateien in der App; Abfragen aus dem Terminal führt er nicht aus. Die offizielle Raycast-Erweiterung durchsucht deine Verbindungen, Tabellen und deinen Abfrageverlauf und führt Abfragen über die gekoppelte App aus." + ], + "links": [ + { + "label": "Integrationen", + "href": "/integrations" + } ] }, { diff --git a/resources/data/content/de/integrations/index.json b/resources/data/content/de/integrations/index.json new file mode 100644 index 00000000..bf3d6611 --- /dev/null +++ b/resources/data/content/de/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "Integrationen", + "description": "Apps und Werkzeuge, die mit TablePro zusammenarbeiten: KI-Clients, Editoren, Automatisierung und Befehlszeile, von TablePro oder von anderen entwickelt." + }, + "header": { + "title": "Integrationen", + "lead": "Apps und Werkzeuge, die mit TablePro zusammenarbeiten.", + "build": "Integration entwickeln" + }, + "filters": { + "label": "Integrationen filtern", + "search": "Suchen", + "category": { + "label": "Kategorie", + "all": "Alle Kategorien" + }, + "platform": { + "label": "TablePro-App", + "all": "Alle" + }, + "tier": { + "label": "Typ", + "all": "Alle Typen" + }, + "submit": "Ergebnisse anzeigen", + "count": { + "one": "{count} Integration", + "other": "{count} Integrationen" + }, + "empty": "Keine Integration passt zu diesen Filtern.", + "clear": "Filter zurücksetzen" + }, + "publish": { + "title": "Deine Integration eintragen", + "guide": "Anleitung zum Einreichen", + "policy": "Aufnahmerichtlinie" + }, + "notice": "Namen und Logos gehören ihren Inhabern. Nur offizielle Integrationen werden von TablePro entwickelt.", + "labels": { + "by": "Von {publisher}", + "closedSource": "Nicht quelloffen", + "tiers": { + "official": "Offiziell", + "partner": "Partner", + "community": "Community" + }, + "categories": { + "ai-clients": "KI-Clients", + "automation": "Automatisierung", + "cloud-hosting": "Cloud-Hosting", + "command-line": "Befehlszeile", + "editors": "Editoren", + "launchers": "Launcher", + "local-development": "Lokale Entwicklung", + "other": "Sonstiges" + } + }, + "show": { + "seoTitle": "Integration für {name}", + "install": { + "built-in": "In TablePro einrichten", + "ships-with-host": "Mit {host} einrichten", + "raycast-store": "Aus dem Raycast Store installieren", + "vscode-marketplace": "Aus dem Visual Studio Marketplace installieren", + "github-release": "Von GitHub herunterladen", + "homebrew": "Mit Homebrew installieren", + "app-store": "Im App Store ansehen", + "alfred-gallery": "Aus der Alfred Gallery installieren", + "page": "Zur Seite von {name}" + }, + "source": "Quellcode", + "details": { + "title": "Informationen", + "publisher": "Herausgeber", + "category": "Kategorie", + "host": "Funktioniert mit", + "hostVersion": "{host} {version} oder neuer", + "apps": "TablePro-App", + "appVersion": "{app} {version} oder neuer", + "license": "Lizenz", + "added": "Aufgenommen", + "verified": "Zuletzt geprüft" + }, + "about": "Beschreibung", + "screenshots": "Bildschirmfotos", + "uses": { + "title": "Was die Integration nutzt", + "surfaces": "TablePro-Funktionen", + "reads": "Liest", + "writes": "Ändert", + "none": "Nichts", + "data": { + "connections": "Verbindungsliste", + "schema": "Schemas", + "data": "Tabellendaten" + }, + "surface": { + "url-scheme": { + "label": "tablepro://-Links", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "MCP-Server", + "docs": "/features/mcp" + }, + "pairing": { + "label": "Kopplung", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "Datenbank-URLs", + "docs": "/connections/urls" + }, + "cli": { + "label": "Befehl tablepro", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "Kurzbefehl-Aktionen", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Senkt die Safe-Mode-Stufe", + "safeModeLink": "Safe-Mode-Stufen", + "internet": "Sendet Daten über das Internet", + "account": "Erfordert ein Konto bei einem anderen Dienst", + "privacy": "Datenschutzerklärung", + "payment": { + "optional": "Kostenlos, mit kostenpflichtigen Optionen", + "paid": "Kostenpflichtig" + } + }, + "support": { + "title": "Hilfe", + "report": "Problem mit diesem Eintrag melden", + "help": "Hilfe bei {publisher} erhalten", + "docs": "Dokumentation" + }, + "disclaimer": "{name} wird von {publisher} entwickelt, nicht von TablePro. TablePro führt die Integration hier auf, leistet dafür aber keinen Support.", + "archived": { + "badge": "Archiviert", + "since": "Archiviert am {date}.", + "reasons": { + "unmaintained": "Sie wird nicht mehr gepflegt.", + "broken": "Sie funktioniert nicht mehr mit TablePro.", + "source-archived": "Ihr Quellcode-Repository ist archiviert.", + "superseded": "Sie wurde durch {name} ersetzt.", + "author-request": "Ihr Autor hat um die Archivierung gebeten.", + "policy": "Sie erfüllt die Aufnahmerichtlinie nicht mehr." + } + } + }, + "taglines": { + "claude-code": "Verbinde Claude Code über den in TablePro integrierten MCP-Server mit deinen Datenbanken.", + "claude-desktop": "Verbinde Claude Desktop über den in TablePro integrierten MCP-Server mit deinen Datenbanken.", + "command-line": "Öffne Datenbank-URLs und SQL-Dateien mit dem Befehl tablepro aus dem Terminal in TablePro.", + "cursor": "Verbinde den Agenten von Cursor über den in TablePro integrierten MCP-Server mit deinen Datenbanken.", + "shortcuts": "Füge mit der Kurzbefehle-App auf iPhone und iPad Zeilen in eine Tabelle ein und öffne eine Verbindung.", + "zed": "Verbinde den Agenten von Zed über den in TablePro integrierten MCP-Server mit deinen Datenbanken." + } +} diff --git a/resources/data/content/en/features/ai-mcp.json b/resources/data/content/en/features/ai-mcp.json index 7fa67565..3aede25c 100644 --- a/resources/data/content/en/features/ai-mcp.json +++ b/resources/data/content/en/features/ai-mcp.json @@ -84,7 +84,8 @@ "paragraphs": [ "AppleScript can connect, run a query and open a table, within the connection’s External Clients level and Safe Mode. tablepro:// links open a connection, a table or a query tab; a query in a link is filled in after you confirm and never runs by itself. Database URLs such as postgresql:// open as connections.", "The tablepro shell command opens those URLs and files in the app; it does not run queries from the terminal. The official Raycast extension searches your connections, tables and query history, and runs queries through the paired app." - ] + ], + "links": [{ "label": "Integrations", "href": "/integrations" }] }, { "id": "privacy", diff --git a/resources/data/content/en/integrations/index.json b/resources/data/content/en/integrations/index.json new file mode 100644 index 00000000..a96a090e --- /dev/null +++ b/resources/data/content/en/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "Integrations", + "description": "Apps and tools that work with TablePro: AI clients, editors, automation and the command line, built by TablePro or by others." + }, + "header": { + "title": "Integrations", + "lead": "Apps and tools that work with TablePro.", + "build": "Build an integration" + }, + "filters": { + "label": "Filter integrations", + "search": "Search", + "category": { + "label": "Category", + "all": "All categories" + }, + "platform": { + "label": "TablePro app", + "all": "Any" + }, + "tier": { + "label": "Type", + "all": "All types" + }, + "submit": "Show results", + "count": { + "one": "{count} integration", + "other": "{count} integrations" + }, + "empty": "No integrations match these filters.", + "clear": "Clear filters" + }, + "publish": { + "title": "List your integration", + "guide": "How to submit", + "policy": "Listing policy" + }, + "notice": "Names and logos belong to their owners. Only Official integrations are built by TablePro.", + "labels": { + "by": "By {publisher}", + "closedSource": "Closed source", + "tiers": { + "official": "Official", + "partner": "Partner", + "community": "Community" + }, + "categories": { + "ai-clients": "AI clients", + "automation": "Automation", + "cloud-hosting": "Cloud hosting", + "command-line": "Command line", + "editors": "Editors", + "launchers": "Launchers", + "local-development": "Local development", + "other": "Other" + } + }, + "show": { + "seoTitle": "{name} integration", + "install": { + "built-in": "Set up in TablePro", + "ships-with-host": "Set up with {host}", + "raycast-store": "Install from the Raycast Store", + "vscode-marketplace": "Install from the Visual Studio Marketplace", + "github-release": "Download from GitHub", + "homebrew": "Install with Homebrew", + "app-store": "View on the App Store", + "alfred-gallery": "Install from the Alfred Gallery", + "page": "Get {name}" + }, + "source": "Source code", + "details": { + "title": "Details", + "publisher": "Publisher", + "category": "Category", + "host": "Works with", + "hostVersion": "{host} {version} or later", + "apps": "TablePro app", + "appVersion": "{app} {version} or later", + "license": "License", + "added": "Listed", + "verified": "Last checked" + }, + "about": "About", + "screenshots": "Screenshots", + "uses": { + "title": "What it uses", + "surfaces": "TablePro features", + "reads": "Reads", + "writes": "Changes", + "none": "Nothing", + "data": { + "connections": "Connection list", + "schema": "Schemas", + "data": "Table data" + }, + "surface": { + "url-scheme": { + "label": "tablepro:// links", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "MCP server", + "docs": "/features/mcp" + }, + "pairing": { + "label": "Pairing", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "Database URLs", + "docs": "/connections/urls" + }, + "cli": { + "label": "tablepro command", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "Shortcuts actions", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Lowers Safe Mode", + "safeModeLink": "Safe Mode levels", + "internet": "Sends data over the internet", + "account": "Needs an account with another service", + "privacy": "Privacy policy", + "payment": { + "optional": "Free, with paid options", + "paid": "Paid" + } + }, + "support": { + "title": "Help", + "report": "Report a problem with this listing", + "help": "Get help from {publisher}", + "docs": "Documentation" + }, + "disclaimer": "{name} is built by {publisher}, not by TablePro. TablePro lists it here but does not support it.", + "archived": { + "badge": "Archived", + "since": "Archived on {date}.", + "reasons": { + "unmaintained": "It is no longer maintained.", + "broken": "It no longer works with TablePro.", + "source-archived": "Its source repository is archived.", + "superseded": "It was replaced by {name}.", + "author-request": "Its author asked for it to be archived.", + "policy": "It no longer meets the listing policy." + } + } + }, + "taglines": { + "claude-code": "Connect Claude Code to your databases through the MCP server built into TablePro.", + "claude-desktop": "Connect Claude Desktop to your databases through the MCP server built into TablePro.", + "command-line": "Open database URLs and SQL files in TablePro from Terminal with the tablepro command.", + "cursor": "Connect Cursor's agent to your databases through the MCP server built into TablePro.", + "shortcuts": "Add rows to a table and open a connection from Shortcuts on iPhone and iPad.", + "zed": "Connect Zed's agent to your databases through the MCP server built into TablePro." + } +} diff --git a/resources/data/content/es/features/ai-mcp.json b/resources/data/content/es/features/ai-mcp.json index 580ab0fe..c1870ac8 100644 --- a/resources/data/content/es/features/ai-mcp.json +++ b/resources/data/content/es/features/ai-mcp.json @@ -91,6 +91,12 @@ "paragraphs": [ "AppleScript puede conectarse, ejecutar una consulta y abrir una tabla, dentro del nivel de External Clients y del Safe Mode de la conexión. Los enlaces tablepro:// abren una conexión, una tabla o una pestaña de consulta; la consulta de un enlace se rellena después de que confirmes y nunca se ejecuta sola. Las URL de bases de datos como postgresql:// se abren como conexiones.", "El comando de shell tablepro abre esas URL y archivos en la app; no ejecuta consultas desde el terminal. La extensión oficial de Raycast busca en tus conexiones, tablas e historial de consultas, y ejecuta consultas a través de la app emparejada." + ], + "links": [ + { + "label": "Integraciones", + "href": "/integrations" + } ] }, { diff --git a/resources/data/content/es/integrations/index.json b/resources/data/content/es/integrations/index.json new file mode 100644 index 00000000..bf7568d3 --- /dev/null +++ b/resources/data/content/es/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "Integraciones", + "description": "Apps y herramientas que funcionan con TablePro: clientes de IA, editores, automatización y línea de comandos, de TablePro o de terceros." + }, + "header": { + "title": "Integraciones", + "lead": "Apps y herramientas que funcionan con TablePro.", + "build": "Crear una integración" + }, + "filters": { + "label": "Filtrar integraciones", + "search": "Buscar", + "category": { + "label": "Categoría", + "all": "Todas las categorías" + }, + "platform": { + "label": "App de TablePro", + "all": "Cualquiera" + }, + "tier": { + "label": "Tipo", + "all": "Todos los tipos" + }, + "submit": "Mostrar resultados", + "count": { + "one": "{count} integración", + "other": "{count} integraciones" + }, + "empty": "Ninguna integración coincide con estos filtros.", + "clear": "Borrar filtros" + }, + "publish": { + "title": "Añade tu integración", + "guide": "Cómo enviarla", + "policy": "Política del directorio" + }, + "notice": "Los nombres y logotipos pertenecen a sus propietarios. TablePro solo desarrolla las integraciones de tipo Oficial.", + "labels": { + "by": "Por {publisher}", + "closedSource": "Código cerrado", + "tiers": { + "official": "Oficial", + "partner": "Socio", + "community": "Comunidad" + }, + "categories": { + "ai-clients": "Clientes de IA", + "automation": "Automatización", + "cloud-hosting": "Alojamiento en la nube", + "command-line": "Línea de comandos", + "editors": "Editores", + "launchers": "Lanzadores", + "local-development": "Desarrollo local", + "other": "Otros" + } + }, + "show": { + "seoTitle": "Integración con {name}", + "install": { + "built-in": "Configurar en TablePro", + "ships-with-host": "Configurar con {host}", + "raycast-store": "Instalar desde la Raycast Store", + "vscode-marketplace": "Instalar desde Visual Studio Marketplace", + "github-release": "Descargar desde GitHub", + "homebrew": "Instalar con Homebrew", + "app-store": "Ver en la App Store", + "alfred-gallery": "Instalar desde la Alfred Gallery", + "page": "Obtener {name}" + }, + "source": "Código fuente", + "details": { + "title": "Detalles", + "publisher": "Desarrollador", + "category": "Categoría", + "host": "Funciona con", + "hostVersion": "{host} {version} o posterior", + "apps": "App de TablePro", + "appVersion": "{app} {version} o posterior", + "license": "Licencia", + "added": "Añadida", + "verified": "Última comprobación" + }, + "about": "Acerca de", + "screenshots": "Capturas de pantalla", + "uses": { + "title": "Qué usa", + "surfaces": "Funciones de TablePro", + "reads": "Lee", + "writes": "Modifica", + "none": "Nada", + "data": { + "connections": "Lista de conexiones", + "schema": "Esquemas", + "data": "Datos de tablas" + }, + "surface": { + "url-scheme": { + "label": "Enlaces tablepro://", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "Servidor MCP", + "docs": "/features/mcp" + }, + "pairing": { + "label": "Emparejamiento", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "URL de bases de datos", + "docs": "/connections/urls" + }, + "cli": { + "label": "Comando tablepro", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "Acciones de Atajos", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Baja el nivel de Safe Mode", + "safeModeLink": "Niveles de Safe Mode", + "internet": "Envía datos por internet", + "account": "Requiere una cuenta en otro servicio", + "privacy": "Política de privacidad", + "payment": { + "optional": "Gratis, con opciones de pago", + "paid": "De pago" + } + }, + "support": { + "title": "Ayuda", + "report": "Informar de un problema con esta ficha", + "help": "Obtener ayuda de {publisher}", + "docs": "Documentación" + }, + "disclaimer": "{name} lo desarrolla {publisher}, no TablePro. TablePro lo incluye en el directorio, pero no ofrece soporte para él.", + "archived": { + "badge": "Archivada", + "since": "Archivada el {date}.", + "reasons": { + "unmaintained": "Ya no tiene mantenimiento.", + "broken": "Ya no funciona con TablePro.", + "source-archived": "Su repositorio de código está archivado.", + "superseded": "La sustituye {name}.", + "author-request": "Su autor pidió archivarla.", + "policy": "Ya no cumple la política del directorio." + } + } + }, + "taglines": { + "claude-code": "Conecta Claude Code a tus bases de datos mediante el servidor MCP integrado en TablePro.", + "claude-desktop": "Conecta Claude Desktop a tus bases de datos mediante el servidor MCP integrado en TablePro.", + "command-line": "Abre URL de bases de datos y archivos SQL en TablePro desde Terminal con el comando tablepro.", + "cursor": "Conecta el agente de Cursor a tus bases de datos mediante el servidor MCP integrado en TablePro.", + "shortcuts": "Añade filas a una tabla y abre una conexión desde Atajos en iPhone y iPad.", + "zed": "Conecta el agente de Zed a tus bases de datos mediante el servidor MCP integrado en TablePro." + } +} diff --git a/resources/data/content/fr/features/ai-mcp.json b/resources/data/content/fr/features/ai-mcp.json index 0405becd..37b57393 100644 --- a/resources/data/content/fr/features/ai-mcp.json +++ b/resources/data/content/fr/features/ai-mcp.json @@ -91,6 +91,12 @@ "paragraphs": [ "AppleScript peut se connecter, exécuter une requête et ouvrir une table, dans les limites du niveau External Clients et du Safe Mode de la connexion. Les liens tablepro:// ouvrent une connexion, une table ou un onglet de requête ; une requête contenue dans un lien est renseignée après confirmation et ne s’exécute jamais d’elle-même. Les URL de bases de données comme postgresql:// s’ouvrent comme des connexions.", "La commande shell tablepro ouvre ces URL et ces fichiers dans l’application ; elle n’exécute pas de requêtes depuis le terminal. L’extension officielle Raycast recherche dans vos connexions, vos tables et votre historique de requêtes, et exécute des requêtes via l’application associée." + ], + "links": [ + { + "label": "Intégrations", + "href": "/integrations" + } ] }, { diff --git a/resources/data/content/fr/integrations/index.json b/resources/data/content/fr/integrations/index.json new file mode 100644 index 00000000..a1dec7bd --- /dev/null +++ b/resources/data/content/fr/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "Intégrations", + "description": "Applications et outils compatibles avec TablePro : clients IA, éditeurs, automatisation et ligne de commande, créés par TablePro ou par d’autres." + }, + "header": { + "title": "Intégrations", + "lead": "Applications et outils compatibles avec TablePro.", + "build": "Créer une intégration" + }, + "filters": { + "label": "Filtrer les intégrations", + "search": "Rechercher", + "category": { + "label": "Catégorie", + "all": "Toutes les catégories" + }, + "platform": { + "label": "Application TablePro", + "all": "Toutes" + }, + "tier": { + "label": "Type", + "all": "Tous les types" + }, + "submit": "Afficher les résultats", + "count": { + "one": "{count} intégration", + "other": "{count} intégrations" + }, + "empty": "Aucune intégration ne correspond à ces filtres.", + "clear": "Effacer les filtres" + }, + "publish": { + "title": "Référencez votre intégration", + "guide": "Comment la soumettre", + "policy": "Politique de référencement" + }, + "notice": "Les noms et logos appartiennent à leurs propriétaires. Seules les intégrations officielles sont créées par TablePro.", + "labels": { + "by": "Par {publisher}", + "closedSource": "Code source fermé", + "tiers": { + "official": "Officielle", + "partner": "Partenaire", + "community": "Communauté" + }, + "categories": { + "ai-clients": "Clients IA", + "automation": "Automatisation", + "cloud-hosting": "Hébergement cloud", + "command-line": "Ligne de commande", + "editors": "Éditeurs de code", + "launchers": "Lanceurs", + "local-development": "Développement local", + "other": "Autres" + } + }, + "show": { + "seoTitle": "Intégration {name}", + "install": { + "built-in": "Configurer dans TablePro", + "ships-with-host": "Configurer avec {host}", + "raycast-store": "Installer depuis le Raycast Store", + "vscode-marketplace": "Installer depuis le Visual Studio Marketplace", + "github-release": "Télécharger depuis GitHub", + "homebrew": "Installer avec Homebrew", + "app-store": "Voir sur l’App Store", + "alfred-gallery": "Installer depuis l’Alfred Gallery", + "page": "Obtenir {name}" + }, + "source": "Code source", + "details": { + "title": "Détails", + "publisher": "Éditeur", + "category": "Catégorie", + "host": "Fonctionne avec", + "hostVersion": "{host} {version} ou version ultérieure", + "apps": "Application TablePro", + "appVersion": "{app} {version} ou version ultérieure", + "license": "Licence", + "added": "Date d’ajout", + "verified": "Dernière vérification" + }, + "about": "À propos", + "screenshots": "Captures d’écran", + "uses": { + "title": "Ce que l’intégration utilise", + "surfaces": "Fonctionnalités de TablePro", + "reads": "Lit", + "writes": "Modifie", + "none": "Rien", + "data": { + "connections": "Liste des connexions", + "schema": "Schémas", + "data": "Données des tables" + }, + "surface": { + "url-scheme": { + "label": "Liens tablepro://", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "Serveur MCP", + "docs": "/features/mcp" + }, + "pairing": { + "label": "Appairage", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "URL de bases de données", + "docs": "/connections/urls" + }, + "cli": { + "label": "Commande tablepro", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "Actions Raccourcis", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Abaisse le niveau de Safe Mode", + "safeModeLink": "Niveaux de Safe Mode", + "internet": "Envoie des données sur Internet", + "account": "Nécessite un compte auprès d’un autre service", + "privacy": "Politique de confidentialité", + "payment": { + "optional": "Gratuit, avec des options payantes", + "paid": "Payant" + } + }, + "support": { + "title": "Aide", + "report": "Signaler un problème avec cette fiche", + "help": "Obtenir de l’aide auprès de {publisher}", + "docs": "Documentation" + }, + "disclaimer": "{name} est développé par {publisher}, et non par TablePro. TablePro référence cette intégration mais n’en assure pas l’assistance.", + "archived": { + "badge": "Archivée", + "since": "Archivée le {date}.", + "reasons": { + "unmaintained": "Elle n’est plus maintenue.", + "broken": "Elle ne fonctionne plus avec TablePro.", + "source-archived": "Son dépôt de code source est archivé.", + "superseded": "Elle a été remplacée par {name}.", + "author-request": "Son auteur a demandé son archivage.", + "policy": "Elle ne respecte plus la politique de référencement." + } + } + }, + "taglines": { + "claude-code": "Connectez Claude Code à vos bases de données via le serveur MCP intégré à TablePro.", + "claude-desktop": "Connectez Claude Desktop à vos bases de données via le serveur MCP intégré à TablePro.", + "command-line": "Ouvrez des URL de bases de données et des fichiers SQL dans TablePro depuis le Terminal avec la commande tablepro.", + "cursor": "Connectez l’agent de Cursor à vos bases de données via le serveur MCP intégré à TablePro.", + "shortcuts": "Ajoutez des lignes à une table et ouvrez une connexion depuis l’app Raccourcis sur iPhone et iPad.", + "zed": "Connectez l’agent de Zed à vos bases de données via le serveur MCP intégré à TablePro." + } +} diff --git a/resources/data/content/id/features/ai-mcp.json b/resources/data/content/id/features/ai-mcp.json index 8c25ed9b..45c4628a 100644 --- a/resources/data/content/id/features/ai-mcp.json +++ b/resources/data/content/id/features/ai-mcp.json @@ -91,6 +91,12 @@ "paragraphs": [ "AppleScript dapat terhubung, menjalankan kueri, dan membuka tabel, sesuai level External Clients serta Safe Mode koneksi. Tautan tablepro:// membuka koneksi, tabel, atau tab kueri; kueri dalam tautan diisikan setelah Anda mengonfirmasi dan tidak pernah berjalan sendiri. URL database seperti postgresql:// terbuka sebagai koneksi.", "Perintah shell tablepro membuka URL tersebut dan file dalam aplikasi; tidak menjalankan kueri dari terminal. Ekstensi resmi Raycast mencari koneksi, tabel, dan riwayat kueri, serta menjalankan kueri melalui aplikasi yang dipasangkan." + ], + "links": [ + { + "label": "Integrasi", + "href": "/integrations" + } ] }, { diff --git a/resources/data/content/id/integrations/index.json b/resources/data/content/id/integrations/index.json new file mode 100644 index 00000000..8af0e1aa --- /dev/null +++ b/resources/data/content/id/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "Integrasi", + "description": "Aplikasi dan alat yang bekerja dengan TablePro: klien AI, editor, otomatisasi, dan baris perintah, dibuat oleh TablePro atau pihak lain." + }, + "header": { + "title": "Integrasi", + "lead": "Aplikasi dan alat yang bekerja dengan TablePro.", + "build": "Buat integrasi" + }, + "filters": { + "label": "Filter integrasi", + "search": "Cari", + "category": { + "label": "Kategori", + "all": "Semua kategori" + }, + "platform": { + "label": "Aplikasi TablePro", + "all": "Semua" + }, + "tier": { + "label": "Jenis", + "all": "Semua jenis" + }, + "submit": "Tampilkan hasil", + "count": { + "one": "{count} integrasi", + "other": "{count} integrasi" + }, + "empty": "Tidak ada integrasi yang cocok dengan filter ini.", + "clear": "Hapus filter" + }, + "publish": { + "title": "Cantumkan integrasi Anda", + "guide": "Cara mengirim", + "policy": "Kebijakan pencantuman" + }, + "notice": "Nama dan logo adalah milik pemiliknya masing-masing. Hanya integrasi Resmi yang dibuat oleh TablePro.", + "labels": { + "by": "Oleh {publisher}", + "closedSource": "Sumber tertutup", + "tiers": { + "official": "Resmi", + "partner": "Mitra", + "community": "Komunitas" + }, + "categories": { + "ai-clients": "Klien AI", + "automation": "Otomatisasi", + "cloud-hosting": "Hosting cloud", + "command-line": "Baris perintah", + "editors": "Editor", + "launchers": "Peluncur", + "local-development": "Pengembangan lokal", + "other": "Lainnya" + } + }, + "show": { + "seoTitle": "Integrasi {name}", + "install": { + "built-in": "Siapkan di TablePro", + "ships-with-host": "Siapkan dengan {host}", + "raycast-store": "Instal dari Raycast Store", + "vscode-marketplace": "Instal dari Visual Studio Marketplace", + "github-release": "Unduh dari GitHub", + "homebrew": "Instal dengan Homebrew", + "app-store": "Lihat di App Store", + "alfred-gallery": "Instal dari Alfred Gallery", + "page": "Dapatkan {name}" + }, + "source": "Kode sumber", + "details": { + "title": "Detail", + "publisher": "Penerbit", + "category": "Kategori", + "host": "Berfungsi dengan", + "hostVersion": "{host} {version} atau lebih baru", + "apps": "Aplikasi TablePro", + "appVersion": "{app} {version} atau lebih baru", + "license": "Lisensi", + "added": "Dicantumkan", + "verified": "Terakhir diperiksa" + }, + "about": "Tentang", + "screenshots": "Tangkapan layar", + "uses": { + "title": "Yang digunakan", + "surfaces": "Fitur TablePro", + "reads": "Membaca", + "writes": "Mengubah", + "none": "Tidak ada", + "data": { + "connections": "Daftar koneksi", + "schema": "Skema", + "data": "Data tabel" + }, + "surface": { + "url-scheme": { + "label": "Tautan tablepro://", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "Server MCP", + "docs": "/features/mcp" + }, + "pairing": { + "label": "Pemasangan", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "URL database", + "docs": "/connections/urls" + }, + "cli": { + "label": "Perintah tablepro", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "Tindakan Pintasan", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Menurunkan Safe Mode", + "safeModeLink": "Level Safe Mode", + "internet": "Mengirim data lewat internet", + "account": "Memerlukan akun di layanan lain", + "privacy": "Kebijakan privasi", + "payment": { + "optional": "Gratis, dengan opsi berbayar", + "paid": "Berbayar" + } + }, + "support": { + "title": "Bantuan", + "report": "Laporkan masalah pada entri ini", + "help": "Dapatkan bantuan dari {publisher}", + "docs": "Dokumentasi" + }, + "disclaimer": "{name} dibuat oleh {publisher}, bukan oleh TablePro. TablePro mencantumkannya di sini tetapi tidak memberikan dukungan untuknya.", + "archived": { + "badge": "Diarsipkan", + "since": "Diarsipkan pada {date}.", + "reasons": { + "unmaintained": "Integrasi ini tidak lagi dikelola.", + "broken": "Integrasi ini tidak lagi berfungsi dengan TablePro.", + "source-archived": "Repositori sumbernya telah diarsipkan.", + "superseded": "Integrasi ini telah digantikan oleh {name}.", + "author-request": "Pembuatnya meminta agar integrasi ini diarsipkan.", + "policy": "Integrasi ini tidak lagi memenuhi kebijakan pencantuman." + } + } + }, + "taglines": { + "claude-code": "Hubungkan Claude Code ke database Anda melalui server MCP bawaan TablePro.", + "claude-desktop": "Hubungkan Claude Desktop ke database Anda melalui server MCP bawaan TablePro.", + "command-line": "Buka URL database dan file SQL di TablePro dari Terminal dengan perintah tablepro.", + "cursor": "Hubungkan agen Cursor ke database Anda melalui server MCP bawaan TablePro.", + "shortcuts": "Tambahkan baris ke tabel dan buka koneksi dari Pintasan di iPhone dan iPad.", + "zed": "Hubungkan agen Zed ke database Anda melalui server MCP bawaan TablePro." + } +} diff --git a/resources/data/content/it/features/ai-mcp.json b/resources/data/content/it/features/ai-mcp.json index b77dd9c5..5a66840d 100644 --- a/resources/data/content/it/features/ai-mcp.json +++ b/resources/data/content/it/features/ai-mcp.json @@ -91,6 +91,12 @@ "paragraphs": [ "AppleScript può connettersi, eseguire una query e aprire una tabella, rispettando i livelli External Clients e Safe Mode della connessione. I link tablepro:// aprono una connessione, una tabella o una scheda di query; una query in un link viene inserita dopo la conferma e non viene mai eseguita autonomamente. Gli URL di database come postgresql:// si aprono come connessioni.", "Il comando shell tablepro apre questi URL e file nell’app; non esegue query dal terminale. L’estensione ufficiale Raycast cerca nelle connessioni, nelle tabelle e nella cronologia ed esegue query tramite l’app abbinata." + ], + "links": [ + { + "label": "Integrazioni", + "href": "/integrations" + } ] }, { diff --git a/resources/data/content/it/integrations/index.json b/resources/data/content/it/integrations/index.json new file mode 100644 index 00000000..096ab519 --- /dev/null +++ b/resources/data/content/it/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "Integrazioni", + "description": "App e strumenti che funzionano con TablePro: client IA, editor, automazione e riga di comando, sviluppati da TablePro o da terzi." + }, + "header": { + "title": "Integrazioni", + "lead": "App e strumenti che funzionano con TablePro.", + "build": "Crea un’integrazione" + }, + "filters": { + "label": "Filtra integrazioni", + "search": "Cerca", + "category": { + "label": "Categoria", + "all": "Tutte le categorie" + }, + "platform": { + "label": "App TablePro", + "all": "Qualsiasi" + }, + "tier": { + "label": "Tipo", + "all": "Tutti i tipi" + }, + "submit": "Mostra risultati", + "count": { + "one": "{count} integrazione", + "other": "{count} integrazioni" + }, + "empty": "Nessuna integrazione corrisponde a questi filtri.", + "clear": "Rimuovi filtri" + }, + "publish": { + "title": "Aggiungi la tua integrazione", + "guide": "Come inviarla", + "policy": "Criteri di inclusione" + }, + "notice": "Nomi e loghi appartengono ai rispettivi proprietari. TablePro sviluppa solo le integrazioni di tipo Ufficiale.", + "labels": { + "by": "Di {publisher}", + "closedSource": "Codice chiuso", + "tiers": { + "official": "Ufficiale", + "partner": "Partner", + "community": "Community" + }, + "categories": { + "ai-clients": "Client IA", + "automation": "Automazione", + "cloud-hosting": "Hosting cloud", + "command-line": "Riga di comando", + "editors": "Editor", + "launchers": "Launcher", + "local-development": "Sviluppo locale", + "other": "Altro" + } + }, + "show": { + "seoTitle": "Integrazione con {name}", + "install": { + "built-in": "Configura in TablePro", + "ships-with-host": "Configura in {host}", + "raycast-store": "Installa dal Raycast Store", + "vscode-marketplace": "Installa dal Visual Studio Marketplace", + "github-release": "Scarica da GitHub", + "homebrew": "Installa con Homebrew", + "app-store": "Vedi sull’App Store", + "alfred-gallery": "Installa dalla Alfred Gallery", + "page": "Ottieni {name}" + }, + "source": "Codice sorgente", + "details": { + "title": "Dettagli", + "publisher": "Sviluppatore", + "category": "Categoria", + "host": "Funziona con", + "hostVersion": "{host} {version} o successivo", + "apps": "App TablePro", + "appVersion": "{app} {version} o successivo", + "license": "Licenza", + "added": "Aggiunta il", + "verified": "Ultimo controllo" + }, + "about": "Informazioni", + "screenshots": "Schermate", + "uses": { + "title": "Cosa usa", + "surfaces": "Funzionalità di TablePro", + "reads": "Legge", + "writes": "Modifica", + "none": "Niente", + "data": { + "connections": "Elenco delle connessioni", + "schema": "Schemi", + "data": "Dati delle tabelle" + }, + "surface": { + "url-scheme": { + "label": "Link tablepro://", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "Server MCP", + "docs": "/features/mcp" + }, + "pairing": { + "label": "Abbinamento", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "URL di database", + "docs": "/connections/urls" + }, + "cli": { + "label": "Comando tablepro", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "Azioni di Comandi Rapidi", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Abbassa il livello Safe Mode", + "safeModeLink": "Livelli di Safe Mode", + "internet": "Invia dati via internet", + "account": "Richiede un account su un altro servizio", + "privacy": "Informativa sulla privacy", + "payment": { + "optional": "Gratuita, con opzioni a pagamento", + "paid": "A pagamento" + } + }, + "support": { + "title": "Assistenza", + "report": "Segnala un problema con questa voce", + "help": "Chiedi aiuto a {publisher}", + "docs": "Documentazione" + }, + "disclaimer": "{name} è sviluppato da {publisher}, non da TablePro. TablePro lo elenca qui, ma non offre assistenza.", + "archived": { + "badge": "Archiviata", + "since": "Archiviata il {date}.", + "reasons": { + "unmaintained": "Non è più mantenuta.", + "broken": "Non funziona più con TablePro.", + "source-archived": "Il repository del codice sorgente è archiviato.", + "superseded": "È stata sostituita da {name}.", + "author-request": "L’autore ha chiesto di archiviarla.", + "policy": "Non soddisfa più i criteri di inclusione." + } + } + }, + "taglines": { + "claude-code": "Collega Claude Code ai tuoi database tramite il server MCP integrato in TablePro.", + "claude-desktop": "Collega Claude Desktop ai tuoi database tramite il server MCP integrato in TablePro.", + "command-line": "Apri URL di database e file SQL in TablePro dal Terminale con il comando tablepro.", + "cursor": "Collega l’agente di Cursor ai tuoi database tramite il server MCP integrato in TablePro.", + "shortcuts": "Aggiungi righe a una tabella e apri una connessione da Comandi Rapidi su iPhone e iPad.", + "zed": "Collega l’agente di Zed ai tuoi database tramite il server MCP integrato in TablePro." + } +} diff --git a/resources/data/content/ja/features/ai-mcp.json b/resources/data/content/ja/features/ai-mcp.json index 477eb797..369d087a 100644 --- a/resources/data/content/ja/features/ai-mcp.json +++ b/resources/data/content/ja/features/ai-mcp.json @@ -91,7 +91,8 @@ "paragraphs": [ "AppleScript は、接続の External Clients レベルと Safe Mode に従って接続、クエリ実行、テーブル表示を行えます。tablepro:// リンクは接続、テーブルやクエリタブを開きます。リンク内のクエリは確認後に入力され、自動実行しません。postgresql:// などのデータベース URL は接続として開きます。", "シェルコマンド tablepro はそれらの URL やファイルをアプリで開き、ターミナルからクエリは実行しません。公式 Raycast 拡張は接続、テーブルとクエリ履歴を検索し、ペアリングしたアプリ経由でクエリを実行します。" - ] + ], + "links": [{ "label": "連携", "href": "/integrations" }] }, { "id": "privacy", diff --git a/resources/data/content/ja/integrations/index.json b/resources/data/content/ja/integrations/index.json new file mode 100644 index 00000000..75ca6739 --- /dev/null +++ b/resources/data/content/ja/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "連携", + "description": "AI クライアント、エディター、自動化、コマンドラインなど、TablePro と連携するアプリとツール。TablePro が開発したものと、他の開発者によるものがあります。" + }, + "header": { + "title": "連携", + "lead": "TablePro と連携するアプリとツール。", + "build": "連携を開発する" + }, + "filters": { + "label": "連携を絞り込む", + "search": "検索", + "category": { + "label": "カテゴリ", + "all": "すべてのカテゴリ" + }, + "platform": { + "label": "TablePro アプリ", + "all": "すべて" + }, + "tier": { + "label": "種類", + "all": "すべての種類" + }, + "submit": "結果を表示", + "count": { + "one": "{count} 件の連携", + "other": "{count} 件の連携" + }, + "empty": "条件に一致する連携はありません。", + "clear": "絞り込みを解除" + }, + "publish": { + "title": "連携を掲載する", + "guide": "申請方法", + "policy": "掲載ポリシー" + }, + "notice": "名称とロゴは各権利者に帰属します。TablePro が開発しているのは公式の連携だけです。", + "labels": { + "by": "提供:{publisher}", + "closedSource": "クローズドソース", + "tiers": { + "official": "公式", + "partner": "パートナー", + "community": "コミュニティ" + }, + "categories": { + "ai-clients": "AI クライアント", + "automation": "自動化", + "cloud-hosting": "クラウドホスティング", + "command-line": "コマンドライン", + "editors": "エディター", + "launchers": "ランチャー", + "local-development": "ローカル開発", + "other": "その他" + } + }, + "show": { + "seoTitle": "{name} 連携", + "install": { + "built-in": "TablePro で設定", + "ships-with-host": "{host} で設定", + "raycast-store": "Raycast Store からインストール", + "vscode-marketplace": "Visual Studio Marketplace からインストール", + "github-release": "GitHub からダウンロード", + "homebrew": "Homebrew でインストール", + "app-store": "App Store で見る", + "alfred-gallery": "Alfred Gallery からインストール", + "page": "{name} を入手" + }, + "source": "ソースコード", + "details": { + "title": "詳細", + "publisher": "提供元", + "category": "カテゴリ", + "host": "対応アプリ", + "hostVersion": "{host} {version} 以降", + "apps": "TablePro アプリ", + "appVersion": "{app} {version} 以降", + "license": "ライセンス", + "added": "掲載日", + "verified": "最終確認日" + }, + "about": "概要", + "screenshots": "スクリーンショット", + "uses": { + "title": "利用する機能とデータ", + "surfaces": "TablePro の機能", + "reads": "読み取り", + "writes": "変更", + "none": "なし", + "data": { + "connections": "接続の一覧", + "schema": "スキーマ", + "data": "テーブルのデータ" + }, + "surface": { + "url-scheme": { + "label": "tablepro:// リンク", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "MCP サーバー", + "docs": "/features/mcp" + }, + "pairing": { + "label": "ペアリング", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "データベース URL", + "docs": "/connections/urls" + }, + "cli": { + "label": "tablepro コマンド", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "ショートカットのアクション", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Safe Mode のレベルを下げる", + "safeModeLink": "Safe Mode のレベル", + "internet": "インターネット経由でデータを送信", + "account": "他のサービスのアカウントが必要", + "privacy": "プライバシーポリシー", + "payment": { + "optional": "無料(有料オプションあり)", + "paid": "有料" + } + }, + "support": { + "title": "ヘルプ", + "report": "この掲載内容の問題を報告", + "help": "{publisher} のサポートを受ける", + "docs": "ドキュメント" + }, + "disclaimer": "{name} は TablePro ではなく {publisher} が開発しています。TablePro はここに掲載していますが、サポートはしていません。", + "archived": { + "badge": "アーカイブ済み", + "since": "{date} にアーカイブされました。", + "reasons": { + "unmaintained": "メンテナンスが終了しています。", + "broken": "TablePro で動作しなくなりました。", + "source-archived": "ソースリポジトリがアーカイブされています。", + "superseded": "{name} に置き換えられました。", + "author-request": "作者の依頼によりアーカイブされました。", + "policy": "掲載ポリシーを満たさなくなりました。" + } + } + }, + "taglines": { + "claude-code": "TablePro 内蔵の MCP サーバーを通じて、Claude Code をデータベースに接続します。", + "claude-desktop": "TablePro 内蔵の MCP サーバーを通じて、Claude Desktop をデータベースに接続します。", + "command-line": "ターミナルから tablepro コマンドで、データベース URL や SQL ファイルを TablePro で開きます。", + "cursor": "TablePro 内蔵の MCP サーバーを通じて、Cursor のエージェントをデータベースに接続します。", + "shortcuts": "iPhone と iPad のショートカットから、テーブルに行を追加したり接続を開いたりできます。", + "zed": "TablePro 内蔵の MCP サーバーを通じて、Zed のエージェントをデータベースに接続します。" + } +} diff --git a/resources/data/content/ko/features/ai-mcp.json b/resources/data/content/ko/features/ai-mcp.json index 0f2f597c..045e9cc1 100644 --- a/resources/data/content/ko/features/ai-mcp.json +++ b/resources/data/content/ko/features/ai-mcp.json @@ -91,7 +91,8 @@ "paragraphs": [ "AppleScript는 연결의 External Clients 수준과 Safe Mode 안에서 연결하고 쿼리를 실행하고 테이블을 열 수 있습니다. tablepro:// 링크는 연결, 테이블 또는 쿼리 탭을 엽니다. 링크의 쿼리는 확인 후 입력되며 자동으로 실행되지 않습니다. postgresql:// 같은 데이터베이스 URL은 연결로 열립니다.", "tablepro 셸 명령은 해당 URL과 파일을 앱에서 열며 터미널에서 쿼리를 실행하지 않습니다. 공식 Raycast 확장은 연결, 테이블 및 쿼리 기록을 검색하고 페어링된 앱을 통해 쿼리를 실행합니다." - ] + ], + "links": [{ "label": "통합", "href": "/integrations" }] }, { "id": "privacy", diff --git a/resources/data/content/ko/integrations/index.json b/resources/data/content/ko/integrations/index.json new file mode 100644 index 00000000..0cefa995 --- /dev/null +++ b/resources/data/content/ko/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "통합", + "description": "AI 클라이언트, 편집기, 자동화, 명령줄 등 TablePro와 함께 작동하는 앱과 도구입니다. TablePro가 만든 것과 다른 개발자가 만든 것이 있습니다." + }, + "header": { + "title": "통합", + "lead": "TablePro와 함께 작동하는 앱과 도구입니다.", + "build": "통합 만들기" + }, + "filters": { + "label": "통합 필터", + "search": "검색", + "category": { + "label": "카테고리", + "all": "모든 카테고리" + }, + "platform": { + "label": "TablePro 앱", + "all": "전체" + }, + "tier": { + "label": "유형", + "all": "모든 유형" + }, + "submit": "결과 보기", + "count": { + "one": "통합 {count}개", + "other": "통합 {count}개" + }, + "empty": "필터에 맞는 통합이 없습니다.", + "clear": "필터 지우기" + }, + "publish": { + "title": "통합 등록하기", + "guide": "제출 방법", + "policy": "등록 정책" + }, + "notice": "이름과 로고의 권리는 각 소유자에게 있습니다. TablePro가 만드는 통합은 공식 통합뿐입니다.", + "labels": { + "by": "{publisher} 제공", + "closedSource": "비공개 소스", + "tiers": { + "official": "공식", + "partner": "파트너", + "community": "커뮤니티" + }, + "categories": { + "ai-clients": "AI 클라이언트", + "automation": "자동화", + "cloud-hosting": "클라우드 호스팅", + "command-line": "명령줄", + "editors": "편집기", + "launchers": "런처", + "local-development": "로컬 개발", + "other": "기타" + } + }, + "show": { + "seoTitle": "{name} 통합", + "install": { + "built-in": "TablePro에서 설정", + "ships-with-host": "{host}에서 설정", + "raycast-store": "Raycast Store에서 설치", + "vscode-marketplace": "Visual Studio Marketplace에서 설치", + "github-release": "GitHub에서 다운로드", + "homebrew": "Homebrew로 설치", + "app-store": "App Store에서 보기", + "alfred-gallery": "Alfred Gallery에서 설치", + "page": "{name} 받기" + }, + "source": "소스 코드", + "details": { + "title": "세부 정보", + "publisher": "게시자", + "category": "카테고리", + "host": "호환 앱", + "hostVersion": "{host} {version} 이상", + "apps": "TablePro 앱", + "appVersion": "{app} {version} 이상", + "license": "라이선스", + "added": "등록일", + "verified": "마지막 확인" + }, + "about": "소개", + "screenshots": "스크린샷", + "uses": { + "title": "사용하는 기능과 데이터", + "surfaces": "TablePro 기능", + "reads": "읽기", + "writes": "변경", + "none": "없음", + "data": { + "connections": "연결 목록", + "schema": "스키마", + "data": "테이블 데이터" + }, + "surface": { + "url-scheme": { + "label": "tablepro:// 링크", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "MCP 서버", + "docs": "/features/mcp" + }, + "pairing": { + "label": "페어링", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "데이터베이스 URL", + "docs": "/connections/urls" + }, + "cli": { + "label": "tablepro 명령", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "단축어 동작", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Safe Mode 수준을 낮춤", + "safeModeLink": "Safe Mode 수준", + "internet": "인터넷으로 데이터 전송", + "account": "다른 서비스 계정 필요", + "privacy": "개인정보 처리방침", + "payment": { + "optional": "무료, 유료 옵션 있음", + "paid": "유료" + } + }, + "support": { + "title": "도움말", + "report": "이 등록 정보의 문제 신고", + "help": "{publisher}에 도움 요청", + "docs": "문서" + }, + "disclaimer": "{name}의 제작자는 TablePro가 아닌 {publisher}입니다. TablePro는 이 목록에 올려 두지만 지원하지는 않습니다.", + "archived": { + "badge": "보관됨", + "since": "{date}에 보관되었습니다.", + "reasons": { + "unmaintained": "더 이상 유지 관리되지 않습니다.", + "broken": "더 이상 TablePro에서 작동하지 않습니다.", + "source-archived": "소스 저장소가 보관되었습니다.", + "superseded": "다음으로 대체되었습니다: {name}.", + "author-request": "작성자의 요청으로 보관되었습니다.", + "policy": "더 이상 등록 정책을 충족하지 않습니다." + } + } + }, + "taglines": { + "claude-code": "TablePro에 내장된 MCP 서버를 통해 Claude Code를 데이터베이스에 연결합니다.", + "claude-desktop": "TablePro에 내장된 MCP 서버를 통해 Claude Desktop을 데이터베이스에 연결합니다.", + "command-line": "터미널에서 tablepro 명령으로 데이터베이스 URL과 SQL 파일을 TablePro에서 엽니다.", + "cursor": "TablePro에 내장된 MCP 서버를 통해 Cursor의 에이전트를 데이터베이스에 연결합니다.", + "shortcuts": "iPhone과 iPad의 단축어에서 테이블에 행을 추가하고 연결을 엽니다.", + "zed": "TablePro에 내장된 MCP 서버를 통해 Zed의 에이전트를 데이터베이스에 연결합니다." + } +} diff --git a/resources/data/content/pt-BR/features/ai-mcp.json b/resources/data/content/pt-BR/features/ai-mcp.json index 75fce647..3b9943d2 100644 --- a/resources/data/content/pt-BR/features/ai-mcp.json +++ b/resources/data/content/pt-BR/features/ai-mcp.json @@ -91,6 +91,12 @@ "paragraphs": [ "AppleScript pode conectar, executar consultas e abrir tabelas, respeitando o nível de External Clients e o Safe Mode da conexão. Links tablepro:// abrem uma conexão, tabela ou aba de consulta; uma consulta em um link é preenchida após sua confirmação e nunca é executada sozinha. URLs de banco como postgresql:// abrem como conexões.", "O comando de shell tablepro abre essas URLs e arquivos no app; não executa consultas pelo terminal. A extensão oficial Raycast pesquisa conexões, tabelas e histórico de consultas e executa consultas pelo app pareado." + ], + "links": [ + { + "label": "Integrações", + "href": "/integrations" + } ] }, { diff --git a/resources/data/content/pt-BR/integrations/index.json b/resources/data/content/pt-BR/integrations/index.json new file mode 100644 index 00000000..73ac31d1 --- /dev/null +++ b/resources/data/content/pt-BR/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "Integrações", + "description": "Apps e ferramentas que funcionam com o TablePro: clientes de IA, editores, automação e linha de comando, do TablePro ou de terceiros." + }, + "header": { + "title": "Integrações", + "lead": "Apps e ferramentas que funcionam com o TablePro.", + "build": "Criar uma integração" + }, + "filters": { + "label": "Filtrar integrações", + "search": "Pesquisar", + "category": { + "label": "Categoria", + "all": "Todas as categorias" + }, + "platform": { + "label": "App do TablePro", + "all": "Qualquer" + }, + "tier": { + "label": "Tipo", + "all": "Todos os tipos" + }, + "submit": "Mostrar resultados", + "count": { + "one": "{count} integração", + "other": "{count} integrações" + }, + "empty": "Nenhuma integração corresponde a esses filtros.", + "clear": "Limpar filtros" + }, + "publish": { + "title": "Adicione sua integração", + "guide": "Como enviar", + "policy": "Política do diretório" + }, + "notice": "Nomes e logotipos pertencem aos seus proprietários. Só as integrações do tipo Oficial são desenvolvidas pelo TablePro.", + "labels": { + "by": "Por {publisher}", + "closedSource": "Código fechado", + "tiers": { + "official": "Oficial", + "partner": "Parceiro", + "community": "Comunidade" + }, + "categories": { + "ai-clients": "Clientes de IA", + "automation": "Automação", + "cloud-hosting": "Hospedagem em nuvem", + "command-line": "Linha de comando", + "editors": "Editores", + "launchers": "Lançadores", + "local-development": "Desenvolvimento local", + "other": "Outros" + } + }, + "show": { + "seoTitle": "Integração com {name}", + "install": { + "built-in": "Configurar no TablePro", + "ships-with-host": "Configurar no {host}", + "raycast-store": "Instalar pela Raycast Store", + "vscode-marketplace": "Instalar pelo Visual Studio Marketplace", + "github-release": "Baixar do GitHub", + "homebrew": "Instalar com o Homebrew", + "app-store": "Ver na App Store", + "alfred-gallery": "Instalar pela Alfred Gallery", + "page": "Obter {name}" + }, + "source": "Código-fonte", + "details": { + "title": "Detalhes", + "publisher": "Desenvolvedor", + "category": "Categoria", + "host": "Funciona com", + "hostVersion": "{host} {version} ou posterior", + "apps": "App do TablePro", + "appVersion": "{app} {version} ou posterior", + "license": "Licença", + "added": "Adicionada em", + "verified": "Última verificação" + }, + "about": "Sobre", + "screenshots": "Capturas de tela", + "uses": { + "title": "O que usa", + "surfaces": "Recursos do TablePro", + "reads": "Lê", + "writes": "Altera", + "none": "Nada", + "data": { + "connections": "Lista de conexões", + "schema": "Esquemas", + "data": "Dados das tabelas" + }, + "surface": { + "url-scheme": { + "label": "Links tablepro://", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "Servidor MCP", + "docs": "/features/mcp" + }, + "pairing": { + "label": "Pareamento", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "URLs de banco de dados", + "docs": "/connections/urls" + }, + "cli": { + "label": "Comando tablepro", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "Ações dos Atalhos", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Reduz o nível do Safe Mode", + "safeModeLink": "Níveis do Safe Mode", + "internet": "Envia dados pela internet", + "account": "Exige uma conta em outro serviço", + "privacy": "Política de privacidade", + "payment": { + "optional": "Grátis, com opções pagas", + "paid": "Pago" + } + }, + "support": { + "title": "Ajuda", + "report": "Relatar um problema nesta página", + "help": "Obter ajuda de {publisher}", + "docs": "Documentação" + }, + "disclaimer": "{name} é desenvolvido por {publisher}, não pelo TablePro. O TablePro o lista aqui, mas não oferece suporte a ele.", + "archived": { + "badge": "Arquivada", + "since": "Arquivada em {date}.", + "reasons": { + "unmaintained": "Não recebe mais manutenção.", + "broken": "Não funciona mais com o TablePro.", + "source-archived": "O repositório do código-fonte foi arquivado.", + "superseded": "Foi substituída por {name}.", + "author-request": "O autor pediu para arquivá-la.", + "policy": "Não atende mais à política do diretório." + } + } + }, + "taglines": { + "claude-code": "Conecte o Claude Code aos seus bancos de dados pelo servidor MCP integrado ao TablePro.", + "claude-desktop": "Conecte o Claude Desktop aos seus bancos de dados pelo servidor MCP integrado ao TablePro.", + "command-line": "Abra URLs de banco de dados e arquivos SQL no TablePro pelo Terminal com o comando tablepro.", + "cursor": "Conecte o agente do Cursor aos seus bancos de dados pelo servidor MCP integrado ao TablePro.", + "shortcuts": "Adicione linhas a uma tabela e abra uma conexão pelos Atalhos no iPhone e iPad.", + "zed": "Conecte o agente do Zed aos seus bancos de dados pelo servidor MCP integrado ao TablePro." + } +} diff --git a/resources/data/content/vi/features/ai-mcp.json b/resources/data/content/vi/features/ai-mcp.json index d92791d7..33fcf218 100644 --- a/resources/data/content/vi/features/ai-mcp.json +++ b/resources/data/content/vi/features/ai-mcp.json @@ -91,6 +91,12 @@ "paragraphs": [ "AppleScript có thể kết nối, chạy query và mở table, trong phạm vi mức External Clients và Safe Mode của connection. Liên kết tablepro:// mở một connection, một table hoặc một tab query; query trong liên kết được điền sẵn sau khi bạn xác nhận và không bao giờ tự chạy. URL cơ sở dữ liệu như postgresql:// mở thành connection.", "Lệnh shell tablepro mở các URL và file đó trong ứng dụng; lệnh này không chạy query từ terminal. Extension Raycast chính thức tìm connection, table và lịch sử query của bạn, và chạy query qua ứng dụng đã ghép nối." + ], + "links": [ + { + "label": "Tích hợp", + "href": "/integrations" + } ] }, { diff --git a/resources/data/content/vi/integrations/index.json b/resources/data/content/vi/integrations/index.json new file mode 100644 index 00000000..dbd8ff74 --- /dev/null +++ b/resources/data/content/vi/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "Tích hợp", + "description": "Ứng dụng và công cụ dùng được với TablePro: AI client, editor, tự động hóa và dòng lệnh, do TablePro hoặc bên khác phát triển." + }, + "header": { + "title": "Tích hợp", + "lead": "Ứng dụng và công cụ dùng được với TablePro.", + "build": "Tự xây dựng tích hợp" + }, + "filters": { + "label": "Lọc tích hợp", + "search": "Tìm kiếm", + "category": { + "label": "Danh mục", + "all": "Mọi danh mục" + }, + "platform": { + "label": "Ứng dụng TablePro", + "all": "Tất cả" + }, + "tier": { + "label": "Loại", + "all": "Mọi loại" + }, + "submit": "Xem kết quả", + "count": { + "one": "{count} tích hợp", + "other": "{count} tích hợp" + }, + "empty": "Không có tích hợp nào khớp với bộ lọc này.", + "clear": "Xóa bộ lọc" + }, + "publish": { + "title": "Đăng tích hợp của bạn", + "guide": "Cách gửi tích hợp", + "policy": "Chính sách đăng tích hợp" + }, + "notice": "Tên và logo thuộc về chủ sở hữu của chúng. Chỉ tích hợp Chính thức do TablePro phát triển.", + "labels": { + "by": "Do {publisher} phát triển", + "closedSource": "Mã nguồn đóng", + "tiers": { + "official": "Chính thức", + "partner": "Đối tác", + "community": "Cộng đồng" + }, + "categories": { + "ai-clients": "AI client", + "automation": "Tự động hóa", + "cloud-hosting": "Hosting trên cloud", + "command-line": "Dòng lệnh", + "editors": "Editor", + "launchers": "Trình khởi chạy", + "local-development": "Phát triển cục bộ", + "other": "Khác" + } + }, + "show": { + "seoTitle": "Tích hợp {name}", + "install": { + "built-in": "Thiết lập trong TablePro", + "ships-with-host": "Thiết lập bằng {host}", + "raycast-store": "Cài từ Raycast Store", + "vscode-marketplace": "Cài từ Visual Studio Marketplace", + "github-release": "Tải về từ GitHub", + "homebrew": "Cài bằng Homebrew", + "app-store": "Xem trên App Store", + "alfred-gallery": "Cài từ Alfred Gallery", + "page": "Tải {name}" + }, + "source": "Mã nguồn", + "details": { + "title": "Chi tiết", + "publisher": "Nhà phát hành", + "category": "Danh mục", + "host": "Dùng với", + "hostVersion": "{host} {version} trở lên", + "apps": "Ứng dụng TablePro", + "appVersion": "{app} {version} trở lên", + "license": "License", + "added": "Ngày đăng", + "verified": "Kiểm tra lần cuối" + }, + "about": "Giới thiệu", + "screenshots": "Ảnh chụp màn hình", + "uses": { + "title": "Tích hợp dùng gì", + "surfaces": "Tính năng TablePro", + "reads": "Đọc", + "writes": "Thay đổi", + "none": "Không có", + "data": { + "connections": "Danh sách connection", + "schema": "Schema", + "data": "Dữ liệu table" + }, + "surface": { + "url-scheme": { + "label": "Liên kết tablepro://", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "MCP server", + "docs": "/features/mcp" + }, + "pairing": { + "label": "Ghép nối", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "URL cơ sở dữ liệu", + "docs": "/connections/urls" + }, + "cli": { + "label": "Lệnh tablepro", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "Tác vụ Phím tắt", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "Hạ mức Safe Mode", + "safeModeLink": "Các mức Safe Mode", + "internet": "Gửi dữ liệu qua internet", + "account": "Cần tài khoản ở một dịch vụ khác", + "privacy": "Chính sách quyền riêng tư", + "payment": { + "optional": "Miễn phí, có tùy chọn trả phí", + "paid": "Trả phí" + } + }, + "support": { + "title": "Trợ giúp", + "report": "Báo vấn đề với mục này", + "help": "Nhờ {publisher} hỗ trợ", + "docs": "Tài liệu" + }, + "disclaimer": "{name} do {publisher} phát triển, không phải TablePro. TablePro liệt kê tích hợp này ở đây nhưng không hỗ trợ nó.", + "archived": { + "badge": "Đã lưu trữ", + "since": "Đã lưu trữ ngày {date}.", + "reasons": { + "unmaintained": "Tích hợp này không còn được duy trì.", + "broken": "Tích hợp này không còn hoạt động với TablePro.", + "source-archived": "Repository mã nguồn của tích hợp này đã được lưu trữ.", + "superseded": "Tích hợp này đã được thay bằng {name}.", + "author-request": "Tác giả yêu cầu lưu trữ tích hợp này.", + "policy": "Tích hợp này không còn đáp ứng chính sách đăng tích hợp." + } + } + }, + "taglines": { + "claude-code": "Kết nối Claude Code với cơ sở dữ liệu của bạn qua MCP server tích hợp sẵn trong TablePro.", + "claude-desktop": "Kết nối Claude Desktop với cơ sở dữ liệu của bạn qua MCP server tích hợp sẵn trong TablePro.", + "command-line": "Mở URL cơ sở dữ liệu và file SQL trong TablePro từ Terminal bằng lệnh tablepro.", + "cursor": "Kết nối agent của Cursor với cơ sở dữ liệu của bạn qua MCP server tích hợp sẵn trong TablePro.", + "shortcuts": "Thêm dòng vào table và mở connection từ Phím tắt trên iPhone và iPad.", + "zed": "Kết nối agent của Zed với cơ sở dữ liệu của bạn qua MCP server tích hợp sẵn trong TablePro." + } +} diff --git a/resources/data/content/zh-Hans/features/ai-mcp.json b/resources/data/content/zh-Hans/features/ai-mcp.json index f142e49b..c5057703 100644 --- a/resources/data/content/zh-Hans/features/ai-mcp.json +++ b/resources/data/content/zh-Hans/features/ai-mcp.json @@ -91,7 +91,8 @@ "paragraphs": [ "AppleScript 可在连接的 External Clients 级别和 Safe Mode 限制内连接、运行查询和打开表。tablepro:// 链接打开连接、表或查询标签页;链接中的查询在确认后填入,从不自行运行。postgresql:// 等数据库 URL 以连接形式打开。", "tablepro shell 命令在应用中打开这些 URL 和文件,不从终端运行查询。官方 Raycast 扩展搜索连接、表和查询历史,并通过已配对应用运行查询。" - ] + ], + "links": [{ "label": "集成", "href": "/integrations" }] }, { "id": "privacy", diff --git a/resources/data/content/zh-Hans/integrations/index.json b/resources/data/content/zh-Hans/integrations/index.json new file mode 100644 index 00000000..cb6f609b --- /dev/null +++ b/resources/data/content/zh-Hans/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "集成", + "description": "与 TablePro 配合使用的应用和工具:AI 客户端、编辑器、自动化和命令行,由 TablePro 或其他开发者构建。" + }, + "header": { + "title": "集成", + "lead": "与 TablePro 配合使用的应用和工具。", + "build": "构建集成" + }, + "filters": { + "label": "筛选集成", + "search": "搜索", + "category": { + "label": "类别", + "all": "所有类别" + }, + "platform": { + "label": "TablePro 应用", + "all": "全部" + }, + "tier": { + "label": "类型", + "all": "所有类型" + }, + "submit": "显示结果", + "count": { + "one": "{count} 个集成", + "other": "{count} 个集成" + }, + "empty": "没有符合这些筛选条件的集成。", + "clear": "清除筛选" + }, + "publish": { + "title": "提交您的集成", + "guide": "提交方法", + "policy": "收录政策" + }, + "notice": "名称和标志归其所有者所有。只有官方集成由 TablePro 构建。", + "labels": { + "by": "由 {publisher} 提供", + "closedSource": "闭源", + "tiers": { + "official": "官方", + "partner": "合作伙伴", + "community": "社区" + }, + "categories": { + "ai-clients": "AI 客户端", + "automation": "自动化", + "cloud-hosting": "云托管", + "command-line": "命令行", + "editors": "编辑器", + "launchers": "启动器", + "local-development": "本地开发", + "other": "其他" + } + }, + "show": { + "seoTitle": "{name} 集成", + "install": { + "built-in": "在 TablePro 中设置", + "ships-with-host": "在 {host} 中设置", + "raycast-store": "从 Raycast Store 安装", + "vscode-marketplace": "从 Visual Studio Marketplace 安装", + "github-release": "从 GitHub 下载", + "homebrew": "使用 Homebrew 安装", + "app-store": "在 App Store 中查看", + "alfred-gallery": "从 Alfred Gallery 安装", + "page": "获取 {name}" + }, + "source": "源代码", + "details": { + "title": "详细信息", + "publisher": "发布者", + "category": "类别", + "host": "适用于", + "hostVersion": "{host} {version} 或更新版本", + "apps": "TablePro 应用", + "appVersion": "{app} {version} 或更新版本", + "license": "许可证", + "added": "收录日期", + "verified": "最后检查" + }, + "about": "简介", + "screenshots": "截图", + "uses": { + "title": "使用的功能和数据", + "surfaces": "TablePro 功能", + "reads": "读取", + "writes": "更改", + "none": "无", + "data": { + "connections": "连接列表", + "schema": "结构", + "data": "表数据" + }, + "surface": { + "url-scheme": { + "label": "tablepro:// 链接", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "MCP 服务器", + "docs": "/features/mcp" + }, + "pairing": { + "label": "配对", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "数据库 URL", + "docs": "/connections/urls" + }, + "cli": { + "label": "tablepro 命令", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "快捷指令操作", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "降低 Safe Mode 级别", + "safeModeLink": "Safe Mode 级别", + "internet": "通过互联网发送数据", + "account": "需要其他服务的账户", + "privacy": "隐私政策", + "payment": { + "optional": "免费,提供付费选项", + "paid": "付费" + } + }, + "support": { + "title": "帮助", + "report": "报告此条目的问题", + "help": "向 {publisher} 获取帮助", + "docs": "文档" + }, + "disclaimer": "{name} 由 {publisher} 构建,而非 TablePro。TablePro 在此收录它,但不提供支持。", + "archived": { + "badge": "已归档", + "since": "归档于 {date}。", + "reasons": { + "unmaintained": "已不再维护。", + "broken": "已无法与 TablePro 配合使用。", + "source-archived": "其源代码仓库已归档。", + "superseded": "已被 {name} 取代。", + "author-request": "应作者要求归档。", + "policy": "已不再符合收录政策。" + } + } + }, + "taglines": { + "claude-code": "通过 TablePro 内置的 MCP 服务器,将 Claude Code 连接到您的数据库。", + "claude-desktop": "通过 TablePro 内置的 MCP 服务器,将 Claude Desktop 连接到您的数据库。", + "command-line": "在终端中使用 tablepro 命令,用 TablePro 打开数据库 URL 和 SQL 文件。", + "cursor": "通过 TablePro 内置的 MCP 服务器,将 Cursor 的智能体连接到您的数据库。", + "shortcuts": "在 iPhone 和 iPad 上通过快捷指令向表添加行、打开连接。", + "zed": "通过 TablePro 内置的 MCP 服务器,将 Zed 的智能体连接到您的数据库。" + } +} diff --git a/resources/data/content/zh-Hant/features/ai-mcp.json b/resources/data/content/zh-Hant/features/ai-mcp.json index 42dba807..f0445b56 100644 --- a/resources/data/content/zh-Hant/features/ai-mcp.json +++ b/resources/data/content/zh-Hant/features/ai-mcp.json @@ -91,7 +91,8 @@ "paragraphs": [ "AppleScript 可在連線的 External Clients 等級和 Safe Mode 限制內連線、執行查詢和開啟資料表。tablepro:// 連結開啟連線、資料表或查詢分頁;連結中的查詢在確認後填入,從不自行執行。postgresql:// 等資料庫 URL 以連線形式開啟。", "tablepro shell 指令在 App 中開啟這些 URL 和檔案,不從終端機執行查詢。官方 Raycast 擴充功能搜尋連線、資料表和查詢歷史,並透過已配對 App 執行查詢。" - ] + ], + "links": [{ "label": "整合", "href": "/integrations" }] }, { "id": "privacy", diff --git a/resources/data/content/zh-Hant/integrations/index.json b/resources/data/content/zh-Hant/integrations/index.json new file mode 100644 index 00000000..8fc6af0c --- /dev/null +++ b/resources/data/content/zh-Hant/integrations/index.json @@ -0,0 +1,166 @@ +{ + "seo": { + "title": "整合", + "description": "與 TablePro 搭配使用的 App 和工具:AI 用戶端、編輯器、自動化和命令列,由 TablePro 或其他開發者打造。" + }, + "header": { + "title": "整合", + "lead": "與 TablePro 搭配使用的 App 和工具。", + "build": "開發整合" + }, + "filters": { + "label": "篩選整合", + "search": "搜尋", + "category": { + "label": "類別", + "all": "所有類別" + }, + "platform": { + "label": "TablePro App", + "all": "全部" + }, + "tier": { + "label": "類型", + "all": "所有類型" + }, + "submit": "顯示結果", + "count": { + "one": "{count} 個整合", + "other": "{count} 個整合" + }, + "empty": "沒有符合這些篩選條件的整合。", + "clear": "清除篩選條件" + }, + "publish": { + "title": "提交您的整合", + "guide": "提交方式", + "policy": "收錄政策" + }, + "notice": "名稱和標誌歸其所有者所有。只有官方整合由 TablePro 打造。", + "labels": { + "by": "由 {publisher} 提供", + "closedSource": "封閉原始碼", + "tiers": { + "official": "官方", + "partner": "合作夥伴", + "community": "社群" + }, + "categories": { + "ai-clients": "AI 用戶端", + "automation": "自動化", + "cloud-hosting": "雲端代管", + "command-line": "命令列", + "editors": "編輯器", + "launchers": "啟動器", + "local-development": "本機開發", + "other": "其他" + } + }, + "show": { + "seoTitle": "{name} 整合", + "install": { + "built-in": "在 TablePro 中設定", + "ships-with-host": "在 {host} 中設定", + "raycast-store": "從 Raycast Store 安裝", + "vscode-marketplace": "從 Visual Studio Marketplace 安裝", + "github-release": "從 GitHub 下載", + "homebrew": "使用 Homebrew 安裝", + "app-store": "在 App Store 查看", + "alfred-gallery": "從 Alfred Gallery 安裝", + "page": "取得 {name}" + }, + "source": "原始碼", + "details": { + "title": "詳細資訊", + "publisher": "發佈者", + "category": "類別", + "host": "適用於", + "hostVersion": "{host} {version} 或更新版本", + "apps": "TablePro App", + "appVersion": "{app} {version} 或更新版本", + "license": "授權", + "added": "收錄日期", + "verified": "最後檢查" + }, + "about": "簡介", + "screenshots": "螢幕截圖", + "uses": { + "title": "使用的功能與資料", + "surfaces": "TablePro 功能", + "reads": "讀取", + "writes": "變更", + "none": "無", + "data": { + "connections": "連線清單", + "schema": "綱要", + "data": "資料表資料" + }, + "surface": { + "url-scheme": { + "label": "tablepro:// 連結", + "docs": "/developers/url-scheme" + }, + "mcp": { + "label": "MCP 伺服器", + "docs": "/features/mcp" + }, + "pairing": { + "label": "配對", + "docs": "/developers/pairing" + }, + "applescript": { + "label": "AppleScript", + "docs": "/developers/applescript" + }, + "database-url": { + "label": "資料庫 URL", + "docs": "/connections/urls" + }, + "cli": { + "label": "tablepro 指令", + "docs": "/integrations/terminal" + }, + "shortcuts": { + "label": "捷徑動作", + "docs": "/integrations/ios-shortcuts" + } + }, + "safeMode": "降低 Safe Mode 等級", + "safeModeLink": "Safe Mode 等級", + "internet": "透過網際網路傳送資料", + "account": "需要其他服務的帳戶", + "privacy": "隱私權政策", + "payment": { + "optional": "免費,提供付費選項", + "paid": "付費" + } + }, + "support": { + "title": "說明", + "report": "回報此項目的問題", + "help": "向 {publisher} 尋求協助", + "docs": "文件" + }, + "disclaimer": "{name} 由 {publisher} 打造,而非 TablePro。TablePro 在此收錄,但不提供支援。", + "archived": { + "badge": "已封存", + "since": "封存於 {date}。", + "reasons": { + "unmaintained": "已不再維護。", + "broken": "已無法與 TablePro 搭配使用。", + "source-archived": "其原始碼儲存庫已封存。", + "superseded": "已由 {name} 取代。", + "author-request": "應作者要求封存。", + "policy": "已不再符合收錄政策。" + } + } + }, + "taglines": { + "claude-code": "透過 TablePro 內建的 MCP 伺服器,將 Claude Code 連線到您的資料庫。", + "claude-desktop": "透過 TablePro 內建的 MCP 伺服器,將 Claude Desktop 連線到您的資料庫。", + "command-line": "在終端機使用 tablepro 指令,以 TablePro 開啟資料庫 URL 和 SQL 檔案。", + "cursor": "透過 TablePro 內建的 MCP 伺服器,將 Cursor 的 AI 代理連線到您的資料庫。", + "shortcuts": "在 iPhone 與 iPad 上透過捷徑將列新增至資料表,並開啟連線。", + "zed": "透過 TablePro 內建的 MCP 伺服器,將 Zed 的 AI 代理連線到您的資料庫。" + } +} diff --git a/resources/data/facts.json b/resources/data/facts.json index 591aa01a..e0d3022c 100644 --- a/resources/data/facts.json +++ b/resources/data/facts.json @@ -5,6 +5,7 @@ "issues": "https://github.com/TableProApp/TablePro/issues", "discussions": "https://github.com/TableProApp/TablePro/discussions", "license": "https://github.com/TableProApp/TablePro/blob/main/LICENSE", + "integrations": "https://github.com/TableProApp/integrations", "appStore": "https://apps.apple.com/app/tablepro/id6761621829", "sponsorsProgram": "https://github.com/sponsors/datlechin", "discord": "https://discord.gg/hCNmUUbnD4", diff --git a/resources/js/components/integrations/README.md b/resources/js/components/integrations/README.md new file mode 100644 index 00000000..49b3e2db --- /dev/null +++ b/resources/js/components/integrations/README.md @@ -0,0 +1,28 @@ +# Integrations pages + +`/integrations` (every locale) and `/integrations/{slug}` (English only) render from two inputs: + +| Input | File | Who writes it | +|---|---|---| +| Entries: names, summaries, links, icons, what each one uses | `resources/data/integrations.json`, `public/images/integrations/*.png` | The sync PR, copied from a TableProApp/integrations release. Never edit them here | +| Page copy, labels and taglines, one file per language | `resources/data/content/{locale}/integrations/index.json` | You | + +## The content file + +- `seo`, `header`, `filters`, `publish`, `notice`: the hub. +- `labels`: shared by the hub and the detail page (tiers, categories, "By {publisher}", "Closed source"). +- `show`: the detail page. It renders in English only today, but the keys exist in every language, so translating the page later is a content change. +- `show.uses.surface.{id}.docs`: a docs.tablepro.app path, the same in every language. +- `taglines`: one sentence per slug. The English value is the registry `summary` it was translated from. + +A value the registry adds later (a category, an install type, a surface, an archive reason) stays off the pages until this file labels it in every language. + +## Taglines + +A language other than English lists an entry only when its `taglines` has the slug and the English file's `taglines` still equals the entry's `summary`. So: + +- A new entry shows in English at once and in another language once its tagline lands there. +- A changed summary hides the entry outside English until the English tagline and its translations are updated together. +- A tagline for a slug the registry removed is ignored. + +The sync PR body lists the active slugs that are still English only. diff --git a/resources/js/components/integrations/install-action.tsx b/resources/js/components/integrations/install-action.tsx new file mode 100644 index 00000000..d65483ec --- /dev/null +++ b/resources/js/components/integrations/install-action.tsx @@ -0,0 +1,31 @@ +import Button from '@/components/ui/button'; +import { CodeBlock } from '@/components/ui/code'; +import { useI18n } from '@/i18n'; +import { relFor } from './model'; +import type { IntegrationDetail, IntegrationPageLabels } from './types'; + +interface InstallActionProps { + integration: IntegrationDetail; + labels: IntegrationPageLabels; +} + +export default function InstallAction({ integration, labels }: InstallActionProps) { + const { m, fmt } = useI18n(); + const { install } = integration; + const label = fmt(labels.install[install.type], { name: integration.name, host: integration.host?.name ?? integration.name }); + + if (install.command !== null) { + return ; + } + + if (install.url === null) { + return null; + } + + return ( + + ); +} diff --git a/resources/js/components/integrations/integration-filters.tsx b/resources/js/components/integrations/integration-filters.tsx new file mode 100644 index 00000000..0076290e --- /dev/null +++ b/resources/js/components/integrations/integration-filters.tsx @@ -0,0 +1,82 @@ +import type { FormEvent } from 'react'; +import Button from '@/components/ui/button'; +import Field, { Input } from '@/components/ui/field'; +import Select from '@/components/ui/select'; +import { useI18n } from '@/i18n'; +import type { FacetOptions } from './model'; +import type { AppPlatform, Category, IntegrationFilters as Filters, IntegrationsHubContent, Tier } from './types'; + +interface IntegrationFiltersProps { + content: IntegrationsHubContent; + filters: Filters; + options: FacetOptions; + appName: (platform: AppPlatform) => string; + onChange: (filters: Filters, urlDelay: number) => void; +} + +// A plain GET form, so the filters work before hydration and without JavaScript. +export default function IntegrationFilters({ content, filters, options, appName, onChange }: IntegrationFiltersProps) { + const { path } = useI18n(); + const copy = content.filters; + const labels = content.labels; + + const submit = (event: FormEvent) => { + event.preventDefault(); + onChange(filters, 0); + }; + + return ( +
+ + onChange({ ...filters, q: event.target.value === '' ? null : event.target.value }, 300)} + /> + + + onChange({ ...filters, platform: value === '' ? null : (value as AppPlatform) }, 0)} + options={[{ value: '', label: copy.platform.all }, ...options.platforms.map((platform) => ({ value: platform, label: appName(platform) }))]} + /> + + +