Add WCAG 2.2 AAA compliance and automated AT-SPI audit tool
- Bring all UI widgets to WCAG 2.2 AAA conformance across all views - Add accessible labels, roles, descriptions, and announcements - Bump focus outlines to 3px, target sizes to 44px AAA minimum - Fix announce()/announce_result() to walk widget tree via parent() - Add AT-SPI accessibility audit script (tools/a11y-audit.py) that checks SC 4.1.2, 1.1.1, 1.3.1, 2.1.1, 2.5.5, 2.5.8, 2.4.8, 2.4.9, 2.4.10, 2.1.3 with JSON report output for CI - Clean up project structure, archive old plan documents
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use rusqlite::{params, Connection, Result as SqlResult};
|
||||
use std::path::PathBuf;
|
||||
use super::catalog;
|
||||
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
@@ -489,6 +490,26 @@ impl Database {
|
||||
self.migrate_to_v19()?;
|
||||
}
|
||||
|
||||
if current_version < 20 {
|
||||
self.migrate_to_v20()?;
|
||||
}
|
||||
|
||||
if current_version < 21 {
|
||||
self.migrate_to_v21()?;
|
||||
}
|
||||
|
||||
if current_version < 22 {
|
||||
self.migrate_to_v22()?;
|
||||
}
|
||||
|
||||
if current_version < 23 {
|
||||
self.migrate_to_v23()?;
|
||||
}
|
||||
|
||||
if current_version < 24 {
|
||||
self.migrate_to_v24()?;
|
||||
}
|
||||
|
||||
// Ensure all expected columns exist (repairs DBs where a migration
|
||||
// was updated after it had already run on this database)
|
||||
self.ensure_columns()?;
|
||||
@@ -1067,6 +1088,191 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-categorize OCS apps using the exhaustive typename mapping.
|
||||
fn migrate_to_v20(&self) -> SqlResult<()> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, ocs_typename FROM catalog_apps WHERE ocs_typename IS NOT NULL",
|
||||
)?;
|
||||
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
for (id, typename) in &rows {
|
||||
let cats = catalog::map_ocs_category(typename);
|
||||
let cats_str = cats.join(";");
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
|
||||
params![cats_str, id],
|
||||
)?;
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![20],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Normalize all catalog app categories (OCS and non-OCS) to clean groups.
|
||||
fn migrate_to_v21(&self) -> SqlResult<()> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, categories, ocs_typename FROM catalog_apps WHERE categories IS NOT NULL AND categories <> ''",
|
||||
)?;
|
||||
let rows: Vec<(i64, String, Option<String>)> = stmt.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
for (id, cats_str, ocs_typename) in &rows {
|
||||
let cats: Vec<String> = cats_str.split(';').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
|
||||
let normalized = if ocs_typename.is_some() {
|
||||
// OCS apps: already mapped by v20, but re-normalize for consistency
|
||||
cats
|
||||
} else {
|
||||
// Non-OCS apps: normalize FreeDesktop categories
|
||||
catalog::normalize_categories(cats)
|
||||
};
|
||||
let new_str = normalized.join(";");
|
||||
if new_str != *cats_str {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
|
||||
params![new_str, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![21],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-normalize all non-OCS categories to clean groups (fixes v21 fallback bug).
|
||||
fn migrate_to_v22(&self) -> SqlResult<()> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, categories FROM catalog_apps WHERE categories IS NOT NULL AND categories <> '' AND ocs_typename IS NULL",
|
||||
)?;
|
||||
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
for (id, cats_str) in &rows {
|
||||
let cats: Vec<String> = cats_str.split(';').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
|
||||
let normalized = catalog::normalize_categories(cats);
|
||||
let new_str = normalized.join(";");
|
||||
if new_str != *cats_str {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
|
||||
params![new_str, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![22],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-categorize all apps with expanded category set (Communication, Multimedia, Photography, Productivity).
|
||||
fn migrate_to_v23(&self) -> SqlResult<()> {
|
||||
// Re-map OCS apps
|
||||
{
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, ocs_typename FROM catalog_apps WHERE ocs_typename IS NOT NULL",
|
||||
)?;
|
||||
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
for (id, typename) in &rows {
|
||||
let cats = catalog::map_ocs_category(typename);
|
||||
let cats_str = cats.join(";");
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
|
||||
params![cats_str, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Re-normalize non-OCS apps
|
||||
{
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, categories FROM catalog_apps WHERE categories IS NOT NULL AND categories <> '' AND ocs_typename IS NULL",
|
||||
)?;
|
||||
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
for (id, cats_str) in &rows {
|
||||
let cats: Vec<String> = cats_str.split(';').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
|
||||
let normalized = catalog::normalize_categories(cats);
|
||||
let new_str = normalized.join(";");
|
||||
if new_str != *cats_str {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
|
||||
params![new_str, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![23],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consolidate to 11 categories (merge thin ones into larger groups).
|
||||
fn migrate_to_v24(&self) -> SqlResult<()> {
|
||||
// Re-map OCS apps
|
||||
{
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, ocs_typename FROM catalog_apps WHERE ocs_typename IS NOT NULL",
|
||||
)?;
|
||||
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
for (id, typename) in &rows {
|
||||
let cats = catalog::map_ocs_category(typename);
|
||||
let cats_str = cats.join(";");
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
|
||||
params![cats_str, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Re-normalize non-OCS apps
|
||||
{
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, categories FROM catalog_apps WHERE categories IS NOT NULL AND categories <> '' AND ocs_typename IS NULL",
|
||||
)?;
|
||||
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})?.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
for (id, cats_str) in &rows {
|
||||
let cats: Vec<String> = cats_str.split(';').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
|
||||
let normalized = catalog::normalize_categories(cats);
|
||||
let new_str = normalized.join(";");
|
||||
if new_str != *cats_str {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
|
||||
params![new_str, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![24],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_appimage(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -2536,16 +2742,9 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get featured catalog apps. Apps with enrichment data (stars or OCS downloads)
|
||||
/// sort first by combined popularity, then unenriched apps get a deterministic
|
||||
/// shuffle that rotates every 15 minutes.
|
||||
/// Get featured catalog apps from a pool of top ~50 popular apps,
|
||||
/// shuffled deterministically every 15 minutes so the carousel rotates.
|
||||
pub fn get_featured_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
|
||||
// Time seed rotates every 15 minutes (900 seconds)
|
||||
let time_seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() / 900;
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {} FROM catalog_apps
|
||||
WHERE icon_url IS NOT NULL AND icon_url != ''
|
||||
@@ -2559,26 +2758,58 @@ impl Database {
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], Self::catalog_app_from_row)?;
|
||||
let mut apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
|
||||
let apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
// Sort by combined popularity: OCS downloads + GitHub stars.
|
||||
// Apps with any enrichment sort first, then deterministic shuffle.
|
||||
Self::shuffle_featured_pool(apps, limit)
|
||||
}
|
||||
|
||||
pub fn get_featured_catalog_apps_by_category(&self, limit: i32, category: &str) -> SqlResult<Vec<CatalogApp>> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM catalog_apps
|
||||
WHERE icon_url IS NOT NULL AND icon_url != ''
|
||||
AND (description IS NOT NULL AND description != ''
|
||||
OR ocs_summary IS NOT NULL AND ocs_summary != '')
|
||||
AND (screenshots IS NOT NULL AND screenshots != ''
|
||||
OR ocs_preview_url IS NOT NULL AND ocs_preview_url != '')
|
||||
AND categories LIKE ?1
|
||||
{}",
|
||||
Self::CATALOG_APP_COLUMNS,
|
||||
Self::CATALOG_DEDUP_FILTER,
|
||||
);
|
||||
let pattern = format!("%{}%", category);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![pattern], Self::catalog_app_from_row)?;
|
||||
let apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
Self::shuffle_featured_pool(apps, limit)
|
||||
}
|
||||
|
||||
/// Sort apps by popularity, take the top pool_size, then deterministically
|
||||
/// shuffle the pool using a time seed that rotates every 15 minutes.
|
||||
fn shuffle_featured_pool(mut apps: Vec<CatalogApp>, limit: i32) -> SqlResult<Vec<CatalogApp>> {
|
||||
const POOL_SIZE: usize = 50;
|
||||
|
||||
// Time seed rotates every 15 minutes (900 seconds)
|
||||
let time_seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() / 900;
|
||||
|
||||
// Sort by combined popularity to find the top pool
|
||||
apps.sort_by(|a, b| {
|
||||
let a_pop = a.ocs_downloads.unwrap_or(0) + a.github_stars.unwrap_or(0);
|
||||
let b_pop = b.ocs_downloads.unwrap_or(0) + b.github_stars.unwrap_or(0);
|
||||
let a_enriched = a_pop > 0;
|
||||
let b_enriched = b_pop > 0;
|
||||
match (a_enriched, b_enriched) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
(true, true) => b_pop.cmp(&a_pop),
|
||||
(false, false) => {
|
||||
let ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
ha.cmp(&hb)
|
||||
}
|
||||
}
|
||||
b_pop.cmp(&a_pop)
|
||||
});
|
||||
|
||||
// Take the top pool, then shuffle deterministically
|
||||
apps.truncate(POOL_SIZE);
|
||||
apps.sort_by(|a, b| {
|
||||
let ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
ha.cmp(&hb)
|
||||
});
|
||||
|
||||
apps.truncate(limit as usize);
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user