From c17f461a975b754c343702a5889d4abd49cf8a41 Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Tue, 3 Nov 2026 10:48:05 +0530 Subject: [PATCH 1/4] Keep self-host popularity and release fields updated on refresh Apply the original star tiers on every refresh and through a previewed backfill. Only a repository-root URL earns stars, and apps sharing a repository elect one row, so variants and duplicates can't inherit a popular project's score. Release fields fill only when empty. apply-qa-overrides now uses the shared environment gate. Adds reviewed repository link fixes for lichenmarkdown, autobd, and benevalibre, and a proposal to unpublish phice. Co-Authored-By: Claude Opus 5.5 --- package.json | 1 + scripts/self-host-apps/README.md | 2 +- scripts/self-host-apps/apply-qa-overrides.mjs | 6 +- ...y-link-fixes-phice-unpublish-proposal.json | 19 ++ .../2026-11-03-repository-link-fixes.json | 56 +++++ scripts/self-host-apps/lib/app-refresh.mjs | 53 ++++ scripts/self-host-apps/lib/popularity.mjs | 73 ++++++ scripts/self-host-apps/refresh-github.mjs | 234 ++++++++++-------- scripts/self-host-apps/refresh-popularity.mjs | 55 ++++ .../self-host-apps/refresh-source-host.mjs | 78 ++---- scripts/self-host-apps/scripts.json | 9 +- stack127_docs/self-host-apps-data.md | 90 ++++++- .../popularity-database.test.mjs | 125 ++++++++++ tests/self-host-apps/popularity.test.mjs | 62 +++++ .../qa-overrides-environment.test.mjs | 87 +++++++ .../refresh-automations.test.mjs | 12 +- .../refresh-github-database.test.mjs | 15 +- 17 files changed, 798 insertions(+), 179 deletions(-) create mode 100644 scripts/self-host-apps/batches/2026-11-03-repository-link-fixes-phice-unpublish-proposal.json create mode 100644 scripts/self-host-apps/batches/2026-11-03-repository-link-fixes.json create mode 100644 scripts/self-host-apps/lib/app-refresh.mjs create mode 100644 scripts/self-host-apps/lib/popularity.mjs create mode 100644 scripts/self-host-apps/refresh-popularity.mjs create mode 100644 tests/self-host-apps/popularity-database.test.mjs create mode 100644 tests/self-host-apps/popularity.test.mjs create mode 100644 tests/self-host-apps/qa-overrides-environment.test.mjs diff --git a/package.json b/package.json index 52922ca5..74633fdd 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "self-host-apps:release-candidate-batch": "node scripts/self-host-apps/release-candidate-batch.mjs", "self-host-apps:collect-github-evidence": "node scripts/self-host-apps/collect-github-evidence.mjs", "self-host-apps:apply-candidate-review": "node scripts/self-host-apps/apply-candidate-review.mjs", + "self-host-apps:refresh-popularity": "node scripts/self-host-apps/refresh-popularity.mjs", "self-host-apps:refresh-github": "node scripts/self-host-apps/refresh-github.mjs", "self-host-apps:refresh-gitlab": "node scripts/self-host-apps/refresh-source-host.mjs --host gitlab", "self-host-apps:refresh-codeberg": "node scripts/self-host-apps/refresh-source-host.mjs --host codeberg", diff --git a/scripts/self-host-apps/README.md b/scripts/self-host-apps/README.md index ad68d75b..5ca08b73 100644 --- a/scripts/self-host-apps/README.md +++ b/scripts/self-host-apps/README.md @@ -33,7 +33,7 @@ Discovery stores raw evidence. It cannot publish or assign final categories, sum - `refresh-github.mjs`, `refresh-source-host.mjs`: mutable source-host facts only. - `qa-gate.mjs`, `qa-report.mjs`, `audit-data-quality.mjs`: publication quality. - `audit-summaries.mjs`, `audit-links*.mjs`, `icon-integrity.mjs`: content checks. -- `apply-qa-overrides.mjs`: researched corrections. +- `apply-qa-overrides.mjs`: researched corrections. Defaults to development; use `--environment production --confirm stack127-production` for production, including `--dry-run`. Preview rolls back; omit `--dry-run` to commit. ## Database direction diff --git a/scripts/self-host-apps/apply-qa-overrides.mjs b/scripts/self-host-apps/apply-qa-overrides.mjs index bdfe439a..f7d28382 100644 --- a/scripts/self-host-apps/apply-qa-overrides.mjs +++ b/scripts/self-host-apps/apply-qa-overrides.mjs @@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises"; import { isDeepStrictEqual } from 'node:util'; import { parseArgs, numberArg } from "./lib/args.mjs"; import { withClient } from "./lib/db.mjs"; +import { databaseOptions } from "./lib/environment.mjs"; import { appColumns, appColumnTypes, validateRecord } from "./lib/schema.mjs"; import { guardPartialCorrection, guardInstallCorrection, mergeReviewedInstallOptions } from "./lib/reviewed-corrections.mjs"; import { installMethods, installOptionsToRow } from "./lib/streamlined-storage.mjs"; @@ -150,8 +151,9 @@ async function applyRecord(client, record, dryRun) { async function main() { const args = parseArgs(); + const databaseUrl = databaseOptions(args); if (!args.file) { - console.error("Usage: node scripts/self-host-apps/apply-qa-overrides.mjs --file overrides.json [--dry-run]"); + console.error("Usage: node scripts/self-host-apps/apply-qa-overrides.mjs --environment development|production [--confirm stack127-production] --file overrides.json [--dry-run]"); process.exit(1); } @@ -177,7 +179,7 @@ async function main() { await client.query("rollback"); throw error; } - }, args["database-url"]); + }, databaseUrl); } main().catch((error) => { diff --git a/scripts/self-host-apps/batches/2026-11-03-repository-link-fixes-phice-unpublish-proposal.json b/scripts/self-host-apps/batches/2026-11-03-repository-link-fixes-phice-unpublish-proposal.json new file mode 100644 index 00000000..27db259e --- /dev/null +++ b/scripts/self-host-apps/batches/2026-11-03-repository-link-fixes-phice-unpublish-proposal.json @@ -0,0 +1,19 @@ +{ + "reviewed_at": "2026-11-03", + "scope": "PROPOSAL, needs owner approval. Unpublish Phice and keep its row: the official repository and Docker image are gone and no official successor was found.", + "apps": [ + { + "slug": "phice", + "review_scope": "partial", + "published": false, + "expected": { + "published": true + }, + "data_source_urls": [ + "https://codeberg.org/api/v1/repos/c4ffe14e/phice", + "https://codeberg.org/api/v1/users/c4ffe14e/repos", + "https://hub.docker.com/r/c4ffe14e/phice" + ] + } + ] +} diff --git a/scripts/self-host-apps/batches/2026-11-03-repository-link-fixes.json b/scripts/self-host-apps/batches/2026-11-03-repository-link-fixes.json new file mode 100644 index 00000000..4f087949 --- /dev/null +++ b/scripts/self-host-apps/batches/2026-11-03-repository-link-fixes.json @@ -0,0 +1,56 @@ +{ + "reviewed_at": "2026-11-03", + "scope": "Published apps that failed the 2026-11-02 source-host refresh: corrected repository addresses, host types, and facts the official repository contradicts. Phice is proposed separately in 2026-11-03-repository-link-fixes-phice-unpublish-proposal.json.", + "apps": [ + { + "slug": "lichenmarkdown", + "review_scope": "partial", + "source_code_url": "https://codeberg.org/ukrudt-net/lichen-markdown", + "release_url": "https://codeberg.org/ukrudt-net/lichen-markdown/releases", + "publisher_url": "https://codeberg.org/ukrudt-net", + "license": "MIT License", + "license_type": "permissive", + "license_spdx_id": "MIT", + "expected": { + "source_code_url": "https://codeberg.org/ukrudt.net/lichen-markdown", + "release_url": "https://codeberg.org/ukrudt.net/lichen-markdown/releases", + "publisher_url": "https://github.com/ukrudt.net", + "license": "GNU Affero General Public License v3.0", + "license_type": "copyleft", + "license_spdx_id": "AGPL-3.0" + }, + "data_source_urls": [ + "https://codeberg.org/ukrudt-net/lichen-markdown", + "https://codeberg.org/ukrudt-net/lichen-markdown/raw/branch/main/LICENSE", + "https://codeberg.org/ukrudt-net/lichen-markdown/raw/branch/main/README.md", + "https://raw.githubusercontent.com/YunoHost-Apps/lichenmarkdown_ynh/master/manifest.toml" + ] + }, + { + "slug": "autobd", + "review_scope": "partial", + "source_code_url": "https://forge.apps.education.fr/educajou/autobd", + "expected": { + "source_code_url": "https://forge.aeif.fr/achampollion/autobd" + }, + "data_source_urls": [ + "https://forge.apps.education.fr/educajou/autobd", + "https://educajou.forge.apps.education.fr/autobd/", + "https://raw.githubusercontent.com/YunoHost-Apps/autobd_ynh/master/manifest.toml" + ] + }, + { + "slug": "benevalibre", + "review_scope": "partial", + "source_code_host": "other", + "expected": { + "source_code_host": "gitlab" + }, + "data_source_urls": [ + "https://benevalibre.org/site/code-source-du-logiciel/", + "https://app.benevalibre.org/docs/administration/index.html", + "https://raw.githubusercontent.com/YunoHost-Apps/benevalibre_ynh/master/manifest.toml" + ] + } + ] +} diff --git a/scripts/self-host-apps/lib/app-refresh.mjs b/scripts/self-host-apps/lib/app-refresh.mjs new file mode 100644 index 00000000..56cd4f8e --- /dev/null +++ b/scripts/self-host-apps/lib/app-refresh.mjs @@ -0,0 +1,53 @@ +// Compare values as PostgreSQL/JSON sees them, including dates and JSON objects. +const normalized = value => { + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return value.map(normalized); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, normalized(value[key])])); + return value ?? null; +}; +export function changedPatch(row, patch) { + return Object.fromEntries(Object.entries(patch).filter(([key, value]) => + JSON.stringify(normalized(key.endsWith('_at') && row[key] ? new Date(row[key]) : row[key])) !== + JSON.stringify(normalized(key.endsWith('_at') && value ? new Date(value) : value)))); +} + +// Caller holds row locks in a transaction. One set-based UPDATE, with no write +// at all when the prepared values are identical. Never update unrelated fields. +export async function writePatches(client, rows) { + if (!rows.length) return 0; + const columns = [...new Set(rows.flatMap(({ patch }) => Object.keys(patch)))]; + if (columns.some(column => !/^[a-z][a-z0-9_]*$/.test(column))) throw new Error('Invalid app column'); + const result = await client.query(` + update public.self_host_apps app set + ${columns.map(column => `${column} = incoming.${column}`).join(', ')} + from jsonb_populate_recordset(null::public.self_host_apps, $1::jsonb) incoming + where app.id = incoming.id + and row(${columns.map(column => `app.${column}`).join(',')}) + is distinct from row(${columns.map(column => `incoming.${column}`).join(',')}) + returning app.slug + `, [JSON.stringify(rows.map(({ row, patch }) => ({ id: row.id, ...Object.fromEntries(columns.map(column => [column, column in patch ? patch[column] : row[column]])) })))]); + return result.rowCount; +} + +export async function refreshApps(client, selection, params, prepare, checkedAt, transaction = true) { + if (transaction) await client.query('begin'); + try { + const { rows } = await client.query(`${selection} for update of app`, params); + const patches = rows.flatMap(row => { + const patch = changedPatch(row, prepare(row)); + return Object.keys(patch).length ? [{ row, patch: { + ...patch, ...(checkedAt ? { source_checked_at: checkedAt, data_checked_at: checkedAt, updated_at: checkedAt } : {}), + } }] : []; + }); + await writePatches(client, patches); + // Successful polling must still move through the queue. data_checked_at is + // ignored by the public-date trigger; source_checked_at changes only with facts. + if (checkedAt && rows.length) await client.query(`update public.self_host_apps + set data_checked_at = $2 where id = any($1::uuid[]) and data_checked_at is distinct from $2::timestamptz`, + [rows.map(row => row.id), checkedAt]); + if (transaction) await client.query('commit'); + } catch (error) { + if (transaction) await client.query('rollback'); + throw error; + } +} diff --git a/scripts/self-host-apps/lib/popularity.mjs b/scripts/self-host-apps/lib/popularity.mjs new file mode 100644 index 00000000..9b9cdcea --- /dev/null +++ b/scripts/self-host-apps/lib/popularity.mjs @@ -0,0 +1,73 @@ +// Rank the complete published catalogue before a caller filters by slug or host. +// A repository root has exactly owner/repo after the hostname. Deeper links +// describe a file, directory, or other page inside somebody else's repository. +export const starRecipientCtes = ` + star_repository_urls as ( + select id, slug, name, popularity_basis, + lower(regexp_replace(regexp_replace( + split_part(split_part(btrim(source_code_url), '#', 1), '?', 1), + '/+$', ''), '[.]git$', '', 'i')) as repository_key + from public.self_host_apps where published = true + ), star_repository_names as ( + select *, regexp_replace(lower(coalesce(name, slug)), '[^a-z0-9]', '', 'g') as app_name, + regexp_replace(regexp_replace(repository_key, '^.*/', ''), '[^a-z0-9]', '', 'g') as repo_name + from star_repository_urls + where repository_key ~ '^https?://[^/]+/[^/]+/[^/]+$' + ), star_recipients as ( + select id from ( + select id, row_number() over (partition by repository_key order by + coalesce('source_stars' = any(popularity_basis), false) desc, + case when app_name = repo_name then 0 + when app_name <> '' and repo_name <> '' and + (starts_with(app_name, repo_name) or starts_with(repo_name, app_name)) then 1 + when repo_name <> '' and position(repo_name in app_name) > 0 then 2 + else 3 end, + length(btrim(coalesce(name, slug))), slug collate "C" + ) as repository_rank + from star_repository_names + ) ranked where repository_rank = 1 + )`; +export const starRecipientColumn = 'exists (select 1 from star_recipients where star_recipients.id = app.id) as star_recipient'; + +// Original tiers: 20260829090000_make_self_host_apps_page_owned.sql. +export const starTiers = [[100000, 100], [50000, 95], [20000, 90], [10000, 85], [5000, 80], [1000, 70], [250, 60], [1, 50]]; +export function starScore(stars) { + return starTiers.find(([minimum]) => Number(stars) >= minimum)?.[1] ?? 0; +} + +export function popularityPatch(row, stars, checkedAt) { + // A missing host value is not evidence that a repository lost its stars. + if (stars === null || stars === undefined) return {}; + const basis = row.popularity_basis ?? []; + const catalogBasis = basis.filter(value => value !== 'source_stars' && !value.startsWith('catalog_score:')); + const savedFloor = basis.find(value => /^catalog_score:\d+(?:\.\d+)?$/.test(value)); + const floor = savedFloor ? Number(savedFloor.split(':')[1]) + : catalogBasis.length ? Number(row.popularity_score ?? 0) : 0; + const tier = starScore(stars); + const score = Math.max(floor, tier); + // Remember the catalog floor when stars overtake it, so later star losses + // can lower the star component without erasing the catalog score. + const nextBasis = floor > 0 + ? [...catalogBasis, `catalog_score:${floor}`, ...(tier > floor ? ['source_stars'] : [])] + : tier > 0 ? ['source_stars'] : []; + if (Number(row.popularity_score ?? 0) === score && JSON.stringify(basis) === JSON.stringify(nextBasis)) return {}; + return { popularity_score: score, popularity_basis: nextBasis, popularity_checked_at: checkedAt }; +} + +export function releasePatch(row, version, publishedAt, checkedAt) { + if (String(row.release_version ?? '').trim() || !String(version ?? '').trim()) return {}; + return { release_version: version, release_published_at: publishedAt ?? null, release_checked_at: checkedAt }; +} + +export function storedFacts(row) { + const github = row.source_code_host === 'github' || row.github_project_id || row.source_code_url?.startsWith('https://github.com/'); + return github ? { + stars: row.project_stars ?? row.source_stars ?? row.github_stars, + version: row.project_release ?? row.source_latest_release ?? row.github_latest_release, + publishedAt: row.project_release_at ?? row.source_latest_release_at ?? row.github_latest_release_at, + } : { stars: row.source_stars, version: row.source_latest_release, publishedAt: row.source_latest_release_at }; +} + +export function derivedPatch(row, facts, checkedAt) { + return { ...(row.star_recipient === true ? popularityPatch(row, facts.stars, checkedAt) : {}), ...releasePatch(row, facts.version, facts.publishedAt, checkedAt) }; +} diff --git a/scripts/self-host-apps/refresh-github.mjs b/scripts/self-host-apps/refresh-github.mjs index 5ebb4a08..8a52de78 100644 --- a/scripts/self-host-apps/refresh-github.mjs +++ b/scripts/self-host-apps/refresh-github.mjs @@ -10,6 +10,10 @@ import { jobSummary } from "./lib/job-summary.mjs"; import { maintenanceFrom as cliMaintenanceFrom } from "../cli-apps/lib/maintenance.mjs"; import { refreshFailed } from "./lib/refresh-health.mjs"; +import { derivedPatch, starRecipientCtes, starRecipientColumn } from "./lib/popularity.mjs"; +import { refreshApps } from "./lib/app-refresh.mjs"; +import { changedColumns } from "../stack127/database/sync-records.mjs"; + const githubGraphqlUrl = "https://api.github.com/graphql"; let cachedGithubToken; @@ -201,7 +205,8 @@ export async function rowsToRefresh(client, args, retryMetadataAvailable = true) let ageCondition = "true"; if (args["max-age-hours"]) { params.push(`${numberArg(args, "max-age-hours", 12)} hours`); - ageCondition = `(project.checked_at is null or project.checked_at <= now() - $${params.length}::interval)`; + const clock = retryMetadataAvailable ? 'greatest(project.last_attempt_at, project.checked_at)' : 'project.checked_at'; + ageCondition = `(${clock} is null or ${clock} <= now() - $${params.length}::interval)`; } const result = await client.query( @@ -304,116 +309,127 @@ export async function writeRepositoryFacts(client, row, repository, checkedAt, d maintenance_status: maintenanceStatus(repository, lastCommitAt), }; + // checked_at enters the page-date comparison; last_attempt_at does not. + // Keep polling progress without invalidating pages for identical host facts. + const repositoryChanged = retryMetadataAvailable ? changedColumns('stored', [ + 'full_name', 'repository_url', 'repo_id', 'node_id', 'owner_type', 'stars', + 'forks', 'watchers', 'open_issues', 'primary_language', 'languages', 'topics', + 'created_at', 'updated_at', 'pushed_at', 'default_branch', 'repo_size_kb', + 'archived', 'disabled', 'is_fork', 'is_template', 'latest_release', + 'latest_release_at', 'last_commit_at', 'license_spdx_id', 'source_url', + 'consecutive_failures', + ]) : 'true'; // Legacy queue has no separate attempt clock. + if (!dryRun) { - await client.query( - ` - insert into public.github_projects ( - owner, repo, full_name, repository_url, repo_id, node_id, owner_type, - stars, forks, watchers, open_issues, primary_language, languages, topics, - created_at, updated_at, pushed_at, default_branch, repo_size_kb, archived, - disabled, is_fork, is_template, latest_release, latest_release_at, - last_commit_at, license_spdx_id, checked_at, source_url${retryMetadataAvailable ? ", last_attempt_at, consecutive_failures" : ""} - ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, - $14::text[], $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, - $25, $26, $27, $28, $29${retryMetadataAvailable ? ", $28, 0" : ""} - ) - on conflict (owner, repo) do update set - full_name = excluded.full_name, - repository_url = excluded.repository_url, - repo_id = excluded.repo_id, - node_id = excluded.node_id, - owner_type = excluded.owner_type, - stars = excluded.stars, - forks = excluded.forks, - watchers = excluded.watchers, - open_issues = excluded.open_issues, - primary_language = excluded.primary_language, - languages = excluded.languages, - topics = excluded.topics, - created_at = excluded.created_at, - updated_at = excluded.updated_at, - pushed_at = excluded.pushed_at, - default_branch = excluded.default_branch, - repo_size_kb = excluded.repo_size_kb, - archived = excluded.archived, - disabled = excluded.disabled, - is_fork = excluded.is_fork, - is_template = excluded.is_template, - latest_release = excluded.latest_release, - latest_release_at = excluded.latest_release_at, - last_commit_at = excluded.last_commit_at, - license_spdx_id = excluded.license_spdx_id, - checked_at = excluded.checked_at, - ${retryMetadataAvailable ? "last_attempt_at = excluded.last_attempt_at, consecutive_failures = 0," : ""} - source_url = excluded.source_url, - row_updated_at = now() - returning id - `, - [ - facts.owner, - facts.repo, - facts.full_name, - facts.repository_url, - facts.repo_id, - facts.node_id, - facts.owner_type, - facts.stars, - facts.forks, - facts.watchers, - facts.open_issues, - facts.primary_language, - JSON.stringify(facts.languages), - facts.topics, - facts.created_at, - facts.updated_at, - facts.pushed_at, - facts.default_branch, - facts.repo_size_kb, - facts.archived, - facts.disabled, - facts.is_fork, - facts.is_template, - facts.latest_release, - facts.latest_release_at, - facts.last_commit_at, - facts.license, - facts.checked_at, - facts.repository_url, - ], - ); - await client.query( - ` - update public.self_host_apps app - set github_project_id = project.id, - license = case when app.license is null or lower(app.license) = 'unknown' then coalesce($2, app.license) else app.license end, - license_spdx_id = coalesce($2, app.license_spdx_id), - license_type = case when $3 = 'unknown' then app.license_type else $3::public.self_host_app_license_type end, - source_model = case when app.source_model = 'unknown' and $5 <> 'unknown' then $5::public.self_host_app_source_model else app.source_model end, - maintenance_status = $4, - data_source_urls = coalesce(( - select array_agg(distinct url order by url) - from unnest(coalesce(app.data_source_urls, '{}') || array[$6]::text[]) as values(url) - ), '{}'), - data_checked_at = $7, - data_confidence = 'high', - updated_at = now() + await client.query('begin'); + try { + await client.query( + ` + insert into public.github_projects as stored ( + owner, repo, full_name, repository_url, repo_id, node_id, owner_type, + stars, forks, watchers, open_issues, primary_language, languages, topics, + created_at, updated_at, pushed_at, default_branch, repo_size_kb, archived, + disabled, is_fork, is_template, latest_release, latest_release_at, + last_commit_at, license_spdx_id, checked_at, source_url${retryMetadataAvailable ? ", last_attempt_at, consecutive_failures" : ""} + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, + $14::text[], $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, + $25, $26, $27, $28, $29${retryMetadataAvailable ? ", $28, 0" : ""} + ) + on conflict (owner, repo) do update set + full_name = excluded.full_name, + repository_url = excluded.repository_url, + repo_id = excluded.repo_id, + node_id = excluded.node_id, + owner_type = excluded.owner_type, + stars = excluded.stars, + forks = excluded.forks, + watchers = excluded.watchers, + open_issues = excluded.open_issues, + primary_language = excluded.primary_language, + languages = excluded.languages, + topics = excluded.topics, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + pushed_at = excluded.pushed_at, + default_branch = excluded.default_branch, + repo_size_kb = excluded.repo_size_kb, + archived = excluded.archived, + disabled = excluded.disabled, + is_fork = excluded.is_fork, + is_template = excluded.is_template, + latest_release = excluded.latest_release, + latest_release_at = excluded.latest_release_at, + last_commit_at = excluded.last_commit_at, + license_spdx_id = excluded.license_spdx_id, + checked_at = case when ${repositoryChanged} then excluded.checked_at else stored.checked_at end, + ${retryMetadataAvailable ? "last_attempt_at = excluded.last_attempt_at, consecutive_failures = 0," : ""} + source_url = excluded.source_url, + row_updated_at = now() + returning id + `, + [ + facts.owner, + facts.repo, + facts.full_name, + facts.repository_url, + facts.repo_id, + facts.node_id, + facts.owner_type, + facts.stars, + facts.forks, + facts.watchers, + facts.open_issues, + facts.primary_language, + JSON.stringify(facts.languages), + facts.topics, + facts.created_at, + facts.updated_at, + facts.pushed_at, + facts.default_branch, + facts.repo_size_kb, + facts.archived, + facts.disabled, + facts.is_fork, + facts.is_template, + facts.latest_release, + facts.latest_release_at, + facts.last_commit_at, + facts.license, + facts.checked_at, + facts.repository_url, + ], + ); + await refreshApps(client, `with ${starRecipientCtes} select app.*, ${starRecipientColumn}, project.id as project_id + from public.self_host_apps app join public.github_projects project + on project.owner = $2 and project.repo = $3 + where app.slug = any($1::text[]) or app.github_project_id = project.id`, + [row.self_host_slugs ?? [row.slug], facts.owner, facts.repo], app => ({ + github_project_id: app.project_id, + license: !app.license || app.license.toLowerCase() === 'unknown' ? facts.license ?? app.license : app.license, + license_spdx_id: facts.license ?? app.license_spdx_id, + license_type: facts.license_type === 'unknown' ? app.license_type : facts.license_type, + source_model: app.source_model === 'unknown' && facts.source_model !== 'unknown' ? facts.source_model : app.source_model, + maintenance_status: facts.maintenance_status, + data_source_urls: [...new Set([...(app.data_source_urls ?? []), facts.repository_url])].sort(), + data_confidence: 'high', + ...derivedPatch(app, { stars: facts.stars, version: facts.latest_release, publishedAt: facts.latest_release_at }, checkedAt), + }), undefined, false); + // A normal terminal_apps UPDATE lets the existing statement trigger touch + // /cli-apps and enqueue cli_apps revalidation; identical values do no work. + await client.query(` + update public.terminal_apps app + set maintenance_status = $3::public.terminal_app_maintenance_status from public.github_projects project - where project.owner = $8 and project.repo = $9 - and app.slug = any($1::text[]) - `, - [row.self_host_slugs ?? [row.slug], facts.license, facts.license_type, facts.maintenance_status, facts.source_model, facts.repository_url, checkedAt, facts.owner, facts.repo], - ); - // A normal terminal_apps UPDATE lets the existing statement trigger touch - // /cli-apps and enqueue cli_apps revalidation; identical values do no work. - await client.query(` - update public.terminal_apps app - set maintenance_status = $3::public.terminal_app_maintenance_status - from public.github_projects project - where app.github_project_id = project.id and project.owner = $1 and project.repo = $2 - and app.published = true - and app.maintenance_status is distinct from $3::public.terminal_app_maintenance_status - `, [facts.owner, facts.repo, cliMaintenanceFrom({ archived: repository.isArchived, pushed_at: repository.pushedAt }, null, new Date(checkedAt).getTime())]); + where app.github_project_id = project.id and project.owner = $1 and project.repo = $2 + and app.published = true + and app.maintenance_status is distinct from $3::public.terminal_app_maintenance_status + `, [facts.owner, facts.repo, cliMaintenanceFrom({ archived: repository.isArchived, pushed_at: repository.pushedAt }, null, new Date(checkedAt).getTime())]); + await client.query('commit'); + } catch (error) { + await client.query('rollback'); + throw error; + } } } diff --git a/scripts/self-host-apps/refresh-popularity.mjs b/scripts/self-host-apps/refresh-popularity.mjs new file mode 100644 index 00000000..cff24577 --- /dev/null +++ b/scripts/self-host-apps/refresh-popularity.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { pathToFileURL } from 'node:url'; +import { parseArgs } from './lib/args.mjs'; +import { databaseOptions } from './lib/environment.mjs'; +import { withClient } from './lib/db.mjs'; +import { derivedPatch, storedFacts, starRecipientCtes, starRecipientColumn } from './lib/popularity.mjs'; +import { changedPatch, writePatches } from './lib/app-refresh.mjs'; + +export function validateArgs(args) { + for (const flag of ['all', 'apply', 'dry-run']) { + if (args[flag] !== undefined && args[flag] !== true) throw new Error(`--${flag} is a flag`); + } + if (args.slug !== undefined && (typeof args.slug !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(args.slug))) throw new Error('--slug needs an app slug'); + if (Boolean(args.slug) === Boolean(args.all)) throw new Error('Choose --slug or --all'); + if (args.apply && args['dry-run']) throw new Error('Use --dry-run or --apply, not both'); +} + +export async function refreshPopularity(client, args, checkedAt = new Date().toISOString()) { + validateArgs(args); + const apply = args.apply === true; + await client.query(apply ? 'begin' : 'begin read only'); + try { + const { rows } = await client.query(`with ${starRecipientCtes} select app.*, ${starRecipientColumn}, project.stars as project_stars, + project.latest_release as project_release, project.latest_release_at as project_release_at + from public.self_host_apps app + left join public.github_projects project on project.id = app.github_project_id + where app.published = true and ($1::text is null or app.slug = $1) + order by app.slug ${apply ? 'for update of app' : ''}`, [args.slug ?? null]); + if (args.slug && rows.length === 0) throw new Error(`Published app not found: ${args.slug}`); + const patches = rows.flatMap(row => { + const patch = changedPatch(row, derivedPatch(row, storedFacts(row), checkedAt)); + return Object.keys(patch).length ? [{ row, patch }] : []; + }); + const written = apply ? await writePatches(client, patches) : 0; + const report = { mode: apply ? 'apply' : 'preview', selected: rows.length, changed: patches.length, written, + changes: patches.map(({ row, patch }) => ({ slug: row.slug, + old: Object.fromEntries(Object.keys(patch).map(key => [key, row[key] ?? null])), new: patch })) }; + await client.query('commit'); + return report; + } catch (error) { + await client.query('rollback'); + throw error; + } +} + +async function main() { + const args = parseArgs(); + validateArgs(args); + const database = databaseOptions(args); + console.log(JSON.stringify(await withClient(client => refreshPopularity(client, args), database), null, 2)); +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(error => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/scripts/self-host-apps/refresh-source-host.mjs b/scripts/self-host-apps/refresh-source-host.mjs index be6ea146..55c9a63e 100644 --- a/scripts/self-host-apps/refresh-source-host.mjs +++ b/scripts/self-host-apps/refresh-source-host.mjs @@ -7,6 +7,9 @@ import { pathToFileURL } from "node:url"; import { jobSummary } from "./lib/job-summary.mjs"; import { refreshFailed } from "./lib/refresh-health.mjs"; +import { derivedPatch, starRecipientCtes, starRecipientColumn } from "./lib/popularity.mjs"; +import { refreshApps } from "./lib/app-refresh.mjs"; + const supportedHosts = new Set(["gitlab", "codeberg"]); function repositoryPath(value, host) { @@ -179,7 +182,7 @@ export async function rowsToRefresh(client, host, args) { if (args["published-only"]) conditions.push("published = true"); if (args["max-age-hours"]) { params.push(numberArg(args, "max-age-hours", 24)); - conditions.push(`(source_checked_at is null or source_checked_at < now() - ($${params.length} * interval '1 hour'))`); + conditions.push(`(greatest(source_checked_at, data_checked_at) is null or greatest(source_checked_at, data_checked_at) < now() - ($${params.length} * interval '1 hour'))`); } const limit = numberArg(args, "limit", 500); params.push(limit); @@ -188,7 +191,7 @@ export async function rowsToRefresh(client, host, args) { select id, slug, source_code_url from public.self_host_apps where ${conditions.join(" and ")} - order by source_checked_at nulls first, slug + order by greatest(source_checked_at, data_checked_at) nulls first, slug limit $${params.length} `, params, @@ -196,62 +199,23 @@ export async function rowsToRefresh(client, host, args) { return result.rows; } -async function writeFacts(client, row, host, facts, checkedAt) { - const values = [ - facts.source_repo_id, - facts.source_full_name, - facts.source_stars, - facts.source_forks, - facts.source_watchers, - facts.source_open_issues, - facts.source_primary_language, - JSON.stringify(facts.source_languages), - facts.source_topics, - facts.source_created_at, - facts.source_updated_at, - facts.source_pushed_at, - facts.source_default_branch, - facts.source_repo_size_kb, - facts.source_archived, - facts.source_disabled, - facts.source_is_fork, - facts.source_is_template, - facts.source_latest_release, - facts.source_latest_release_at, - facts.source_last_commit_at, - checkedAt, - ]; +export async function writeFacts(client, row, host, facts, checkedAt) { const licenseType = licenseTypeFromSpdx(facts.license); - const maintenance = maintenanceStatus(facts.source_archived, facts.source_last_commit_at); - await client.query( - ` - update public.self_host_apps - set source_repo_id = $2, source_full_name = $3, source_stars = $4, - source_forks = $5, source_watchers = $6, source_open_issues = $7, - source_primary_language = $8, source_languages = $9::jsonb, - source_topics = $10::text[], source_created_at = $11, source_updated_at = $12, - source_pushed_at = $13, source_default_branch = $14, source_repo_size_kb = $15, - source_archived = $16, source_disabled = $17, source_is_fork = $18, - source_is_template = $19, source_latest_release = $20, - source_latest_release_at = $21, source_last_commit_at = $22, - source_checked_at = $23, maintenance_status = $24, - license = case when license is null or lower(license) = 'unknown' then coalesce($25, license) else license end, - license_spdx_id = coalesce(license_spdx_id, $25), - license_type = case - when license_type = 'unknown' and $26 <> 'unknown' then $26::public.self_host_app_license_type - else license_type - end, - data_source_urls = coalesce(( - select array_agg(distinct url order by url) - from unnest(coalesce(data_source_urls, '{}') || array[$27]::text[]) as values(url) - ), '{}'), - data_checked_at = $23, - data_confidence = 'high', - updated_at = now() - where id = $1 - `, - [row.id, ...values, maintenance, facts.license, licenseType, row.source_code_url], - ); + await refreshApps(client, `with ${starRecipientCtes} select app.*, ${starRecipientColumn} from public.self_host_apps app where app.id = $1`, [row.id], app => { + const sourceFacts = Object.fromEntries(Object.entries(facts).filter(([key]) => key.startsWith('source_'))); + // Preserve the check time when the facts are unchanged: it is part of the + // page-date trigger's comparison, so rewriting it would invalidate pages. + return { + ...sourceFacts, + maintenance_status: maintenanceStatus(facts.source_archived, facts.source_last_commit_at), + license: !app.license || app.license.toLowerCase() === 'unknown' ? facts.license ?? app.license : app.license, + license_spdx_id: app.license_spdx_id ?? facts.license, + license_type: app.license_type === 'unknown' && licenseType !== 'unknown' ? licenseType : app.license_type, + data_source_urls: [...new Set([...(app.data_source_urls ?? []), row.source_code_url])].sort(), + data_confidence: 'high', + ...derivedPatch(app, { stars: facts.source_stars, version: facts.source_latest_release, publishedAt: facts.source_latest_release_at }, checkedAt), + }; + }, checkedAt); } export async function refreshSourceHost(client, args) { diff --git a/scripts/self-host-apps/scripts.json b/scripts/self-host-apps/scripts.json index df918c92..58103f4a 100644 --- a/scripts/self-host-apps/scripts.json +++ b/scripts/self-host-apps/scripts.json @@ -212,8 +212,8 @@ }, { "name": "self-host-apps:apply-qa-overrides", - "command": "node scripts/self-host-apps/apply-qa-overrides.mjs --file scripts/self-host-apps/batches/YYYY-MM-DD-qa-reviewed.json --dry-run", - "purpose": "Apply reviewed app corrections and append their source URLs." + "command": "npm run self-host-apps:apply-qa-overrides -- --environment development --file scripts/self-host-apps/batches/YYYY-MM-DD-qa-reviewed.json --dry-run", + "purpose": "Apply reviewed app corrections and append their source URLs. --dry-run rolls back; production requires --environment production --confirm stack127-production, including previews." }, { "name": "self-host-apps:apply-artifact-cleanup", @@ -229,5 +229,10 @@ "name": "self-host-apps:sync-local-to-prod", "command": "npm run self-host-apps:sync-local-to-prod -- --confirm-reviewed-push --dry-run", "purpose": "Exceptional guarded upsert from reviewed local data; never drafts missing production rows by default." + }, + { + "name": "self-host-apps:refresh-popularity", + "command": "npm run self-host-apps:refresh-popularity -- --environment development --all", + "purpose": "Preview published app popularity and missing releases from stored facts; --slug selects one app, --apply writes. Production requires --confirm stack127-production." } ] diff --git a/stack127_docs/self-host-apps-data.md b/stack127_docs/self-host-apps-data.md index af9660bf..0988f66f 100644 --- a/stack127_docs/self-host-apps-data.md +++ b/stack127_docs/self-host-apps-data.md @@ -76,7 +76,7 @@ GitHub without a curated override. Only changed maintenance values are written. The existing terminal-app and GitHub-project triggers touch `/cli-apps` and queue its cache refresh; the job does not write public page dates by hand. -`checked_at` is the last successful GitHub fact refresh. `last_attempt_at` records +`checked_at` records a successful GitHub refresh that changed facts. `last_attempt_at` records successful and failed attempts, and `consecutive_failures` resets after success. The refresh queue orders by the later of the attempt and successful check dates, so inaccessible repositories move behind older work without appearing fresh. @@ -94,6 +94,75 @@ keep their successful check dates and are retried on later daily runs. Other providers keep using the existing `source_*` columns until a provider table is worth adding. That's a planned transition, not a second source of truth for self-host apps. +## Popularity and releases + +The default list order uses `popularity_score`. GitHub, GitLab and Codeberg +refreshes use one rule in `scripts/self-host-apps/lib/popularity.mjs`: + +| Stars | Score | +| --- | --- | +| None or zero | 0 | +| 1–249 | 50 | +| 250–999 | 60 | +| 1,000–4,999 | 70 | +| 5,000–9,999 | 80 | +| 10,000–19,999 | 85 | +| 20,000–49,999 | 90 | +| 50,000–99,999 | 95 | +| 100,000+ | 100 | + +These are the original tiers from migration +`20260829090000_make_self_host_apps_page_owned.sql`. A missing star count leaves +the current score alone. A known zero can lower a score based only on stars to 0. + +A positive catalog score is a floor: low stars cannot reduce it. Catalog presence +with score 0 does not count as a score. Star-only scores follow star gains and +losses. When stars overtake a catalog score, `popularity_basis` keeps a +`catalog_score:` entry so later star losses preserve that floor. +The score's basis and check time change only when the score or basis changes. + +Stars count only for a repository-root URL (`owner/repo`), not links to files, +folders or other pages inside it. Repository URLs match without regard to case, +trailing slashes or `.git`. Among published apps sharing a repository, only one +row receives its star tier: first prefer an existing `source_stars` basis, then +the best name match to the repository, then the shortest name, then slug. Name +matching ignores spaces and punctuation; an exact match comes before a prefix +match, then a name containing the repository name. Other rows keep their existing +score and basis unchanged. Ranking uses the whole published catalogue, even for +a single-app refresh or backfill. Release fills still apply independently to +variants and links inside repositories. + + +Refreshes fill `release_version`, `release_published_at` and `release_checked_at` +only when the version is empty and a fetched release exists. Existing versions, +including values such as `5.2.2 LTS`, stay untouched. There is no reliable marker +to separate fetched versions from reviewed versions. This also means a version +filled by this job stays unchanged on later runs. The source-host release fields +still track the latest fetched release. Details prefer the shared release fields; +list cards use the shared release date. + +Unchanged app facts do not rewrite popularity or release check times. GitHub +keeps its attempt time current but preserves its fact check time when facts are +unchanged. GitLab and Codeberg use `data_checked_at` to move successful attempts +through the queue; that field is excluded from public page-date comparisons. +Their `source_checked_at` advances when facts change. Existing triggers handle +page dates and cache refreshes; these commands do not set page dates directly. + +Repair published apps from stored facts without contacting code hosts: + +```bash +npm run self-host-apps:refresh-popularity -- --environment development --all +npm run self-host-apps:refresh-popularity -- --environment development --all --apply +``` + +Use `--slug ` instead of `--all` for one app. Preview is read-only and +prints each changed field's old and new values, plus selected, changed and written +counts. Apply locks the selected app rows and uses one set-based update in a +transaction. It changes only popularity and missing release fields. Re-running +it with the same stored facts writes nothing. Both production commands require +`--environment production --confirm stack127-production`; review the preview +before adding `--apply`. + ## Evidence and QA `data_source_urls`, `data_checked_at`, `data_confidence`, and `technical_fact_status` hold a short evidence summary for each app. They replace the old field-level provenance rows. @@ -108,6 +177,21 @@ and support levels, and applying replaces existing options. Review the proposed links and support levels before running an explicit apply. Use `apply-qa-overrides.mjs` for reviewed editorial fact corrections. +QA overrides use the shared database gate. They default to development and ignore +inherited `NODE_ENV` and `DATABASE_URL`. `--database-url` is rejected. Production +requires `--environment production --confirm stack127-production`, including +previews. Keep the reviewed `expected` values on partial corrections: a changed +value stops the batch. A correction cannot leave an app published with an invalid +classification. +Preview runs the existing write checks inside a transaction and rolls it back; +it is not a read-only preview. Omitting `--dry-run` commits the batch. + +```bash +npm run self-host-apps:apply-qa-overrides -- --environment development --file --dry-run +npm run self-host-apps:apply-qa-overrides -- --environment development --file +``` + + ## Reviewed artifact cleanup The cleanup manifest records reviewed helper containers, image variants, and duplicate app rows. Preview it before a change: @@ -152,7 +236,9 @@ This adds records to `/self-host-apps`. It does not create a route or page. npm run self-host-apps:apply-resource-estimates -- --environment development --file --apply-reviewed ``` -4. Refresh only that app's repository facts: `self-host-apps:refresh-github -- --slug ` for GitHub, `self-host-apps:refresh-gitlab -- --slug ` for GitLab, or `self-host-apps:refresh-codeberg -- --slug ` for Codeberg. Pass `--environment development`. Check the real directory at desktop and mobile width, including the app's icon, install links, estimates and filters. +4. Refresh only that app's repository facts: `self-host-apps:refresh-github -- --slug ` for GitHub, `self-host-apps:refresh-gitlab -- --slug ` for GitLab, or `self-host-apps:refresh-codeberg -- --slug ` for Codeberg. Pass `--environment development`. For apps without a repository refresh, or to repair + existing stored facts, preview `npm run self-host-apps:refresh-popularity -- --environment development --slug ` + and repeat with `--apply` after reviewing the changes. Check the real directory at desktop and mobile width, including the app's icon, install links, estimates and filters. 5. After the owner authorizes production changes, run the same claim preview, claim, review preview, review apply, estimate preview, estimate apply and per-app refresh on homelab. Replace `--environment development` with `--environment production --confirm stack127-production` on every command. Use homelab because uploading to production from the MacBook is very slow. Check the live directory after cache refresh completes. `new` means discovery has not run duplicate checks. `queued` means dedupe found no match; `needs_review` often means a possible name match. Exact-ID manual selection accepts all three because the reviewer checks duplicates. It also reports already `enriching` rows and leaves them alone. Missing, published, rejected or duplicate IDs stop the whole claim. `--ids ` is also supported. Claiming uses one transaction and touches no other candidates. Top-N selection still only claims `queued` and `needs_review`; it does not bypass dedupe. Re-running a claim is safe while rows are enriching. Before the review commits, claiming the same IDs again is safe. After the review commits, resume at estimates; do not reclaim or reapply that review. Estimates and repository refreshes can be re-run. Release an abandoned enriching claim with `npm run self-host-apps:release-candidate-batch -- --environment development --file --to needs_review`; use `--environment production --confirm stack127-production` for production. Release leaves completed candidates alone. diff --git a/tests/self-host-apps/popularity-database.test.mjs b/tests/self-host-apps/popularity-database.test.mjs new file mode 100644 index 00000000..ec722a2b --- /dev/null +++ b/tests/self-host-apps/popularity-database.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import pg from 'pg'; +import { resolveDatabaseUrl } from '../../scripts/stack127/tool-pages/lib.mjs'; +import { refreshPopularity } from '../../scripts/self-host-apps/refresh-popularity.mjs'; +import { writeFacts } from '../../scripts/self-host-apps/refresh-source-host.mjs'; + +const databaseTest = {skip:process.env.SELF_HOST_APPS_TEST_ENVIRONMENT !== 'development'}; +test('backfill preview, apply, repeat, release preservation and source refresh are isolated and roll back', databaseTest, async () => { + const client = new pg.Client({connectionString:resolveDatabaseUrl({environment:'development'}),connectionTimeoutMillis:15000}); + try { + await client.connect(); + await client.query('begin'); + await client.query(`create temporary table apps_fixture as select * from public.self_host_apps where false; + create temporary table projects_fixture as select * from public.github_projects where false; + create temporary table write_events (slug text); + create function pg_temp.record_write() returns trigger language plpgsql as $$ + begin insert into pg_temp.write_events values (new.slug); return new; end $$; + create trigger writes after update on apps_fixture for each row execute function pg_temp.record_write(); + insert into apps_fixture (id,slug,published,source_code_host,source_stars,popularity_score,popularity_basis,release_version,source_latest_release,license_type) + values (gen_random_uuid(),'zero',true,'gitlab',5760,0,'{}',null,'v1','unknown'), + (gen_random_uuid(),'manual',true,'gitlab',363,95,'{source_stars}','5.2.2 LTS','v6','unknown'), + (gen_random_uuid(),'draft',false,'gitlab',100000,0,'{}',null,'v1','unknown');`); + const isolated = {query(sql,params) { + if (/^(begin(?: read only)?|commit|rollback)$/i.test(sql.trim())) return Promise.resolve({rows:[]}); + return client.query(sql.replaceAll('public.self_host_apps','pg_temp.apps_fixture').replaceAll('public.github_projects','pg_temp.projects_fixture'),params); + }}; + await client.query("update apps_fixture set source_code_url='https://gitlab.com/example/' || slug"); + await client.query('delete from write_events'); // Count only the writes the code under test makes. + const before = (await client.query('select * from apps_fixture order by slug')).rows; + const preview = await refreshPopularity(isolated,{all:true}); + assert.equal(preview.changed,2); + assert.deepEqual((await client.query('select * from apps_fixture order by slug')).rows,before); + assert.equal((await client.query('select count(*)::int n from write_events')).rows[0].n,0); + assert.equal((await refreshPopularity(isolated,{all:true,apply:true})).written,2); + const rows = (await client.query('select * from apps_fixture order by slug')).rows; + assert.equal(rows.find(r=>r.slug==='manual').release_version,'5.2.2 LTS'); + assert.equal(rows.find(r=>r.slug==='zero').release_version,'v1'); + assert.equal(rows.find(r=>r.slug==='zero').popularity_score,80); + assert.equal(rows.find(r=>r.slug==='draft').popularity_score,0); + assert.equal((await refreshPopularity(isolated,{all:true,apply:true})).written,0); + assert.equal((await client.query('select count(*)::int n from write_events')).rows[0].n,2); + const row=rows.find(r=>r.slug==='zero'); + await writeFacts(isolated,row,'gitlab',{source_stars:20000,source_latest_release:'v2',source_latest_release_at:null,source_archived:false,source_last_commit_at:null,license:null},new Date().toISOString()); + const refreshed=(await client.query("select * from apps_fixture where slug='zero'")).rows[0]; + assert.equal(refreshed.popularity_score,90); + assert.equal(refreshed.source_stars,20000); + assert.equal(refreshed.release_version,'v1'); + // Use the installed projection that controls page-date/cache comparisons. + await client.query("update apps_fixture set self_hosting_status='self_hostable' where slug='zero'"); + const publicValue = async () => (await client.query("select public.stack127_directory_record_value('self_host_apps',to_jsonb(app)) value from apps_fixture app where slug='zero'")).rows[0].value; + const beforeRepeat = await publicValue(); + await writeFacts(isolated,row,'gitlab',{source_stars:20000,source_latest_release:'v2',source_latest_release_at:null,source_archived:false,source_last_commit_at:null,license:null},new Date(Date.now()+60000).toISOString()); + assert.deepEqual(await publicValue(),beforeRepeat,'An identical refresh must not change the page-date/cache projection'); + + } finally { + await client.query('rollback').catch(()=>{}); + await client.end(); + } +}); + +test('repository roots elect one published star row and preserve variants across backfill and refresh', databaseTest, async () => { + const client = new pg.Client({connectionString:resolveDatabaseUrl({environment:'development'}),connectionTimeoutMillis:15000,query_timeout:60000}); + try { + await client.connect(); + await client.query('begin'); + await client.query("set local statement_timeout='60s'"); + await client.query(`create temporary table apps_fixture as select * from public.self_host_apps where false; + create temporary table projects_fixture as select * from public.github_projects where false;`); + const seeds = [ + ['aquacomputer-d5next','Aquacomputer D5 Next','https://github.com/torvalds/linux/blob/master/drivers/hwmon/aquacomputer_d5next.c',24,[],true], + ['tree-link','Tree link','https://github.com/torvalds/linux/tree/master',38,[],true], + ['issues-link','Issues link','https://github.com/torvalds/linux/issues',40,[],true], + ['dify','Dify','https://GitHub.com/LangGenius/DiFy.git/',100,['source_stars'],true], + ['dify-ai','Dify AI','https://github.com/langgenius/dify/',0,[],true], + ['comfyui','ComfyUI','https://github.com/comfyanonymous/ComfyUI',0,[],true], + ['comfyui-intel-xpu','ComfyUI Intel XPU','https://github.com/comfyanonymous/comfyui.git',18,[],true], + ['syncthing','Syncthing','https://github.com/syncthing/syncthing',70,['source_stars'],true], + ['binhex-syncthing','Binhex Syncthing','https://github.com/syncthing/syncthing.git/',70,['source_stars'],true], + ['established','Established wrapper','https://github.com/example/project',90,['source_stars'],true], + ['project','Project','https://github.com/example/project',0,[],true], + ['long-name','Alpha Beta','https://github.com/example/shortest',0,[],true], + ['short-name','Gamma','https://github.com/example/shortest',0,[],true], + ['alpha','Beta','https://github.com/example/unrelated',0,[],true], + ['zeta','Zeta','https://github.com/example/unrelated',0,[],true], + ['draft','Unrelated','https://github.com/example/unrelated',0,['source_stars'],false], + ]; + await client.query(`insert into apps_fixture + (id,slug,name,source_code_url,popularity_score,popularity_basis,published,source_stars,source_latest_release,source_code_host,license_type) + select gen_random_uuid(),value->>0,value->>1,value->>2,(value->>3)::integer, + array(select jsonb_array_elements_text(value->4)),(value->>5)::boolean,200000,'v1','github','unknown' + from jsonb_array_elements($1::jsonb)`,[JSON.stringify(seeds)]); + const isolated = {query(sql,params) { + if (/^(begin(?: read only)?|commit|rollback)$/i.test(sql.trim())) return Promise.resolve({rows:[]}); + return client.query(sql.replaceAll('public.self_host_apps','pg_temp.apps_fixture').replaceAll('public.github_projects','pg_temp.projects_fixture'),params); + }}; + // A single-slug request must still rank the other published apps. + const variantPreview = await refreshPopularity(isolated,{slug:'comfyui-intel-xpu'}); + assert.equal(variantPreview.changes[0].new.release_version,'v1'); + assert.equal(variantPreview.changes[0].new.popularity_score,undefined); + assert.equal((await client.query("select release_version from apps_fixture where slug='comfyui-intel-xpu'")).rows[0].release_version,null); + await refreshPopularity(isolated,{all:true,apply:true}); + const state = Object.fromEntries((await client.query('select * from apps_fixture')).rows.map(row=>[row.slug,row])); + for (const [slug,score] of Object.entries({ + 'aquacomputer-d5next':24,'tree-link':38,'issues-link':40, + dify:100,'dify-ai':0,comfyui:100,'comfyui-intel-xpu':18, + syncthing:100,'binhex-syncthing':70,established:100,project:0,'long-name':0,'short-name':100,alpha:100,zeta:0,draft:0, + })) assert.equal(state[slug].popularity_score,score,slug); + assert.equal(state['comfyui-intel-xpu'].release_version,'v1'); + // The elected row now has source_stars; re-running is a complete no-op. + assert.equal((await refreshPopularity(isolated,{all:true,apply:true})).written,0); + await client.query("update apps_fixture set release_version=null where slug='comfyui-intel-xpu'"); + await writeFacts(isolated,state['comfyui-intel-xpu'],'codeberg',{ + source_stars:300000,source_latest_release:'v2',source_latest_release_at:null, + source_archived:false,source_last_commit_at:null,license:null, + },new Date().toISOString()); + const variant = (await client.query("select * from apps_fixture where slug='comfyui-intel-xpu'")).rows[0]; + assert.equal(variant.popularity_score,18); + assert.deepEqual(variant.popularity_basis,[]); + assert.equal(variant.release_version,'v2'); + } finally { + await client.query('rollback').catch(()=>{}); + await client.end(); + } +}); diff --git a/tests/self-host-apps/popularity.test.mjs b/tests/self-host-apps/popularity.test.mjs new file mode 100644 index 00000000..4237906a --- /dev/null +++ b/tests/self-host-apps/popularity.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { starScore, popularityPatch, releasePatch, storedFacts, derivedPatch } from '../../scripts/self-host-apps/lib/popularity.mjs'; +import { changedPatch, writePatches } from '../../scripts/self-host-apps/lib/app-refresh.mjs'; +import { databaseOptions } from '../../scripts/self-host-apps/lib/environment.mjs'; +import { refreshPopularity, validateArgs } from '../../scripts/self-host-apps/refresh-popularity.mjs'; +const at = '2026-11-03T00:00:00.000Z'; + +test('original star tiers include both sides of every boundary', () => { + for (const [stars, score] of [[null,0],[0,0],[1,50],[249,50],[250,60],[999,60],[1000,70],[4999,70],[5000,80],[9999,80],[10000,85],[19999,85],[20000,90],[49999,90],[50000,95],[99999,95],[100000,100]]) assert.equal(starScore(stars),score); +}); +test('star scores follow gains and losses; catalog floors survive both', () => { + const row = {popularity_score:61,popularity_basis:['unraid_apps','catalog_or_repository_presence']}; + const low = {...row, ...popularityPatch(row, 1, at)}; + assert.equal(low.popularity_score,61); + const high = {...low,...popularityPatch(low,20000,at)}; + assert.equal(high.popularity_score,90); + assert.equal(popularityPatch(high,1,at).popularity_score,61); + assert.equal(popularityPatch({popularity_score:95,popularity_basis:['source_stars']},1,at).popularity_score,50); + assert.equal(popularityPatch({popularity_score:0,popularity_basis:['unraid_apps']},5000,at).popularity_score,80); + assert.deepEqual(popularityPatch({popularity_score:0,popularity_basis:[]},0,at),{}); + assert.deepEqual(popularityPatch(row,null,at),{}); +}); +test('identical scores and facts do not change check dates or write', async () => { + const row = {popularity_score:80,popularity_basis:['source_stars']}; + assert.deepEqual(popularityPatch(row,6000,at),{}); + assert.deepEqual(changedPatch({a:new Date(at),b:{x:1,y:2}}, {a:at,b:{y:2,x:1}}),{}); + assert.equal(await writePatches({query(){throw Error('unexpected write');}}, []),0); +}); +test('release fill preserves hand-verified values, including their dates', () => { + assert.deepEqual(releasePatch({release_version:'5.2.2 LTS'},'v6',at,at),{}); + assert.deepEqual(releasePatch({},null,at,at),{}); + assert.equal(releasePatch({},'v6',at,at).release_version,'v6'); + assert.equal(storedFacts({source_code_host:'github',project_stars:0,source_stars:100}).stars,0); +}); +test('preview is read-only and reports changes without any write query', async () => { + const queries=[]; + const client={async query(sql){queries.push(sql);return {rows:sql.includes('select app.*')?[{id:'one',slug:'test',star_recipient:true,source_stars:6000,popularity_score:0,popularity_basis:[],source_latest_release:'v1'}]:[]};}}; + const report=await refreshPopularity(client,{all:true},at); + assert.equal(queries[0],'begin read only'); + assert.equal(report.changed,1); + assert.equal(report.written,0); + assert.equal(report.changes[0].new.popularity_score,80); + assert.ok(!queries.some(sql=>/\b(update|insert|delete)\b/i.test(sql))); +}); +test('selection and apply flags must be explicit', () => { + for(const args of [{},{all:true,slug:'app'},{all:true,apply:'false'},{all:true,apply:true,'dry-run':true}]) assert.throws(()=>validateArgs(args)); +}); + +test('production previews and writes require the shared confirmation gate', () => { + assert.throws(() => databaseOptions({environment:'production',all:true}), /Production requires --confirm stack127-production/); + assert.throws(() => databaseOptions({environment:'production',all:true,apply:true}), /Production requires --confirm stack127-production/); +}); + +test('ineligible repository rows preserve scores and still fill releases', () => { + for (const score of [0,24,70,100]) { + const row = {star_recipient:false,popularity_score:score,popularity_basis:['source_stars']}; + const patch = derivedPatch(row,{stars:200000,version:'v1',publishedAt:at},at); + assert.deepEqual(patch,{release_version:'v1',release_published_at:at,release_checked_at:at}); + } + assert.equal(derivedPatch({star_recipient:true,popularity_score:0,popularity_basis:[]},{stars:200000},at).popularity_score,100); +}); diff --git a/tests/self-host-apps/qa-overrides-environment.test.mjs b/tests/self-host-apps/qa-overrides-environment.test.mjs new file mode 100644 index 00000000..bebd3f25 --- /dev/null +++ b/tests/self-host-apps/qa-overrides-environment.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +test('QA override CLI selects the named database, gates production, and retains correction guards', t => { + const dir = mkdtempSync(join(tmpdir(), 'self-host-qa-gate-')); + t.after(() => rmSync(dir, {recursive:true,force:true})); + writeFileSync(join(dir,'.env.local'),'DATABASE_URL=postgres://development/db\n'); + writeFileSync(join(dir,'.env.database'),'DATABASE_URL=postgres://production/db\n'); + const report = join(dir,'calls.json'); + const hook = join(dir,'mock-db.mjs'); + writeFileSync(hook, ` + import { registerHooks } from 'node:module'; + registerHooks({load(url,context,nextLoad) { + if (url.endsWith('/scripts/self-host-apps/lib/db.mjs')) return { + format:'module',shortCircuit:true,source: \` + import {writeFileSync} from 'node:fs'; + export async function withClient(work,databaseUrl) { + const calls=[]; + try { + return await work({query:async (sql,params)=>{ + calls.push(sql); + if (sql.includes('select * from public.self_host_apps')) return {rowCount:1,rows:[{ + id:'app-id',slug:'example',published:false,source_code_url:'https://example.org/old' + }]}; + return {rowCount:1,rows:[]}; + }}); + } finally {writeFileSync(process.env.QA_TEST_REPORT,JSON.stringify({databaseUrl,calls}));} + } + \` + }; + return nextLoad(url,context); + }}); + `); + const file = join(dir,'reviewed.json'); + const review = {slug:'example',review_scope:'partial',source_code_url:'https://example.org/new', + expected:{source_code_url:'https://example.org/old'},data_source_urls:['https://example.org/docs']}; + const script = fileURLToPath(new URL('../../scripts/self-host-apps/apply-qa-overrides.mjs',import.meta.url)); + const run = (args, record = review) => { + rmSync(report,{force:true}); + writeFileSync(file,JSON.stringify([record])); + const result = spawnSync(process.execPath,['--import',hook,script,'--file',file,...args],{ + cwd:dir,encoding:'utf8',env:{...process.env,NODE_ENV:'production',DATABASE_URL:'postgres://unintended/db',QA_TEST_REPORT:report}, + }); + return {...result,report:existsSync(report)?JSON.parse(readFileSync(report,'utf8')):null}; + }; + for (const flags of [[],['--dry-run']]) { + const result = run(flags); + assert.equal(result.status,0,result.stderr); + assert.equal(result.report.databaseUrl,'postgres://development/db'); + assert.equal(result.report.calls.at(-1),flags.length?'rollback':'commit'); + assert.ok(result.report.calls.some(sql=>sql.includes('update public.self_host_apps'))); + } + for (const flags of [[],['--dry-run']]) { + const refused = run(['--environment','production',...flags]); + assert.equal(refused.status,1); + assert.match(refused.stderr,/Production requires --confirm stack127-production/); + assert.equal(refused.report,null,'The production gate must run before connecting'); + const result = run(['--environment','production','--confirm','stack127-production',...flags]); + assert.equal(result.status,0,result.stderr); + assert.equal(result.report.databaseUrl,'postgres://production/db'); + assert.equal(result.report.calls.at(-1),flags.length?'rollback':'commit'); + } + for (const flags of [['--environment','development'],['--database-url','postgres://override/db'],['--dry-run=false']]) { + const result = run(flags); + if(flags[0]==='--environment') { + assert.equal(result.status,0,result.stderr); + assert.equal(result.report.databaseUrl,'postgres://development/db'); + } else { + assert.equal(result.status,1); + assert.equal(result.report,null); + } + } + const stale = run(['--dry-run'],{...review,expected:{source_code_url:'https://example.org/stale'}}); + assert.equal(stale.status,1); + assert.match(stale.stderr,/changed since review/); + assert.equal(stale.report.calls.at(-1),'rollback'); + assert.ok(!stale.report.calls.some(sql=>sql.includes('update public.self_host_apps'))); + const invalid = run(['--dry-run'],{...review,published:true}); + assert.equal(invalid.status,1); + assert.match(invalid.stderr,/correction cannot leave this app published/); + assert.equal(invalid.report.calls.at(-1),'rollback'); +}); diff --git a/tests/self-host-apps/refresh-automations.test.mjs b/tests/self-host-apps/refresh-automations.test.mjs index dbe809ea..ccaa5513 100644 --- a/tests/self-host-apps/refresh-automations.test.mjs +++ b/tests/self-host-apps/refresh-automations.test.mjs @@ -78,16 +78,18 @@ for (const status of [200, 403, 404]) { }); const writes = []; const client = { async query(sql, values) { - if (sql.includes("select id, slug")) return { rows: [{ id: "app-id", slug: "gitlab", source_code_url: "https://gitlab.com/gitlab-org/gitlab" }] }; - writes.push(values); - return { rows: [] }; + if (sql.includes("select id, slug, source_code_url")) return { rows: [{ id: "app-id", slug: "gitlab", source_code_url: "https://gitlab.com/gitlab-org/gitlab" }] }; + if (sql.includes('select app.*')) return { rows: [{ id:'app-id', slug:'gitlab', star_recipient:true, popularity_score:0, popularity_basis:[] }] }; + if (sql.includes('jsonb_populate_recordset')) writes.push(...JSON.parse(values[0])); + return { rows: [], rowCount:1 }; } }; const report = await refreshSourceHost(client, { host: "gitlab" }); assert.equal(report.refreshed, 1); assert.equal(report.failed, 0); assert.equal(writes.length, 1); - assert.equal(writes[0][3], 123); - assert.equal(writes[0][19], null); + assert.equal(writes[0].source_stars, 123); + assert.equal(writes[0].popularity_score, 50); + assert.equal(writes[0].source_latest_release, undefined); }); } diff --git a/tests/self-host-apps/refresh-github-database.test.mjs b/tests/self-host-apps/refresh-github-database.test.mjs index f6776d25..5a65591d 100644 --- a/tests/self-host-apps/refresh-github-database.test.mjs +++ b/tests/self-host-apps/refresh-github-database.test.mjs @@ -24,10 +24,12 @@ async function fixture(t, work) { alter table github_projects_fixture drop column if exists last_attempt_at cascade, drop column if exists consecutive_failures; create temporary table self_host_apps_fixture ( - slug text primary key, source_code_url text, github_owner text, github_repo text, github_project_id uuid, + id uuid default gen_random_uuid() primary key, slug text unique, name text, source_code_url text, github_owner text, github_repo text, github_project_id uuid, source_code_host text default 'github', published boolean default true, source_model public.self_host_app_source_model default 'unknown', license text, license_spdx_id text, license_type public.self_host_app_license_type default 'unknown', + popularity_score integer default 0, popularity_basis text[] default '{}', popularity_checked_at timestamptz, + release_version text, release_published_at timestamptz, release_checked_at timestamptz, maintenance_status text, data_source_urls text[], data_checked_at timestamptz, data_confidence text, updated_at timestamptz ) on commit drop; create temporary table terminal_apps ( @@ -38,6 +40,7 @@ async function fixture(t, work) { `); let schemaChecks = 0; const isolated = { query(sql, params) { + if (/^(begin|commit|rollback)$/i.test(sql.trim())) return Promise.resolve({rows:[],rowCount:0}); if (sql.includes("from pg_attribute")) schemaChecks++; return client.query(sql.replaceAll("public.github_projects", "pg_temp.github_projects_fixture") .replaceAll("public.self_host_apps", "pg_temp.self_host_apps_fixture") @@ -173,6 +176,16 @@ test("the shared queue unions published CLI projects, deduplicates memberships, "A CLI slug must not update an unrelated self-host app with the same slug"); assert.deepEqual((await client.query("select maintenance_status from pg_temp.terminal_apps where published and slug in ('older','cli-alias')")).rows.map(row => row.maintenance_status), ["active", "active"]); const shared = rows.find(row => row.github_repo === "failed"); + await writeRepositoryFacts(isolated, shared, { nameWithOwner: 'example/failed', stargazerCount: 5760, + latestRelease: {tagName:'v1',publishedAt:recoveredAt} }, recoveredAt, false); + assert.equal((await client.query("select count(*)::int n from self_host_apps_fixture where slug in ('also-failed','failed') and popularity_score=80 and release_version='v1'")).rows[0].n, 1); + assert.equal((await client.query("select count(*)::int n from self_host_apps_fixture where slug in ('also-failed','failed') and release_version='v1'")).rows[0].n, 2); + assert.deepEqual((await client.query("select popularity_score,popularity_basis from self_host_apps_fixture where slug='also-failed'")).rows[0], + {popularity_score:0,popularity_basis:[]}, 'The second app gets the release but no repository star score'); + const appBefore = (await client.query("select *, xmin::text from self_host_apps_fixture where slug='failed'")).rows[0]; + await writeRepositoryFacts(isolated, shared, { nameWithOwner: 'example/failed', stargazerCount: 5760, + latestRelease: {tagName:'v1',publishedAt:recoveredAt} }, attempt, false); + assert.deepEqual((await client.query("select *, xmin::text from self_host_apps_fixture where slug='failed'")).rows[0], appBefore); await writeFailureAttempt(isolated, shared, attempt, false); const retryQueue = (await rowsToRefresh(isolated, { "published-only": true, all: true })).map(row => row.github_repo); assert.ok(retryQueue.indexOf("failed") > retryQueue.indexOf("older")); From 99e31060e72974aabf5d28d07537fe2174837388 Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Tue, 3 Nov 2026 10:52:33 +0530 Subject: [PATCH 2/4] Treat GitLab subgroup projects as repository roots for star scores Co-Authored-By: Claude Opus 5.5 --- scripts/self-host-apps/lib/popularity.mjs | 10 +++++++--- tests/self-host-apps/popularity-database.test.mjs | 9 +++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/self-host-apps/lib/popularity.mjs b/scripts/self-host-apps/lib/popularity.mjs index 9b9cdcea..6e71c632 100644 --- a/scripts/self-host-apps/lib/popularity.mjs +++ b/scripts/self-host-apps/lib/popularity.mjs @@ -1,9 +1,10 @@ // Rank the complete published catalogue before a caller filters by slug or host. -// A repository root has exactly owner/repo after the hostname. Deeper links -// describe a file, directory, or other page inside somebody else's repository. +// A repository root is owner/repo after the hostname. GitLab also nests projects in +// subgroups, such as nodes/typescript/duniter. Other deeper links describe a file, +// directory, or other page inside somebody else's repository. export const starRecipientCtes = ` star_repository_urls as ( - select id, slug, name, popularity_basis, + select id, slug, name, popularity_basis, source_code_host, lower(regexp_replace(regexp_replace( split_part(split_part(btrim(source_code_url), '#', 1), '?', 1), '/+$', ''), '[.]git$', '', 'i')) as repository_key @@ -13,6 +14,9 @@ export const starRecipientCtes = ` regexp_replace(regexp_replace(repository_key, '^.*/', ''), '[^a-z0-9]', '', 'g') as repo_name from star_repository_urls where repository_key ~ '^https?://[^/]+/[^/]+/[^/]+$' + or (source_code_host = 'gitlab' + and repository_key ~ '^https?://[^/]+/[^/]+(/[^/]+)+$' + and repository_key !~ '/(-|blob|tree|raw|issues|merge_requests|commits?|releases|wikis?)(/|$)') ), star_recipients as ( select id from ( select id, row_number() over (partition by repository_key order by diff --git a/tests/self-host-apps/popularity-database.test.mjs b/tests/self-host-apps/popularity-database.test.mjs index ec722a2b..c6a377c9 100644 --- a/tests/self-host-apps/popularity-database.test.mjs +++ b/tests/self-host-apps/popularity-database.test.mjs @@ -90,6 +90,14 @@ test('repository roots elect one published star row and preserve variants across select gen_random_uuid(),value->>0,value->>1,value->>2,(value->>3)::integer, array(select jsonb_array_elements_text(value->4)),(value->>5)::boolean,200000,'v1','github','unknown' from jsonb_array_elements($1::jsonb)`,[JSON.stringify(seeds)]); + await client.query(`insert into apps_fixture + (id,slug,name,source_code_url,popularity_score,popularity_basis,published,source_stars,source_code_host,license_type) + select gen_random_uuid(),value->>0,value->>0,value->>1,0,'{}',true,5000,'gitlab','unknown' + from jsonb_array_elements($1::jsonb)`,[JSON.stringify([ + ['duniter','https://git.duniter.org/nodes/typescript/duniter'], + ['gitlab-file','https://gitlab.com/group/project/-/blob/main/README.md'], + ['gitlab-old-tree','https://gitlab.com/group/project/tree/main'], + ])]); const isolated = {query(sql,params) { if (/^(begin(?: read only)?|commit|rollback)$/i.test(sql.trim())) return Promise.resolve({rows:[]}); return client.query(sql.replaceAll('public.self_host_apps','pg_temp.apps_fixture').replaceAll('public.github_projects','pg_temp.projects_fixture'),params); @@ -105,6 +113,7 @@ test('repository roots elect one published star row and preserve variants across 'aquacomputer-d5next':24,'tree-link':38,'issues-link':40, dify:100,'dify-ai':0,comfyui:100,'comfyui-intel-xpu':18, syncthing:100,'binhex-syncthing':70,established:100,project:0,'long-name':0,'short-name':100,alpha:100,zeta:0,draft:0, + duniter:80,'gitlab-file':0,'gitlab-old-tree':0, })) assert.equal(state[slug].popularity_score,score,slug); assert.equal(state['comfyui-intel-xpu'].release_version,'v1'); // The elected row now has source_stars; re-running is a complete no-op. From 54d441d9536a7a13489ffa9c5b826efa0d8f6a69 Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Tue, 3 Nov 2026 10:53:19 +0530 Subject: [PATCH 3/4] Check the last shortcut apps against official sources Fix Canva, Salesforce, Prezi, 7-Zip, Codex, and Superhuman rows from vendor sources. WPS took its help pages down, so its source is now an archived official copy and its check date stays honest. Arc has no full official list left, so it is unchanged. Also fixes a short Clip Studio section intro. Co-Authored-By: Claude Opus 5.5 --- .../wps-office/content/intro.mdx | 2 +- .../keyboard-shortcuts/altplusctrl-backlog.md | 2 +- lib/shortcuts/apps/7-zip.ts | 12 +--- lib/shortcuts/apps/adobe-premiere-rush.ts | 2 +- lib/shortcuts/apps/canva.ts | 56 +++++++++---------- lib/shortcuts/apps/clip-studio-paint.ts | 2 +- lib/shortcuts/apps/codex.ts | 4 +- lib/shortcuts/apps/numbers.ts | 2 +- lib/shortcuts/apps/prezi.ts | 35 ++++++------ lib/shortcuts/apps/salesforce.ts | 30 ++++++---- lib/shortcuts/apps/superhuman.ts | 28 +++++----- lib/shortcuts/apps/wps-office.ts | 20 +++---- lib/shortcuts/catalog.generated.ts | 11 ++-- 13 files changed, 105 insertions(+), 101 deletions(-) diff --git a/app/(site)/(directories)/keyboard-shortcuts/wps-office/content/intro.mdx b/app/(site)/(directories)/keyboard-shortcuts/wps-office/content/intro.mdx index 66101a9f..cf8f7259 100644 --- a/app/(site)/(directories)/keyboard-shortcuts/wps-office/content/intro.mdx +++ b/app/(site)/(directories)/keyboard-shortcuts/wps-office/content/intro.mdx @@ -1 +1 @@ -WPS Office is a free office suite with a word processor, spreadsheets, and slides that open Microsoft Office files. The keys are split by app: Writer, Spreadsheets, and Presentation on Windows, and a separate set for the Mac app. +WPS Office is a free office suite with a word processor, spreadsheets, and slides that open Microsoft Office files. The keys are split by app: Writer, Spreadsheets, and Presentation, and a separate set for the Mac app. diff --git a/content_docs/keyboard-shortcuts/altplusctrl-backlog.md b/content_docs/keyboard-shortcuts/altplusctrl-backlog.md index 72562d45..c180b90f 100644 --- a/content_docs/keyboard-shortcuts/altplusctrl-backlog.md +++ b/content_docs/keyboard-shortcuts/altplusctrl-backlog.md @@ -257,7 +257,7 @@ Research must start with the current first-party shortcut reference for the name | Wix | `wix` | Verified | https://support.wix.com/en/article/wix-editor-keyboard-shortcuts-in-the-editor | | Wondershare Filmora | `wondershare-filmora` | Verified | https://filmora.wondershare.com/guide/keyboard-shortcuts-on-windows.html | | WordPress Admin | `wordpress-admin` | Verified | https://wordpress.org/documentation/article/keyboard-shortcuts-block-editor/ | -| WPS Office | `wps-office` | Verified | https://www.wps.com/academy/popular/keyboard-shortcuts/ | +| WPS Office | `wps-office` | Verified | https://web.archive.org/web/20260124110908/http://help.wps.com/articles/the-shortcuts-of-wps-office-for-mac/ | | Wrike | `wrike` | Verified | https://help.wrike.com/hc/en-us/articles/1500005225202-Gantt-Chart-Keyboard-Shortcuts | | X (Twitter) | `x-twitter` | Verified | https://help.x.com/en/using-x/how-to-post | | Xcode | `xcode` | Blocked | https://developer.apple.com/documentation/xcode | diff --git a/lib/shortcuts/apps/7-zip.ts b/lib/shortcuts/apps/7-zip.ts index c71bf3ed..5f1418ea 100644 --- a/lib/shortcuts/apps/7-zip.ts +++ b/lib/shortcuts/apps/7-zip.ts @@ -12,7 +12,7 @@ export const sevenZip: AppShortcuts = { "7-Zip 26.02 File Manager defaults for Windows. These keys apply to the graphical File Manager, not the 7z command-line program or Explorer context menus.", sourceUrl: "https://github.com/ip7z/7zip/blob/26.02/CPP/7zip/UI/FileManager/PanelKey.cpp", sourceLabel: "7-Zip 26.02 File Manager key handler", - lastVerified: "2026-08-31", + lastVerified: "2026-11-03", groups: [ { id: "files-and-folders", @@ -42,19 +42,13 @@ export const sevenZip: AppShortcuts = { { id: "clipboard-and-selection", title: "Clipboard and selection", - intro: "Use Windows clipboard commands or 7-Zip's numeric keypad bindings to build and change a selection.", + intro: "Copy item names as text, or use 7-Zip's numeric keypad bindings to build and change a selection.", shortcuts: [ - { id: "cut", action: "Cut the selected items", keys: win(["Ctrl", "X"]) }, { id: "copy", - action: "Copy the selected items to the clipboard", + action: "Copy the selected item names to the clipboard as text", keys: [{ platform: "windows", keys: ["Ctrl", "C"], alternatives: [["Ctrl", "Insert"]] }], }, - { - id: "paste", - action: "Paste items from the clipboard", - keys: [{ platform: "windows", keys: ["Ctrl", "V"], alternatives: [["Shift", "Insert"]] }], - }, { id: "select-all", action: "Select all items", keys: [{ platform: "windows", keys: ["Ctrl", "A"], alternatives: [["Shift", "Numpad +"]] }] }, { id: "select-mask", action: "Select items by a name mask", keys: win(["Numpad +"]) }, { id: "deselect-mask", action: "Deselect items by a name mask", keys: win(["Numpad -"]) }, diff --git a/lib/shortcuts/apps/adobe-premiere-rush.ts b/lib/shortcuts/apps/adobe-premiere-rush.ts index dde18ce7..94406f0a 100644 --- a/lib/shortcuts/apps/adobe-premiere-rush.ts +++ b/lib/shortcuts/apps/adobe-premiere-rush.ts @@ -22,7 +22,7 @@ export const adobePremiereRush: AppShortcuts = { "Adobe discontinued Premiere Rush on September 30, 2026. These keys are for existing desktop installations and may not work.", sourceUrl: "https://helpx.adobe.com/premiere-rush/premiere-rush-discontinuation.html", sourceLabel: "Adobe Premiere Rush discontinuation", - lastVerified: "2026-08-31", + lastVerified: "2026-11-03", groups: [ { id: "projects-and-editing", diff --git a/lib/shortcuts/apps/canva.ts b/lib/shortcuts/apps/canva.ts index 5eaadbde..3468c1d2 100644 --- a/lib/shortcuts/apps/canva.ts +++ b/lib/shortcuts/apps/canva.ts @@ -47,7 +47,7 @@ export const canva: AppShortcuts = { "Canva editor on Windows and macOS. Some keys work only on one platform, in Canva Docs, or in the Mac desktop app.", sourceUrl: "https://www.canva.com/help/canva-keyboard-shortcuts/", sourceLabel: "Canva keyboard shortcuts", - lastVerified: "2026-08-31", + lastVerified: "2026-11-03", groups: [ { id: "basic-editing", @@ -55,33 +55,33 @@ export const canva: AppShortcuts = { intro: "These commands apply across Canva's desktop editor. Quick actions can be opened with a single slash or the platform modifier chord.", shortcuts: [ both("undo", "Undo", ["Ctrl", "Z"], ["Cmd", "Z"]), - both("redo", "Redo", ["Ctrl", "Y"], ["Cmd", "Y"], [["Ctrl", "Shift", "Z"]]), + both("redo", "Redo", ["Ctrl", "Y"], ["Cmd", "Y"], [["Ctrl", "Shift", "Z"]], [["Shift", "Cmd", "Z"]]), both("save", "Save", ["Ctrl", "S"], ["Cmd", "S"]), both("select-all", "Select all", ["Ctrl", "A"], ["Cmd", "A"]), both("sidebar", "Show or hide the sidebar", ["Ctrl", "/"], ["Cmd", "/"]), shared("add-text", "Add text", ["T"]), shared("add-rectangle", "Add a rectangle", ["R"]), shared("add-line", "Add a line", ["L"]), - windows("add-curved-line", "Add a curved line", ["Shift", "\\"]), + both("add-curved-line", "Add an elbowed line", ["Shift", "\\"], ["Cmd", "\\"]), shared("add-circle", "Add a circle", ["C"]), both("add-link", "Add a link", ["Ctrl", "K"], ["Cmd", "K"]), - windows("add-sticky", "Add a sticky note", ["S"]), + shared("add-sticky", "Add a sticky note", ["S"]), both("add-emoji", "Add an emoji in Canva Docs or comments", ["Win", ";"], ["Shift", ";"]), both("add-page", "Add an empty page", ["Ctrl", "Enter"], ["Cmd", "Return"]), both("delete-page", "Delete an empty page", ["Ctrl", "Backspace"], ["Cmd", "Delete"]), both("toolbar", "Move focus to the toolbar", ["Ctrl", "F1"], ["Cmd", "F1"]), both("canvas", "Skip focus to the canvas", ["Ctrl", "F2"], ["Cmd", "F2"]), both("quick-actions", "Open Quick actions", ["/"], ["/"], [["Ctrl", "E"]], [["Cmd", "E"]]), - windows("layers", "Show Layers", ["Alt", "1"]), - windows("lock-guides", "Lock guides", ["Alt", "Ctrl", ";"]), - windows("selected-comment", "Go to the comment on the selected element", ["Alt", "Ctrl", "J"]), - windows("go-to-page", "Go to a page", ["Alt", "Ctrl", "G"]), - windows("page-break", "Add a page break", ["Ctrl", "Enter"]), - windows("duration", "Toggle duration", ["Alt", "D"]), - windows("print", "Print", ["Ctrl", "P"]), - windows("next-surface", "Move to the next editor surface", ["Ctrl", "F6"]), - windows("previous-surface", "Move to the previous editor surface", ["Shift", "Ctrl", "F6"]), - windows("side-panel-focus", "Move focus to the side panel", ["Ctrl", "F3"]), + both("layers", "Show Layers", ["Alt", "1"], ["Option", "1"]), + both("lock-guides", "Lock guides", ["Alt", "Ctrl", ";"], ["Option", "Cmd", ";"]), + both("selected-comment", "Go to the comment on the selected element", ["Alt", "Ctrl", "J"], ["Option", "Cmd", "J"]), + both("go-to-page", "Go to a page", ["Alt", "Ctrl", "G"], ["Option", "Cmd", "G"]), + both("page-break", "Add a page break", ["Ctrl", "Enter"], ["Cmd", "Return"]), + both("duration", "Toggle duration", ["Alt", "D"], ["Option", "D"]), + both("print", "Print", ["Ctrl", "P"], ["Cmd", "P"]), + both("next-surface", "Move to the next editor surface", ["Ctrl", "F6"], ["Cmd", "F6"]), + both("previous-surface", "Move to the previous editor surface", ["Shift", "Ctrl", "F6"], ["Shift", "Cmd", "F6"]), + both("side-panel-focus", "Move focus to the side panel", ["Ctrl", "F3"], ["Cmd", "F3"]), ], }, { @@ -90,7 +90,7 @@ export const canva: AppShortcuts = { intro: "Selection movement and resizing use arrows for small steps and Shift-arrows for large steps. Rotation uses platform-specific punctuation modifiers.", shortcuts: [ both("delete-element", "Delete selected elements", ["Backspace"], ["Delete"], [["Delete"]]), - windows("duplicate-element", "Duplicate selected elements", ["Ctrl", "D"]), + both("duplicate-element", "Duplicate selected elements", ["Ctrl", "D"], ["Cmd", "D"]), both("group", "Group selected elements", ["Ctrl", "G"], ["Cmd", "G"]), both("ungroup", "Ungroup selected elements", ["Ctrl", "Shift", "G"], ["Cmd", "Shift", "G"]), both("lock", "Lock the selected element", ["Alt", "Shift", "L"], ["Option", "Shift", "L"]), @@ -99,7 +99,7 @@ export const canva: AppShortcuts = { both("backward", "Move selected elements backward", ["Ctrl", "["], ["Cmd", "["]), both("front", "Move selected elements to the front", ["Alt", "Ctrl", "]"], ["Option", "Cmd", "]"]), both("back", "Move selected elements to the back", ["Alt", "Ctrl", "["], ["Option", "Cmd", "["]), - both("tidy", "Tidy up selected elements", ["Alt", "Shift", "T"], ["Shift", "Cmd", "T"]), + both("tidy", "Tidy up selected elements", ["Alt", "Shift", "T"], ["Option", "Shift", "T"]), shared("select-next", "Select the next element", ["Tab"]), shared("select-previous", "Select the previous element", ["Shift", "Tab"]), shared("move-left", "Move selected elements left by a small step", ["Left"]), @@ -110,10 +110,10 @@ export const canva: AppShortcuts = { shared("move-right-large", "Move selected elements right by a large step", ["Shift", "Right"]), shared("move-up-large", "Move selected elements up by a large step", ["Shift", "Up"]), shared("move-down-large", "Move selected elements down by a large step", ["Shift", "Down"]), - both("rotate-left", "Rotate selected elements left by a small step", ["Alt", ","], [","]), - both("rotate-right", "Rotate selected elements right by a small step", ["Alt", "."], ["."]), - both("rotate-left-large", "Rotate selected elements left by a large step", ["Alt", "Shift", ","], ["Shift", ","]), - both("rotate-right-large", "Rotate selected elements right by a large step", ["Alt", "Shift", "."], ["Shift", "."]), + both("rotate-left", "Rotate selected elements left by a small step", ["Alt", ","], ["Option", ","]), + both("rotate-right", "Rotate selected elements right by a small step", ["Alt", "."], ["Option", "."]), + both("rotate-left-large", "Rotate selected elements left by a large step", ["Alt", "Shift", ","], ["Option", "Shift", ","]), + both("rotate-right-large", "Rotate selected elements right by a large step", ["Alt", "Shift", "."], ["Option", "Shift", "."]), both("resize-left", "Resize selected elements left by a small step", ["Ctrl", "Left"], ["Cmd", "Left"]), both("resize-right", "Resize selected elements right by a small step", ["Ctrl", "Right"], ["Cmd", "Right"]), both("resize-up", "Resize selected elements up by a small step", ["Ctrl", "Up"], ["Cmd", "Up"]), @@ -134,9 +134,9 @@ export const canva: AppShortcuts = { both("bold", "Bold text", ["Ctrl", "B"], ["Cmd", "B"]), both("italic", "Italicize text", ["Ctrl", "I"], ["Cmd", "I"]), both("underline", "Underline text", ["Ctrl", "U"], ["Cmd", "U"]), - windows("strikethrough", "Strikethrough text", ["Shift", "Ctrl", "S"]), - windows("subscript", "Apply subscript", ["Ctrl", ","]), - windows("superscript", "Apply superscript", ["Ctrl", "."]), + both("strikethrough", "Strikethrough text", ["Shift", "Ctrl", "S"], ["Shift", "Cmd", "S"]), + both("subscript", "Apply subscript", ["Ctrl", ","], ["Cmd", ","]), + both("superscript", "Apply superscript", ["Ctrl", "."], ["Cmd", "."]), both("uppercase", "Change text to uppercase", ["Shift", "Ctrl", "K"], ["Shift", "Cmd", "K"]), both("align-left", "Align text left", ["Shift", "Ctrl", "L"], ["Shift", "Cmd", "L"]), both("align-center", "Align text center", ["Shift", "Ctrl", "C"], ["Shift", "Cmd", "C"]), @@ -155,9 +155,9 @@ export const canva: AppShortcuts = { windows("checklist", "Create a checklist", ["Ctrl", "Shift", "9"]), both("copy-style", "Copy text style", ["Alt", "Ctrl", "C"], ["Option", "Cmd", "C"]), both("paste-style", "Paste text style", ["Alt", "Ctrl", "V"], ["Option", "Cmd", "V"]), - windows("body-style", "Apply Body text style in Canva Docs", ["Ctrl", "Shift", "0"]), - windows("title-style", "Apply Title style in Canva Docs", ["Ctrl", "Shift", "1"]), - windows("subtitle-style", "Apply Subtitle style in Canva Docs", ["Ctrl", "Shift", "2"]), + both("body-style", "Apply Body text style in Canva Docs", ["Alt", "Ctrl", "0"], ["Option", "Cmd", "0"]), + both("title-style", "Apply Title style in Canva Docs", ["Alt", "Ctrl", "1"], ["Option", "Cmd", "1"]), + both("subtitle-style", "Apply Subtitle style in Canva Docs", ["Alt", "Ctrl", "2"], ["Option", "Cmd", "2"]), ], }, { @@ -167,7 +167,6 @@ export const canva: AppShortcuts = { shortcuts: [ shared("play-pause", "Play or pause video", ["Space"]), shared("mute", "Mute or unmute video", ["M"]), - macos("loop", "Loop video", ["Option", "Cmd", "L"]), both("zoom-in", "Zoom in", ["Ctrl", "+"], ["Cmd", "+"]), both("zoom-out", "Zoom out", ["Ctrl", "-"], ["Cmd", "-"]), both("actual-size", "Zoom to actual size", ["Ctrl", "0"], ["Cmd", "0"]), @@ -187,7 +186,7 @@ export const canva: AppShortcuts = { shortcuts: [ both("add-comment", "Add a comment", ["Alt", "Ctrl", "N"], ["Option", "Cmd", "N"]), shared("comment-emoji", "Add an emoji to a comment", ["Shift", ";"]), - shared("comment-new-line", "Start a new line in a comment", ["Enter"], [["Shift", "Enter"]]), + both("comment-new-line", "Start a new line in a comment", ["Enter"], ["Return"], [["Shift", "Enter"]], [["Shift", "Return"]]), macos("comment-submit", "Submit a comment", ["Cmd", "Return"]), ], }, @@ -198,7 +197,6 @@ export const canva: AppShortcuts = { shortcuts: [ macos("app-quit", "Quit the Canva app", ["Cmd", "Q"]), macos("app-reload", "Reload the Canva app", ["Cmd", "R"]), - macos("app-full-screen", "Enter full-screen mode", ["Ctrl", "Cmd", "F"]), macos("tab-close", "Close the current tab", ["Cmd", "W"]), macos("tab-next", "Select the next tab", ["Option", "Cmd", "Right"]), macos("tab-previous", "Select the previous tab", ["Option", "Cmd", "Left"]), diff --git a/lib/shortcuts/apps/clip-studio-paint.ts b/lib/shortcuts/apps/clip-studio-paint.ts index cc39dd1d..7ea428a6 100644 --- a/lib/shortcuts/apps/clip-studio-paint.ts +++ b/lib/shortcuts/apps/clip-studio-paint.ts @@ -27,7 +27,7 @@ export const clipStudioPaint: AppShortcuts = { { id: "files-and-settings", title: "Files and settings", - intro: "Save As has three factory alternatives.", + intro: "Create, open, save, and print files, and reach settings. Save As has three factory alternatives.", shortcuts: [ mod("new", "Create a new file", ["Ctrl", "N"]), mod("open", "Open a file", ["Ctrl", "O"]), diff --git a/lib/shortcuts/apps/codex.ts b/lib/shortcuts/apps/codex.ts index 9239301c..ab00d628 100644 --- a/lib/shortcuts/apps/codex.ts +++ b/lib/shortcuts/apps/codex.ts @@ -12,7 +12,7 @@ export const codex: AppShortcuts = { "Codex CLI TUI defaults for Windows, macOS, and Linux. You can rebind supported actions with /keymap or tui.keymap in config.toml; some Mac terminals report Alt as Option.", sourceUrl: "https://github.com/openai/codex/blob/main/codex-rs/tui/src/keymap.rs", sourceLabel: "official Codex CLI TUI keymap", - lastVerified: "2026-08-26", + lastVerified: "2026-11-03", groups: [ { id: "steering", @@ -24,7 +24,7 @@ export const codex: AppShortcuts = { { id: "interrupt", action: "Interrupt the active turn", keys: combo(platforms, ["Esc"]) }, { id: "edit-queued", action: "Edit the most recently queued message", keys: combo(platforms, ["Alt", "Up"]), note: "Shift+Left is also a built-in binding. Option may be reported for Alt on macOS." }, { id: "side-conversation", action: "Switch between a side conversation and its parent", keys: combo(platforms, ["Ctrl", "/"]) }, - { id: "open-agents", action: "Open the agent-session overview", keys: combo(platforms, ["Alt", "A"]) }, + { id: "open-agents", action: "Open the agent-session overview", keys: [], note: "No default key is assigned. Set tui.keymap.global.open_agents to add one." }, ], }, { diff --git a/lib/shortcuts/apps/numbers.ts b/lib/shortcuts/apps/numbers.ts index a08395c0..0da60156 100644 --- a/lib/shortcuts/apps/numbers.ts +++ b/lib/shortcuts/apps/numbers.ts @@ -16,7 +16,7 @@ export const numbers: AppShortcuts = { "Numbers for Mac. System Settings can change some shortcuts, and portable Macs may need Fn for function keys.", sourceUrl: "https://support.apple.com/guide/numbers/keyboard-shortcuts-tana45192591/mac", sourceLabel: "Apple Numbers keyboard shortcuts for Mac", - lastVerified: "2026-09-01", + lastVerified: "2026-11-03", groups: [ { id: "files-and-window", diff --git a/lib/shortcuts/apps/prezi.ts b/lib/shortcuts/apps/prezi.ts index eef37d70..19fd4760 100644 --- a/lib/shortcuts/apps/prezi.ts +++ b/lib/shortcuts/apps/prezi.ts @@ -1,11 +1,11 @@ import { perPlatform } from "../build.ts"; import type { AppShortcuts, Shortcut } from "../types.ts"; -const platforms = ["windows", "macos", "linux"] as const; -const dual = (id: string, action: string, windowsLinux: string[], mac: string[], note?: string): Shortcut => ({ +const platforms = ["windows", "macos"] as const; +const dual = (id: string, action: string, windows: string[], mac: string[], note?: string): Shortcut => ({ id, action, - keys: perPlatform(platforms, { windows: windowsLinux, linux: windowsLinux, macos: mac }), + keys: perPlatform(platforms, { windows, macos: mac }), ...(note ? { note } : {}), }); @@ -15,7 +15,7 @@ export const prezi: AppShortcuts = { category: "Presentation", platforms: [...platforms], versionContext: - "Mac uses Cmd and Option where Windows uses Ctrl and Alt. Some keys act differently while you present.", + "Prezi Present editor on Windows and Mac. Some modifier keys differ between the two, so check the row for your platform.", sourceUrl: "https://support.prezi.com/hc/en-us/articles/360003499013-How-to-use-keyboard-shortcuts-in-Prezi-Present", sourceLabel: "Prezi Present keyboard shortcuts", lastVerified: "2026-09-01", @@ -23,7 +23,7 @@ export const prezi: AppShortcuts = { { id: "editing", title: "Editing", - intro: "Select, copy, paste, group, duplicate, and undo changes while editing a Prezi presentation.", + intro: "Select, copy, paste, group, duplicate, find, and undo changes while editing a Prezi presentation.", shortcuts: [ dual("select-all", "Select all", ["Ctrl", "A"], ["Cmd", "A"]), dual("copy", "Copy", ["Ctrl", "C"], ["Cmd", "C"]), @@ -31,33 +31,36 @@ export const prezi: AppShortcuts = { dual("paste", "Paste", ["Ctrl", "V"], ["Cmd", "V"]), dual("undo", "Undo", ["Ctrl", "Z"], ["Cmd", "Z"]), dual("group", "Group selected objects", ["Ctrl", "G"], ["Cmd", "G"]), + dual("ungroup", "Ungroup selected objects", ["Ctrl", "Shift", "G"], ["Cmd", "Shift", "G"]), dual("duplicate", "Duplicate the selection", ["Ctrl", "D"], ["Cmd", "D"]), - dual("delete", "Delete the selection", ["Delete"], ["Delete"]), - dual("escape", "Cancel the current selection or action", ["Escape"], ["Escape"]), + dual("find-replace", "Find and replace text", ["Ctrl", "F"], ["Cmd", "F"]), + dual("delete", "Delete the selection", ["Backspace"], ["Backspace"]), ], }, { id: "canvas-and-tools", title: "Canvas and tools", - intro: "Move around the Prezi canvas and switch between the hand tool and selection work without repeatedly opening the toolbar.", + intro: "Move around the Prezi canvas and switch to the hand tool without repeatedly opening the toolbar.", shortcuts: [ dual("hand-tool", "Toggle the hand tool", ["H"], ["H"]), dual("pan", "Pan the canvas", ["Space"], ["Space"], "Hold Space while dragging the canvas."), dual("zoom", "Zoom the canvas", ["Mouse wheel"], ["Mouse wheel"]), - dual("left-right", "Move through the presentation path", ["Left", "Right"], ["Left", "Right"]), - dual("center-selection", "Center the selected object", ["Shift", "1"], ["Shift", "1"]), + { + id: "left-right", + action: "Move through the presentation path", + keys: perPlatform(platforms, { windows: ["Left"], macos: ["Left"] }).map((entry) => ({ ...entry, alternatives: [["Right"]] })), + }, ], }, { id: "transform", title: "Object transformation", - intro: "Use modifier keys while dragging handles to change snapping, resize from the center, or keep an object proportional.", + intro: "Use modifier keys while dragging to change snapping, resize from the center, rotate, or select several objects.", shortcuts: [ - dual("disable-snapping", "Disable snapping while transforming", ["Alt", "Click-drag"], ["Option", "Click-drag"]), - dual("resize-center", "Resize from an object's center", ["Ctrl", "Drag corner"], ["Cmd", "Drag corner"]), - dual("constrain", "Constrain a transformation", ["Shift", "Drag"], ["Shift", "Drag"]), - dual("move-object", "Move an object", ["Drag"], ["Drag"]), - dual("rotate-object", "Rotate an object", ["Drag rotation handle"], ["Drag rotation handle"]), + dual("disable-snapping", "Disable snapping while dragging transformation handles", ["Alt", "Drag"], ["Cmd", "Drag"]), + dual("resize-center", "Resize from an object's center", ["Ctrl", "Drag corner"], ["Option", "Drag corner"]), + dual("select-rectangle", "Draw a rectangle to select multiple objects", ["Shift", "Drag"], ["Shift", "Drag"]), + dual("rotate-object", "Rotate a selected object", ["Ctrl", "Alt", "Click"], ["Cmd", "Click"], "On Mac, click the object's corner."), ], }, ], diff --git a/lib/shortcuts/apps/salesforce.ts b/lib/shortcuts/apps/salesforce.ts index 96668b74..9005985f 100644 --- a/lib/shortcuts/apps/salesforce.ts +++ b/lib/shortcuts/apps/salesforce.ts @@ -17,6 +17,15 @@ const shared = (id: string, action: string, keys: string[], note?: string): Shor ...(note ? { note } : {}), }); +const either = (id: string, action: string, keys: string[], alternative: string[], note?: string): Shortcut => ({ + id, + action, + keys: perPlatform(platforms, { windows: keys, macos: keys }).map((entry) => ({ ...entry, alternatives: [alternative] })), + ...(note ? { note } : {}), +}); + +const consoleOnly = "Lightning console apps only."; + export const salesforce: AppShortcuts = { slug: "salesforce", name: "Salesforce", @@ -26,7 +35,7 @@ export const salesforce: AppShortcuts = { "Lightning Experience shortcuts are enabled by default, ignore letter case, and can be turned off in the Help menu. Console shortcuts apply in Lightning console apps.", sourceUrl: "https://help.salesforce.com/s/articleView?id=accessibility_keyboard_shortcuts_lex.htm&language=en_US&type=5", sourceLabel: "official Salesforce Lightning keyboard shortcuts", - lastVerified: "2026-09-01", + lastVerified: "2026-11-03", groups: [ { id: "lightning-global", @@ -38,7 +47,7 @@ export const salesforce: AppShortcuts = { dual("quick-text", "Insert quick text", ["Ctrl", "."], ["Cmd", "."]), shared("macros", "Open or close Macros", ["M"]), shared("notes", "Open or close Notes", ["N"]), - dual("post-feed", "Post to the feed", ["Ctrl", "F6"], ["Cmd", "F6"], "This standard Lightning shortcut submits a post or comment."), + dual("post-feed", "Post to the feed", ["Ctrl", "Enter"], ["Cmd", "Enter"], "Submits a post or comment. It doesn't work on other publisher actions, such as emails and polls."), shared("search", "Place the cursor in the search box", ["/"]), dual("shortcut-menu", "Show the shortcut menu", ["Ctrl", "/"], ["Cmd", "/"]), dual("save", "Save a record", ["Ctrl", "S"], ["Cmd", "S"], "Focus a field before saving."), @@ -52,13 +61,14 @@ export const salesforce: AppShortcuts = { shared("focus-notifications", "Focus notifications", ["C"]), shared("focus-notifications-reverse", "Focus the previous notification", ["Shift", "C"]), shared("go-composer", "Go to the composer window", ["G", "then", "D"]), - shared("go-item-menu", "Go to the item menu", ["G", "then", "I"]), - shared("go-navigation-item", "Go to the selected navigation item", ["G", "then", "L"]), + shared("go-item-menu", "Go to the item menu", ["G", "then", "I"], consoleOnly), + shared("go-navigation-item", "Go to the selected navigation item", ["G", "then", "L"], consoleOnly), shared("go-publisher", "Go to the publisher", ["G", "then", "P"]), - shared("go-subtab", "Go to the active subtab", ["G", "then", "S"]), + shared("go-subtab", "Go to the active subtab", ["G", "then", "S"], consoleOnly), shared("go-utility-bar", "Go to the utility bar", ["G", "then", "U"]), - shared("go-workspace-tab", "Go to the active workspace tab", ["G", "then", "W"]), - dual("go-modifier", "Start a go-to command inside a dialog, form, or input", ["Ctrl", "Alt", "G"], ["Cmd", "Option", "G"]), + shared("go-workspace-tab", "Go to the active workspace tab", ["G", "then", "W"], consoleOnly), + dual("go-modifier", "Start a go-to command inside a dialog, form, or input", ["Ctrl", "Alt", "G"], ["Cmd", "Option", "G"], consoleOnly), + dual("go-other-region", "Move among regions of certain pages", ["Ctrl", "F6"], ["Cmd", "F6"]), ], }, { @@ -67,8 +77,8 @@ export const salesforce: AppShortcuts = { intro: "Open the composer overflow menu and move between open composer windows from anywhere in a Lightning app.", shortcuts: [ shared("composer-overflow", "Open the composer overflow menu", ["G", "then", "D"]), - shared("composer-list", "Move through open composer windows", ["Up Arrow"], "Down Arrow moves in the opposite direction."), - shared("composer-switch", "Move between open composer windows", ["Left Arrow"], "Right Arrow moves in the opposite direction."), + either("composer-list", "Move through open composer windows in the overflow menu", ["Up Arrow"], ["Down Arrow"]), + either("composer-switch", "Move between open composer windows", ["Left Arrow"], ["Right Arrow"]), ], }, { @@ -88,7 +98,7 @@ export const salesforce: AppShortcuts = { title: "Lightning console tabs", intro: "Refresh or close workspace tabs and move between focused tabs with the Lightning console commands.", shortcuts: [ - shared("switch-tabs", "Switch between workspace tabs and subtabs", ["Left Arrow"], "Right Arrow moves in the opposite direction."), + either("switch-tabs", "Switch between workspace tabs and subtabs", ["Left Arrow"], ["Right Arrow"], "Focus must be on the tab itself."), shared("close-workspace-tab", "Close the current workspace tab", ["Shift", "X"]), shared("close-all-tabs", "Close all unpinned tabs", ["Shift", "W"]), shared("refresh-tab", "Refresh the current workspace tab", ["Shift", "R"]), diff --git a/lib/shortcuts/apps/superhuman.ts b/lib/shortcuts/apps/superhuman.ts index b29155b4..008fa18f 100644 --- a/lib/shortcuts/apps/superhuman.ts +++ b/lib/shortcuts/apps/superhuman.ts @@ -9,10 +9,10 @@ export const superhuman: AppShortcuts = { category: "Email", platforms: [...platforms], versionContext: - "Superhuman Mail desktop shortcuts for Windows and Mac. Open Superhuman Command to see the current shortcut list.", + "Superhuman Mail desktop shortcuts for Windows and Mac, from Superhuman's official v8 keyboard shortcut PDFs (Mac Edition and Windows & Linux Edition). The full list is in the app: press Cmd/Ctrl+K, then choose Shortcuts.", sourceUrl: "https://help.superhuman.com/hc/en-us/articles/46005701270541-Keyboard-Shortcuts-in-Superhuman-Mail", sourceLabel: "official Superhuman Mail keyboard shortcuts", - lastVerified: "2026-09-01", + lastVerified: "2026-11-03", groups: [ { id: "inbox", @@ -21,7 +21,7 @@ export const superhuman: AppShortcuts = { shortcuts: [ { id: "command", action: "Open Superhuman Command", keys: combo(platforms, ["Ctrl", "K"], ["Cmd", "K"]) }, { id: "search", action: "Search", keys: perPlatform(platforms, { windows: ["/"], macos: ["/"] }) }, - { id: "undo", action: "Undo", keys: combo(platforms, ["Ctrl", "Z"], ["Cmd", "Z"]) }, + { id: "undo", action: "Undo", keys: perPlatform(platforms, { windows: ["Z"], macos: ["Z"] }) }, { id: "select-conversation", action: "Select a conversation", keys: perPlatform(platforms, { windows: ["X"], macos: ["X"] }) }, { id: "select-all", action: "Select all conversations from here", keys: combo(platforms, ["Ctrl", "A"], ["Cmd", "A"]) }, { id: "select-all-conversations", action: "Select all conversations", keys: combo(platforms, ["Ctrl", "Shift", "A"], ["Cmd", "Shift", "A"]) }, @@ -52,10 +52,10 @@ export const superhuman: AppShortcuts = { { id: "italic", action: "Italic", keys: perPlatform(platforms, { windows: ["Ctrl", "I"], macos: ["Cmd", "I"] }) }, { id: "underline", action: "Underline", keys: perPlatform(platforms, { windows: ["Ctrl", "U"], macos: ["Cmd", "U"] }) }, { id: "hyperlink", action: "Add a hyperlink", keys: combo(platforms, ["Ctrl", "K"], ["Cmd", "K"]) }, - { id: "discard-draft", action: "Discard a draft", keys: perPlatform(platforms, { windows: ["Shift", "\\"], macos: ["Shift", "\\"] }) }, - { id: "send", action: "Send", keys: perPlatform(platforms, { windows: ["Enter"], macos: ["Return"] }) }, - { id: "send-done", action: "Send and mark done", keys: perPlatform(platforms, { windows: ["Shift", "Enter"], macos: ["Shift", "Return"] }) }, - { id: "instant-send", action: "Send immediately", keys: combo(platforms, ["Ctrl", "Enter"], ["Cmd", "Return"]) }, + { id: "discard-draft", action: "Discard a draft", keys: combo(platforms, ["Ctrl", "Shift", ","], ["Cmd", "Shift", ","]) }, + { id: "send", action: "Send", keys: combo(platforms, ["Ctrl", "Enter"], ["Cmd", "Enter"]) }, + { id: "send-done", action: "Send and mark done", keys: combo(platforms, ["Ctrl", "Shift", "Enter"], ["Cmd", "Shift", "Enter"]) }, + { id: "instant-send", action: "Send immediately", keys: perPlatform(platforms, { macos: ["Cmd", "Shift", "Z"] }) }, ], }, { @@ -63,19 +63,19 @@ export const superhuman: AppShortcuts = { title: "Navigation and tabs", intro: "Navigation shortcuts move between folders, messages, splits, tabs, accounts, and the conversation page while preserving focus.", shortcuts: [ - { id: "open-inbox", action: "Open Inbox", keys: perPlatform(platforms, { windows: ["G", "I"], macos: ["G", "I"] }) }, - { id: "open-sent", action: "Open Sent", keys: perPlatform(platforms, { windows: ["G", "S"], macos: ["G", "S"] }) }, - { id: "open-drafts", action: "Open Drafts", keys: perPlatform(platforms, { windows: ["G", "D"], macos: ["G", "D"] }) }, - { id: "open-calendar", action: "Open Calendar", keys: perPlatform(platforms, { windows: ["G", "0"], macos: ["G", "0"] }) }, + { id: "open-inbox", action: "Open Inbox", keys: perPlatform(platforms, { windows: ["G", "then", "I"], macos: ["G", "then", "I"] }) }, + { id: "open-sent", action: "Open Sent", keys: perPlatform(platforms, { windows: ["G", "then", "T"], macos: ["G", "then", "T"] }) }, + { id: "open-drafts", action: "Open Drafts", keys: perPlatform(platforms, { windows: ["G", "then", "D"], macos: ["G", "then", "D"] }) }, + { id: "open-calendar", action: "Open the Calendar day view", keys: perPlatform(platforms, { windows: ["0"], macos: ["0"] }) }, { id: "next-conversation", action: "Open the next conversation", keys: perPlatform(platforms, { windows: ["J"], macos: ["J"] }) }, { id: "previous-conversation", action: "Open the previous conversation", keys: perPlatform(platforms, { windows: ["K"], macos: ["K"] }) }, { id: "next-tab", action: "Open the next tab", keys: combo(platforms, ["Ctrl", "Shift", "]"], ["Cmd", "Shift", "]"]) }, { id: "previous-tab", action: "Open the previous tab", keys: combo(platforms, ["Ctrl", "Shift", "["], ["Cmd", "Shift", "["]) }, { id: "switch-tab", action: "Switch to a tab by number", keys: combo(platforms, ["Ctrl", "1"], ["Cmd", "1"]) }, - { id: "new-window", action: "Open a new window", keys: combo(platforms, ["Ctrl", "Shift", "N"], ["Cmd", "Shift", "N"]) }, + { id: "new-window", action: "Open a new window", keys: combo(platforms, ["Ctrl", "N"], ["Cmd", "N"]) }, { id: "close-tab", action: "Close the current tab", keys: combo(platforms, ["Ctrl", "W"], ["Cmd", "W"]) }, - { id: "copy-link", action: "Copy the page link", keys: combo(platforms, ["Ctrl", "L"], ["Cmd", "L"]) }, - { id: "back", action: "Go back", keys: combo(platforms, ["Ctrl", "["], ["Cmd", "["]) }, + { id: "copy-link", action: "Copy the page link", keys: combo(platforms, ["Alt", "/"], ["Ctrl", "/"]) }, + { id: "back", action: "Go back", keys: perPlatform(platforms, { windows: ["Esc"], macos: ["Esc"] }) }, ], }, ], diff --git a/lib/shortcuts/apps/wps-office.ts b/lib/shortcuts/apps/wps-office.ts index cec4098c..a9b9c11a 100644 --- a/lib/shortcuts/apps/wps-office.ts +++ b/lib/shortcuts/apps/wps-office.ts @@ -36,14 +36,14 @@ export const wpsOffice: AppShortcuts = { category: "Office suite", platforms: [...platforms], versionContext: - "Keys differ between Writer, Spreadsheets, and Presentation, and the Mac app has its own set. Linux keys aren't listed.", - sourceUrl: "https://help.wps.com/articles/the-shortcuts-of-wps-office-for-mac/", - sourceLabel: "official WPS Office for Mac shortcuts", - lastVerified: "2026-11-03", + "Keys differ between Writer, Spreadsheets, and Presentation, and the Mac app has its own set. Linux keys aren't listed. WPS has taken down its shortcut help pages, so these keys come from archived copies of them.", + sourceUrl: "https://web.archive.org/web/20260124110908/http://help.wps.com/articles/the-shortcuts-of-wps-office-for-mac/", + sourceLabel: "official WPS Office for Mac shortcuts, archived January 2026", + lastVerified: "2026-09-01", groups: [ { id: "writer-windows", - title: "Writer on Windows", + title: "Writer", intro: "These Writer commands cover document editing, formatting, navigation, and file actions.", shortcuts: [ dual("writer-select-all", "Select all", ["Ctrl", "A"], ['Cmd', 'A']), @@ -75,7 +75,7 @@ export const wpsOffice: AppShortcuts = { }, { id: "spreadsheet-windows", - title: "Spreadsheets on Windows", + title: "Spreadsheets", intro: "These Spreadsheet commands cover cell editing, selection, ranges, formulas, and worksheet tabs.", shortcuts: [ dual("sheet-new-line", "Start a new line inside a cell", ["Alt", "Enter"], ['Option', 'Return']), @@ -96,7 +96,7 @@ export const wpsOffice: AppShortcuts = { windowsOnly("sheet-previous-tab", "Move to the previous worksheet", ["Ctrl", "Page Up"]), dual("sheet-fill-right", "Fill right", ["Ctrl", "R"], ['Cmd', 'R']), dual("sheet-save", "Save", ["Ctrl", "S"], ['Cmd', 'S']), - dual("sheet-insert", "Insert cells, rows, or columns", ["Ctrl", "Shift", "+"], ['Cmd', 'Shift', '+']), + dual("sheet-insert", "Insert cells, rows, or columns", ["Ctrl", "Shift", "="], ['Cmd', 'Shift', '+']), dual("sheet-extend", "Extend the selection by one data region", ["Ctrl", "Shift", "Arrow keys"], ['Cmd', 'Shift', 'Arrow keys']), dual("sheet-undo", "Undo", ["Ctrl", "Z"], ['Cmd', 'Z']), dual("sheet-redo", "Redo", ["Ctrl", "Y"], ['Shift', 'Cmd', 'Z']), @@ -106,7 +106,7 @@ export const wpsOffice: AppShortcuts = { }, { id: "presentation-windows", - title: "Presentation on Windows", + title: "Presentation", intro: "These Presentation commands cover slides, formatting, grouping, and slide shows.", shortcuts: [ windowsOnly("presentation-new", "Create a new presentation", ["Ctrl", "N"]), @@ -116,14 +116,14 @@ export const wpsOffice: AppShortcuts = { dual("presentation-paste", "Paste", ["Ctrl", "V"], ['Cmd', 'V']), dual("presentation-bold", "Bold text", ["Ctrl", "B"], ['Cmd', 'B']), dual("presentation-italic", "Italicize text", ["Ctrl", "I"], ['Cmd', 'I']), - dual("presentation-underline", "Underline text", ["Ctrl", "U"], ['Cmd', 'U']), + macOnly("presentation-underline", "Underline text", ["Cmd", "U"]), windowsOnly("presentation-group", "Group selected objects", ["Ctrl", "G"]), windowsOnly("presentation-ungroup", "Ungroup selected objects", ["Ctrl", "Shift", "G"]), dual("presentation-show", "Start the slide show", ["F5"], ['F5']), dual("presentation-current", "Start the slide show from the current slide", ["Shift", "F5"], ['Shift', 'F5']), windowsOnly("presentation-grid", "Show or hide the grid", ["Shift", "F9"]), windowsOnly("presentation-thesaurus", "Open the thesaurus", ["Shift", "F7"]), - windowsOnly("presentation-export", "Export", ["Ctrl", "E"]), + dual("presentation-center", "Center text", ["Ctrl", "E"], ['Cmd', 'E']), ], }, { diff --git a/lib/shortcuts/catalog.generated.ts b/lib/shortcuts/catalog.generated.ts index d901c6de..eb1f9b17 100644 --- a/lib/shortcuts/catalog.generated.ts +++ b/lib/shortcuts/catalog.generated.ts @@ -28,7 +28,7 @@ export const shortcutAppCatalog = [ "platforms": [ "windows" ], - "shortcutCount": 46 + "shortcutCount": 44 }, { "slug": "ableton-live", @@ -426,7 +426,7 @@ export const shortcutAppCatalog = [ "windows", "macos" ], - "shortcutCount": 123 + "shortcutCount": 121 }, { "slug": "cinema-4d", @@ -1585,10 +1585,9 @@ export const shortcutAppCatalog = [ "category": "Presentation", "platforms": [ "windows", - "macos", - "linux" + "macos" ], - "shortcutCount": 19 + "shortcutCount": 18 }, { "slug": "pro-tools", @@ -1700,7 +1699,7 @@ export const shortcutAppCatalog = [ "windows", "macos" ], - "shortcutCount": 31 + "shortcutCount": 32 }, { "slug": "scrivener", From 5888f11c1ee694f9068400c8d6a54f76aad9f8aa Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Tue, 3 Nov 2026 10:56:37 +0530 Subject: [PATCH 4/4] Show Superhuman's Windows Instant Send key and Prezi's Mac corner target Co-Authored-By: Claude Opus 5.5 --- lib/shortcuts/apps/prezi.ts | 2 +- lib/shortcuts/apps/superhuman.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/shortcuts/apps/prezi.ts b/lib/shortcuts/apps/prezi.ts index 19fd4760..eb69685b 100644 --- a/lib/shortcuts/apps/prezi.ts +++ b/lib/shortcuts/apps/prezi.ts @@ -60,7 +60,7 @@ export const prezi: AppShortcuts = { dual("disable-snapping", "Disable snapping while dragging transformation handles", ["Alt", "Drag"], ["Cmd", "Drag"]), dual("resize-center", "Resize from an object's center", ["Ctrl", "Drag corner"], ["Option", "Drag corner"]), dual("select-rectangle", "Draw a rectangle to select multiple objects", ["Shift", "Drag"], ["Shift", "Drag"]), - dual("rotate-object", "Rotate a selected object", ["Ctrl", "Alt", "Click"], ["Cmd", "Click"], "On Mac, click the object's corner."), + dual("rotate-object", "Rotate a selected object", ["Ctrl", "Alt", "Click"], ["Cmd", "Click corner"]), ], }, ], diff --git a/lib/shortcuts/apps/superhuman.ts b/lib/shortcuts/apps/superhuman.ts index 008fa18f..8cf1521d 100644 --- a/lib/shortcuts/apps/superhuman.ts +++ b/lib/shortcuts/apps/superhuman.ts @@ -55,7 +55,7 @@ export const superhuman: AppShortcuts = { { id: "discard-draft", action: "Discard a draft", keys: combo(platforms, ["Ctrl", "Shift", ","], ["Cmd", "Shift", ","]) }, { id: "send", action: "Send", keys: combo(platforms, ["Ctrl", "Enter"], ["Cmd", "Enter"]) }, { id: "send-done", action: "Send and mark done", keys: combo(platforms, ["Ctrl", "Shift", "Enter"], ["Cmd", "Shift", "Enter"]) }, - { id: "instant-send", action: "Send immediately", keys: perPlatform(platforms, { macos: ["Cmd", "Shift", "Z"] }) }, + { id: "instant-send", action: "Send immediately", keys: perPlatform(platforms, { windows: ["Shift", "Z"], macos: ["Cmd", "Shift", "Z"] }) }, ], }, {