]> ##2026.08.10 - Fix: searching in the modern view found nothing for words that Community Applications matches dozens of apps on. CA searches an app's full description and its ExtraSearchTerms, a hidden keyword list many templates carry, while this grid searched the name, category and repository alone. So "emulator" came back empty here and listed Companion, romm, ipxbox and twenty others with modern view switched off. Every word of a query is now matched the way CA matches it, each word having to appear somewhere and any field counting, across the name, author, repository, image, categories, the full description and those hidden keywords. - Apps whose name matches are listed ahead of apps that only mention the word further down their description, so a search for "plex" no longer buries Plex under everything that talks about it. The chosen Sort By still orders each of the two groups. - Clearing the search with Community Applications' own X button now clears the grid with it. CA empties the box through jQuery, which fires no input event, so the grid stayed filtered on a query that was no longer on screen. - A search now covers the whole store rather than the category that happened to be open, which is what CA does: it disables its category menu for the duration of a search. ##2026.08.06c - Every card now shows when the app itself last shipped, on the same line as the Added date: Added at the left edge of the card's foot, Updated at the right. For a Docker app the date is when the image was last published to its registry, the same figure Community Applications shows as "Last Update" in its own drawer, and it is left off an app pinned to a tag other than :latest because that number belongs to the repository rather than to the pinned tag. Plugins carry no such field in the feed, so their date-formed version number (2026.06.10a) is read back as the release date it is, and shown without a time of day since only the day is real there. A semver plugin version yields no date rather than a guess. Roughly seven of every ten apps have one. - Both dates now read as an interval while they are recent ("just now", "3 hours ago", "yesterday", "12 days ago") and switch to the full date past a month, where an interval stops helping. The exact timestamp is in the tooltip. - The card's date line reserves its height whether or not either date is known, so a card missing a date no longer lifts its own button row above the rest of its grid row. - The Info drawer's description no longer hides behind a SHOW MORE button. The block keeps the same height and scrolls, so the whole description is one gesture away instead of an expand followed by a scroll. - The drawer's close bar is now 40px, the same height as Unraid's own menu bar, rather than a taller strip of its own. - Descriptions written as hard-wrapped plain text no longer render as ragged, indented fragments. Many templates carry a line break every eighty characters followed by four spaces of indent, which is invisible in the editor they were written in and nonsense in a panel of a different width. Those wraps are now joined back into paragraphs, while a deliberate blank line between paragraphs, and a short line that was meant to stand alone, are both left as written. - The description block is the same width as the Details card below it, instead of the 90% Community Applications gives it. - The category menu no longer starts a search bar's height below the toolbar, which left a hole in the left column. Home now sits on the search box's line. The menu also picks up the grid's own styling, and marks the entry you are actually looking at, which it never did before. - The version Community Applications prints at the foot of that menu is now one quiet line rather than two lines set in the same type as the navigation above it. A footnote should not read as another place to click. ##2026.08.03a - Three rolling archives of Community Applications' own template catalog, kept in this plugin's data directory as catalog_weekly.json.gz, catalog_monthly.json.gz and catalog_yearly.json.gz. Each is a gzip of CA's templates_new.json exactly as CA wrote it, so there is always a copy no older than a week, one no older than a month, and one no older than a year. The data directory is on the Unraid flash, so the check for whether an archive is due reads no file at all, the catalog is only opened once a window has elapsed, and windows that come due together share a single compression pass. Steady state is one write a week. - Docker-unavailable cards keep the grid aligned: the reason now sits above the button row rather than below it, so every card in a row still bottom-aligns its buttons and its Added line. The notice strip's edges line up with the tile grid. ##2026.08.03 - Fix: the modern grid was empty after a reboot. Community Applications keeps its app catalog in /tmp, so it is gone on every boot until CA's own Apps page has re-downloaded the feed. The modern grid loaded first, found nothing, printed "No apps to show" and stayed that way until a manual reload, which is why the store looked empty in modern view but full with modern view switched off. It now recognises a catalog that has not been published yet, says so, and fills in by itself the moment CA writes it. - The modern view now mirrors CA's behaviour when Docker is not running. CA never empties the store in that state: it lists every app, keeps plugins fully installable, and blocks only Docker installs. The grid now does the same, with CA's own reason (Docker service not enabled, Docker failed to start, or array not started) shown above the grid and on each Docker card, and Install disabled on those cards only. ##2026.08.02 - Four new Sort By orders: Trending (this year), Trending (all time), Trending % (this year) and Trending % (all time). All five trending windows now rank by GitHub stars and differ only in the period they measure. - "This year" is a real 365-day star delta. The plugin's daily snapshots cannot reach back that far on a young install, so the year-ago baseline is binary-searched out of the repo's stargazer list (about four requests for a 1,000-star repo) and cached for 30 days. Repos created inside the window cost no request at all. The biggest repos are walked first, so a capped run still produces a correct leaderboard, and browsing fills the rest in page by page. - "All time" is every star the repo has ever gained. Its percentage form is a lifetime rate (stars per year of repo age) because the other percent sorts divide by the star count at the window's start, which at a repo's creation is zero. It separates 5,000 stars in two years from 20,000 in twelve. - Fix (critical): the star fetcher crashed on every run before writing any data. Apps with no GitHub repository passed null into a parameter typed as a plain string, which is a TypeError in PHP 8, so the run died partway through building the catalog. Star counts, trends and the app list had silently stopped updating. - A GitHub token that cannot read star dates is now detected with a single probe request instead of thousands of failed ones, and is reported in plain language. GitHub refuses fine-grained tokens (github_pat_...) access to the stargazers endpoint, which is what the "this year" windows are built from; a classic token (ghp_...) with no scopes reads them. The settings page explains this when it applies, and the empty grid says which of the two reasons it is empty for. - The short trending windows no longer re-walk the whole catalog every scan. Once a repo has a star snapshot older than a day the snapshots are used outright, so that pass is now limited to repos that genuinely lack a baseline. - The Sort By dropdown is grouped (Name, Popularity, Trending, Trending %) rather than one flat list of fifteen near-identical labels, and each trending entry carries a tooltip naming exactly what it measures. - Fix: updating the plugin did not replace its files. Unraid's plugin manager skips any bundled file that already exists, so an update over a running install kept every old file and only took effect after a reboot (the plugin webroot lives in RAM). The installer now clears its own webroot before unpacking. The data directory on the flash, with the settings and star history, is untouched. - Renamed to "Unraid Modern App Store" throughout, and the plugin id is now modern.appstore: its folders are /usr/local/emhttp/plugins/modern.appstore and /boot/config/plugins/modern.appstore, and the settings page moved to Settings -> Utilities -> Unraid Modern App Store. Installing this version migrates the GitHub token and the whole star history from the old id and removes the old plugin, so nothing has to be set up again. - Fix: templates with no author showed a raw ca.unraid.net link across the card (CA leaves Author empty for most plugins and puts the .plg URL in its place). The repository owner's name is used instead, and a URL is never shown as an author. - Cards can no longer be pushed out of shape by a template: long names, authors and categories wrap or ellipsise inside the card, and a long title stops short of the star badges instead of running under them. - Removed sortinject.php. It was left over from the old design and was the only code that wrote into Community Applications' own cache files; the modern grid has not used it since. The plugin now writes nothing outside its own folders. - If inject.js ever fails to load, the page no longer stays blank: CA's stock view is restored after 5 seconds. - Stars are now fetched for the apps on screen instead of the whole catalog. Browsing to a page tops up whatever that page is missing, and an app is only re-checked if it has never been tried or its last attempt is over a week old. - The refresh icon now offers "Refresh this page" (rescan what you are looking at, ignoring the weekly window) or "Refresh everything" (the full catalog scan, still limited to once every 3 days). - Fix: CA's stock grid, sort row and Results-Per-Page button no longer flash on screen before the modern view takes over on a page load. - Fix: about half the catalog had no GitHub star count. CA stopped publishing most Project URLs directly and now hands out opaque ca.unraid.net/cdn/... redirectors, so there was no github.com link left to read. Those are resolved once and cached, and GitHub Pages URLs (owner.github.io/repo) now map to their repo too. Apps whose project link is a plain homepage still have no stars, because there is no repository to count. - The progress bar names the link-resolution pass, which runs before the star fetch on the first scan after this update. - Info drawer: Description, Details and Maintainer are now one full-width column of equal-width cards, with Maintainer compressed to two rows. - Tiles now show when an app was added to the App Store (date and time from CA's own feed), in small text at the bottom right of the card. - Fix: the Details table text had no breathing room and Unraid's global table background painted a lighter slab behind it, so the values looked clipped at the card edge. - Fix: the drawer's X close glyph is centred in its circle (the shared button gap was pushing it off centre). - Info drawer redesigned. The app header, description, Details and Maintainer now read as one modern card layout instead of stock CA styling: - CLOSE is a round X in a slim bar that stays pinned to the top while the drawer scrolls, rather than a pill floating in the corner. - Details labels are legible (they were near-invisible dark grey), values are right-aligned, and long repository names no longer get clipped. - Details and Maintainer sit side by side in cards, and stack on a phone. - The Maintainer's All Apps / Profile / Favourite buttons match the rest of the drawer instead of being stock white App Store pills. - Section headings, screenshots, spotlight, moderator notes and the change log all follow the same type scale and card treatment. - Colours now derive from the active Unraid theme, so the drawer is readable on the white themes too. - Fix: the drawer CLOSE button text is now light (was black on dark, invisible until hovered). - Fix: tile buttons no longer wrap to two lines ("Pin App" stayed on one line and all five buttons fit one row). - Info drawer: removed the blank space at the top and moved CLOSE up in line with the app title. - Fix: the Info-drawer description text was black on the dark card (unreadable); CA set it via a more specific rule, now forced to a light colour. - Fix: preview screenshots now open in a built-in lightbox. CA's own gallery closed the whole Info drawer when a preview opened and re-opened it on close, which flashed the drawer blank and could show two images overlaid. The drawer now stays put behind the lightbox. - Repo renamed to unraid-modern-appstore; the plugin update URL now points there. - Fix: the Sort By dropdown is vertically aligned with the toolbar labels. - Renamed to "Unraid Modern Appstore" (settings page, in-app labels, README and the GitHub page). The internal plugin id is unchanged, so no reinstall is required. - Fix: CA's "Updating Content / Please Wait" modal and spinner no longer get stuck open over the modern grid. - Home now shows All Apps in the modern grid (was empty); the empty-state message is centered. - Tiles show whether an app is a Docker container or a Plugin, and duplicate listings of the same app are collapsed. - Tiles now have direct Project and Support buttons (no submenu); Install opens the template in a new tab. - The chosen sort persists across reloads, and resets to Newest after 20 minutes away from the Apps page. - Higher-contrast buttons and description text in the modern Info drawer. - Pinned Apps and Installed Apps now render in the modern grid (with GitHub stars), because CA's own Pinned/Installed views are broken in its 2026.07 rewrite (they showed the home screen). Pin/Unpin works from the tiles and the drawer alike. - Home and category browsing stay in the modern grid; only Previous Apps, Action Centre and Repositories still hand off to CA for now. - Fix: the leaked CA category label next to the toolbar is hidden; each view shows a proper bold title (All Apps / Pinned Apps / Installed Apps) with its count. - Fix: Pin App writes CA's real key (image ref + SortName) so pins actually appear. - Modern Info drawer: the slide-in app drawer is restyled to match the grid (rounded icon, orange Install, pill buttons, carded description, orange section titles). Turning the modern view off restores CA's stock drawer. - Fix: CA's own views (Pinned Apps, Installed Apps, Previous Apps, Action Centre, Repositories) now display CA's native list instead of being hidden behind our grid. Selecting All Apps, a category or searching returns to the modern grid. - Fix: the Pin App button now pins with CA's real key (image ref + SortName), so pinned apps show up under Pinned Apps exactly like CA's own drawer button. - Tiles: stars and Docker-pull count now sit inline in the top-right corner; a Pin App button was added (pins via CA's own pinApp, same as the Info drawer); the toggle is labelled "Modern view". - Icons: apps whose template ships no icon now fall back to the GitHub owner's avatar instead of a blank question mark. - Downloads: the count is Docker image pulls (what CA reports). Apps built on an official base image (e.g. nginx:alpine) inherited that base image's pull count (nginx = billions), which is meaningless for the app, so those are dropped from the count. Language packs are excluded from the grid. - The stock CA sort row and Results-Per-Page button are hidden while the modern view is on (our grid has its own), and restored when it is off. - Accent colour matches the Unraid logo orange. - Rearchitected the GitHub view. Community Applications' 2026.07 rewrite broke its own client-side sort (any sort collapsed the list to ~36 apps). The addon now renders its own grid over the full catalog: reliable sorting by name, Unraid downloads, newest, GitHub stars and trending; real pagination; search; and category filtering. Clicking a tile opens CA's own Info/Install drawer, so installs are unchanged. A toggle switches back to the stock CA view at any time. - Tiles show the app description, author and category with modern Info/Support/ Install buttons, a GitHub-star badge and an Unraid-download-count badge. - Language packs are filtered out of the app grid, matching CA. - Trending is limited to day/week/month (accurate star-history deltas) and each trending view lists only apps that actually moved, instead of the whole catalog. The unreliable "this year" window was removed until a year of history exists. - Accent colour matches the Unraid logo orange. - Fix: "Newest to the App Store" and "Unraid Downloads" intermittently fell back to alphabetical order. They relied on CA's native sort, which does not re-apply after CA rebuilds the list A-Z on a results-per-page change. Every sort now runs through the addon's own self-healing path, so the order is stable and correct. - Fix: the 96-results-per-page default did not always take effect. The old code marked itself done before 96 was actually applied; it now confirms 96 is active and retries until it is. - Change: removed the "GitHub" item from the left menu. The Sort By dropdown covers everything it did, so browsing is just CA's normal All Apps view. - Fix: the Sort By dropdown is now present in BOTH the GitHub view and All Apps (it was missing whenever CA hid its sort area), and the search row no longer sits jammed against the nav bar. - Fix: the Sort By dropdown was invisible. It was placed inside CA's #sortIconArea, which CA hides as a unit (clearSearchBox calls hideSortIcons), so it vanished in the GitHub view. The dropdown now lives in the toolbar row next to the search box, which CA never hides; CA's own sort links are still hidden in place. - Fix: sorting during a search. The Community Applications 2026.07.21 rewrite sorts three view caches (displayed.json plus allSearchResults.json and catSearchResults.json) and rebuilds them from the raw feed on every search and category change, which dropped our metrics, so a GitHub sort silently fell back to feed order and put 0-star apps above 50k-star ones. All three caches are now injected, and the addon re-injects and re-sorts once after each CA render. - Change: one "Sort By:" dropdown instead of two competing sort controls. It holds CA's own orders (Name Ascending/Descending, Unraid Downloads) alongside ours (Newest to the App Store, GitHub Stars, Trending, Trending %). CA's own sort links are hidden, not removed, and its orders are applied by driving CA's own controls, so nothing about CA is modified. - Change: results per page defaults to 96 (CA's largest) once per browser, so a stars or trending ranking shows a meaningful page. Your choice is respected after that. - Feature: "Trending %" sort (today/week/month/year) in the GitHub view. Ranks by RELATIVE star growth (window delta / stars at the window's start), so fast-growing small apps surface above mega-repos that dominate the absolute-delta sort. A 10-star baseline floor keeps trivial repos (2->4 stars) out of the top. - Fix: derive each app's repo from its Project URL only, not Support. Support URLs are "get help" links that template authors routinely point at an umbrella project's issues/discussions page (every Immich component links to github.com/immich-app/immich), which mis-attributed that repo's ~108k stars to unrelated components (immich-postgres, immich-redis, etc.). Also ignores GitHub non-repo paths (issues/discussions/org pages). - Fix (critical): store data on the flash (/boot/config/plugins/modern.appstore) instead of /mnt/user/appdata. The install hook created its data dir under /mnt/user before the array mounted, which made Unraid's shfs refuse to mount /mnt/user and hid every user share. Existing installs are migrated automatically. - Fix: compatibility with the Community Applications 7.2.3 rewrite. - Star badge sits in the tile's top-right corner (clear of the app icon); on Official/Installed cards it slides left of CA's corner ribbon. - GitHub sort works again (star + trending): sort injection targets CA's displayed.json regardless of layout churn. - Fix: trending (day/week/month/year) is now a real, uncapped star delta from the daily snapshot history instead of a single stargazer page that saturated at 100 and tied every hot repo. Recompute path added (--trends-only, no API). - GitHub star counts on every CA app tile. - "GitHub" view: the real App Store catalog, sortable by stars, trending, or newest. - Per-user GitHub token (set in Settings); no secrets shipped. stars map for the badge painter * - apps.json : full catalog (name, path, icon, author, category, stars, * downloads, repo age, trend deltas) for the dedicated GitHub view * Records a star-history snapshot per run so trending (1d/1w/1m/1y) can be * computed over time. * * The 1d/1w/1m windows come from those daily snapshots. A YEAR window cannot: * a fresh install has no year-old snapshot and would never populate one, so the * year-ago baseline is walked out of GitHub's stargazer list instead (see * backfill_year_baselines) and cached per repo. created_at is recorded too, so * the grid can rank by lifetime growth rate. * * Persistent data (DB + JSON) lives in a configurable appdata dir on the cache * SSD so it survives reboots; served copies go to the tmpfs webroot. curl_multi * keep-alive for speed; a flock guarantees one scan at a time. * * NEVER writes to any CA-owned path. NEVER logs the token. The User-Agent names * the project only; it carries nothing about the server it runs on. */ error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE); const UA = 'unraid-modern-appstore/1.0 (+https://github.com/ghzgod/unraid-modern-appstore)'; const PLUGIN = 'modern.appstore'; $cfgPath = '/boot/config/plugins/' . PLUGIN . '/' . PLUGIN . '.cfg'; $defaults = [ 'cfg' => $cfgPath, 'data-dir' => '', // resolved below (cfg DATA_DIR or appdata default) 'db' => '', 'out-dir' => '/usr/local/emhttp/plugins/' . PLUGIN, 'ca-cache' => '/tmp/community.applications/tempFiles/templates_new.json', 'limit' => '0', 'concurrency' => '8', 'manual' => '0', // 1 = invoked by the Refresh button (records manual time) 'sg-limit' => '0', // cap repos for the stargazer-trend backfill (0 = all) 'new-only' => '0', // 1 = only fetch repos not yet in the DB (newly-published apps) 'only-paths' => '', // file of CA template paths, one per line: scan only these apps 'stale-days' => '0', // with --only-paths: skip repos already tried within N days 'trends-only' => '0', // 1 = no network: recompute trend deltas from stored star history and rewrite JSON 'year-limit' => '400',// max repos to walk for a year-ago star baseline per run (0 = no cap) ]; // ---- arg parsing ----------------------------------------------------------- $opt = $defaults; for ($i = 1; $i < $argc; $i++) { if (substr($argv[$i], 0, 2) === '--') { $key = substr($argv[$i], 2); if (!array_key_exists($key, $opt)) continue; $next = $argv[$i + 1] ?? null; if ($next === null || substr($next, 0, 2) === '--') { $opt[$key] = '1'; // valueless flag, e.g. "--new-only" } else { $opt[$key] = $next; $i++; // "--limit 5" } } } $limit = (int)$opt['limit']; $concurrency = max(1, min(16, (int)$opt['concurrency'])); $trendsOnly = ((int)$opt['trends-only'] === 1); // recompute trends from history, no GitHub calls // ---- read cfg early (TOKEN + DATA_DIR) ------------------------------------- $cfg = is_file($opt['cfg']) ? @parse_ini_file($opt['cfg']) : []; $token = trim($cfg['TOKEN'] ?? ''); $cfgDataDir = trim($cfg['DATA_DIR'] ?? ''); $appdataDefault = '/boot/config/plugins/modern.appstore'; $dataDir = $opt['data-dir'] !== '' ? $opt['data-dir'] : ($cfgDataDir !== '' ? $cfgDataDir : $appdataDefault); $dataDir = rtrim($dataDir, '/'); $db_path = $opt['db'] !== '' ? $opt['db'] : ($dataDir . '/stars.db'); $outDir = rtrim($opt['out-dir'], '/'); @mkdir($dataDir, 0755, true); $status = [ 'ran_at' => time(), 'repos_total' => 0, 'ok' => 0, 'not_modified' => 0, 'missing' => 0, 'rate_remaining' => null, 'errors' => [], ]; function write_status(array $status, string $outDir, string $dataDir): void { $json = json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @file_put_contents($outDir . '/status.json', $json); @file_put_contents($dataDir . '/status.json', $json); } function write_progress(string $outDir, bool $running, int $done, int $total, array $status, string $phase = 'stars'): void { @file_put_contents($outDir . '/progress.json', json_encode([ 'running' => $running, 'done' => $done, 'total' => $total, 'phase' => $phase, 'ok' => $status['ok'], 'not_modified' => $status['not_modified'], 'missing' => $status['missing'], 'errors' => count($status['errors']), 'updated_at' => time(), ], JSON_UNESCAPED_SLASHES)); } // ---- single-instance lock -------------------------------------------------- $lockFh = @fopen($dataDir . '/fetch.lock', 'c'); if ($lockFh && !flock($lockFh, LOCK_EX | LOCK_NB)) { fwrite(STDERR, "fetch_stars: another scan is already running; exiting.\n"); exit(0); } if ((int)$opt['manual'] === 1) { @file_put_contents($dataDir . '/last_manual.json', json_encode(['ts' => time()])); } // ---- rolling archives of CA's catalog -------------------------------------- // Three snapshots of Community Applications' own template catalog kept in this // plugin's data directory: one never older than a week, one than a month, one // than a year. Each is a gzip of CA's templates_new.json exactly as CA wrote it. // // That data directory lives on the Unraid flash, which is a USB stick, so this // is written as seldom as it can be. The due check is a filemtime comparison // that opens nothing; the 27 MB catalog is only read once a window has actually // elapsed; it is gzipped (5.5 MB, measured) before it lands; and when two windows // come due in the same run the bytes are compressed once and reused. Steady // state is one write a week, one a month, one a year. const ARCHIVE_WINDOWS = ['weekly' => 604800, 'monthly' => 2592000, 'yearly' => 31536000]; function archive_path(string $dataDir, string $name): string { return $dataDir . '/catalog_' . $name . '.json.gz'; } // Which archives exist and when each was taken, for status.json. function archive_state(string $dataDir): array { $out = []; foreach (ARCHIVE_WINDOWS as $name => $window) { $ts = @filemtime(archive_path($dataDir, $name)); $out[$name] = $ts === false ? 0 : $ts; } return $out; } // Returns the names of the archives actually written this run (often none). function archive_catalogs(string $src, string $dataDir): array { $now = time(); $due = []; foreach (ARCHIVE_WINDOWS as $name => $window) { $path = archive_path($dataDir, $name); $ts = @filemtime($path); if ($ts === false || ($now - $ts) >= $window) $due[$name] = $path; } if (!$due || !is_file($src)) return []; $raw = @file_get_contents($src); // read-only, CA's file is never touched if ($raw === false || $raw === '') return []; $gz = @gzencode($raw, 6); unset($raw); if ($gz === false) return []; $written = []; $len = strlen($gz); foreach ($due as $name => $path) { // temp file then rename, so an interrupted write cannot leave a // truncated archive standing in for a good one $tmp = $path . '.tmp'; if (@file_put_contents($tmp, $gz) === $len && @rename($tmp, $path)) $written[] = $name; else @unlink($tmp); } return $written; } $archived = archive_catalogs($opt['ca-cache'], $dataDir); if ($archived) fwrite(STDERR, 'fetch_stars: archived CA catalog (' . implode(', ', $archived) . ")\n"); $status['archives'] = archive_state($dataDir); if ($token === '' && !$trendsOnly) { $status['errors'][] = 'No GitHub token configured.'; write_status($status, $outDir, $dataDir); write_progress($outDir, false, 0, 0, $status); fwrite(STDERR, "fetch_stars: no token; aborting.\n"); exit(0); } if (!is_file($opt['ca-cache'])) { $status['errors'][] = 'CA catalog cache not found at ' . $opt['ca-cache']; write_status($status, $outDir, $dataDir); write_progress($outDir, false, 0, 0, $status); exit(0); } $apps = @unserialize(file_get_contents($opt['ca-cache'])); // read-only if (!is_array($apps)) { $status['errors'][] = 'Failed to unserialize CA catalog cache.'; write_status($status, $outDir, $dataDir); write_progress($outDir, false, 0, 0, $status); exit(0); } // ---- derive owner/repo per app --------------------------------------------- // Only the Project URL is a reliable source for an app's OWN repo. The Support // URL is a "get help" link that template authors routinely point at an umbrella // project's issues/discussions page (e.g. every Immich component links to // github.com/immich-app/immich), which would mis-attribute that project's star // count to unrelated components (postgres, redis, ...). Derive from Project // only, and ignore GitHub non-repo paths (issues/discussions/org pages/etc). // // CA no longer publishes most Project URLs directly: it hands out opaque // https://ca.unraid.net/cdn/ redirectors that 302 to the real link. Those // carry no "github.com" for the regex to find, which is why roughly half the // catalog had no star count. Those links are resolved once and cached in // cdn_links.json, so a rescan costs nothing for links already seen. const CDN_PREFIX = 'https://ca.unraid.net/cdn/'; function is_cdn_link(string $url): bool { return strncmp($url, CDN_PREFIX, strlen(CDN_PREFIX)) === 0; } // HEAD each link, following redirects, and record where it lands. Failures are // deliberately NOT cached, so a transient outage retries on the next scan. function resolve_cdn_links(array $urls, int $concurrency, ?callable $onProgress = null): array { $out = []; $total = count($urls); $done = 0; $queue = array_values($urls); $mh = curl_multi_init(); $active = []; $spawn = function () use (&$queue, &$active, $mh) { if (!$queue) return; $url = array_shift($queue); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_NOBODY => true, // HEAD: we only want the destination CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 4, CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_USERAGENT => UA, CURLOPT_RETURNTRANSFER => true, ]); curl_multi_add_handle($mh, $ch); $active[(int)$ch] = ['h' => $ch, 'url' => $url]; }; for ($i = 0; $i < $concurrency; $i++) $spawn(); do { curl_multi_exec($mh, $running); curl_multi_select($mh, 0.5); while ($info = curl_multi_info_read($mh)) { $ch = $info['handle']; $meta = $active[(int)$ch] ?? null; if ($meta) { $final = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); // Cache on any real response, including a 404 destination: the // redirect still told us where the link points, and a dead repo // is better recorded than re-resolved on every scan. Only a // transport failure (code 0) is left uncached, to retry later. if ($final !== '' && $final !== $meta['url'] && $code > 0) $out[$meta['url']] = $final; unset($active[(int)$ch]); } curl_multi_remove_handle($mh, $ch); curl_close($ch); $done++; if ($onProgress && $done % 25 === 0) $onProgress($done, $total); $spawn(); } } while ($running > 0 || $active || $queue); curl_multi_close($mh); return $out; } // An author line is a person or org, never a URL. CA leaves Author empty for // most plugins and puts the .plg URL (cdn-wrapped) in Repository, so falling // back to that printed a link across the card. Prefer the repository owner's // name, then the GitHub owner, then nothing at all. // $repoFull is null for every app with no GitHub repository behind it, which is // most of the catalog, so the parameter has to accept null: a bare "string" type // makes this a TypeError and kills the run before any JSON is written. function display_author(array $app, ?string $repoFull = ''): string { $repoFull = (string)$repoFull; $au = trim((string)($app['Author'] ?? '')); if ($au !== '' && !preg_match('~^https?://~i', $au)) return $au; $rn = trim((string)($app['RepoName'] ?? $app['Repo'] ?? '')); $rn = trim(preg_replace('~[\x27\x{2019}]s Repository$~ui', '', $rn)); if ($rn !== '' && !preg_match('~^https?://~i', $rn)) return $rn; if ($repoFull !== '' && strpos($repoFull, '/') !== false) return explode('/', $repoFull)[0]; return ''; } function derive_repo(array $app, array $cdnCache): ?array { $url = $app['Project'] ?? ''; if (!$url) return null; if (is_cdn_link($url)) $url = $cdnCache[$url] ?? ''; if (!$url) return null; if (preg_match('~github\.com/([^/]+)/([^/#?\s]+)~i', $url, $m)) { $owner = strtolower($m[1]); $repo = preg_replace('~\.git$~', '', $m[2]); if (in_array($owner, ['orgs','sponsors','topics','marketplace','features','about','apps'], true)) return null; if (in_array(strtolower($repo), ['issues','discussions','wiki','pulls','releases'], true)) return null; return ['owner' => $m[1], 'repo' => $repo, 'full' => strtolower($m[1] . '/' . $repo)]; } // GitHub Pages: owner.github.io/repo is that repo's site; owner.github.io on // its own is the user-pages repo, which is literally named owner.github.io. if (preg_match('~^https?://([^./]+)\.github\.io(?:/([^/#?\s]+))?~i', $url, $m)) { $owner = $m[1]; $repo = ($m[2] ?? '') !== '' ? $m[2] : ($owner . '.github.io'); return ['owner' => $owner, 'repo' => $repo, 'full' => strtolower($owner . '/' . $repo)]; } return null; } // --only-paths restricts the whole run to the apps the grid is currently // showing, so browsing fills stars in where they are actually being read // instead of scanning the entire 3600-app catalog. $wantedPaths = null; if (trim($opt['only-paths']) !== '' && is_file($opt['only-paths'])) { $wantedPaths = []; foreach (file($opt['only-paths'], FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { $line = trim($line); if ($line !== '') $wantedPaths[$line] = 1; } } function app_wanted(array $app, ?array $wantedPaths): bool { if ($wantedPaths === null) return true; $p = $app['Path'] ?? ''; return $p !== '' && isset($wantedPaths[$p]); } // resolve every unseen CDN Project link in one batched pass before deriving $cdnPath = $dataDir . '/cdn_links.json'; $cdnCache = @json_decode((string)@file_get_contents($cdnPath), true); if (!is_array($cdnCache)) $cdnCache = []; $pending = []; foreach ($apps as $app) { if (!is_array($app)) continue; if (!app_wanted($app, $wantedPaths)) continue; $u = $app['Project'] ?? ''; if ($u && is_cdn_link($u) && !isset($cdnCache[$u])) $pending[$u] = $u; } if ($pending && !$trendsOnly) { fwrite(STDERR, 'fetch_stars: resolving ' . count($pending) . " CA cdn links...\n"); write_progress($outDir, true, 0, count($pending), $status, 'links'); $resolved = resolve_cdn_links($pending, $concurrency, function ($done, $total) use ($outDir, $status) { write_progress($outDir, true, $done, $total, $status, 'links'); }); if ($resolved) { $cdnCache = $resolved + $cdnCache; @file_put_contents($cdnPath, json_encode($cdnCache, JSON_UNESCAPED_SLASHES)); } $status['cdn_resolved'] = count($resolved); $status['cdn_pending'] = count($pending) - count($resolved); } $status['cdn_cached'] = count($cdnCache); $repoMeta = []; // full => ['owner','repo'] $appRepoMap = []; // app index => full $wantedRepos = $wantedPaths === null ? null : []; foreach ($apps as $idx => $app) { if (!is_array($app)) continue; $d = derive_repo($app, $cdnCache); if (!$d) continue; $repoMeta[$d['full']] = ['owner' => $d['owner'], 'repo' => $d['repo']]; $appRepoMap[$idx] = $d['full']; // note which repos the requested page needs; the map itself stays whole so // the JSON export at the end still covers the entire catalog if ($wantedRepos !== null && app_wanted($app, $wantedPaths)) $wantedRepos[$d['full']] = 1; } $status['repos_total'] = count($repoMeta); // ---- SQLite ---------------------------------------------------------------- if (!class_exists('SQLite3')) { $status['errors'][] = 'SQLite3 extension unavailable.'; write_status($status, $outDir, $dataDir); exit(1); } try { $db = new SQLite3($db_path); $db->busyTimeout(8000); } catch (Throwable $e) { $status['errors'][] = 'SQLite open failed: ' . $e->getMessage(); write_status($status, $outDir, $dataDir); exit(1); } $db->exec('CREATE TABLE IF NOT EXISTS repos ( repo TEXT PRIMARY KEY, owner TEXT, name TEXT, stars INTEGER, etag TEXT, http_status INTEGER, fetched_at INTEGER)'); // trend columns persist computed deltas so a --new-only run doesn't wipe them. // created_at = repo creation (drives the lifetime growth-rate sort); y_base is // the stargazer-walked star count at y_cut, refreshed every 30 days. foreach (['t1', 't7', 't30', 't365', 'created_at', 'y_base', 'y_cut', 'y_at'] as $tcol) { @$db->exec("ALTER TABLE repos ADD COLUMN $tcol INTEGER"); } $db->exec('CREATE TABLE IF NOT EXISTS star_history (repo TEXT, ts INTEGER, stars INTEGER)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_hist ON star_history(repo, ts)'); function db_get(SQLite3 $db, string $repo): ?array { $s = $db->prepare('SELECT stars, etag, created_at FROM repos WHERE repo = :r'); $s->bindValue(':r', $repo, SQLITE3_TEXT); return $s->execute()->fetchArray(SQLITE3_ASSOC) ?: null; } // created_at is COALESCEd rather than assigned: a 304 carries no body, so those // runs pass null and must not blank a value an earlier 200 already recorded. function db_upsert(SQLite3 $db, string $repo, string $owner, string $name, ?int $stars, ?string $etag, int $hs, int $ts, ?int $created = null): void { $s = $db->prepare('INSERT INTO repos (repo, owner, name, stars, etag, http_status, fetched_at, created_at) VALUES (:repo,:owner,:name,:stars,:etag,:hs,:ts,:cr) ON CONFLICT(repo) DO UPDATE SET owner=:owner,name=:name,stars=:stars,etag=:etag,http_status=:hs,fetched_at=:ts, created_at=COALESCE(:cr, created_at)'); $s->bindValue(':repo', $repo, SQLITE3_TEXT); $s->bindValue(':owner', $owner, SQLITE3_TEXT); $s->bindValue(':name', $name, SQLITE3_TEXT); $s->bindValue(':stars', $stars, $stars === null ? SQLITE3_NULL : SQLITE3_INTEGER); $s->bindValue(':etag', $etag, $etag === null ? SQLITE3_NULL : SQLITE3_TEXT); $s->bindValue(':hs', $hs, SQLITE3_INTEGER); $s->bindValue(':ts', $ts, SQLITE3_INTEGER); $s->bindValue(':cr', $created, $created === null ? SQLITE3_NULL : SQLITE3_INTEGER); $s->execute(); } // ---- concurrent scan (curl_multi + pooled keep-alive) ---------------------- $starsByRepo = []; $total = 0; $scanned = 0; $stop = false; $newRepoSet = null; $queue = array_keys($repoMeta); // trends-only: no network. Fill stars from the DB (below) and jump to the trend // recompute + JSON rebuild, so a hot fix to the trend maths takes effect at once. if (!$trendsOnly) { // --new-only: limit to repos we have never recorded (newly-published apps). // This runs on a frequent cron and bypasses the manual-refresh cooldown so new // app-store repos get their stars within the hour, without re-scanning the rest. $newOnly = ((int)$opt['new-only'] === 1); $newRepoSet = null; if ($newOnly) { $existing = []; $er = $db->query('SELECT repo FROM repos'); while ($row = $er->fetchArray(SQLITE3_ASSOC)) $existing[$row['repo']] = 1; $queue = array_values(array_filter($queue, function ($k) use ($existing) { return !isset($existing[$k]); })); if (empty($queue)) { write_progress($outDir, false, 0, 0, $status); fwrite(STDERR, "fetch_stars: new-only, no new repos.\n"); exit(0); } $newRepoSet = array_flip($queue); } // --only-paths: fetch stars for just the apps the grid is showing if ($wantedRepos !== null) { $queue = array_values(array_filter($queue, function ($k) use ($wantedRepos) { return isset($wantedRepos[$k]); })); } // --stale-days: drop repos already tried inside the window, whatever the result. // Keying on fetched_at rather than stars means a 404 is not retried on every // page view, while a repo with no stars yet (never tried) always qualifies. $staleDays = (int)$opt['stale-days']; if ($staleDays > 0) { $cut = time() - $staleDays * 86400; $recent = []; $rr = $db->query('SELECT repo FROM repos WHERE fetched_at > ' . (int)$cut); while ($row = $rr->fetchArray(SQLITE3_ASSOC)) $recent[$row['repo']] = 1; $queue = array_values(array_filter($queue, function ($k) use ($recent) { return !isset($recent[$k]); })); } if ($wantedPaths !== null && empty($queue)) { write_progress($outDir, false, 0, 0, $status); fwrite(STDERR, "fetch_stars: page scan, nothing due.\n"); exit(0); } if ($limit > 0) $queue = array_slice($queue, 0, $limit); $total = count($queue); $stop = false; $scanned = 0; write_progress($outDir, true, 0, $total, $status); $mh = curl_multi_init(); $inflight = []; $hdr = []; $makeHandle = function (string $full) use (&$repoMeta, &$inflight, &$hdr, $mh, $token, $db) { $m = $repoMeta[$full]; $row = db_get($db, $full); $ch = curl_init('https://api.github.com/repos/' . rawurlencode($m['owner']) . '/' . rawurlencode($m['repo'])); $h = ['Authorization: Bearer ' . $token, 'User-Agent: ' . UA, 'Accept: application/vnd.github+json', 'X-GitHub-Api-Version: 2022-11-28']; // Skip the ETag for repos whose created_at we don't have yet: a 304 has no // body to read it from, so an already-cached repo would never fill it in. // Costs one full response per repo, once, then ETags resume. if (!empty($row['etag']) && !empty($row['created_at'])) $h[] = 'If-None-Match: ' . $row['etag']; $id = spl_object_id($ch); $hdr[$id] = []; curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $h, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_HEADERFUNCTION => function ($c, $line) use (&$hdr, $id) { $p = explode(':', $line, 2); if (count($p) === 2) $hdr[$id][strtolower(trim($p[0]))] = trim($p[1]); return strlen($line); }, ]); $inflight[$id] = ['full' => $full, 'row' => $row, 'ch' => $ch]; curl_multi_add_handle($mh, $ch); }; for ($i = 0; $i < $concurrency && $queue; $i++) $makeHandle(array_shift($queue)); do { curl_multi_exec($mh, $running); if ($running) curl_multi_select($mh, 1.0); while ($done = curl_multi_info_read($mh)) { $ch = $done['handle']; $id = spl_object_id($ch); $ctx = $inflight[$id] ?? null; unset($inflight[$id]); $heads = $hdr[$id] ?? []; unset($hdr[$id]); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $body = curl_multi_getcontent($ch); $err = curl_error($ch); curl_multi_remove_handle($mh, $ch); curl_close($ch); if (!$ctx) continue; $full = $ctx['full']; $row = $ctx['row']; $m = $repoMeta[$full]; $scanned++; if (isset($heads['x-ratelimit-remaining'])) $status['rate_remaining'] = (int)$heads['x-ratelimit-remaining']; if ($code === 200) { $j = json_decode($body, true); $stars = (int)(($j['stargazers_count']) ?? 0); $created = isset($j['created_at']) ? (strtotime($j['created_at']) ?: null) : null; db_upsert($db, $full, $m['owner'], $m['repo'], $stars, $heads['etag'] ?? ($row['etag'] ?? null), 200, time(), $created); $starsByRepo[$full] = $stars; $status['ok']++; } elseif ($code === 304) { $stars = (int)($row['stars'] ?? 0); db_upsert($db, $full, $m['owner'], $m['repo'], $stars, $row['etag'] ?? null, 304, time()); $starsByRepo[$full] = $stars; $status['not_modified']++; } elseif ($code === 404 || $code === 451) { db_upsert($db, $full, $m['owner'], $m['repo'], null, $row['etag'] ?? null, $code, time()); $status['missing']++; } elseif ($code === 403 || $code === 429) { if (!$stop) $status['errors'][] = "Rate limited ($code) after $scanned repos; resuming next run via ETags."; $stop = true; } else { $status['errors'][] = "HTTP $code for $full" . ($err ? " ($err)" : ''); if (isset($row['stars'])) $starsByRepo[$full] = (int)$row['stars']; } if ($scanned % 25 === 0) write_progress($outDir, true, $scanned, $total, $status); if (!$stop && $queue) $makeHandle(array_shift($queue)); } } while ($running || !empty($inflight) || (!$stop && $queue)); foreach ($inflight as $ctx) { @curl_multi_remove_handle($mh, $ctx['ch']); @curl_close($ctx['ch']); } curl_multi_close($mh); } // end if (!$trendsOnly) // merge in repos not scanned this run (in trends-only mode this loads ALL stars) $res = $db->query('SELECT repo, stars FROM repos WHERE stars IS NOT NULL'); while ($ar = $res->fetchArray(SQLITE3_ASSOC)) { if (!isset($starsByRepo[$ar['repo']])) $starsByRepo[$ar['repo']] = (int)$ar['stars']; } // ---- star-history snapshot (~1/day per repo) + trend computation ----------- $now = time(); if (!$trendsOnly) { $db->exec('BEGIN'); $ins = $db->prepare('INSERT INTO star_history (repo, ts, stars) VALUES (:r,:t,:s)'); $lastQ = $db->prepare('SELECT MAX(ts) AS m FROM star_history WHERE repo = :r'); foreach ($starsByRepo as $repo => $st) { $lastQ->reset(); $lastQ->bindValue(':r', $repo, SQLITE3_TEXT); $last = (int)($lastQ->execute()->fetchArray(SQLITE3_ASSOC)['m'] ?? 0); if ($last < $now - 20 * 3600) { $ins->reset(); $ins->bindValue(':r', $repo, SQLITE3_TEXT); $ins->bindValue(':t', $now, SQLITE3_INTEGER); $ins->bindValue(':s', (int)$st, SQLITE3_INTEGER); $ins->execute(); } } $db->exec('DELETE FROM star_history WHERE ts < ' . ($now - 400 * 86400)); $db->exec('COMMIT'); } // end if (!$trendsOnly) $baseQ = $db->prepare('SELECT stars FROM star_history WHERE repo=:r AND ts<=:c ORDER BY ts DESC LIMIT 1'); $oldQ = $db->prepare('SELECT stars, ts FROM star_history WHERE repo=:r ORDER BY ts ASC LIMIT 1'); function trend_at(SQLite3Stmt $baseQ, string $repo, int $cutoff): ?int { $baseQ->reset(); $baseQ->bindValue(':r', $repo, SQLITE3_TEXT); $baseQ->bindValue(':c', $cutoff, SQLITE3_INTEGER); $row = $baseQ->execute()->fetchArray(SQLITE3_ASSOC); return $row ? (int)$row['stars'] : null; } function delta(?int $base, int $cur): ?int { return $base === null ? null : ($cur - $base); } /* * Uncapped trend for a rolling window: current stars minus the snapshot at (or * just before) the window's start. If star history is younger than the window * but still covers >=60% of it, approximate using the OLDEST snapshot (so a * "30-day" trend works off ~25 days of history rather than showing nothing). * Only when history is too short to be meaningful do we fall back to $fallback * (the stargazer-page count, which saturates at 100 and can't rank hot repos). */ function trend_window(SQLite3Stmt $baseQ, SQLite3Stmt $oldQ, string $repo, int $window, int $stars, int $now, ?int $fallback): ?int { $base = trend_at($baseQ, $repo, $now - $window); if ($base !== null) return $stars - $base; $oldQ->reset(); $oldQ->bindValue(':r', $repo, SQLITE3_TEXT); $o = $oldQ->execute()->fetchArray(SQLITE3_ASSOC); if ($o && ($now - (int)$o['ts']) >= 0.6 * $window) return $stars - (int)$o['stars']; return $fallback; } /** * Does this token have starred_at access? * * GitHub refuses the stargazers endpoint to FINE-GRAINED tokens (github_pat_*) * with a 403 "Resource not accessible by personal access token" that has * nothing to do with the rate limit, and GraphQL refuses it the same way. Only * a CLASSIC token (ghp_*) can read it, with no scopes needed. Without it the * only star timeline available is this plugin's own daily snapshots. * * One probe request per run settles it, which beats a stored flag (it notices * a swapped token immediately) and beats discovering it 2,000 failed requests * in. Returns true when starred_at is readable. */ function stargazers_readable(array $repoMeta, string $token, array &$status): bool { $m = reset($repoMeta); if (!$m) return false; $ch = curl_init('https://api.github.com/repos/' . rawurlencode($m['owner']) . '/' . rawurlencode($m['repo']) . '/stargazers?per_page=1'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'User-Agent: ' . UA, 'Accept: application/vnd.github.star+json', 'X-GitHub-Api-Version: 2022-11-28'], ]); $body = (string)curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 200) return true; if ($code === 403 && strpos($body, 'not accessible by personal access token') !== false) { $status['stargazers_blocked'] = true; $status['errors'][] = 'This GitHub token cannot read star dates, so the "this year" trending windows ' . 'stay empty until the plugin has recorded a year of its own history. A classic token ' . '(ghp_...) with no scopes can read them; a fine-grained token (github_pat_...) cannot.'; return false; } // anything else (rate limit, transient error) is not a permission verdict: // skip the walks this run and try again on the next one $status['stargazers_probe'] = $code; return false; } /** * Stargazer-timestamp trend backfill. Fetches each repo's newest stargazer page * (per_page=100, the last page) with star+json so every star carries starred_at, * then counts stars gained in the last 1d/7d/30d/365d. Repos above ~40k stars * exceed GitHub's stargazer pagination cap (~400 pages) so they're skipped here * and fall back to the daily snapshots. Returns full => [c1,c7,c30,c365]. */ function backfill_trends(array $repoMeta, array $starsByRepo, string $token, string $outDir, array &$status, int $sgLimit, ?array $restrict = null): array { $now = time(); $periods = [86400, 7 * 86400, 30 * 86400, 365 * 86400]; $trends = []; $list = []; foreach ($starsByRepo as $full => $s) { if ($restrict !== null && !isset($restrict[$full])) continue; // new-only: just the new repos if ($s <= 0 || $s > 40000) continue; $m = $repoMeta[$full] ?? null; if (!$m) continue; $list[] = ['full' => $full, 'owner' => $m['owner'], 'name' => $m['repo'], 'page' => max(1, (int)ceil($s / 100))]; } if ($sgLimit > 0) $list = array_slice($list, 0, $sgLimit); $total = count($list); $i = 0; $done = 0; $stop = false; $C = 8; if ($total === 0) return $trends; $mh = curl_multi_init(); $inflight = []; $add = function ($it) use (&$inflight, $mh, $token) { $url = 'https://api.github.com/repos/' . rawurlencode($it['owner']) . '/' . rawurlencode($it['name']) . '/stargazers?per_page=100&page=' . $it['page']; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'User-Agent: ' . UA, 'Accept: application/vnd.github.star+json', 'X-GitHub-Api-Version: 2022-11-28'], ]); $inflight[spl_object_id($ch)] = $it['full']; curl_multi_add_handle($mh, $ch); }; for (; $i < $C && $i < $total; $i++) $add($list[$i]); do { curl_multi_exec($mh, $run); if ($run) curl_multi_select($mh, 1.0); while ($d = curl_multi_info_read($mh)) { $ch = $d['handle']; $id = spl_object_id($ch); $full = $inflight[$id] ?? null; unset($inflight[$id]); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $body = curl_multi_getcontent($ch); curl_multi_remove_handle($mh, $ch); curl_close($ch); if ($full) { $done++; if ($code === 200) { $arr = json_decode($body, true); $c = [0, 0, 0, 0]; if (is_array($arr)) foreach ($arr as $it) { $t = isset($it['starred_at']) ? strtotime($it['starred_at']) : 0; if ($t) for ($p = 0; $p < 4; $p++) if ($t >= $now - $periods[$p]) $c[$p]++; } $trends[$full] = $c; } elseif ($code === 403 || $code === 429) { $stop = true; } } if (!$stop && $i < $total) { $add($list[$i]); $i++; } if ($done % 50 === 0) write_progress($outDir, true, $done, $total, $status); } } while ($run || !empty($inflight) || (!$stop && $i < $total)); curl_multi_close($mh); if ($stop) $status['errors'][] = 'Stargazer trend backfill hit a rate limit; trends partial this run.'; return $trends; } /** * Year-ago star baseline: how many of a repo's stars predate the 365-day window. * * The daily snapshots cannot answer this. A window only resolves once history * reaches back that far, so on any install younger than a year the "this year" * sort would rank nothing at all, and backfill_trends' single newest stargazer * page saturates at 100, tying every repo that gained more than that. * * GitHub returns stargazers OLDEST FIRST with starred_at attached, so the split * point is found by binary-searching pages for the first one that opens inside * the window, then counting across that boundary page. ~log2(pages) requests: * a 1,000-star repo costs 4, a 40,000-star repo costs 10, and a repo with 100 * stars or fewer costs 1. Pages already pulled during the search are reused. * * Deliberate limits, because they affect how the numbers should be read: * - The list holds CURRENT stargazers only, so a star given two years ago and * since removed is invisible. The baseline is therefore a slight undercount * and the year's gain a slight overcount. It ranks correctly; it is not an * audit. * - GitHub stops paginating stargazers at 400 pages, so repos over 40,000 * stars are left to the snapshot path rather than guessed at. * - Repos created inside the window need no request: every star they have is * this year's, so the baseline is 0. * * Returns full => baseline stars at $cutoff. */ function backfill_year_baselines(array $repoMeta, array $starsByRepo, array $rowMeta, string $token, string $outDir, array &$status, int $cap, ?array $restrict, int $now): array { $cutoff = $now - 365 * 86400; $out = []; $search = []; foreach ($starsByRepo as $full => $stars) { if ($restrict !== null && !isset($restrict[$full])) continue; if (!isset($repoMeta[$full])) continue; $meta = $rowMeta[$full] ?? []; // a baseline drifts by exactly the time since it was taken; refreshing // monthly keeps the window between 365 and ~395 days, close enough to // rank by and a large saving over recomputing it every run if (!empty($meta['y_at']) && ($now - (int)$meta['y_at']) < 30 * 86400) continue; if ($stars <= 0) { $out[$full] = 0; continue; } $created = (int)($meta['created_at'] ?? 0); if ($created > 0 && $created >= $cutoff) { $out[$full] = 0; continue; } // free: repo is younger than the window if ($stars > 40000) continue; // past GitHub's stargazer pagination cap $search[] = ['full' => $full, 'stars' => (int)$stars]; } // biggest repos first: they decide the top of every trending ranking, so a // capped run still produces a correct-looking leaderboard usort($search, function ($a, $b) { return $b['stars'] - $a['stars']; }); $deferred = 0; if ($cap > 0 && count($search) > $cap) { $deferred = count($search) - $cap; $search = array_slice($search, 0, $cap); } $status['year_free'] = count($out); if (!$search) { if ($deferred) $status['year_deferred'] = $deferred; return $out; } $state = []; foreach ($search as $it) { $pages = min(400, max(1, (int)ceil($it['stars'] / 100))); $state[$it['full']] = ['stars' => $it['stars'], 'pages' => $pages, 'lo' => 1, 'hi' => $pages, 'ans' => $pages + 1, 'cache' => [], 'phase' => 'search']; } $queue = array_keys($state); $total = count($queue); $done = 0; $stop = false; // fold a fetched page into the repo's search, then say which page it needs // next; null means the repo is finished and its baseline is recorded $consume = function ($full, $page, $arr) use (&$state, $cutoff) { $s = &$state[$full]; $ts = []; if (is_array($arr)) foreach ($arr as $it) { $t = isset($it['starred_at']) ? strtotime($it['starred_at']) : 0; if ($t) $ts[] = $t; } $s['cache'][$page] = $ts; if ($s['phase'] !== 'search') return; if (!$ts) { $s['hi'] = $page - 1; return; } // short/empty page: the end is below it if ($ts[0] >= $cutoff) { $s['ans'] = $page; $s['hi'] = $page - 1; } else { $s['lo'] = $page + 1; } }; $advance = function ($full) use (&$state, &$out, $cutoff) { $s = &$state[$full]; if ($s['phase'] === 'search') { if ($s['lo'] <= $s['hi']) return (int)(($s['lo'] + $s['hi']) >> 1); $s['phase'] = 'count'; } // ans = first page that OPENS inside the window, so the boundary itself // lies in the page before it (or in the last page, if none opens inside) $q = ($s['ans'] > $s['pages']) ? $s['pages'] : $s['ans'] - 1; if ($q <= 0) { $out[$full] = 0; return null; } // every star is inside the window if (!isset($s['cache'][$q])) return $q; $c = 0; foreach ($s['cache'][$q] as $t) if ($t < $cutoff) $c++; $out[$full] = max(0, min($s['stars'], ($q - 1) * 100 + $c)); return null; }; $mh = curl_multi_init(); $inflight = []; $hdrs = []; $C = 8; $add = function ($full, $page) use (&$inflight, &$hdrs, $mh, $token, $repoMeta) { $m = $repoMeta[$full]; $url = 'https://api.github.com/repos/' . rawurlencode($m['owner']) . '/' . rawurlencode($m['repo']) . '/stargazers?per_page=100&page=' . $page; $ch = curl_init($url); $id = spl_object_id($ch); $hdrs[$id] = []; curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'User-Agent: ' . UA, 'Accept: application/vnd.github.star+json', 'X-GitHub-Api-Version: 2022-11-28'], CURLOPT_HEADERFUNCTION => function ($c, $line) use (&$hdrs, $id) { $p = explode(':', $line, 2); if (count($p) === 2) $hdrs[$id][strtolower(trim($p[0]))] = trim($p[1]); return strlen($line); }, ]); $inflight[$id] = ['full' => $full, 'page' => $page]; curl_multi_add_handle($mh, $ch); }; // kick a repo off, or drop it if it somehow needs nothing $startNext = function () use (&$queue, &$state, $advance, $add, &$done) { while ($queue) { $full = array_shift($queue); $p = $advance($full); if ($p === null) { $done++; continue; } // resolved without a request $add($full, $p); return true; } return false; }; for ($i = 0; $i < $C; $i++) if (!$startNext()) break; do { curl_multi_exec($mh, $run); if ($run) curl_multi_select($mh, 1.0); while ($d = curl_multi_info_read($mh)) { $ch = $d['handle']; $id = spl_object_id($ch); $ctx = $inflight[$id] ?? null; unset($inflight[$id]); $heads = $hdrs[$id] ?? []; unset($hdrs[$id]); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $body = curl_multi_getcontent($ch); curl_multi_remove_handle($mh, $ch); curl_close($ch); if (!$ctx) continue; $full = $ctx['full']; if (isset($heads['x-ratelimit-remaining'])) { $status['rate_remaining'] = (int)$heads['x-ratelimit-remaining']; // leave headroom for the next star scan rather than draining the // hour here; whatever is left is picked up on the following run if ((int)$heads['x-ratelimit-remaining'] < 200) $stop = true; } if ($code === 403 || $code === 429) { $stop = true; $done++; continue; } if ($code !== 200) { $done++; if (!$stop) $startNext(); continue; } // give up on this repo, not the run $consume($full, $ctx['page'], json_decode($body, true)); $next = $advance($full); if ($next !== null && !$stop) { $add($full, $next); continue; } $done++; if ($done % 25 === 0) write_progress($outDir, true, $done, $total, $status, 'year'); if (!$stop) $startNext(); } } while ($run || !empty($inflight)); curl_multi_close($mh); if ($stop) $status['errors'][] = 'Year-baseline walk stopped early on the GitHub rate limit; the rest resumes next run.'; if ($deferred) $status['year_deferred'] = $deferred; $status['year_walked'] = count($out) - (int)($status['year_free'] ?? 0); return $out; } // Existing repo rows: created_at drives the free skips above, y_at the refresh // window, and the snapshot floor decides who still needs the capped stargazer // fallback for the SHORT windows. $rowMeta = []; $rm = $db->query('SELECT repo, created_at, y_base, y_cut, y_at FROM repos'); while ($r = $rm->fetchArray(SQLITE3_ASSOC)) { $rowMeta[$r['repo']] = ['created_at' => (int)$r['created_at'], 'y_at' => (int)$r['y_at']]; } // backfill_trends answers the 1d/1w/1m windows for repos the snapshot history // cannot yet cover. Once a repo has a snapshot older than a day the snapshots // win outright, so re-walking the whole catalog every run bought nothing and // cost a request per repo; restrict it to repos that genuinely lack a baseline. $sgNeed = []; if (!$trendsOnly) { $histFloor = $now - 86400; $seen = []; $hq = $db->query('SELECT repo, MIN(ts) AS m FROM star_history GROUP BY repo'); while ($r = $hq->fetchArray(SQLITE3_ASSOC)) $seen[$r['repo']] = (int)$r['m']; foreach ($starsByRepo as $full => $_) { if (!isset($seen[$full]) || $seen[$full] > $histFloor) $sgNeed[$full] = 1; } if ($newRepoSet !== null) $sgNeed = array_intersect_key($sgNeed, $newRepoSet); if ($wantedRepos !== null) $sgNeed = array_intersect_key($sgNeed, $wantedRepos); } // Both backfills below read starred_at, so a token that cannot see it makes // every one of their requests a guaranteed 403. Settle that with one probe. $sgOk = (!$trendsOnly && $token !== '') ? stargazers_readable($repoMeta, $token, $status) : false; $sgTrends = ($trendsOnly || !$sgOk || !$sgNeed) ? [] : backfill_trends($repoMeta, $starsByRepo, $token, $outDir, $status, (int)$opt['sg-limit'], $sgNeed); // Year-ago baselines. On a page scan this is limited to the apps on screen, so // browsing fills the year window in exactly the way browsing fills stars in. $yearNew = ($trendsOnly || !$sgOk) ? [] : backfill_year_baselines($repoMeta, $starsByRepo, $rowMeta, $token, $outDir, $status, (int)$opt['year-limit'], $wantedRepos, $now); if ($yearNew) { $yu = $db->prepare('UPDATE repos SET y_base=:b, y_cut=:c, y_at=:a WHERE repo=:r'); $db->exec('BEGIN'); foreach ($yearNew as $full => $b) { $yu->reset(); $yu->bindValue(':b', (int)$b, SQLITE3_INTEGER); $yu->bindValue(':c', $now - 365 * 86400, SQLITE3_INTEGER); $yu->bindValue(':a', $now, SQLITE3_INTEGER); $yu->bindValue(':r', $full, SQLITE3_TEXT); $yu->execute(); } $db->exec('COMMIT'); } // every stored baseline, including repos untouched this run $yBase = []; $yq = $db->query('SELECT repo, y_base FROM repos WHERE y_base IS NOT NULL'); while ($r = $yq->fetchArray(SQLITE3_ASSOC)) $yBase[$r['repo']] = (int)$r['y_base']; // repo creation dates, for the lifetime growth-rate sort $createdAt = []; $cq = $db->query('SELECT repo, created_at FROM repos WHERE created_at IS NOT NULL'); while ($r = $cq->fetchArray(SQLITE3_ASSOC)) $createdAt[$r['repo']] = (int)$r['created_at']; // persist freshly-computed trends so a later --new-only run keeps them if ($sgTrends) { $tu = $db->prepare('UPDATE repos SET t1=:a,t7=:b,t30=:c,t365=:d WHERE repo=:r'); $db->exec('BEGIN'); foreach ($sgTrends as $full => $c) { $tu->reset(); $tu->bindValue(':a', $c[0], SQLITE3_INTEGER); $tu->bindValue(':b', $c[1], SQLITE3_INTEGER); $tu->bindValue(':c', $c[2], SQLITE3_INTEGER); $tu->bindValue(':d', $c[3], SQLITE3_INTEGER); $tu->bindValue(':r', $full, SQLITE3_TEXT); $tu->execute(); } $db->exec('COMMIT'); } // read all stored trends (existing repos preserved + the ones we just refreshed) $dbTrends = []; $trq = $db->query('SELECT repo, t1, t7, t30, t365 FROM repos WHERE t7 IS NOT NULL'); while ($row = $trq->fetchArray(SQLITE3_ASSOC)) { $dbTrends[$row['repo']] = [(int)$row['t1'], (int)$row['t7'], (int)$row['t30'], (int)$row['t365']]; } // ---- build outputs --------------------------------------------------------- $byId = $byName = $byRepo = $byPath = []; $catalog = []; // for apps.json foreach ($apps as $idx => $app) { if (!is_array($app)) continue; $name = $app['Name'] ?? ''; if ($name === '') continue; $full = $appRepoMap[$idx] ?? null; $stars = ($full !== null && isset($starsByRepo[$full])) ? (int)$starsByRepo[$full] : null; if ($stars !== null) { $byRepo[$full] = $stars; if (isset($app['ID'])) $byId[(string)$app['ID']] = $stars; $byName[strtolower(trim($name))] = $stars; // last-wins (not unique) if (!empty($app['Path'])) $byPath[$app['Path']] = $stars; // unique per template } $t1 = $t7 = $t30 = $t365 = null; $created = null; if ($stars !== null && $full !== null) { // Prefer uncapped snapshot deltas; the stored stargazer-page counts // saturate at 100 and can't rank fast-growing repos, so they're only a // last-resort fallback for windows longer than our star history. $fb = $dbTrends[$full] ?? [null, null, null, null]; $t1 = trend_window($baseQ, $oldQ, $full, 86400, $stars, $now, $fb[0]); $t7 = trend_window($baseQ, $oldQ, $full, 7 * 86400, $stars, $now, $fb[1]); $t30 = trend_window($baseQ, $oldQ, $full, 30 * 86400, $stars, $now, $fb[2]); // Year window, best source first: a genuine year-old snapshot if this // install has run that long, else the stargazer-walked baseline, else // the capped fallback. The walk is what makes "this year" work at all // on an install younger than a year. $snap365 = trend_at($baseQ, $full, $now - 365 * 86400); if ($snap365 !== null) $t365 = $stars - $snap365; elseif (isset($yBase[$full])) $t365 = max(0, $stars - $yBase[$full]); else $t365 = trend_window($baseQ, $oldQ, $full, 365 * 86400, $stars, $now, $fb[3]); $created = $createdAt[$full] ?? null; } $desc = (string)($app['Overview'] ?? ''); $desc = preg_replace('/\[[^\]]{1,40}\]/', ' ', $desc); // strip BBCode ([br], [b], [/b], …) $desc = trim(preg_replace('/\s+/', ' ', strip_tags($desc))); if (function_exists('mb_substr')) { if (mb_strlen($desc) > 240) $desc = mb_substr($desc, 0, 237) . '…'; } else { if (strlen($desc) > 240) $desc = substr($desc, 0, 237) . '…'; } $cat = is_array($app['Category'] ?? null) ? implode(' ', $app['Category']) : ($app['Category'] ?? ''); $cat = trim(str_replace(':', ' ', explode(' ', trim($cat))[0] ?? '')); // first category, no colons $catalog[] = [ 'n' => $name, 'p' => $app['Path'] ?? '', 'ic' => $app['Icon'] ?? '', 'au' => display_author($app, $full), 'ct' => $cat, 'de' => $desc, 'pr' => $app['Project'] ?? '', 'su' => $app['Support'] ?? '', 'rp' => $full, 's' => $stars, 'dl' => (int)($app['downloads'] ?? 0), 'ca' => $created, // repo creation, unix ts (lifetime growth-rate sort) 't1' => $t1, 't7' => $t7, 't30' => $t30, 't365' => $t365, ]; } $starsJson = json_encode(['generated' => $now, 'byId' => $byId, 'byName' => $byName, 'byRepo' => $byRepo, 'byPath' => $byPath], JSON_UNESCAPED_SLASHES); $appsJson = json_encode(['generated' => $now, 'apps' => $catalog], JSON_UNESCAPED_SLASHES); foreach ([$outDir, $dataDir] as $d) { @file_put_contents($d . '/stars.json', $starsJson); @file_put_contents($d . '/apps.json', $appsJson); } write_status($status, $outDir, $dataDir); write_progress($outDir, false, $scanned, $total, $status); fwrite(STDERR, sprintf("fetch_stars: repos=%d ok=%d notmod=%d missing=%d rate=%s errors=%d apps=%d sgTrends=%d year=%d(free=%d,left=%d)\n", $status['repos_total'], $status['ok'], $status['not_modified'], $status['missing'], var_export($status['rate_remaining'], true), count($status['errors']), count($catalog), count($sgTrends), count($yearNew), (int)($status['year_free'] ?? 0), (int)($status['year_deferred'] ?? 0))); exit(0); ]]> false, 'cooldown' => true, 'next_allowed' => $last + COOLDOWN, 'last' => $last]); exit; } if (trim(@shell_exec("pgrep -f 'fetch_stars\\.php' 2>/dev/null") ?? '') !== '') { echo json_encode(['started' => false, 'running' => true]); exit; } @file_put_contents($base . '/progress.json', json_encode([ 'running' => true, 'done' => 0, 'total' => 0, 'ok' => 0, 'not_modified' => 0, 'missing' => 0, 'errors' => 0, 'updated_at' => $now, ], JSON_UNESCAPED_SLASHES)); @exec('nohup php ' . escapeshellarg($php) . ' --manual 1 >/dev/null 2>&1 &'); echo json_encode(['started' => true, 'running' => true]); ]]> /dev/null"); $p = @json_decode(@file_get_contents($base . '/progress.json'), true) ?: []; $p['running'] = false; $p['updated_at'] = time(); @file_put_contents($base . '/progress.json', json_encode($p, JSON_UNESCAPED_SLASHES)); echo json_encode(['canceled' => true]); ]]> false, 'throttled' => true]); exit; } if (trim(@shell_exec("pgrep -f 'fetch_stars\\.php' 2>/dev/null") ?? '') !== '') { echo json_encode(['started' => false, 'running' => true]); exit; } @file_put_contents($f, json_encode(['ts' => $now])); @exec('nohup php ' . escapeshellarg($php) . ' --new-only 1 >/dev/null 2>&1 &'); echo json_encode(['started' => true]); ]]> , "stars": { "": } } * * force=1 comes from the Refresh menu's "this page" option and ignores the * staleness window. It never bypasses the fetcher's own single-instance lock. * * Paths are validated against CA's catalog and only ever written to a temp * file; nothing from the request reaches a shell. */ header('Content-Type: application/json'); const MAX_PATHS = 200; // a grid page is 96; leave headroom, refuse floods const STALE_DAYS = 7; // re-check an app at most once a week const RUN_CAP = 240; // seconds a page scan may take before we answer anyway $base = '/usr/local/emhttp/plugins/modern.appstore'; $fetcher = $base . '/fetch_stars.php'; $caCache = '/tmp/community.applications/tempFiles/templates_new.json'; $cfg = @parse_ini_file('/boot/config/plugins/modern.appstore/modern.appstore.cfg') ?: []; $dataDir = rtrim(trim($cfg['DATA_DIR'] ?? '') ?: '/boot/config/plugins/modern.appstore', '/'); $raw = file_get_contents('php://input'); $req = json_decode($raw ?: '[]', true); $paths = is_array($req['paths'] ?? null) ? $req['paths'] : []; $force = !empty($req['force']); // keep only real catalog paths, so nothing invented by the caller is written out $valid = []; $apps = @unserialize(@file_get_contents($caCache)); if (is_array($apps)) { $known = []; foreach ($apps as $a) { if (is_array($a) && !empty($a['Path'])) $known[$a['Path']] = 1; } foreach ($paths as $p) { if (!is_string($p) || $p === '') continue; if (isset($known[$p])) $valid[$p] = 1; if (count($valid) >= MAX_PATHS) break; } } if (!$valid) { echo json_encode(['scanned' => 0, 'stars' => (object)[]]); exit; } $listFile = tempnam(sys_get_temp_dir(), 'asga_paths_'); @file_put_contents($listFile, implode("\n", array_keys($valid))); $cmd = 'php ' . escapeshellarg($fetcher) . ' --only-paths ' . escapeshellarg($listFile) . ' --stale-days ' . ($force ? '0' : (string)STALE_DAYS); @exec('timeout ' . RUN_CAP . ' ' . $cmd . ' >/dev/null 2>&1'); @unlink($listFile); // answer from the catalog the fetcher just rewrote, so the grid can repaint the // badges in place rather than re-downloading the whole app list $out = []; $mine = @json_decode((string)@file_get_contents($dataDir . '/apps.json'), true); foreach (($mine['apps'] ?? []) as $a) { if (!empty($a['p']) && isset($valid[$a['p']])) $out[$a['p']] = isset($a['s']) ? $a['s'] : null; } echo json_encode(['scanned' => count($valid), 'stars' => $out ?: (object)[]], JSON_UNESCAPED_SLASHES); ]]> , "feedReady": , "docker": {...}, * "apps": [ { p,n,sn,ic,ct,s,dl,fs,lu,lk,ca,sx,t1,t7,t30,t365 } ] } * p = template path (passed to CA's showSidebarApp for Info/Install) * n = display name sn = lowercase sort-name * ic = icon URL ct = category * s = GitHub stars (or null) dl = Unraid downloads (or 0) * fs = FirstSeen unix ts (date added; 0 if unknown) * lu = last-update unix ts (0 if unknown) lk = its source: 'r' registry push, 'v' plugin version * sa = last star-fetch attempt for this app's repo (0 = never tried) * sx = text the grid searches but never shows (full description + hidden keywords) * ca = repo creation unix ts (or null), for the lifetime growth-rate sort * t1/t7/t30/t365 = star trend deltas (day/week/month/year) */ header('Content-Type: application/json'); $dataDir = '/boot/config/plugins/modern.appstore'; $caTmp = '/tmp/community.applications/tempFiles'; // An author line is a person or org, never a URL. CA leaves Author empty for // most plugins and puts the .plg URL (cdn-wrapped) in Repository, so a stored or // feed value that is a link is dropped in favour of the repository owner's name. // Long values are trimmed here too, so one bad record cannot dominate a card. function display_author($stored, array $t) { foreach ([$stored, $t['Author'] ?? ''] as $cand) { $cand = trim((string)$cand); if ($cand !== '' && !preg_match('~^https?://~i', $cand)) return clip($cand, 60); } $rn = trim((string)($t['RepoName'] ?? $t['Repo'] ?? '')); $rn = trim(preg_replace('~[\x27\x{2019}]s Repository$~ui', '', $rn)); if ($rn !== '' && !preg_match('~^https?://~i', $rn)) return clip($rn, 60); return ''; } // Hard cap on any single-line field the grid renders, so a malformed template // cannot blow out a card. The CSS wraps too; this keeps the payload sane. function clip($s, $max) { $s = trim((string)$s); if (function_exists('mb_strlen')) return mb_strlen($s) > $max ? mb_substr($s, 0, $max - 1) . '…' : $s; return strlen($s) > $max ? substr($s, 0, $max - 1) . '…' : $s; } // When the app itself last shipped, and where that date came from. // // Docker apps: CA's LastUpdate is the registry push time for the image. It is // the repository's date, not a specific tag's, so an app pinned to a tag other // than :latest would be shown a date that has nothing to do with the version it // installs. CA's own drawer suppresses the row in exactly that case, and so does // this. Returns 'r' for those. // // Plugins: the feed carries no LastUpdate for them. Unraid plugin versions are // dates by convention (2026.06.10a), which IS the release date, so that is read // back out. Only the day is meaningful there, so the grid renders it without a // time. Returns 'v'. Semver-style plugin versions (1.3.13) yield nothing rather // than a guess. function last_update(array $t) { if (!empty($t['Plugin'])) { $v = (string)($t['pluginVersion'] ?? ''); if (preg_match('/^(20\d{2})\.(0[1-9]|1[0-2])\.(0[1-9]|[12]\d|3[01])(?![\d])/', $v, $m)) { return [(int)mktime(0, 0, 0, (int)$m[2], (int)$m[3], (int)$m[1]), 'v']; } return [0, '']; } $tag = strtolower(explode(':', trim((string)($t['Repository'] ?? '')))[1] ?? ''); if ($tag !== '' && $tag !== 'latest') return [0, '']; $lu = (int)($t['LastUpdate'] ?? 0); return $lu > 1 ? [$lu, 'r'] : [0, '']; } // Community Applications' own docker-availability check, mirrored from its // skins/Narrow/skin.php: the daemon counts as up only when its pid file points // at a live process, and when it is not CA keeps listing every app but blocks // docker installs and says why. The reason is read off the array state and the // docker service setting, exactly as CA derives its 1/2/3 warning code. // 1 = array started, docker service disabled // 2 = array started, docker enabled but the daemon failed to start // 3 = array not started // CA suppresses the warning entirely while its own install disclaimer has not // been accepted (nothing is installable then anyway), so this reports that as // warn=false rather than inventing a message CA would not show. function docker_state() { $pid = @file_get_contents('/var/run/dockerd.pid'); if ($pid !== false && is_dir('/proc/' . trim($pid))) { return ['running' => true, 'reason' => 0, 'warn' => false]; } $vars = @parse_ini_file('/var/local/emhttp/var.ini') ?: []; $dcfg = @parse_ini_file('/boot/config/docker.cfg') ?: []; if (($vars['mdState'] ?? '') !== 'STARTED') $reason = 3; elseif (($dcfg['DOCKER_ENABLED'] ?? '') !== 'yes') $reason = 1; else $reason = 2; $accepted = is_file('/boot/config/plugins/community.applications/accepted'); return ['running' => false, 'reason' => $reason, 'warn' => $accepted]; } function read_json_ro($path) { if (!is_file($path)) return null; $raw = @file_get_contents($path); if ($raw === false) return null; $d = @json_decode($raw, true); if ($d === null) $d = @unserialize($raw); // CA files are serialized return is_array($d) ? $d : null; } // our own catalog (already has name/path/icon/category/stars/trends for every app) $ours = read_json_ro("$dataDir/apps.json"); $byPath = []; foreach (($ours['apps'] ?? []) as $a) { if (!empty($a['p'])) $byPath[$a['p']] = $a; } // CA's master template list: name, FirstSeen, downloads, displayable flags. // It lives in /tmp, so it is gone after every boot until CA's Apps page has // downloaded the feed again. The modern grid loads faster than that download, // so an empty read here means "not ready yet", never "the catalog is empty", // and the grid is told which of the two it is so it can wait instead of // printing "No apps to show" and sitting there until a manual reload. $tmpl = read_json_ro("$caTmp/templates_new.json") ?: []; $feedReady = !empty($tmpl); // When each repo was last tried for stars, so the grid can ask for a scan of // only the apps on screen that are missing or stale. Read-only; no DB is fine. $fetchedAt = []; if (class_exists('SQLite3') && is_file("$dataDir/stars.db")) { try { $sdb = new SQLite3("$dataDir/stars.db", SQLITE3_OPEN_READONLY); $sdb->busyTimeout(2000); $r = $sdb->query('SELECT repo, fetched_at FROM repos'); while ($row = $r->fetchArray(SQLITE3_ASSOC)) $fetchedAt[$row['repo']] = (int)$row['fetched_at']; $sdb->close(); } catch (Throwable $e) { $fetchedAt = []; } } $out = []; foreach ($tmpl as $t) { if (!is_array($t)) continue; // match CA's "All Apps" visibility: displayable, not blacklisted, not deprecated if (empty($t['Displayable'])) continue; if (!empty($t['Blacklist'])) continue; if (!empty($t['Deprecated'])) continue; // language packs are CA UI translations, not apps; CA files them under a // "Language:" category. Exclude them from the app grid entirely. if (!empty($t['Language'])) continue; $path = $t['Path'] ?? ''; if ($path === '') continue; $mine = $byPath[$path] ?? null; $name = $t['Name'] ?? ($mine['n'] ?? ''); if ($name === '') continue; // Download count comes from CA's catalog (DockerHub pulls of the app's // image). BUT apps built on an official base image (nginx, redis, postgres) // reference it as a bare "name:tag" with no owner namespace, and then this // number is the BASE image's global pulls (e.g. nginx = 13.2B), not the // app's. That's misleading, so we drop it for those; real app images are // "owner/name" and keep their genuine count. $dl = (int)($t['downloads'] ?? 0); $imgName = explode(':', trim($t['Repository'] ?? ''))[0]; if ($imgName === '' || strpos($imgName, '/') === false) $dl = 0; // description: prefer our stored copy, fall back to CA's Overview; trim to a // card-sized blurb (tiles clamp it anyway) to keep the payload lean. $desc = $mine['de'] ?? ($t['Overview'] ?? ''); $desc = trim(preg_replace('/\s+/', ' ', strip_tags($desc))); if (strlen($desc) > 400) $desc = substr($desc, 0, 400) . '…'; // Text the grid searches but never shows. Community Applications matches a // query against the app's FULL description and against ExtraSearchTerms, a // hidden keyword list many templates carry, which is why a search for // "emulator" finds apps there that never say it in their name. The blurb // above is trimmed for the card (the stored copy is shorter still), so the // searchable text rides along separately, capped so that one 15KB overview // cannot dominate the payload. $overview = trim(preg_replace('/\s+/', ' ', strip_tags((string)($t['Overview'] ?? '')))); $sx = clip(trim((string)($t['ExtraSearchTerms'] ?? '') . ' ' . $overview), 2000); // CA carries FirstSeen = 1 for apps that predate its record-keeping (all of // binhex's catalog, 78 apps today). That is a sentinel, not a 1970 date, and // CA's own fixTemplates() blanks it the same way ("if Date == 1, Date = null"). // Report it as unknown so the grid omits the line instead of printing 1969. $fs = (int)($t['FirstSeen'] ?? 0); if ($fs <= 1) $fs = 0; list($lu, $lk) = last_update($t); $out[] = [ 'p' => $path, 'n' => $name, 'sn' => strtolower($t['SortName'] ?? $name), 'ic' => $mine['ic'] ?? ($t['Icon'] ?? ''), 'ct' => clip($mine['ct'] ?? ($t['Category'] ?? ''), 48), 'au' => display_author($mine['au'] ?? '', $t), 'de' => $desc, 'sx' => $sx, 'pr' => $mine['pr'] ?? ($t['Project'] ?? ''), 'su' => $mine['su'] ?? ($t['Support'] ?? ''), 'ri' => $t['Repository'] ?? '', // image ref, CA's pin key part 1 'pn' => $t['SortName'] ?? $name, // exact SortName, CA's pin key part 2 'rp' => $mine['rp'] ?? '', // owner/repo, for the icon fallback 'ty' => !empty($t['Plugin']) ? 'plugin' : 'docker', // app type 'pu' => $t['PluginURL'] ?? '', // plugin .plg url (plugins install differently) 's' => isset($mine['s']) ? $mine['s'] : null, 'dl' => $dl, 'fs' => $fs, 'lu' => $lu, 'lk' => $lk, 'sa' => $fetchedAt[strtolower($mine['rp'] ?? '')] ?? 0, 'ca' => $mine['ca'] ?? null, 't1' => $mine['t1'] ?? null, 't7' => $mine['t7'] ?? null, 't30' => $mine['t30'] ?? null, 't365'=> $mine['t365'] ?? null, ]; } // Whether the configured token can read star dates. The year trending windows // depend on them, so the grid needs to tell an empty result from an unreadable // one rather than just showing "No apps to show". $scan = read_json_ro("$dataDir/status.json") ?: []; $starDates = empty($scan['stargazers_blocked']); // JSON_INVALID_UTF8_SUBSTITUTE: some feed descriptions carry stray bytes that // would otherwise make json_encode() return false and emit an empty body. echo json_encode( ['generated' => time(), 'count' => count($out), 'starDates' => $starDates, 'feedReady' => $feedReady, 'docker' => docker_state(), 'apps' => $out], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ); ]]> &" * (e.g. "leppermesiah/belot:latest&Belot"). */ header('Content-Type: application/json'); // pinned: CA's flash file, keyed "&" $file = '/boot/config/plugins/community.applications/pinned_appsV2.json'; $pinned = []; if (is_file($file)) { $raw = @file_get_contents($file); $d = @unserialize($raw); if ($d === false) $d = @json_decode($raw, true); if (is_array($d)) { foreach ($d as $k => $v) { if ($v !== false && $v !== null && $v !== '') $pinned[] = (string)$k; } } } // installed: image refs (repo, tag stripped) from the user's Docker templates $installed = []; foreach (glob('/boot/config/plugins/dockerMan/templates-user/*.xml') ?: [] as $x) { $c = @file_get_contents($x); if ($c !== false && preg_match('#([^<]+)#', $c, $m)) { $repo = strtolower(trim($m[1])); if ($repo !== '') { $repo = explode(':', $repo)[0]; $installed[$repo] = true; } } } echo json_encode(['pinned' => $pinned, 'installed' => array_keys($installed)]); ]]> 1, so a page is only auto-scanned once var scanInFlight = false, scanPending = false, scanTimer = null; var pinnedSet = null, installedSet = null; // false once the server reports the GitHub token cannot read star dates, // which is what the year trending windows are built from var starDates = true; // CA's docker availability, as applist.php reports it. Docker being down // does NOT empty the store: CA still lists everything and still installs // plugins, it only blocks docker installs and says why. The modern view is // a re-skin of CA's own catalog, so it behaves identically. var docker = { running: true, reason: 0, warn: false }; var DOCKER_MSG = { 1: 'Docker Service Not Enabled', 2: 'Docker system failed to start', 3: 'Array not started' }; // CA's app catalog lives in /tmp, so it is gone after every boot until CA's // own page has re-downloaded the feed. Our grid loads first, and used to // paint "No apps to show" and stay that way until a manual reload. var feedReady = true, feedWaits = 0; var FEED_POLL_MS = 3000, FEED_MAX_WAITS = 100; // give up after ~5 minutes // CA's Pinned/Installed views are broken in the 2026.07 rewrite (they render // the home screen), so the modern grid renders those itself (view.special). // The few views we can't yet rebuild from our data are handed back to CA. var caSpecial = false; var CA_SPECIAL = /^(previous_apps|prev_docker|prev_plugins|action_centre|repos)$/; function stripTag(ri) { return (ri || '').toLowerCase().split(':')[0]; } function loadViews(cb) { fetch(PREFIX + 'pinned.php?_=' + Date.now()) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (j) { pinnedSet = new Set((j && j.pinned) || []); installedSet = new Set((j && j.installed) || []); cb && cb(); }) .catch(function () { pinnedSet = pinnedSet || new Set(); installedSet = installedSet || new Set(); cb && cb(); }); } // Every trending sort ranks by GitHub stars; they differ only in the window. // Day/week/month come from the plugin's own daily star snapshots. The year // window can't: a fresh install has no year-old snapshot, so the server // walks each repo's stargazer list for a real year-ago baseline (see // backfill_year_baselines in fetch_stars.php) and hands it over as t365. // All time is the repo's whole life: every star it has ever gained, and for // the percentage variant the lifetime rate, since dividing by the star count // at a repo's birth would be dividing by zero. // Trending sorts also FILTER to apps that actually moved in that window, so // the view is a real "what's hot" list, not the whole catalog with a few // movers on top and everything else in feed order. var SORT_OPTS = [ { g: 'Name', v: 'name_asc', label: 'Name Ascending', cmp: function (a, b) { return a.sn < b.sn ? -1 : a.sn > b.sn ? 1 : 0; } }, { g: 'Name', v: 'name_desc', label: 'Name Descending', cmp: function (a, b) { return a.sn < b.sn ? 1 : a.sn > b.sn ? -1 : 0; } }, { g: 'Popularity', v: 'downloads', label: 'Unraid Downloads', cmp: numDesc('dl') }, { g: 'Popularity', v: 'new', label: 'Newest to the App Store', cmp: numDesc('fs') }, { g: 'Popularity', v: 'ghstars', label: 'GitHub Stars', cmp: numDesc('s') }, { g: 'Trending', v: 'ght1', label: 'Trending (today)', cmp: numDesc('t1'), filter: hasTrend('t1'), hint: 'Stars gained in the last day' }, { g: 'Trending', v: 'ght7', label: 'Trending (this week)', cmp: numDesc('t7'), filter: hasTrend('t7'), hint: 'Stars gained in the last 7 days' }, { g: 'Trending', v: 'ght30', label: 'Trending (this month)', cmp: numDesc('t30'), filter: hasTrend('t30'), hint: 'Stars gained in the last 30 days' }, { g: 'Trending', v: 'ght365', label: 'Trending (this year)', cmp: numDesc('t365'), filter: hasTrend('t365'), hint: 'Stars gained in the last 365 days' }, { g: 'Trending', v: 'ghtall', label: 'Trending (all time)', cmp: numDesc('s'), filter: hasStars, hint: 'Every star the repo has ever gained' }, { g: 'Trending %', v: 'ghp1', label: 'Trending % (today)', cmp: pctDesc('t1'), filter: hasPct('t1'), hint: 'Growth today, against the star count a day ago' }, { g: 'Trending %', v: 'ghp7', label: 'Trending % (this week)', cmp: pctDesc('t7'), filter: hasPct('t7'), hint: 'Growth this week, against the star count 7 days ago' }, { g: 'Trending %', v: 'ghp30', label: 'Trending % (this month)', cmp: pctDesc('t30'), filter: hasPct('t30'), hint: 'Growth this month, against the star count 30 days ago' }, { g: 'Trending %', v: 'ghp365', label: 'Trending % (this year)', cmp: pctDesc('t365'), filter: hasPct('t365'), hint: 'Growth this year, against the star count a year ago' }, { g: 'Trending %', v: 'ghpall', label: 'Trending % (all time)', cmp: rateDesc, filter: hasRate, hint: 'Lifetime growth rate: stars per year since the repo was created' } ]; function optFor(v) { for (var i = 0; i < SORT_OPTS.length; i++) if (SORT_OPTS[i].v === v) return SORT_OPTS[i]; return SORT_OPTS[0]; } // numeric descending; null/undefined sinks to the bottom function numDesc(k) { return function (a, b) { var x = a[k], y = b[k]; if (x == null) x = -Infinity; if (y == null) y = -Infinity; return y - x; }; } // relative growth: window delta / stars at window start, 10-star floor so // tiny repos (2->4 = +100%) don't dominate. Mirrors the old server logic. function pct(a, k) { var d = a[k]; if (d == null || a.s == null) return -Infinity; var base = a.s - d; if (base < 10) return -Infinity; return d / base; } function pctDesc(k) { return function (a, b) { return pct(b, k) - pct(a, k); }; } // All-time rate: total stars over the repo's age in years. A repo has zero // stars at birth, so the percentage form used by the other windows would // divide by zero; the lifetime average is the meaningful all-time ranking. // The 3-month age floor stops a repo published last week from posting an // extrapolated rate in the tens of thousands. var YEAR_MS = 365.25 * 24 * 3600 * 1000; function rate(a) { if (a.s == null || a.s < 10 || !a.ca) return -Infinity; var yrs = (Date.now() - a.ca * 1000) / YEAR_MS; return a.s / Math.max(0.25, yrs); } function rateDesc(a, b) { return rate(b) - rate(a); } // trending filters: only apps that actually gained stars in the window function hasTrend(k) { return function (a) { return a[k] != null && a[k] > 0; }; } function hasPct(k) { return function (a) { return pct(a, k) > 0; }; } function hasStars(a) { return a.s != null && a.s > 0; } function hasRate(a) { return rate(a) > 0; } function fmt(n) { if (n == null) return ''; var x = Math.abs(n); if (x >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'; if (x >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; if (x >= 1e3) return (n / 1e3).toFixed(x >= 1e4 ? 0 : 1).replace(/\.0$/, '') + 'k'; return '' + n; } function loadApps(cb) { fetch(PREFIX + 'applist.php?_=' + Date.now()) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (j) { APPS = dedupe((j && j.apps) || []); if (j && j.starDates === false) starDates = false; if (j && j.docker) docker = j.docker; // no answer at all counts as not ready, so a failed request retries // rather than freezing the grid on an empty catalog feedReady = !!j && j.feedReady !== false; cb && cb(); }) .catch(function () { APPS = APPS || []; feedReady = false; cb && cb(); }); } // Poll until CA has written its catalog, then paint. Only the wait is new; // the grid renders from the same endpoint either way. function waitForFeed() { if (feedReady || feedWaits >= FEED_MAX_WAITS) return; feedWaits++; setTimeout(function () { loadApps(function () { render(); waitForFeed(); }); }, FEED_POLL_MS); } // The feed carries the same app under multiple templates (e.g. a repo and a // mirror), so the grid showed duplicates. Collapse by name+author, keeping // the richer record (has an icon, then the longer description). function dedupe(apps) { var by = {}, order = []; apps.forEach(function (a) { var key = (a.n || '').toLowerCase().trim() + '|' + (a.au || '').toLowerCase().trim(); if (!by[key]) { by[key] = a; order.push(key); return; } var cur = by[key]; var better = (!!a.ic && !cur.ic) || ((!!a.ic === !!cur.ic) && (a.de || '').length > (cur.de || '').length); if (better) by[key] = a; }); return order.map(function (k) { return by[k]; }); } // ---- sort persistence: remember the last sort, but reset to Newest if it // has been more than 20 minutes since the Apps page was last opened. ---- function initSort() { try { var ts = parseInt(localStorage.getItem('asga_visit_ts') || '0', 10); var saved = localStorage.getItem('asga_sort'); if (saved && optFor(saved).v === saved && (Date.now() - ts) < 20 * 60 * 1000) view.sort = saved; localStorage.setItem('asga_visit_ts', '' + Date.now()); } catch (e) {} } function saveSort() { try { localStorage.setItem('asga_sort', view.sort); localStorage.setItem('asga_visit_ts', '' + Date.now()); } catch (e) {} } // ---- filtering + sorting ---- function catMatch(a, cat) { if (!cat) return true; var c = (a.ct || '').toLowerCase(); cat = cat.toLowerCase(); return c === cat || c.indexOf(cat) >= 0; } // Community Applications matches a query against a wide set of fields: the // app's name, its author and repository, its categories, its FULL // description, and the hidden ExtraSearchTerms many templates carry. This // grid matched the name, category and repository alone, so a word like // "emulator" came back empty here while CA found dozens of apps that only // carry it mid-blurb. applist.php hands us that unshown text as sx. The // haystack is joined once per app and kept on the record. function haystack(a) { if (a.__hay === undefined) { a.__hay = [a.n, a.sn, a.au, a.rp, a.ri, a.ct, a.de, a.sx].join(' ').toLowerCase(); } return a.__hay; } // CA's filterMatch(): every word of the query has to turn up somewhere, but // any field will do, so "linuxserver plex" can match on two of them. function qMatch(a, words) { var h = haystack(a); for (var i = 0; i < words.length; i++) if (h.indexOf(words[i]) < 0) return false; return true; } // CA lists apps whose name matches above ones that only mention the word in // their blurb, and so does this: a search for "plex" should not bury Plex // under everything that talks about it. The chosen sort still orders within // each of the two groups. function nameMatch(a, words) { var h = ((a.n || '') + ' ' + (a.sn || '') + ' ' + (a.au || '') + ' ' + (a.rp || '')).toLowerCase(); for (var i = 0; i < words.length; i++) if (h.indexOf(words[i]) < 0) return false; return true; } function currentList() { var words = view.q.trim().toLowerCase().split(/\s+/).filter(Boolean); var opt = optFor(view.sort); var list = APPS.filter(function (a) { if (view.special === 'pinned') { if (!pinnedSet || !pinnedSet.has((a.ri || '') + '&' + (a.pn || ''))) return false; } else if (view.special === 'installed') { if (!installedSet || !installedSet.has(stripTag(a.ri))) return false; } if (opt.filter && !opt.filter(a)) return false; // e.g. trending: only movers if (!catMatch(a, view.cat)) return false; if (words.length && !qMatch(a, words)) return false; return true; }); list.sort(opt.cmp); if (words.length) { var named = [], other = []; for (var i = 0; i < list.length; i++) (nameMatch(list[i], words) ? named : other).push(list[i]); list = named.concat(other); } return list; } // ---- rendering ---- function ensureGrid() { var host = document.querySelector('.mainArea'); if (!host) return null; var wrap = document.getElementById('asga-view'); if (wrap) return wrap; wrap = document.createElement('div'); wrap.id = 'asga-view'; wrap.innerHTML = '' + '
' + '
' + '
'; var anchor = document.getElementById('templates_content'); if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(wrap, anchor); else host.appendChild(wrap); // one delegated click handles tiles + their Info/Support/Install buttons document.getElementById('asga-grid').addEventListener('click', function (e) { var el = e.target; var tile = el.closest ? el.closest('.asga-tile') : null; if (!tile) return; var p = tile.getAttribute('data-apppath'), n = tile.getAttribute('data-appname'); var btn = el.closest ? el.closest('.asga-btn') : null; if (btn) { e.stopPropagation(); if (btn.classList.contains('asga-install')) { installApp(tile); } else if (btn.classList.contains('asga-project')) { openExt(tile.getAttribute('data-project')); } else if (btn.classList.contains('asga-support')) { openExt(tile.getAttribute('data-support')); } else if (btn.classList.contains('asga-pin')) { pinApp(tile, btn); } else { // Info try { window.showSidebarApp(p, n); } catch (err) {} } return; } // click anywhere else on the card opens the Info/Install drawer try { window.showSidebarApp(p, n); } catch (err) {} }); return wrap; } // pin/unpin via CA's own pinApp action (keyed by RepoName & Name, exactly // like CA's drawer button), and reflect the toggled state on our button. function pinApp(tile, btn) { // CA keys pins by "&" (exactly what its own drawer // sends), so pinnedApps() can find the template again. var repo = tile.getAttribute('data-pinrepo') || '', name = tile.getAttribute('data-pinname') || ''; if (!repo) return; var key = repo + '&' + name; var willPin = btn.textContent !== 'Unpin'; btn.textContent = willPin ? 'Unpin' : 'Pin App'; btn.classList.toggle('asga-pinned', willPin); if (pinnedSet) { if (willPin) pinnedSet.add(key); else pinnedSet.delete(key); } try { window.post({ action: 'pinApp', repository: repo, name: name }, function () { if (view.special === 'pinned') render(); // reflect an unpin immediately in the pinned view }); } catch (e) {} } function openExt(url) { if (url) try { window.open(url, '_blank', 'noopener'); } catch (e) {} } // Screenshot lightbox. CA's own gallery (magnificPopup) closes the whole // drawer when a preview opens and re-opens it on close, which flashes the // pane blank and, because it re-binds each time, sometimes shows two images // overlaid. In modern view we intercept the click and show our own overlay // instead, so the drawer stays put. function wireLightbox() { if (document.body.__asgaLightbox) return; document.body.__asgaLightbox = true; var srcOf = function (el) { return el.getAttribute('href') || (el.querySelector('img') && el.querySelector('img').getAttribute('src')) || el.getAttribute('src'); }; document.addEventListener('click', function (e) { if (!isOn()) return; // previews are ; the app icon is // img.popupIcon.screenshot (handle it too so CA's gallery never fires). var scr = e.target.closest ? e.target.closest('#sidenavContent .screenshot') : null; if (!scr) return; e.preventDefault(); e.stopImmediatePropagation(); if (scr.classList.contains('popupIcon')) { var s = srcOf(scr); if (s) openLightbox([s], 0); return; } var items = [].slice.call(document.querySelectorAll('#sidenavContent .screenshot')).filter(function (el) { return !el.classList.contains('popupIcon'); }); var srcs = items.map(srcOf).filter(Boolean); openLightbox(srcs, Math.max(0, items.indexOf(scr))); }, true); } // CA renders an app's Overview by turning newlines into
and leading // indentation into   runs. Templates are authored in a plain-text // editor, so many of them carry hard-wrapped prose: a break mid-sentence // every 80-odd characters, each followed by four spaces of indent. Poured // into a drawer of a different width that reads as ragged, indented // nonsense rather than paragraphs. // // This is the one place the modern view rewrites CA's markup rather than // only restyling it, because the damage is in the markup:   is a real // character and a
is a real element, so no stylesheet can undo either. // It is confined to the description block, it runs only in modern view, and // it touches nothing CA reads back. // // A lone break inside a long line is a hard wrap and becomes a space. A run // of two or more breaks is the author separating paragraphs and is left // alone. A lone break after a SHORT line is left alone too, because that is // how a template writes a list ("Port: 8080" on its own line), and joining // those would be the same vandalism in the other direction. var WRAP_MIN = 60; function wireDescriptionTidy() { if (document.body.__asgaDescTidy) return; document.body.__asgaDescTidy = true; var host = document.getElementById('sidenavContent') || document.querySelector('.sidenav'); if (!host) return; // our own edits re-enter this, but the per-element guard makes that a // no-op, and CA hands us a fresh element for every app it opens new MutationObserver(tidyDescription).observe(host, { childList: true, subtree: true }); tidyDescription(); } function tidyDescription() { if (!isOn()) return; var el = document.querySelector('#sidenavContent .popupDescription'); if (!el || el.__asgaTidied) return; el.__asgaTidied = true; // the indent first:   does not collapse the way a space does, so it // has to become one before the lines are joined var walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); var node; while ((node = walk.nextNode())) { if (node.nodeValue.indexOf(' ') !== -1) node.nodeValue = node.nodeValue.replace(/ +/g, ' '); } var brs = [].slice.call(el.querySelectorAll('br')); for (var i = 0; i < brs.length; i++) { var br = brs[i]; if (!br.parentNode) continue; if (isBr(meaningBefore(br)) || isBr(meaningAfter(br))) continue; // paragraph break if (lineBefore(br).length < WRAP_MIN) continue; // deliberate short line br.parentNode.replaceChild(document.createTextNode(' '), br); } } function isBr(n) { return !!n && n.nodeName === 'BR'; } function blankText(n) { return n && n.nodeType === 3 && !/[^\s ]/.test(n.nodeValue); } function meaningBefore(n) { n = n.previousSibling; while (blankText(n)) n = n.previousSibling; return n; } function meaningAfter(n) { n = n.nextSibling; while (blankText(n)) n = n.nextSibling; return n; } // the text of the line this break ends, back to the previous break. Reading // it after earlier joins is intended: once a paragraph starts being rebuilt // its later wraps look long too, which is exactly when they should join. function lineBefore(br) { var s = '', n = br.previousSibling; while (n && n.nodeName !== 'BR') { s = (n.textContent || '') + s; n = n.previousSibling; } return s.replace(/\s+/g, ' ').trim(); } function openLightbox(srcs, idx) { if (!srcs.length) return; var i = idx; var ov = document.createElement('div'); ov.className = 'asga-lightbox'; ov.innerHTML = '' + (srcs.length > 1 ? '' : '') + ''; var imgEl = ov.querySelector('.asga-lb-img'); var show = function () { imgEl.src = srcs[(i + srcs.length) % srcs.length]; }; show(); var close = function () { ov.remove(); document.removeEventListener('keydown', key, true); }; var key = function (ev) { if (ev.key === 'Escape') close(); else if (ev.key === 'ArrowRight') { i++; show(); } else if (ev.key === 'ArrowLeft') { i--; show(); } }; ov.addEventListener('click', function (ev) { if (ev.target.classList.contains('asga-lb-close') || ev.target === ov) return close(); if (ev.target.classList.contains('asga-lb-next')) { i++; show(); } else if (ev.target.classList.contains('asga-lb-prev')) { i--; show(); } }); document.addEventListener('keydown', key, true); document.body.appendChild(ov); } // Install in a NEW tab. Docker apps open CA's template editor at // /Apps/AddContainer; plugins open CA's plugin-install page. Same targets CA // uses, just forced into a new tab. function installApp(tile) { var p = tile.getAttribute('data-apppath'), ty = tile.getAttribute('data-type'), pu = tile.getAttribute('data-plugurl'); if (ty === 'plugin') { // plugins: let CA drive its own plugin install (its flow differs from docker) try { window.showSidebarApp(p, tile.getAttribute('data-appname')); } catch (e) {} return; } if (!docker.running) return; // nothing to install into; the card says why try { window.open('/Apps/AddContainer?xmlTemplate=default:' + encodeURIComponent(p), '_blank', 'noopener'); } catch (e) {} } function makeTile(a) { var tile = document.createElement('div'); tile.className = 'asga-tile'; tile.setAttribute('data-apppath', a.p); tile.setAttribute('data-appname', a.n); if (a.pr) tile.setAttribute('data-project', a.pr); if (a.su) tile.setAttribute('data-support', a.su); if (a.ri) { tile.setAttribute('data-pinrepo', a.ri); tile.setAttribute('data-pinname', a.pn || a.n); } tile.setAttribute('data-type', a.ty || 'docker'); if (a.pu) tile.setAttribute('data-plugurl', a.pu); tile.title = a.n; // header: icon + name/author/category var head = document.createElement('div'); head.className = 'asga-tile-head'; var iconWrap = document.createElement('div'); iconWrap.className = 'asga-tile-icon'; var img = document.createElement('img'); var fallback = '/plugins/dynamix.docker.manager/images/question.png'; // icon fallback chain: the app's own icon, else the GitHub owner's avatar // (many templates ship no icon URL), else CA's question mark. var ghAvatar = (a.rp && a.rp.indexOf('/') > 0) ? ('https://github.com/' + a.rp.split('/')[0] + '.png?size=128') : ''; img.src = a.ic || ghAvatar || fallback; img.loading = 'lazy'; img.alt = ''; img.onerror = function () { if (ghAvatar && this.src !== ghAvatar && this.src.indexOf('github.com') < 0) { this.src = ghAvatar; return; } if (this.src.indexOf('question.png') < 0) this.src = fallback; }; iconWrap.appendChild(img); head.appendChild(iconWrap); // stars + downloads sit inline in the tile's top-right corner var badges = document.createElement('div'); badges.className = 'asga-tile-badges'; if (a.s != null) { var badge = document.createElement('span'); badge.className = 'ghstars-badge'; badge.textContent = '★ ' + fmt(a.s); badge.title = a.s + ' GitHub stars'; badges.appendChild(badge); } if (a.dl > 0) { var dlb = document.createElement('span'); dlb.className = 'ghdl-badge'; dlb.textContent = '⤓ ' + fmt(a.dl); dlb.title = a.dl.toLocaleString() + ' Docker image pulls'; badges.appendChild(dlb); } if (badges.children.length) { tile.appendChild(badges); tile.classList.add('asga-has-badges'); } var htext = document.createElement('div'); htext.className = 'asga-tile-htext'; var name = document.createElement('div'); name.className = 'asga-tile-name'; name.textContent = a.n; htext.appendChild(name); if (a.au) { var au = document.createElement('div'); au.className = 'asga-tile-author'; au.textContent = a.au; htext.appendChild(au); } var metaRow = document.createElement('div'); metaRow.className = 'asga-tile-metarow'; var typ = document.createElement('span'); typ.className = 'asga-type asga-type-' + (a.ty || 'docker'); typ.textContent = (a.ty === 'plugin') ? 'Plugin' : 'Docker'; metaRow.appendChild(typ); if (a.ct) { var cat = document.createElement('span'); cat.className = 'asga-tile-cat'; cat.textContent = a.ct; metaRow.appendChild(cat); } htext.appendChild(metaRow); head.appendChild(htext); tile.appendChild(head); // description (verbiage) if (a.de) { var desc = document.createElement('div'); desc.className = 'asga-tile-desc'; desc.textContent = a.de; tile.appendChild(desc); } // Why Install is off, in the flowing part of the card. It has to sit // ABOVE the button row: that row carries margin-top:auto and is what // bottom-aligns every card, so a line placed after it would push the // buttons up on blocked cards only and leave the row ragged. if (blocked(a)) { tile.classList.add('asga-tile-blocked'); var note = document.createElement('div'); note.className = 'asga-tile-blocked-note'; note.textContent = (DOCKER_MSG[docker.reason] || 'Docker not available') + ', install unavailable'; tile.appendChild(note); } // Info / Pin / Project / Support / Install buttons (Project + Support are // direct links, no submenu) var btns = document.createElement('div'); btns.className = 'asga-tile-btns'; btns.appendChild(mkBtn('Info', 'asga-info')); if (a.ri) { var isPinned = pinnedSet && pinnedSet.has(a.ri + '&' + (a.pn || '')); var pb = mkBtn(isPinned ? 'Unpin' : 'Pin App', 'asga-pin'); if (isPinned) pb.classList.add('asga-pinned'); btns.appendChild(pb); } if (a.pr) btns.appendChild(mkBtn('Project', 'asga-project')); if (a.su) btns.appendChild(mkBtn('Support', 'asga-support')); var ib = mkBtn('Install', 'asga-install'); // Docker down: the card still lists the app and still opens its Info // drawer, only Install is off, exactly as CA behaves. if (blocked(a)) { ib.classList.add('asga-btn-off'); ib.title = (DOCKER_MSG[docker.reason] || 'Docker is not available') + ', Docker apps cannot be installed'; } btns.appendChild(ib); tile.appendChild(btns); // when CA's feed first saw this app, and when the app itself last shipped, // on one line at the foot of the card: Added at the left edge, Updated at // the right. Both halves are appended even when their date is unknown, so // the Updated column stays put on a card that knows only one of the two, // and so every button row in a grid row still bottom-aligns. var dates = document.createElement('div'); dates.className = 'asga-tile-dates'; var added = addedLabel(a.fs); var ad = document.createElement('div'); ad.className = 'asga-tile-added'; // The label is an interval for anything recent, so the exact date leads the // tooltip: hovering is the only way back to it. if (added) { ad.textContent = added; ad.title = absDate(a.fs, a.fs > 1433649600) + '\n' + 'When the Community Applications feed first saw this app. Your server ' + 'only picks it up on its next feed refresh, so an app can appear here days later.'; } dates.appendChild(ad); var updated = updatedLabel(a.lu, a.lk); var up = document.createElement('div'); up.className = 'asga-tile-updated'; if (updated) { up.textContent = updated; up.title = absDate(a.lu, a.lk === 'r') + '\n' + (a.lk === 'v' ? 'Release date of this plugin\'s current version, read from the version number itself.' : 'When this app\'s image was last published to its container registry.'); } dates.appendChild(up); tile.appendChild(dates); return tile; } // The absolute form. CA's FirstSeen is a unix timestamp, and it floors // anything older than its own record-keeping to 1433000000 (Jun 2015); for // those the time of day is an artefact, so the caller asks for date only. function absDate(ts, withTime) { var d = new Date(ts * 1000); if (isNaN(d.getTime())) return ''; var opts = { year: 'numeric', month: 'short', day: 'numeric' }; if (withTime) { opts.hour = 'numeric'; opts.minute = '2-digit'; } try { return d.toLocaleString(undefined, opts); } catch (e) { return d.toDateString(); } } // A recent date reads better as an interval than as a timestamp: "3 hours // ago" places an app against now, where "Aug 6, 2026, 2:14 PM" has to be // worked out first. Past a month the interval stops helping ("94 days ago" // is worse than a date), so that is where this returns nothing and the // caller falls back to absDate. // dayOnly is for a value that was only ever a day, such as a plugin's // date-formed version number. Its clock reads midnight because that is what // a bare date parses to, not because anything happened then, so those never // report hours or minutes. var REL_MAX_AGE = 30 * 86400; function relDate(ts, dayOnly) { var now = Math.floor(Date.now() / 1000); var age = now - ts; // a feed clock running ahead of ours would otherwise read "-2 hours ago" if (age < 0 || age > REL_MAX_AGE) return ''; if (!dayOnly) { if (age < 60) return 'just now'; if (age < 3600) return countOf(Math.floor(age / 60), 'minute') + ' ago'; if (age < 86400) return countOf(Math.floor(age / 3600), 'hour') + ' ago'; } var days = dayGap(ts, now); if (days <= 0) return 'today'; if (days === 1) return 'yesterday'; return countOf(days, 'day') + ' ago'; } function countOf(n, unit) { return n + ' ' + unit + (n === 1 ? '' : 's'); } // Calendar days apart rather than 24-hour blocks, so 11pm last night is // "yesterday" to someone reading at 1am, which is what they would call it. function dayGap(ts, now) { var a = new Date(ts * 1000), b = new Date(now * 1000); a.setHours(0, 0, 0, 0); b.setHours(0, 0, 0, 0); return Math.round((b - a) / 86400000); } function addedLabel(fs) { if (!fs) return ''; var abs = absDate(fs, fs > 1433649600); return abs ? 'Added ' + (relDate(fs, false) || abs) : ''; } // A registry push has a real time of day and is shown with one. A plugin's // date-formed version carries no time, so that variant stops at the day // rather than inventing midnight. function updatedLabel(lu, lk) { if (!lu) return ''; var abs = absDate(lu, lk === 'r'); return abs ? 'Updated ' + (relDate(lu, lk !== 'r') || abs) : ''; } function mkBtn(label, cls) { var b = document.createElement('span'); b.className = 'asga-btn ' + cls; b.textContent = label; return b; } // A trending sort filters to apps that moved in its window, so an empty grid // is ambiguous: nothing moved, or the data for that window was never // gathered. The year windows are the ones that can genuinely be unavailable, // so say which it is instead of leaving a blank page. function emptySortNote() { if (view.sort !== 'ght365' && view.sort !== 'ghp365') return ''; if (!starDates) { return 'The "this year" windows need GitHub star dates, which the configured token cannot read. ' + 'A classic token (ghp_...) with no scopes can read them; a fine-grained token (github_pat_...) cannot. ' + 'Swap the token in Settings, or wait for the plugin to record a year of its own star history.'; } return 'No star data reaches back a year yet. This fills in as the plugin records history, ' + 'or straight away once a full catalog scan has run.'; } // The catalog is CA's file in /tmp, rebuilt by CA's own Apps page after a // boot. Until it lands there is nothing to show and nothing wrong, so say // that rather than "No apps to show", which reads like an empty store. function feedWaitNote() { if (feedWaits >= FEED_MAX_WAITS) { return 'Community Applications has not published its app catalog yet. Reload this page, ' + 'or turn Modern view off once to let Community Applications download the feed.'; } return 'Waiting for Community Applications to download the app catalog. ' + 'It is rebuilt after every reboot, and this grid fills in as soon as it lands.'; } // Same message CA puts in its page banner, restated inside the grid. CA's // banner is dismissible and stays dismissed for a month by cookie, so the // modern view says it itself rather than relying on a banner that may not // be there. Plugins remain fully installable, which is the point of it. function renderDockerNotice() { var el = document.getElementById('asga-dockerwarn'); if (!el) return; if (docker.running || !docker.warn) { el.style.display = 'none'; el.textContent = ''; return; } el.textContent = '⚠ ' + (DOCKER_MSG[docker.reason] || 'Docker apps not available to install') + '. Only plugins can be installed or managed until Docker is running. Docker apps are still listed here.'; el.style.display = ''; } // A docker app cannot be installed while the daemon is down; a plugin can. function blocked(a) { return !docker.running && (a.ty || 'docker') !== 'plugin'; } function render() { if (!isOn() || caSpecial) return; var wrap = ensureGrid(); if (!wrap) return; renderDockerNotice(); var list = currentList(); var total = list.length; var pages = Math.max(1, Math.ceil(total / view.perPage)); if (view.page > pages) view.page = pages; if (view.page < 1) view.page = 1; var start = (view.page - 1) * view.perPage; var pageItems = list.slice(start, start + view.perPage); var grid = document.getElementById('asga-grid'); grid.textContent = ''; if (!total) { var empty = document.createElement('div'); empty.className = 'asga-empty'; empty.textContent = !feedReady ? feedWaitNote() : view.special === 'pinned' ? 'No pinned apps yet. Use the Pin App button on any app to add it here.' : view.special === 'installed' ? 'No installed apps matched the App Store catalog.' : view.q ? 'No apps match "' + view.q + '".' : emptySortNote() || 'No apps to show.'; grid.appendChild(empty); } else { var frag = document.createDocumentFragment(); for (var i = 0; i < pageItems.length; i++) frag.appendChild(makeTile(pageItems[i])); grid.appendChild(frag); } var noun = view.special === 'pinned' ? 'pinned apps' : view.special === 'installed' ? 'installed apps' : 'apps'; var title = view.special === 'pinned' ? 'Pinned Apps' : view.special === 'installed' ? 'Installed Apps' : (view.cat ? view.catLabel : 'All Apps'); var from = total ? start + 1 : 0, to = Math.min(start + view.perPage, total); var cnt = document.getElementById('asga-count'); cnt.textContent = ''; var h = document.createElement('span'); h.className = 'asga-count-title'; h.textContent = title; var s = document.createElement('span'); s.className = 'asga-count-sub'; s.textContent = total ? (' ' + from + '–' + to + ' of ' + total + ' ' + noun + (view.q ? ' matching "' + view.q + '"' : '')) : ''; cnt.appendChild(h); cnt.appendChild(s); renderPager(pages); markCurrentCategory(); pageItemsNow = pageItems; queueScan(); try { window.scrollTo(0, 0); } catch (e) {} } // Stars are fetched for the apps on screen rather than the whole 3600-app // catalog: paging or filtering tops up whatever the new page is missing. // Anything tried within the last week is left alone (the server enforces // that too), and each path is only auto-requested once per page load. function queueScan() { clearTimeout(scanTimer); scanTimer = setTimeout(function () { scanVisible(false); }, 400); } function scanVisible(force) { if (!isOn()) return; // a page turn while a scan is running would otherwise be dropped, so the // new page is picked up as soon as the current request lands if (scanInFlight) { scanPending = true; return; } scanPending = false; var now = Math.floor(Date.now() / 1000); var want = []; for (var i = 0; i < pageItemsNow.length; i++) { var a = pageItemsNow[i]; if (!a || !a.p) continue; var due = force || a.s == null || !a.sa || (now - a.sa) > 7 * 86400; if (!due) continue; if (!force && scanAsked[a.p]) continue; want.push(a.p); } if (!want.length) return; want.forEach(function (p) { scanAsked[p] = 1; }); scanInFlight = true; fetch(PREFIX + 'scanpage.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths: want, force: !!force }) }) .then(function (r) { return r.ok ? r.json() : null; }) .catch(function () { return null; }) .then(function (j) { scanInFlight = false; if (scanPending) queueScan(); var stars = (j && j.stars) || {}; var byPath = {}; for (var i = 0; i < APPS.length; i++) byPath[APPS[i].p] = APPS[i]; want.forEach(function (p) { var a = byPath[p]; if (!a) return; a.sa = now; // tried; don't ask again this week if (Object.prototype.hasOwnProperty.call(stars, p)) a.s = stars[p]; }); paintStars(stars); }); } // Repaint badges in place. A full render() would jump the page back to the // top under the user while they are reading. function paintStars(stars) { var grid = document.getElementById('asga-grid'); if (!grid) return; var tiles = grid.querySelectorAll('.asga-tile'); for (var i = 0; i < tiles.length; i++) { var t = tiles[i]; var p = t.getAttribute('data-apppath'); var v = stars[p]; if (v == null) continue; var wrap = t.querySelector('.asga-tile-badges'); if (!wrap) { wrap = document.createElement('div'); wrap.className = 'asga-tile-badges'; t.appendChild(wrap); } t.classList.add('asga-has-badges'); // reserves title space for the badges var b = wrap.querySelector('.ghstars-badge'); if (!b) { b = document.createElement('span'); b.className = 'ghstars-badge'; wrap.insertBefore(b, wrap.firstChild); } b.textContent = '\u2605 ' + fmt(v); b.title = v + ' GitHub stars'; } } function renderPager(pages) { var p = document.getElementById('asga-pager'); p.textContent = ''; if (pages <= 1) return; var mk = function (label, page, disabled, cur) { var b = document.createElement('a'); b.className = 'asga-page' + (cur ? ' asga-page-cur' : '') + (disabled ? ' asga-page-off' : ''); b.textContent = label; if (!disabled && !cur) b.addEventListener('click', function () { view.page = page; render(); }); return b; }; p.appendChild(mk('‹ Prev', view.page - 1, view.page <= 1, false)); // windowed page numbers around current var win = 3, lo = Math.max(1, view.page - win), hi = Math.min(pages, view.page + win); if (lo > 1) { p.appendChild(mk('1', 1, false, view.page === 1)); if (lo > 2) p.appendChild(dots()); } for (var n = lo; n <= hi; n++) p.appendChild(mk('' + n, n, false, n === view.page)); if (hi < pages) { if (hi < pages - 1) p.appendChild(dots()); p.appendChild(mk('' + pages, pages, false, view.page === pages)); } p.appendChild(mk('Next ›', view.page + 1, view.page >= pages, false)); } function dots() { var s = document.createElement('span'); s.className = 'asga-dots'; s.textContent = '…'; return s; } // ---- our toolbar (toggle + dropdown + refresh) in CA's search row ---- function addSortBar() { var host = document.getElementById('searchFilter'); if (!host || document.getElementById('asga-bar')) return; // grouped, because a flat list of fifteen orders is a wall of near-identical // labels; the four Trending/Trending % families read at a glance this way var opts = '', group = ''; SORT_OPTS.forEach(function (o) { if (o.g !== group) { if (group) opts += ''; opts += ''; group = o.g; } opts += ''; }); if (group) opts += ''; var bar = document.createElement('span'); bar.id = 'asga-bar'; bar.className = 'asga-bar'; bar.innerHTML = '' + 'Sort By:' + '' + '
'; host.appendChild(bar); var sel = document.getElementById('asga-sortsel'); sel.value = view.sort; sel.addEventListener('change', function (e) { view.sort = e.target.value; view.page = 1; saveSort(); render(); }); document.getElementById('asga-refresh').addEventListener('click', onRefreshClick); var cb = document.getElementById('asga-toggle-cb'); cb.checked = isOn(); cb.addEventListener('change', function () { setOn(cb.checked); }); } // GitHub view on/off, persisted. When off, we un-hide CA's own grid and let // the stock App Store view take over; when on, our grid drives the page. function isOn() { try { return localStorage.getItem('asga_view_off') !== '1'; } catch (e) { return true; } } function setOn(on) { try { localStorage.setItem('asga_view_off', on ? '0' : '1'); } catch (e) {} applyViewMode(); if (on) { render(); } else { // hand control back to CA: show its grid and load its All-Apps view fresh var all = document.querySelector('.caMenuItem.allApps'); if (all) try { all.click(); } catch (e) {} } } function applyViewMode() { var persisted = isOn(); var showOurs = persisted && !caSpecial; // a CA special view temporarily wins document.body.classList.toggle('asga-active', showOurs); // CSS hides CA's grid only when ours shows // the loader sets this on before CA's markup is parsed, so its // stock controls never flash; keep the two in step when toggling live document.documentElement.classList.toggle('asga-pre', showOurs); var v = document.getElementById('asga-view'); if (v) v.style.display = showOurs ? '' : 'none'; var sw = document.querySelector('.asga-sortwrap'); if (sw) sw.style.display = showOurs ? '' : 'none'; var cb = document.getElementById('asga-toggle-cb'); if (cb) cb.checked = persisted; // toggle reflects the persisted choice } // filter as the user types in CA's own search box (CA's hidden results are ignored) function applySearch(q) { if (!isOn()) return; q = q || ''; if (q === view.q) return; if (caSpecial) { caSpecial = false; applyViewMode(); } // leave a CA view when searching // CA disables its category menu for the duration of a search, so a search // there always spans the whole store. This does the same rather than // quietly searching inside whichever category was last opened. view.special = ''; view.cat = ''; view.catLabel = 'All Apps'; view.q = q; view.page = 1; render(); } function wireSearch() { var box = document.getElementById('searchBox'); if (!box || box.__asgaWired) return; box.__asgaWired = true; var deb; box.addEventListener('input', function () { clearTimeout(deb); deb = setTimeout(function () { applySearch(box.value); }, 120); }); // CA's clear button empties the box with jQuery's .val(), which fires no // input event, so the grid stayed filtered on a query the user could no // longer see. The box is read back after CA's own handler has run. document.addEventListener('click', function (e) { if (!e.target || !e.target.closest) return; if (!e.target.closest('.searchSubmit, #searchButton')) return; setTimeout(function () { applySearch(box.value); }, 0); }, true); } // CA's left menu never shows which entry you are looking at, so the grid // marks it itself. Pinned and Installed are our own views and match on their // data-category; everything else matches on the category we are filtered to, // and an unfiltered grid marks whichever entry means "all of it". function markCurrentCategory() { var items = document.querySelectorAll('.caMenuItem, .startupButton'); for (var i = 0; i < items.length; i++) { var it = items[i], cat = it.getAttribute('data-category') || ''; var on; if (view.special) on = (cat === (view.special === 'pinned' ? 'pinned_apps' : 'installed_apps')); else if (view.cat) on = (cat === view.cat); else on = it.classList.contains('allApps') || cat === 'All'; it.classList.toggle('asga-menu-cur', !!on); } } // left-menu category clicks filter our grid (capture phase, we don't stop CA) function wireCategories() { if (document.body.__asgaCatWired) return; document.body.__asgaCatWired = true; document.addEventListener('click', function (e) { var item = e.target.closest ? e.target.closest('.caMenuItem[data-category], .startupButton') : null; if (!item) return; var cat = item.getAttribute('data-category') || ''; var label = (item.textContent || '').trim(); var box = document.getElementById('searchBox'); // Pinned + Installed: CA's own views are broken, so render them ourselves. if (cat === 'pinned_apps' || cat === 'installed_apps') { caSpecial = false; view.special = (cat === 'installed_apps') ? 'installed' : 'pinned'; view.cat = ''; view.q = ''; if (box) box.value = ''; view.page = 1; loadViews(function () { applyViewMode(); render(); }); return; } if (CA_SPECIAL.test(cat)) { // Previous Apps / Action Centre / Repositories: hand back to CA for now. caSpecial = true; applyViewMode(); return; } // Home (startup screens) and All Apps both mean the full catalog for us. caSpecial = false; view.special = ''; var homeLike = item.classList.contains('startupButton') || /^(onlynew|spotlight|top_trending|home)$/.test(cat); if (homeLike || cat === 'All' || cat === 'New' || cat === '' || item.classList.contains('allApps')) { view.cat = ''; view.catLabel = 'All Apps'; } else { view.cat = cat; view.catLabel = label || cat; } view.q = ''; if (box) box.value = ''; view.page = 1; applyViewMode(); setTimeout(render, 0); }, true); } // ---- no-token warning ---- function showWarningIfNeeded() { var cfg = window.__modernAppStore || {}; if (cfg.hasToken) return; if (document.querySelector('.ghstars-warning')) return; var main = document.querySelector('.mainArea'); if (!main) return; var w = document.createElement('div'); w.className = 'ghstars-warning'; var msg = document.createElement('span'); msg.innerHTML = '⚠ Unraid Modern App Store: no GitHub personal access token configured, so ' + 'star counts are disabled. Add a token in Settings → Unraid Modern App Store.'; var x = document.createElement('span'); x.className = 'ghstars-warning-x'; x.title = 'Dismiss'; x.textContent = '✕'; x.addEventListener('click', function () { w.parentNode && w.parentNode.removeChild(w); }); w.appendChild(msg); w.appendChild(x); main.insertBefore(w, main.firstChild); } // ---- refresh + progress (thin top bar) ---- // The icon offers the cheap option first: rescan what is on screen. A full // catalog scan is still there, with its own 3-day cooldown. function onRefreshClick(e) { if (e) { e.stopPropagation(); e.preventDefault(); } var host = document.getElementById('asga-refresh'); if (!host) return; var open = document.querySelector('.asga-refmenu'); if (open) { open.remove(); return; } var menu = document.createElement('div'); menu.className = 'asga-refmenu'; menu.innerHTML = 'Refresh this page' + 'Refresh everything'; host.parentNode.insertBefore(menu, host.nextSibling); menu.addEventListener('click', function (ev) { var item = ev.target.closest ? ev.target.closest('.asga-refitem') : null; if (!item) return; menu.remove(); if (item.getAttribute('data-act') === 'page') scanVisible(true); else refreshAll(); }); setTimeout(function () { document.addEventListener('click', function close(ev) { if (menu.contains(ev.target)) return; menu.remove(); document.removeEventListener('click', close, true); }, true); }, 0); } function refreshAll() { if (!window.confirm('Fetch the latest GitHub star data for every app now? Allowed once every 3 days.')) return; fetch(PREFIX + 'refresh.php?_=' + Date.now()).then(function (r) { return r.ok ? r.json() : null; }) .catch(function () { return null; }) .then(function (res) { if (res && res.cooldown) { var d = Math.max(1, Math.ceil((res.next_allowed - Math.floor(Date.now() / 1000)) / 86400)); alert('Already refreshed recently. Next refresh allowed in ~' + d + ' day(s).'); return; } startPolling(); }); } function ensureTopBar() { var bar = document.getElementById('ghstars-topbar'); if (bar) return bar; bar = document.createElement('div'); bar.id = 'ghstars-topbar'; bar.className = 'ghstars-topbar'; bar.style.display = 'none'; bar.innerHTML = '
'; document.body.appendChild(bar); bar.querySelector('.ghstars-topbar-x').addEventListener('click', function () { fetch(PREFIX + 'cancel.php?_=' + Date.now()).catch(function () {}); }); return bar; } function pollProgress() { fetch(PREFIX + 'progress.json?_=' + Date.now()).then(function (r) { return r.ok ? r.json() : null; }) .then(function (p) { var bar = ensureTopBar(); var stale = p && p.updated_at && (Math.floor(Date.now() / 1000) - p.updated_at) > 90; if (p && p.running && !stale) { wasRunning = true; bar.style.display = ''; var pct = p.total > 0 ? Math.min(100, Math.round(p.done / p.total * 100)) : 3; bar.querySelector('.ghstars-topbar-fill').style.width = pct + '%'; // a scan opens with a link-resolution pass (CA hides most project // URLs behind redirectors), which is slow enough to need its own label var what = p.phase === 'links' ? 'Resolving app links… ' : 'Pulling GitHub stars… '; bar.querySelector('.ghstars-topbar-label').textContent = what + (p.total > 0 ? (p.done + '/' + p.total) : 'starting…'); setTimeout(pollProgress, 1200); } else { bar.style.display = 'none'; if (wasRunning) { wasRunning = false; loadApps(function () { render(); }); } polling = false; } }).catch(function () { polling = false; }); } function startPolling() { if (polling) return; polling = true; pollProgress(); } function triggerNewScan() { fetch(PREFIX + 'newscan.php?_=' + Date.now()) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (res) { if (res && res.started) setTimeout(startPolling, 1000); }) .catch(function () {}); } // ---- lifecycle ---- // CA keeps re-rendering its own (now hidden) grid; re-attach our UI if CA // rebuilt the toolbar, but the grid itself only re-renders on user actions. // CA opens an "Updating Content / Please Wait" modal on every Apps visit and // relies on its own render finishing to close it. Our grid replaces CA's // render, so that modal (and CA's spinner) can get stuck open. Close it. function dismissCaLoading() { if (!isOn()) return; if (document.querySelector('.updateContent-swal')) { try { if (typeof window.myCloseAlert === 'function') window.myCloseAlert(true); else if (window.swal && window.swal.close) window.swal.close(); } catch (e) {} } } function attachUI() { addSortBar(); wireSearch(); wireCategories(); wireLightbox(); wireDescriptionTidy(); showWarningIfNeeded(); applyViewMode(); dismissCaLoading(); if (isOn() && !document.getElementById('asga-view')) render(); } function start() { initSort(); // restore last sort (or reset to Newest after 20 min) triggerNewScan(); loadViews(); // pin/installed membership, so tiles show correct pin state loadApps(function () { attachUI(); render(); waitForFeed(); // no-op unless CA's catalog is still being downloaded var main = document.querySelector('.mainArea') || document.body; var pending = false; var mo = new MutationObserver(function () { if (pending) return; pending = true; setTimeout(function () { pending = false; attachUI(); }, 200); }); mo.observe(main, { childList: true, subtree: true }); startPolling(); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start(); } catch (e) { /* never break CA */ } })(); ]]>
inline, before any of CA's markup is parsed, so the same things are hidden from the first frame. */ html.asga-pre #templates_content, html.asga-pre #sortIconArea, html.asga-pre #maxPerPage, html.asga-pre .categoryLine, html.asga-pre .spinner.fixed { display: none !important; } /* our toolbar row lives in CA's search row */ #searchFilter { margin-top: 6px; } .asga-bar { display: inline-flex; align-items: center; gap: 16px; margin-left: 14px; vertical-align: middle; font-size: 1.2rem; color: var(--sidebar-text); } .asga-sortwrap { display: inline-flex; align-items: center; gap: 8px; } .asga-bar-label { white-space: nowrap; } /* constrain the native select's box height so its glyphs center on the same line as the adjacent text labels (a taller default box made it sit high) */ .asga-sortsel { vertical-align: middle; font-size: 1.2rem; height: 1.7rem; line-height: 1.7rem; padding: 0 4px; } .asga-refreshlink { cursor: pointer; opacity: 0.8; font-size: 1.4rem; text-decoration: none; color: inherit; } .asga-refreshlink:hover { opacity: 1; } /* refresh menu (page vs everything) */ .asga-sortwrap { position: relative; } .asga-refmenu { position: absolute; top: 26px; right: 0; z-index: 30; background: var(--background-color, #1c1c1c); border: 1px solid rgba(127,127,127,0.4); border-radius: 8px; min-width: 170px; overflow: hidden; box-shadow: 0 6px 18px rgba(0,0,0,0.45); } .asga-refitem { display: block; padding: 9px 14px; font-size: 1.15rem; white-space: nowrap; cursor: pointer; } .asga-refitem:hover { background: rgba(255,140,47,0.18); } /* GitHub-view on/off toggle */ .asga-toggle { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } .asga-toggle input { position: absolute; opacity: 0; width: 0; height: 0; } .asga-toggle-track { position: relative; width: 40px; height: 22px; border-radius: 22px; background: rgba(127,127,127,0.35); transition: background 0.15s ease; } .asga-toggle-knob { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform 0.15s ease; } .asga-toggle input:checked + .asga-toggle-track { background: #ff8c2f; } .asga-toggle input:checked + .asga-toggle-track .asga-toggle-knob { transform: translateX(18px); } .asga-toggle-lbl { white-space: nowrap; } /* ---- our grid ---- */ #asga-view { margin: 6px 10px 40px; } .asga-count { margin: 6px 2px 16px; } .asga-count-title { font-size: 1.6rem; font-weight: 700; color: var(--text-color, inherit); } .asga-count-sub { font-size: 1.05rem; opacity: 0.55; } .asga-empty { grid-column: 1 / -1; padding: 80px 8px; font-size: 1.3rem; opacity: 0.7; text-align: center; } .asga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; } .asga-tile { position: relative; background: var(--background-color, rgba(127,127,127,0.06)); border: 1px solid rgba(127, 127, 127, 0.22); border-radius: 8px; padding: 14px; display: flex; flex-direction: column; transition: border-color 0.15s ease; cursor: pointer; /* Templates come from a public feed: names, authors and categories can be absurdly long or a single unbreakable token (a URL). Break anywhere rather than let a card spill over its neighbour, and clip as a last resort. */ overflow: hidden; overflow-wrap: break-word; word-break: break-word; } .asga-tile > *, .asga-tile-head > *, .asga-tile-htext > * { min-width: 0; max-width: 100%; } .asga-tile:hover { border-color: #ff8c2f; } .asga-tile-head { display: flex; gap: 12px; align-items: flex-start; } .asga-tile-icon { position: relative; width: 64px; height: 64px; flex: 0 0 64px; } .asga-tile-icon img { width: 64px; height: 64px; object-fit: contain; border-radius: 10px; } .asga-tile-htext { min-width: 0; flex: 1; } /* the badges float over the top-right corner, so a long title has to stop short of them instead of running underneath */ .asga-has-badges .asga-tile-name { padding-right: 104px; } .asga-tile-name { font-size: 1.25rem; font-weight: 700; line-height: 1.2; color: var(--text-color, inherit); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .asga-tile-author { font-size: 1rem; opacity: 0.7; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .asga-tile-metarow { display: flex; align-items: center; gap: 8px; margin-top: 3px; } .asga-tile-cat { font-size: 0.95rem; opacity: 0.55; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .asga-type { font-size: 0.8rem; font-weight: 600; letter-spacing: 0.02em; padding: 1px 7px; border-radius: 8px; white-space: nowrap; } .asga-type-docker { color: #6ab7ff; background: rgba(106,183,255,0.14); } .asga-type-plugin { color: #ff9d5c; background: rgba(255,140,47,0.16); } .asga-tile-desc { font-size: 1rem; line-height: 1.35; opacity: 0.85; margin: 10px 0 12px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } /* Added and Updated share one line at the foot of the card, Added pinned to the left edge and Updated to the right. Pushing them to opposite edges is what keeps them from colliding on a narrow card, and it lines the Updated dates of every card in a grid row up with each other. */ .asga-tile-dates { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-top: 10px; font-size: 0.92rem; opacity: 0.55; line-height: 1.5; /* One line is reserved whether or not either date is known, because a shorter footer would pull that card's button row a line lower than its neighbours', and the button row is what bottom-aligns a whole grid row. */ min-height: 1.5em; } /* min-width:0 is what lets a flex child ellipsise instead of forcing the row wider than the card. */ .asga-tile-added, .asga-tile-updated { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } .asga-tile-updated { text-align: right; } .asga-tile-btns { margin-top: auto; display: flex; flex-wrap: nowrap; gap: 5px; position: relative; } .asga-btn { cursor: pointer; user-select: none; white-space: nowrap; font-size: 0.92rem; padding: 4px 9px; border-radius: 20px; border: 1px solid rgba(127,127,127,0.4); color: var(--text-color, inherit); background: rgba(127,127,127,0.08); } .asga-btn:hover { border-color: #ff8c2f; background: rgba(255,140,47,0.14); } .asga-btn.asga-pinned { border-color: #ff8c2f; background: rgba(255,140,47,0.2); color: #ff8c2f; } /* support dropdown */ .asga-supmenu { position: absolute; bottom: 34px; left: 0; z-index: 20; background: var(--background-color, #1c1c1c); border: 1px solid rgba(127,127,127,0.4); border-radius: 6px; min-width: 120px; box-shadow: 0 4px 14px rgba(0,0,0,0.4); overflow: hidden; } .asga-suplink { display: block; padding: 7px 12px; font-size: 1rem; color: var(--text-color, inherit); text-decoration: none; } .asga-suplink:hover { background: rgba(255,140,47,0.16); } /* stars + downloads inline in the tile's top-right corner */ .asga-tile-badges { position: absolute; top: 10px; right: 10px; z-index: 5; display: flex; gap: 6px; align-items: center; } .ghstars-badge, .ghdl-badge { font-size: 11px; line-height: 1; font-weight: 600; background: rgba(0, 0, 0, 0.55); border: 1px solid rgba(127, 127, 127, 0.3); padding: 3px 7px; border-radius: 10px; pointer-events: none; white-space: nowrap; } .ghstars-badge { color: #f0a500; } .ghdl-badge { color: #4ab8c9; } /* pager */ .asga-pager { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; align-items: center; margin: 22px 0 8px; } .asga-page { cursor: pointer; padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(127,127,127,0.25); font-size: 1.05rem; color: var(--text-color, inherit); text-decoration: none; } .asga-page:hover { border-color: #ff8c2f; } .asga-page-cur { background: #ff8c2f; color: #fff; border-color: #ff8c2f; cursor: default; } .asga-page-off { opacity: 0.4; cursor: default; } .asga-dots { opacity: 0.5; padding: 0 2px; } /* Docker unavailable: the grid keeps listing every app, exactly as Community Applications does, and marks the ones that cannot be installed right now. */ /* Left and right edges sit on the tile grid's, not on the 2px optical inset the text-only count line uses, because this is a bordered box like the tiles are. The 10px bottom plus the count's 6px top reproduces the 16px the count line already leaves above the grid, so the strip joins the existing rhythm. */ .asga-dockerwarn { margin: 0 0 10px; padding: 8px 12px; background: rgba(255, 140, 47, 0.1); color: #d98324; border: 1px solid rgba(255, 140, 47, 0.45); border-radius: 6px; font-size: 1.05rem; } .asga-tile-blocked { border-style: dashed; } .asga-tile-blocked .asga-tile-icon img { opacity: 0.6; } /* Sits under the description, above the bottom-aligned button row. 12px below matches the description's own bottom margin so the card keeps one rhythm. */ .asga-tile-blocked-note { margin: 0 0 12px; font-size: 0.92rem; color: #e05c4a; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .asga-btn.asga-btn-off { opacity: 0.4; cursor: not-allowed; } .asga-btn.asga-btn-off:hover { border-color: rgba(127,127,127,0.4); background: rgba(127,127,127,0.08); } /* yellow no-token warning banner */ .ghstars-warning { margin: 8px 10px; padding: 8px 12px; background: #fff3cd; color: #7a5d00; border: 1px solid #ffe69c; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 13px; } .ghstars-warning a { color: #7a5d00; text-decoration: underline; } .ghstars-warning-x { cursor: pointer; font-weight: bold; opacity: 0.7; } .ghstars-warning-x:hover { opacity: 1; } /* ===== left category menu ===== CA's menu column starts level with the grid's heading, which leaves roughly fifty pixels of empty column beside the search row and reads as a hole in the page. It is pulled up onto the toolbar's line, and given the grid's own language: rounded rows, an accent hover, and the entry you are actually looking at marked, which CA never does. */ /* CA paints its sticky search band across the whole width even though the left 195px of it is reserved, and empty, for this menu. That empty strip is the gap above Home. The band is narrowed to the grid it actually belongs to, and the menu is lifted into the space that frees, which puts Home on the search box's line instead of a screen-width below it. */ body.asga-active .searchArea { padding-left: 0 !important; margin-left: 195px; } body.asga-active .searchArea { padding-bottom: 4px; } /* The menu has no leading offset of its own: it starts where CA's content column starts, a full search band below the toolbar. Lifting it into that band means getting past two clips. .ca_display_area is a flex row, so its overflow:hidden contains nothing that needs containing. .content carries overflow:auto but never scrolls (its scrollHeight and clientHeight are equal, because the page scrolls on the body), so that clip is vestigial too. With both lifted the menu can rise onto the search box's line. It also has to outrank CA's sticky band, which is z-index 6 and, since the rule above pushed it off this column, transparent here. */ body.asga-active .content.menuAdjust, body.asga-active .ca_display_area { overflow: visible; } body.asga-active .menuItems.menuAdjust { margin-top: -46px; position: relative; z-index: 7; } body.asga-active .caMenu { margin: 0; padding: 0; } /* fit-content rather than a full-width block: filling the column wraps the longer category names onto two lines, and a highlight that hugs its label reads better than one that runs to the column edge. It has to stay a block so the entries keep one per line. */ body.asga-active .caMenu .caMenuItem, body.asga-active .caMenu .startupButton { display: block; width: fit-content; max-width: 100%; padding: 3px 8px; margin: 1px 0 1px -8px; border-radius: 6px; line-height: 1.4; cursor: pointer; transition: background 0.12s ease, color 0.12s ease; } body.asga-active .caMenu .caMenuItem:hover, body.asga-active .caMenu .startupButton:hover { background: rgba(255, 140, 47, 0.14); color: #ff8c2f; } body.asga-active .caMenu .asga-menu-cur { background: rgba(255, 140, 47, 0.18); color: #ff8c2f; font-weight: 600; } /* CA greys an entry it cannot offer (no favourite repo set). Keep it reading as unavailable instead of lighting up under the cursor like a live one. */ body.asga-active .caMenu .caMenuItem:not(.caMenuEnabled) { opacity: 0.45; cursor: default; } body.asga-active .caMenu .caMenuItem:not(.caMenuEnabled):hover { background: none; color: inherit; } /* CA closes the menu with its own version, as a bare "VERSION" list item and the number on the line below it, both at the size of a navigation link. A footnote set in the same type as the navigation reads as another destination. The two items are pulled onto one quiet line instead. They carry no class of their own, so the pair is reached through the id CA puts on the number. Nothing breaks in a browser without :has(), the label simply stays as it was. */ body.asga-active .caMenu li:has(> #caInstalledVersion), body.asga-active .caMenu li:has(+ li > #caInstalledVersion) { display: inline-block; width: auto; font-size: 1.05rem; line-height: 1.4; opacity: 0.5; } body.asga-active .caMenu li:has(+ li > #caInstalledVersion) { margin-right: 5px; letter-spacing: 0.06em; } /* ===== modern Info drawer ===== CA populates its own right-slide sidebar (#sidenavContent) when a tile is clicked. We restyle it here, scoped to body.asga-active, so it matches the modern grid; with the Modern view toggled off none of this applies and CA's stock drawer styling returns. We only override styles, never CA markup. Colours derive from --sidebar-text (the theme's own drawer foreground) via color-mix rather than hardcoded greys, so the drawer stays legible on the white themes too. CA hardcodes near-black text on several of these blocks (.popupDescription, every .caButton) with !important, so those need !important back. --asga-* tokens keep the mixes in one place. */ body.asga-active .sidenav { --asga-fg: var(--sidebar-text, #f2f2f2); --asga-muted: color-mix(in srgb, var(--asga-fg) 72%, transparent); --asga-line: color-mix(in srgb, var(--asga-fg) 16%, transparent); --asga-surface: color-mix(in srgb, var(--asga-fg) 7%, transparent); --asga-accent: #ff8c2f; background: var(--sidebar-background, #1b1b1b); border-left: 1px solid color-mix(in srgb, var(--asga-accent) 40%, transparent); box-shadow: -12px 0 40px rgba(0, 0, 0, 0.45); padding-top: 0 !important; /* CA adds 40px of blank at the top */ } body.asga-active #sidenavContent { padding: 0 0 48px; position: relative; } body.asga-active .popup { margin: 0; padding: 0 24px; } /* Close: a slim bar that sticks to the top of the scroll area, holding a round ✕. CA renders the word "CLOSE" in this span; we swap the glyph in CSS so the button reads the same in every language. 40px is Unraid's own menu bar height, so the drawer opens with a strip of the same weight as the navigation it covers rather than one of its own invention. */ body.asga-active .popupCloseArea { position: sticky; top: 0; z-index: 20; display: flex; justify-content: flex-end; align-items: center; height: 40px; margin: 0; padding: 0 24px; width: auto; background: var(--sidebar-background, #1b1b1b); border-bottom: 1px solid var(--asga-line); } /* header: icon + name + author + action pills, all on one row */ body.asga-active .ca_popupIconArea { display: flex; gap: 20px; align-items: flex-start; position: static; top: auto; height: auto; /* CA pins it to 15.5rem */ padding: 22px 0 22px; margin: 0; border-bottom: 1px solid var(--asga-line); } body.asga-active .popupIcon { flex: 0 0 auto; } body.asga-active .popupIcon img { width: 88px; height: 88px; margin: 0; padding: 0; object-fit: contain; border-radius: 18px; background: color-mix(in srgb, var(--asga-fg) 20%, transparent); border: 1px solid var(--asga-line); } body.asga-active .popupIcon i { padding: 0; font-size: 8rem; } /* CA absolutely positions .popupInfo at a fixed offset; put it back in flow */ body.asga-active .popupInfo { position: static; flex: 1 1 auto; min-width: 0; } body.asga-active .popupName { font-size: 2.6rem; font-weight: 700; line-height: 1.2; max-height: none; } body.asga-active .popupAuthorMain { font-size: 1.5rem; line-height: 1.4; margin: 5px 0 16px; color: var(--asga-muted); overflow-wrap: break-word; word-break: break-word; } /* nothing from a template may push the drawer sideways either */ body.asga-active #sidenavContent { overflow-wrap: break-word; word-break: break-word; } /* action pills (Install / Support / Pin) and every other CA button in the drawer, including the Maintainer row, share one look */ body.asga-active #sidenavContent .caButton { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; box-sizing: border-box; font-size: 1.4rem; line-height: 1.2; padding: 9px 20px; margin: 0 8px 8px 0; border-radius: 999px; border: 1px solid var(--asga-line); background: var(--asga-surface); color: var(--asga-fg) !important; transition: background 0.15s ease, border-color 0.15s ease; } body.asga-active #sidenavContent .caButton * { color: inherit !important; text-decoration: none; } body.asga-active #sidenavContent .caButton:hover { border-color: var(--asga-accent); background: color-mix(in srgb, var(--asga-accent) 20%, transparent); color: var(--asga-accent) !important; } /* primary action (Install / Actions) is the one solid button */ body.asga-active #sidenavContent .caButton.actionsPopup { background: var(--asga-accent); border-color: var(--asga-accent); color: #16130f !important; font-weight: 600; } body.asga-active #sidenavContent .caButton.actionsPopup:hover { filter: brightness(1.1); color: #16130f !important; } /* toggled-on states: pinned app, favourited repo */ body.asga-active #sidenavContent .caButton.pinPopup.pinned, body.asga-active #sidenavContent .caButton.ca_favouriteRepo.fav { border-color: var(--asga-accent); color: var(--asga-accent) !important; background: color-mix(in srgb, var(--asga-accent) 18%, transparent); } /* the close control itself: a round ✕. CA renders the word "CLOSE" inside the span, so the glyph is swapped in CSS and the label hidden, which keeps the button identical in every language. Selector carries #sidenavContent so it outranks the shared .caButton rule above. */ body.asga-active #sidenavContent .popUpClose.caButton { display: flex; align-items: center; justify-content: center; /* 30px inside the 40px bar, so the circle keeps a margin instead of filling the strip edge to edge */ width: 30px; height: 30px; padding: 0; margin: 0; border-radius: 50%; gap: 0; /* else the shared button gap offsets the glyph from centre */ font-size: 0; line-height: 0; border: 1px solid var(--asga-line); background: var(--asga-surface); color: var(--asga-fg) !important; transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; } body.asga-active #sidenavContent .popUpClose.caButton::before { content: '\2715'; font-size: 15px; line-height: 1; } body.asga-active #sidenavContent .popUpClose.caButton:hover { border-color: var(--asga-accent); background: color-mix(in srgb, var(--asga-accent) 22%, transparent); color: var(--asga-accent) !important; transform: scale(1.06); } /* description block. CA sets this text near-black via a more specific rule (.popupDescription.popup_readmore), so force the theme colour back. */ /* CA sizes this block at width:90% and forces padding:2rem, which leaves it visibly narrower than the Details card directly below it and on a different inner rhythm. Both are undone here so the two blocks read as one column. */ body.asga-active .popupDescription { display: block; background: var(--asga-surface); border: 1px solid var(--asga-line); border-radius: 12px; width: auto; box-sizing: border-box; padding: 18px 22px !important; margin: 22px 0 0; font-size: 1.5rem; line-height: 1.6 !important; } body.asga-active .popupDescription, body.asga-active .popupDescription * { color: var(--asga-fg) !important; } body.asga-active .popupDescription a { color: var(--asga-accent) !important; text-decoration: underline; } /* CA runs readmore.js over this block: it clamps the text to a fixed height and hangs a SHOW MORE toggle underneath. The drawer already scrolls, so that is a click that buys nothing. The clamp height is kept, the text scrolls inside it, and the toggle is dropped. readmore.js writes height, max-height and overflow as inline styles, so every one of them needs !important to answer it. overscroll-behavior keeps a scroll that reaches the bottom of the description from carrying on into the drawer behind it. */ body.asga-active .popupDescription { height: auto !important; max-height: 29rem !important; overflow-y: auto !important; overscroll-behavior: contain; } body.asga-active #sidenavContent .ca_readmore, body.asga-active #sidenavContent .readmore-js-toggle { display: none !important; } /* section titles: small orange eyebrow labels, not 2.5rem headings */ body.asga-active .rightTitle, body.asga-active .popupAuthorTitle, body.asga-active .chartTitle, body.asga-active .changelogTitle, body.asga-active .templateErrors, body.asga-active .additionalRequirementsHeader { font-size: 1.3rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--asga-accent); margin: 0 0 12px; } /* Details + Maintainer as side-by-side cards (they stack when the drawer is narrow) */ body.asga-active .popupInfoSection { display: flex; flex-direction: column; gap: 16px; margin: 22px 0 0; font-size: 1.5rem; line-height: 1.5; } body.asga-active .popupInfoLeft { margin: 0; overflow: visible; background: var(--asga-surface); border: 1px solid var(--asga-line); border-radius: 12px; padding: 18px 22px; } /* CA sets display:contents on .popupTable.contents, which drops the table box */ body.asga-active .popupTable { display: table; width: 100%; table-layout: auto; border-collapse: collapse; margin: 0; background: transparent; } body.asga-active .popupTable td { width: auto; padding: 9px 0; border-bottom: 1px solid var(--asga-line); font-size: 1.4rem; line-height: 1.45; vertical-align: top; } body.asga-active .popupTable tr:last-child td { border-bottom: none; } body.asga-active .popupTableLeft { width: 1%; padding-right: 16px; color: var(--asga-muted); white-space: nowrap; } /* one row carries an inline white-space:nowrap that clips long repo names */ body.asga-active .popupTableRight { text-align: right; font-weight: 600; overflow: visible; white-space: normal !important; overflow-wrap: anywhere; } /* Maintainer is always the second column CA emits, and it holds little: keep it to two rows (heading, then avatar + name on the left with the repo buttons trailing on the right) so it doesn't tower next to the Details card. */ body.asga-active .popupInfoSection > .popupInfoLeft:nth-child(2) { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } body.asga-active .popupAuthorTitle { flex: 0 0 100%; margin-bottom: 0; } body.asga-active .popupAuthorTitle + div { display: flex; align-items: center; gap: 12px; flex: 1 1 auto; margin: 0; } body.asga-active .popupAuthor { font-size: 1.7rem; font-weight: 600; margin: 0; line-height: 1.3; overflow-wrap: anywhere; } body.asga-active .popupAuthorIcon { order: -1; float: none; flex: 0 0 auto; display: block; } body.asga-active img.popupAuthorIcon { width: 44px; height: 44px; border-radius: 10px; object-fit: contain; background: var(--asga-surface); border: 1px solid var(--asga-line); } body.asga-active .popupInfoLeft .caButton { width: auto; font-size: 1.3rem; padding: 8px 16px; margin: 0; } /* donate copy gets its own row rather than sitting in the button run */ body.asga-active .donateText { flex: 0 0 100%; margin: 4px 0 0; color: var(--asga-muted); font-size: 1.3rem; } body.asga-active .donateDiv { flex: 0 0 100%; margin: 0; } /* screenshots strip */ body.asga-active #sidenavContent .screenshot { display: inline-block; margin: 0; } body.asga-active #sidenavContent .screen { height: 96px; min-width: 0; max-width: 190px; margin: 22px 12px 0 0; border-radius: 10px; border: 1px solid var(--asga-line); } body.asga-active #sidenavContent .screen:hover { border-color: var(--asga-accent); transform: none; } /* moderator / spotlight / changelog / errors, matched to the card language */ body.asga-active .modComment { margin: 22px 0 0; padding: 14px 18px; border: 1px solid var(--asga-line); border-left: 3px solid var(--asga-accent); border-radius: 12px; background: var(--asga-surface); } body.asga-active .moderatorCommentHeader { font-size: 1.3rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--asga-accent); } body.asga-active .moderatorComment { font-size: 1.5rem; line-height: 1.5; } body.asga-active .spotlightPopup { display: flex; gap: 16px; align-items: flex-start; margin: 22px 0 0; padding: 16px 18px; border: 1px solid var(--asga-line); border-radius: 12px; background: var(--asga-surface); } /* CA hangs the spotlight icon on negative margins and pads the text block by 10rem, both of which collide inside a flex row */ body.asga-active .spotlightIconArea { display: flex; flex-direction: column; align-items: center; gap: 6px; float: none; width: auto; flex: 0 0 auto; } body.asga-active .spotlightIconArea img.spotlightIcon { width: 56px; height: auto; margin: 0 !important; object-fit: contain; } body.asga-active .spotlightDate { font-size: 1.2rem; margin: 0 !important; color: var(--asga-muted); } body.asga-active .spotlightInfoArea { margin: 0; padding: 0; min-width: 0; } body.asga-active .spotlightWhy { font-size: 1.3rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--asga-accent); margin-bottom: 6px; } body.asga-active .spotlightMessage { font-size: 1.5rem; line-height: 1.5; } body.asga-active .spotlightWho { margin-top: 8px; color: var(--asga-muted); } body.asga-active .charts.chartTitle, body.asga-active .changelogTitle, body.asga-active .templateErrors { margin-top: 28px; } body.asga-active .chartMenu { padding: 0 0 0 16px; font-size: 1.4rem; } body.asga-active .changelog, body.asga-active .changelogMessage { font-size: 1.5rem; line-height: 1.6; margin-top: 8px; } body.asga-active .additionalRequirements { margin: 0; font-size: 1.5rem; } body.asga-active .additionalRequirementsHeader { margin-top: 22px; } body.asga-active .templateErrorsList { font-size: 1.4rem; line-height: 1.5; margin-left: 18px; } /* CA's "statistics gathered every 30 days" footnote: a footnote, not a headline */ body.asga-active .ca_note { font-size: 1.25rem; color: var(--asga-muted); } body.asga-active .ca_note.ca_bold { font-weight: 400; } /* our screenshot lightbox (replaces CA's drawer-closing gallery) */ .asga-lightbox { position: fixed; inset: 0; z-index: 10050; background: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center; } .asga-lb-img { max-width: 92vw; max-height: 88vh; object-fit: contain; border-radius: 6px; box-shadow: 0 10px 50px rgba(0,0,0,0.6); } .asga-lb-close { position: fixed; top: 18px; right: 24px; font-size: 2rem; line-height: 1; color: #fff; cursor: pointer; opacity: 0.85; user-select: none; } .asga-lb-close:hover { opacity: 1; } .asga-lb-nav { position: fixed; top: 50%; transform: translateY(-50%); font-size: 3.4rem; line-height: 1; color: #fff; cursor: pointer; opacity: 0.7; user-select: none; padding: 0 18px; } .asga-lb-nav:hover { opacity: 1; } .asga-lb-prev { left: 10px; } .asga-lb-next { right: 10px; } /* thin fixed progress bar at the very top of the page (no layout impact) */ .ghstars-topbar { position: fixed; top: 0; left: 0; right: 0; z-index: 9999; height: 22px; line-height: 22px; background: rgba(0, 0, 0, 0.85); color: #eee; font-size: 11px; padding-left: 10px; } .ghstars-topbar-fill { position: absolute; top: 0; left: 0; bottom: 0; width: 0; background: rgba(240, 165, 0, 0.35); transition: width 0.4s ease; z-index: -1; } .ghstars-topbar-label { color: #f0a500; } .ghstars-topbar-x { position: absolute; right: 10px; cursor: pointer; color: #d9534f; } ]]> Utilities -> Unraid Modern App Store). */ require_once "$docroot/plugins/dynamix/include/Wrappers.php"; $gas_dir = "/boot/config/plugins/modern.appstore"; $gas_cfg = "$gas_dir/modern.appstore.cfg"; $gas_cron = "$gas_dir/modern.appstore.cron"; $gas_php = "/usr/local/emhttp/plugins/modern.appstore/fetch_stars.php"; $gas_msg = ""; if (!is_dir($gas_dir)) @mkdir($gas_dir, 0755, true); function gas_write_cfg($path, $token, $service, $datadir) { $token = str_replace(["\"", "\n", "\r"], "", $token); $datadir = rtrim(str_replace(["\"", "\n", "\r"], "", $datadir), '/'); if ($datadir === '') $datadir = '/boot/config/plugins/modern.appstore'; $service = ($service === 'enabled') ? 'enabled' : 'disabled'; $out = "TOKEN=\"$token\"\nSERVICE=\"$service\"\nDATA_DIR=\"$datadir\"\n"; file_put_contents($path, $out); @chmod($path, 0600); @mkdir($datadir, 0755, true); } function gas_write_cron($path) { $php = "php /usr/local/emhttp/plugins/modern.appstore/fetch_stars.php"; // full scan every 3 days + an hourly check that pulls only NEWLY published // app-store repos (bypasses the manual-refresh cooldown) $lines = "0 4 */3 * * $php >/dev/null 2>&1\n23 * * * * $php --new-only 1 >/dev/null 2>&1\n"; file_put_contents($path, $lines); @exec("/usr/local/sbin/update_cron 2>/dev/null"); } if ($_POST) { if (isset($_POST['SAVE'])) { $token = $_POST['TOKEN'] ?? ''; $service = $_POST['SERVICE'] ?? 'disabled'; $datadir = $_POST['DATA_DIR'] ?? ''; gas_write_cfg($gas_cfg, $token, $service, $datadir); gas_write_cron($gas_cron); $gas_msg = "Settings saved." . (trim($token) === '' ? " WARNING: no token set - star counts are disabled." : ""); } if (isset($_POST['REFRESH'])) { @exec("nohup php " . escapeshellarg($gas_php) . " >/dev/null 2>&1 &"); $gas_msg = "Refresh started in the background. Reload this page in a few minutes for updated stats."; } } $cfg = is_file($gas_cfg) ? @parse_ini_file($gas_cfg) : []; $token = $cfg['TOKEN'] ?? ''; $service = $cfg['SERVICE'] ?? 'enabled'; $datadir = $cfg['DATA_DIR'] ?? '/boot/config/plugins/modern.appstore'; $status = @json_decode(@file_get_contents("/usr/local/emhttp/plugins/modern.appstore/status.json"), true) ?: []; ?>
_(Enable Unraid Modern App Store)_: : > Turn the plugin on or off. When on, it adds GitHub star badges to the Community Applications app tiles and a sortable GitHub view. _(GitHub Personal Access Token)_: : > A GitHub personal access token is required. Without one the GitHub API allows only 60 requests per hour, which is far too few for the catalog; a token raises the limit to 5,000 per hour. > > To create a token, open github.com/settings/tokens/new (GitHub → Settings → Developer settings → Personal access tokens → Generate new token (classic)). Give it any name, leave all scopes unchecked since it only needs to read public star counts, click Generate token, and paste the value here. > > **Use a classic token (it starts with `ghp_`), not a fine-grained one (`github_pat_`).** GitHub refuses fine-grained tokens access to star dates, and the two "this year" trending sorts are built from those dates. Everything else, including the "all time" sorts, works with either kind. > > While this field is empty the Apps page shows a yellow warning and star counts stay disabled. _(Database directory)_: : > Where the star database (stars.db) and the generated JSON files live. Defaults to this plugin's appdata folder on the cache pool so the data survives reboots. Change it only if you want the data stored elsewhere; the folder is created automatically.   :
Star dates unavailable
GitHub refused this token access to star dates (the stargazers endpoint), which is what the Trending (this year) and Trending % (this year) sorts are built from, so those two stay empty. This is how GitHub treats fine-grained tokens (github_pat_...); a classic token (ghp_...) reads them with no scopes enabled. Replacing the token above and running Refresh now fills the year windows in. Every other sort, including both all time ones, is unaffected.
 
Last scan
Ran at
Repos with a GitHub link
Updated
Unchanged
Missing repos
Rate limit remaining
Errors
No scan has run yet. Click Refresh now to populate star counts.
]]>
window.__modernAppStore={hasToken:$gas_has,settingsUrl:'/Settings/ModernAppStore'};\n"; echo "\n"; /* Class inline, before CA's markup is parsed, so its stock grid, sort row and Results-Per-Page button never paint and then disappear. inject.js keeps this in step when the modern view is toggled off. */ echo "\n"; echo "\n"; } ?> ]]> /dev/null mv -f "$APPDATA/$OLD_ID.cfg" "$APPDATA/modern.appstore.cfg" 2>/dev/null rm -f "$APPDATA/$OLD_ID.cron" sed -i "s#$OLD_ID#modern.appstore#g" "$APPDATA/modern.appstore.cfg" 2>/dev/null echo " Migrated settings and star history from the previous plugin id." fi rm -f "$OLDDATA/$OLD_ID.cron" rm -f "/boot/config/plugins/$OLD_ID.plg" rm -rf "/usr/local/emhttp/plugins/$OLD_ID" rm -rf "$OLDDATA" /usr/local/sbin/update_cron 2>/dev/null fi CFG=/boot/config/plugins/modern.appstore/modern.appstore.cfg # seed an EMPTY token only if no config exists yet (preserves an existing token) if [ ! -f "$CFG" ]; then printf 'TOKEN=""\nSERVICE="enabled"\nDATA_DIR="%s"\n' "$APPDATA" > "$CFG" chmod 600 "$CFG" fi # migrate a legacy DATA_DIR under /mnt/user off the array (it breaks shfs mounting) if grep -q 'DATA_DIR="/mnt/user' "$CFG" 2>/dev/null; then sed -i 's#^DATA_DIR=.*#DATA_DIR="'"$APPDATA"'"#' "$CFG" fi CRON=/boot/config/plugins/modern.appstore/modern.appstore.cron # full scan every 3 days; hourly check that only pulls NEWLY published repos { echo '0 4 */3 * * php /usr/local/emhttp/plugins/modern.appstore/fetch_stars.php >/dev/null 2>&1' echo '23 * * * * php /usr/local/emhttp/plugins/modern.appstore/fetch_stars.php --new-only 1 >/dev/null 2>&1' } > "$CRON" /usr/local/sbin/update_cron 2>/dev/null # restore persisted star data into the tmpfs webroot so badges work after a reboot cp -f "$APPDATA/stars.json" /usr/local/emhttp/plugins/modern.appstore/ 2>/dev/null cp -f "$APPDATA/apps.json" /usr/local/emhttp/plugins/modern.appstore/ 2>/dev/null cp -f "$APPDATA/status.json" /usr/local/emhttp/plugins/modern.appstore/ 2>/dev/null echo "----------------------------------------------------" echo " Unraid Modern App Store installed." echo " Set your GitHub token: Settings -> Utilities -> Unraid Modern App Store" echo "----------------------------------------------------" ]]> /dev/null rm -rf /usr/local/emhttp/plugins/modern.appstore rm -rf /boot/config/plugins/modern.appstore ]]>