|
| 1 | +<?php |
| 2 | + |
| 3 | +namespace App\Http\Controllers; |
| 4 | + |
| 5 | +use App\Support\Content\ContentRepository; |
| 6 | +use App\Support\Content\IntegrationCatalog; |
| 7 | +use App\Support\Localization\Locales; |
| 8 | +use Illuminate\Http\Request; |
| 9 | +use Illuminate\Support\Arr; |
| 10 | +use Illuminate\Support\Carbon; |
| 11 | +use Illuminate\Support\Facades\App; |
| 12 | +use Inertia\Inertia; |
| 13 | +use Inertia\Response; |
| 14 | +use InvalidArgumentException; |
| 15 | + |
| 16 | +/** |
| 17 | + * @phpstan-type Icon array{src: string, width: int, height: int} |
| 18 | + * @phpstan-type Summary array{slug: string, name: string, publisher: string, tier: string, categories: list<string>, platforms: list<string>, closedSource: bool, tagline: string, keywords: list<string>, icon: Icon|null} |
| 19 | + * @phpstan-type Filters array{q: string|null, category: string|null, platform: string|null, tier: string|null} |
| 20 | + */ |
| 21 | +class IntegrationController extends Controller |
| 22 | +{ |
| 23 | + private const array TIERS = ['official', 'partner', 'community']; |
| 24 | + |
| 25 | + // The registry names the OS a TablePro app runs on; the site names the app. |
| 26 | + private const array PLATFORMS = ['macos' => 'mac', 'ios' => 'ios', 'ipados' => 'ios']; |
| 27 | + |
| 28 | + private const array NETWORK = ['none', 'local', 'internet']; |
| 29 | + |
| 30 | + private const array PAYMENT = ['free', 'optional', 'paid']; |
| 31 | + |
| 32 | + private const int QUERY_LENGTH = 100; |
| 33 | + |
| 34 | + private const string IMAGES = '/images/integrations/'; |
| 35 | + |
| 36 | + public function index(Request $request, ContentRepository $content, IntegrationCatalog $catalog): Response |
| 37 | + { |
| 38 | + $locale = App::getLocale(); |
| 39 | + $page = $content->page('integrations/index', $locale); |
| 40 | + $listed = $this->listed($catalog, $page, $content, $locale); |
| 41 | + |
| 42 | + return Inertia::render('Integrations/Index', [ |
| 43 | + 'content' => Arr::except($page, ['taglines', 'show']), |
| 44 | + 'integrations' => $listed, |
| 45 | + 'filters' => $this->filters($request, $listed), |
| 46 | + ]); |
| 47 | + } |
| 48 | + |
| 49 | + public function show(ContentRepository $content, IntegrationCatalog $catalog, string $slug): Response |
| 50 | + { |
| 51 | + $integration = $catalog->find($slug); |
| 52 | + |
| 53 | + abort_if($integration === null, 404); |
| 54 | + |
| 55 | + $locale = App::getLocale(); |
| 56 | + $page = $content->page('integrations/index', $locale); |
| 57 | + |
| 58 | + return Inertia::render('Integrations/Show', [ |
| 59 | + 'content' => Arr::only($page, ['labels', 'show']), |
| 60 | + 'integration' => $this->detail($integration, $page, $catalog), |
| 61 | + 'dates' => [ |
| 62 | + 'added' => $this->date($integration['addedAt'], $locale), |
| 63 | + 'verified' => $this->date($integration['lastVerifiedAt'], $locale), |
| 64 | + 'archived' => $this->date($integration['status']['since'] ?? null, $locale), |
| 65 | + ], |
| 66 | + ]); |
| 67 | + } |
| 68 | + |
| 69 | + /** |
| 70 | + * @param array<string, mixed> $page |
| 71 | + * @return list<Summary> |
| 72 | + */ |
| 73 | + private function listed(IntegrationCatalog $catalog, array $page, ContentRepository $content, string $locale): array |
| 74 | + { |
| 75 | + $default = $locale === Locales::default(); |
| 76 | + $taglines = $this->taglines($page); |
| 77 | + $sources = $default ? [] : $this->taglines($content->page('integrations/index', Locales::default())); |
| 78 | + $categories = $this->labelled($page, 'labels.categories'); |
| 79 | + $listed = []; |
| 80 | + |
| 81 | + foreach ($catalog->all() as $integration) { |
| 82 | + $slug = $integration['slug']; |
| 83 | + |
| 84 | + if (($integration['status']['state'] ?? null) !== 'active') { |
| 85 | + continue; |
| 86 | + } |
| 87 | + |
| 88 | + // Another language lists an entry only while its tagline there was translated from the current summary. |
| 89 | + if ($default) { |
| 90 | + $tagline = $integration['summary']; |
| 91 | + } elseif (($sources[$slug] ?? null) === $integration['summary'] && isset($taglines[$slug])) { |
| 92 | + $tagline = $taglines[$slug]; |
| 93 | + } else { |
| 94 | + continue; |
| 95 | + } |
| 96 | + |
| 97 | + $listed[] = [ |
| 98 | + 'slug' => $slug, |
| 99 | + 'name' => $integration['name'], |
| 100 | + 'publisher' => $integration['publisher']['name'], |
| 101 | + 'tier' => $this->tier($integration), |
| 102 | + 'categories' => $this->known($integration['categories'], $categories), |
| 103 | + 'platforms' => $this->platforms($integration['platforms']), |
| 104 | + 'closedSource' => $integration['closedSource'] === true, |
| 105 | + 'tagline' => $tagline, |
| 106 | + 'keywords' => array_values(array_filter($integration['keywords'] ?? [], 'is_string')), |
| 107 | + 'icon' => $this->image($integration['icon']['128'] ?? null), |
| 108 | + ]; |
| 109 | + } |
| 110 | + |
| 111 | + $rank = array_flip(self::TIERS); |
| 112 | + |
| 113 | + usort($listed, fn(array $a, array $b): int => [$rank[$a['tier']], strtolower($a['name'])] <=> [$rank[$b['tier']], strtolower($b['name'])]); |
| 114 | + |
| 115 | + return $listed; |
| 116 | + } |
| 117 | + |
| 118 | + /** |
| 119 | + * @param array<string, mixed> $page |
| 120 | + * @return array<string, string> |
| 121 | + */ |
| 122 | + private function taglines(array $page): array |
| 123 | + { |
| 124 | + $taglines = is_array($page['taglines'] ?? null) ? $page['taglines'] : []; |
| 125 | + |
| 126 | + return array_filter($taglines, fn(mixed $tagline): bool => is_string($tagline) && trim($tagline) !== ''); |
| 127 | + } |
| 128 | + |
| 129 | + /** |
| 130 | + * @param list<Summary> $listed |
| 131 | + * @return Filters |
| 132 | + */ |
| 133 | + private function filters(Request $request, array $listed): array |
| 134 | + { |
| 135 | + $value = function (string $key, string $field) use ($request, $listed): ?string { |
| 136 | + $value = $request->query($key); |
| 137 | + $present = array_merge(...array_map(fn(array $entry): array => (array) $entry[$field], $listed)); |
| 138 | + |
| 139 | + return is_string($value) && in_array($value, $present, true) ? $value : null; |
| 140 | + }; |
| 141 | + |
| 142 | + $q = $request->query('q'); |
| 143 | + $q = is_string($q) ? trim(mb_substr(trim($q), 0, self::QUERY_LENGTH)) : ''; |
| 144 | + |
| 145 | + return [ |
| 146 | + 'q' => $q !== '' ? $q : null, |
| 147 | + 'category' => $value('category', 'categories'), |
| 148 | + 'platform' => $value('platform', 'platforms'), |
| 149 | + 'tier' => $value('tier', 'tier'), |
| 150 | + ]; |
| 151 | + } |
| 152 | + |
| 153 | + /** |
| 154 | + * @param array<string, mixed> $integration |
| 155 | + * @param array<string, mixed> $page |
| 156 | + * @return array<string, mixed> |
| 157 | + */ |
| 158 | + private function detail(array $integration, array $page, IntegrationCatalog $catalog): array |
| 159 | + { |
| 160 | + $status = $integration['status']; |
| 161 | + $disclosures = $integration['disclosures']; |
| 162 | + $install = $integration['install']; |
| 163 | + $host = $integration['host'] ?? null; |
| 164 | + $versions = $integration['minTableProVersion'] ?? []; |
| 165 | + $replacement = is_string($status['replacement'] ?? null) ? $catalog->find($status['replacement']) : null; |
| 166 | + $type = in_array($install['type'], $this->labelled($page, 'show.install'), true) ? $install['type'] : 'page'; |
| 167 | + $data = $this->labelled($page, 'show.uses.data'); |
| 168 | + |
| 169 | + return [ |
| 170 | + 'slug' => $integration['slug'], |
| 171 | + 'name' => $integration['name'], |
| 172 | + 'summary' => $integration['summary'], |
| 173 | + 'description' => array_values(array_filter(array_map('trim', preg_split('/\n\s*\n/', $integration['description']) ?: []))), |
| 174 | + 'tier' => $this->tier($integration), |
| 175 | + 'archived' => $status['state'] !== 'active' ? [ |
| 176 | + 'reason' => in_array($status['reason'] ?? null, $this->labelled($page, 'show.archived.reasons'), true) ? $status['reason'] : null, |
| 177 | + 'note' => $this->text($status['note'] ?? null), |
| 178 | + 'replacement' => $replacement !== null ? ['slug' => $replacement['slug'], 'name' => $replacement['name']] : null, |
| 179 | + ] : null, |
| 180 | + 'publisher' => ['name' => $integration['publisher']['name'], 'url' => $this->https($integration['publisher']['url'] ?? null)], |
| 181 | + 'categories' => $this->known($integration['categories'], $this->labelled($page, 'labels.categories')), |
| 182 | + 'platforms' => $this->platforms($integration['platforms']), |
| 183 | + 'versions' => [ |
| 184 | + 'mac' => $this->text($versions['macos'] ?? null), |
| 185 | + 'ios' => $this->text($versions['ios'] ?? $versions['ipados'] ?? null), |
| 186 | + ], |
| 187 | + 'host' => is_array($host) ? [ |
| 188 | + 'name' => $host['name'], |
| 189 | + 'url' => $this->https($host['url'] ?? null), |
| 190 | + 'minVersion' => $this->text($host['minVersion'] ?? null), |
| 191 | + 'note' => $this->text($host['note'] ?? null), |
| 192 | + ] : null, |
| 193 | + 'closedSource' => $integration['closedSource'] === true, |
| 194 | + 'license' => $this->text($integration['license'] ?? null), |
| 195 | + 'source' => $this->https($integration['source']['url'] ?? null), |
| 196 | + 'install' => [ |
| 197 | + 'type' => $type, |
| 198 | + 'url' => $this->https($install['url']), |
| 199 | + 'command' => $type === 'homebrew' ? $this->brew($install) : null, |
| 200 | + ], |
| 201 | + 'surfaces' => $this->known($integration['surfaces'], $this->labelled($page, 'show.uses.surface')), |
| 202 | + 'reads' => $this->known($disclosures['reads'] ?? [], $data), |
| 203 | + 'writes' => $this->known($disclosures['writes'] ?? [], $data), |
| 204 | + 'accessNote' => $this->text($disclosures['accessNote'] ?? null), |
| 205 | + 'lowersSafeMode' => ($disclosures['lowersSafeMode'] ?? false) === true, |
| 206 | + 'safeModeNote' => $this->text($disclosures['safeModeNote'] ?? null), |
| 207 | + 'network' => in_array($disclosures['network'] ?? null, self::NETWORK, true) ? $disclosures['network'] : null, |
| 208 | + 'networkNote' => $this->text($disclosures['networkNote'] ?? null), |
| 209 | + 'account' => ($disclosures['account'] ?? false) === true, |
| 210 | + 'payment' => in_array($disclosures['payment'] ?? null, self::PAYMENT, true) ? $disclosures['payment'] : null, |
| 211 | + 'paymentNote' => $this->text($disclosures['paymentNote'] ?? null), |
| 212 | + 'links' => [ |
| 213 | + 'docs' => $this->https($integration['links']['docs'] ?? null), |
| 214 | + 'issues' => $this->https($integration['links']['issues'] ?? $integration['links']['support'] ?? null), |
| 215 | + 'privacy' => $this->https($integration['links']['privacy'] ?? null), |
| 216 | + ], |
| 217 | + 'icon' => $this->image($integration['icon']['128'] ?? null), |
| 218 | + 'screenshots' => $this->screenshots($integration['screenshots']), |
| 219 | + ]; |
| 220 | + } |
| 221 | + |
| 222 | + /** |
| 223 | + * @param list<mixed> $screenshots |
| 224 | + * @return list<array{src: string, width: int, height: int, alt: string}> |
| 225 | + */ |
| 226 | + private function screenshots(array $screenshots): array |
| 227 | + { |
| 228 | + $shots = []; |
| 229 | + |
| 230 | + foreach ($screenshots as $shot) { |
| 231 | + $image = $this->image($shot); |
| 232 | + |
| 233 | + if ($image !== null && is_string($shot['alt'] ?? null)) { |
| 234 | + $shots[] = [...$image, 'alt' => $shot['alt']]; |
| 235 | + } |
| 236 | + } |
| 237 | + |
| 238 | + return $shots; |
| 239 | + } |
| 240 | + |
| 241 | + /** |
| 242 | + * @param array<string, mixed> $integration |
| 243 | + */ |
| 244 | + private function tier(array $integration): string |
| 245 | + { |
| 246 | + // An unknown tier gets the most cautious treatment: the disclaimer and rel="ugc nofollow". |
| 247 | + return in_array($integration['tier'], self::TIERS, true) ? $integration['tier'] : 'community'; |
| 248 | + } |
| 249 | + |
| 250 | + /** |
| 251 | + * @param list<mixed> $platforms |
| 252 | + * @return list<string> |
| 253 | + */ |
| 254 | + private function platforms(array $platforms): array |
| 255 | + { |
| 256 | + $apps = array_map(fn(mixed $platform): ?string => is_string($platform) ? (self::PLATFORMS[$platform] ?? null) : null, $platforms); |
| 257 | + |
| 258 | + return array_values(array_intersect(array_unique(self::PLATFORMS), $apps)); |
| 259 | + } |
| 260 | + |
| 261 | + /** |
| 262 | + * @param array<string, mixed> $page |
| 263 | + * @return list<string> |
| 264 | + */ |
| 265 | + private function labelled(array $page, string $key): array |
| 266 | + { |
| 267 | + // A value the registry adds later stays off the page until the content file labels it. |
| 268 | + $labels = Arr::get($page, $key); |
| 269 | + |
| 270 | + return is_array($labels) ? array_map('strval', array_keys($labels)) : []; |
| 271 | + } |
| 272 | + |
| 273 | + /** |
| 274 | + * @param list<mixed> $values |
| 275 | + * @param list<string> $known |
| 276 | + * @return list<string> |
| 277 | + */ |
| 278 | + private function known(array $values, array $known): array |
| 279 | + { |
| 280 | + return array_values(array_intersect($known, $values)); |
| 281 | + } |
| 282 | + |
| 283 | + /** |
| 284 | + * @return Icon|null |
| 285 | + */ |
| 286 | + private function image(mixed $image): ?array |
| 287 | + { |
| 288 | + 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) { |
| 289 | + return null; |
| 290 | + } |
| 291 | + |
| 292 | + return ['src' => self::IMAGES . $image['file'], 'width' => (int) $image['width'], 'height' => (int) $image['height']]; |
| 293 | + } |
| 294 | + |
| 295 | + /** |
| 296 | + * @param array<string, mixed> $install |
| 297 | + */ |
| 298 | + private function brew(array $install): ?string |
| 299 | + { |
| 300 | + $token = $install['token'] ?? null; |
| 301 | + |
| 302 | + if (! is_string($token) || preg_match('/^[a-z0-9][a-z0-9@+._-]{0,99}$/', $token) !== 1) { |
| 303 | + return null; |
| 304 | + } |
| 305 | + |
| 306 | + return ($install['kind'] ?? null) === 'cask' ? "brew install --cask {$token}" : "brew install {$token}"; |
| 307 | + } |
| 308 | + |
| 309 | + private function https(mixed $url): ?string |
| 310 | + { |
| 311 | + if (! is_string($url) || ! str_starts_with($url, 'https://') || filter_var($url, FILTER_VALIDATE_URL) === false) { |
| 312 | + return null; |
| 313 | + } |
| 314 | + |
| 315 | + return parse_url($url, PHP_URL_USER) === null ? $url : null; |
| 316 | + } |
| 317 | + |
| 318 | + private function text(mixed $value): ?string |
| 319 | + { |
| 320 | + return is_string($value) && trim($value) !== '' ? $value : null; |
| 321 | + } |
| 322 | + |
| 323 | + private function date(mixed $value, string $locale): ?string |
| 324 | + { |
| 325 | + if (! is_string($value) || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { |
| 326 | + return null; |
| 327 | + } |
| 328 | + |
| 329 | + try { |
| 330 | + $date = Carbon::createFromFormat('!Y-m-d', $value); |
| 331 | + } catch (InvalidArgumentException) { |
| 332 | + return null; |
| 333 | + } |
| 334 | + |
| 335 | + return $date !== null && $date->format('Y-m-d') === $value ? $date->locale($locale)->isoFormat('LL') : null; |
| 336 | + } |
| 337 | +} |
0 commit comments