diff --git a/.htaccess b/.htaccess index 6e6005b..2464153 100644 --- a/.htaccess +++ b/.htaccess @@ -32,6 +32,10 @@ RewriteRule "^keyboard/(.*)$" "/script/keyboard/keyboard.php?id=$1" [END] RewriteRule "^increment-download/(.*)$" "/script/increment-download/increment-download.php?id=$1" [END] +#### Rewrites for /script folder: /app-downloads-increment + +RewriteRule "^app-downloads-increment/([^/]+)/([^/]+)/(.*)$" "/script/app-downloads-increment/app-downloads-increment.php?product=$1&version=$2&tier=$3" [END] + #### Rewrites for /script folder: /model RewriteRule "^model(/)?$" "/script/model-search/model-search.php" [END] diff --git a/schemas/app-downloads-increment/1.0/app-downloads-increment.json b/schemas/app-downloads-increment/1.0/app-downloads-increment.json new file mode 100644 index 0000000..0ae5fe2 --- /dev/null +++ b/schemas/app-downloads-increment/1.0/app-downloads-increment.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$ref": "#/definitions/app-downloads-increment", + + "definitions": { + "app-downloads-increment": { + "type": "object", + "required": [ + "product", + "version", + "tier", + "count" + ], + "additionalProperties": true, + "properties": { + "product": { "type": "string" }, + "version": { "type": "string" }, + "tier": { "type": "string" }, + "count": { "type": "integer" } + } + } + } +} \ No newline at end of file diff --git a/schemas/windows-update/17.0/sample.json b/schemas/windows-update/17.0/sample.json new file mode 100644 index 0000000..95426a0 --- /dev/null +++ b/schemas/windows-update/17.0/sample.json @@ -0,0 +1,178 @@ +{ + "msi": { + "name": "Keyman for Windows MSI installer", + "version": "18.0.245", + "date": "2025-12-03", + "platform": "win", + "stability": "stable", + "file": "keymandesktop.msi", + "md5": "98EABC9C8F4803B11C5EF2EF64F0D802", + "type": "msi", + "build": "245", + "size": 110870528, + "url": "https://downloads.keyman.com/windows/stable/18.0.245/keymandesktop.msi" + }, + "setup": { + "name": "Keyman for Windows setup bootstrap", + "version": "18.0.245", + "date": "2025-12-03", + "platform": "win", + "stability": "stable", + "file": "setup.exe", + "md5": "C32FD8926909F1E447BBC72AF8B16380", + "type": "exe", + "build": "245", + "size": 4032904, + "url": "https://downloads.keyman.com/windows/stable/18.0.245/setup.exe" + }, + "bundle": { + "name": "Keyman for Windows", + "version": "18.0.245", + "date": "2025-12-03", + "platform": "win", + "stability": "stable", + "file": "keyman-18.0.245.exe", + "md5": "2BE0290AF6DF49BA736CEDCE50D1E763", + "type": "exe", + "build": "245", + "size": 111668424, + "url": "https://downloads.keyman.com/windows/stable/18.0.245/keyman-18.0.245.exe" + }, + "keyboards": { + "khmer_angkor": { + "id": "khmer_angkor", + "name": "Khmer Angkor", + "license": "mit", + "authorName": "Makara Sok", + "authorEmail": "makara_sok@sil.org", + "description": "
Khmer Unicode keyboard layout based on the NiDA keyboard layout.\nAutomatically corrects many common keying errors.
", + "languages": { + "km": { + "examples": [ + { + "keys": "x j m E r", + "note": "Name of language", + "text": "\u1781\u17d2\u1798\u17c2\u179a" + } + ], + "font": { + "family": "Busra", + "source": [ + "Busra-Regular.ttf" + ] + }, + "oskFont": { + "family": "KbdKhmr", + "source": [ + "KbdKhmr.ttf" + ] + }, + "languageName": "Khmer", + "displayName": "Khmer" + } + }, + "lastModifiedDate": "2025-12-16T08:51:56.000Z", + "packageFilename": "khmer_angkor.kmp", + "packageFileSize": 2541924, + "jsFilename": "khmer_angkor.js", + "jsFileSize": 74154, + "packageIncludes": [ + "visualKeyboard", + "welcome", + "documentation", + "fonts" + ], + "version": "2.4", + "encodings": [ + "unicode" + ], + "platformSupport": { + "windows": "full", + "macos": "full", + "linux": "full", + "desktopWeb": "full", + "ios": "full", + "android": "full", + "mobileWeb": "full" + }, + "minKeymanVersion": "10.0", + "sourcePath": "release/k/khmer_angkor", + "helpLink": "https://help.keyman.com/keyboard/khmer_angkor", + "related": { + "khmer10": { + "deprecates": true + }, + "basic_kbdkni": { + "deprecates": false + } + }, + "url": "https://keyman.com/go/package/download/khmer_angkor?version=2.4&platform=windows&tier=stable&update=0" + }, + "sil_ipa": { + "id": "sil_ipa", + "name": "IPA (SIL)", + "license": "mit", + "authorName": "Martin Hosken, Lorna Evans", + "authorEmail": "fonts@sil.org", + "description": "The keyboard layout is described in terms of an IPA chart rather than a\nkeyboard. This is because many base characters are typed as a sequence\nof a letter followed by one of <, > or = which are characters used to\nchange a base character to another base character. Diacritics are typed\nas sequences of an appropriate key.
", + "languages": { + "und-latn": { + "examples": [], + "font": { + "family": "Charis", + "source": [ + "Charis-Regular.ttf" + ] + }, + "oskFont": { + "family": "Charis", + "source": [ + "Charis-Regular.ttf" + ] + }, + "languageName": "Undetermined", + "scriptName": "Latin", + "displayName": "Undetermined (Latin)" + } + }, + "lastModifiedDate": "2025-06-09T21:19:30.000Z", + "packageFilename": "sil_ipa.kmp", + "packageFileSize": 3626354, + "jsFilename": "sil_ipa.js", + "jsFileSize": 88661, + "packageIncludes": [ + "documentation", + "welcome", + "fonts" + ], + "version": "2.0.2", + "encodings": [ + "unicode" + ], + "platformSupport": { + "windows": "full", + "macos": "full", + "linux": "full", + "desktopWeb": "full", + "ios": "full", + "android": "full", + "mobileWeb": "full" + }, + "minKeymanVersion": "17.0", + "sourcePath": "release/sil/sil_ipa", + "helpLink": "https://help.keyman.com/keyboard/sil_ipa", + "related": { + "ipauni11": { + "deprecates": true + }, + "ipauni111": { + "deprecates": true + }, + "ipa93_km5": { + "deprecates": false + } + }, + "url": "https://keyman.com/go/package/download/sil_ipa?version=2.0.2&platform=windows&tier=stable&update=0" + } + } +} \ No newline at end of file diff --git a/script/app-downloads-increment/app-downloads-increment.inc.php b/script/app-downloads-increment/app-downloads-increment.inc.php new file mode 100644 index 0000000..887fea6 --- /dev/null +++ b/script/app-downloads-increment/app-downloads-increment.inc.php @@ -0,0 +1,28 @@ +prepare('EXEC sp_app_downloads_increment :product, :version, :tier'); + $stmt->bindParam(":product", $product); + $stmt->bindParam(":version", $version); + $stmt->bindParam(":tier", $tier); + $stmt->execute(); + $data = $stmt->fetchAll(); + if(count($data) == 0) { + return NULL; + } + + $obj = [ + 'product' => $data[0]['product'], + 'version' => $data[0]['version'], + 'tier' => $data[0]['tier'], + 'count' => intval($data[0]['count']) + ]; + + return $obj; + } + } diff --git a/script/app-downloads-increment/app-downloads-increment.php b/script/app-downloads-increment/app-downloads-increment.php new file mode 100644 index 0000000..3e5ff27 --- /dev/null +++ b/script/app-downloads-increment/app-downloads-increment.php @@ -0,0 +1,72 @@ +api_keyman_com .'/schemas/app-downloads-increment/1.0/app-downloads-increment.json#>; rel="describedby"'); + + $AllowGet = isset($_REQUEST['debug']); + + if(!$AllowGet && $_SERVER['REQUEST_METHOD'] != 'POST') { + fail('POST required'); + } + + if(!isset($_REQUEST['key'])) { + fail('key parameter must be set'); + } + + // Note: we don't currently unit-test this one + if(KeymanHosts::Instance()->Tier() === KeymanHosts::TIER_DEVELOPMENT) + $key = 'local'; + else + $key = $env['API_KEYMAN_COM_INCREMENT_DOWNLOAD_KEY']; + + if($_REQUEST['key'] !== $key) { + fail('Invalid key'); + } + + if(!isset($_REQUEST['product']) || + !isset($_REQUEST['version']) || + !isset($_REQUEST['tier']) + ) { + // We don't constrain what the product / version / tier may be here, because + // we may add other products in the future + fail('product, version, tier parameters must be set'); + } + + $product = $_REQUEST['product']; + $version = $_REQUEST['version']; + $tier = $_REQUEST['tier']; + + /** + * POST https://api.keyman.com/app-downloads-increment/product/version/tier + * + * Increments the download counter for a single product identified by + * `product`, `version`, and `tier`. Returns the new total count for the + * product/version/tier for the day + * + * https://api.keyman.com/schemas/app-downloads-increment.json is JSON schema + * + * @param product the name of the product to increment ( "android", "ios", + * "linux", "macos", "web", "windows", "developer"...) + * @param version the version number ("1.2.3") + * @param tier the tier of the product ("alpha", "beta", "stable") + * @param key internal key to allow endpoint to run + */ + + $json = \Keyman\Site\com\keyman\api\AppDownloads::increment($mssql, $product, $version, $tier); + if($json === NULL) { + fail("Failed to increment stat, invalid parameters [$product, $version, $tier]?", 401); + } + + echo json_encode($json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); \ No newline at end of file diff --git a/script/statistics/annual-statistics.inc.php b/script/statistics/annual-statistics.inc.php index 96f50d8..ce57995 100644 --- a/script/statistics/annual-statistics.inc.php +++ b/script/statistics/annual-statistics.inc.php @@ -7,44 +7,48 @@ namespace Keyman\Site\com\keyman\api; - // strip out repeated columns with numeric keys (by default the results returned - // give each column twice, once with a column name, and once with a column index) - function filter_columns_by_name($data) { - $result = []; - foreach($data as $row) { - $r = []; - foreach($row as $id => $val) { - if(!is_numeric($id)) { - $r[$id] = intval($val); - } - } - array_push($result, $r); - } - return $result; - } - class AnnualStatistics { function execute($mssql, $startDate, $endDate) { + return $this->_execute('sp_annual_statistics', $mssql, $startDate, $endDate); + } - $stmt = $mssql->prepare('EXEC sp_annual_statistics :prmStartDate, :prmEndDate'); + function executeDownloadsByMonth($mssql, $startDate, $endDate) { + return $this->_execute('sp_keyboard_downloads_by_month_statistics', $mssql, $startDate, $endDate); + } - $stmt->bindParam(":prmStartDate", $startDate); - $stmt->bindParam(":prmEndDate", $endDate); + function executeKeyboards($mssql, $startDate, $endDate) { + return $this->_execute('sp_statistics_keyboard_downloads_by_id', $mssql, $startDate, $endDate); + } - $stmt->execute(); - $data = $stmt->fetchAll(); - return filter_columns_by_name($data); + function executeAppDownloadsByMonth($mssql, $startDate, $endDate) { + return $this->_execute('sp_app_downloads_by_month_statistics', $mssql, $startDate, $endDate); } - function executeDownloadsByMonth($mssql, $startDate, $endDate) { - $stmt = $mssql->prepare('EXEC sp_keyboard_downloads_by_month_statistics :prmStartDate, :prmEndDate'); + private function _execute($proc, $mssql, $startDate, $endDate) { + $stmt = $mssql->prepare("EXEC $proc :prmStartDate, :prmEndDate"); $stmt->bindParam(":prmStartDate", $startDate); $stmt->bindParam(":prmEndDate", $endDate); $stmt->execute(); $data = $stmt->fetchAll(); - return filter_columns_by_name($data); + return $this->filter_columns_by_name($data); + } + + // strip out repeated columns with numeric keys (by default the results returned + // give each column twice, once with a column name, and once with a column index) + private function filter_columns_by_name($data) { + $result = []; + foreach($data as $row) { + $r = []; + foreach($row as $id => $val) { + if(!is_numeric($id)) { + $r[$id] = is_numeric($val) ? intval($val) : $val; + } + } + array_push($result, $r); + } + return $result; } } diff --git a/script/statistics/annual.php b/script/statistics/annual.php index c9b2df0..6fdd38e 100644 --- a/script/statistics/annual.php +++ b/script/statistics/annual.php @@ -30,5 +30,7 @@ $stats = new \Keyman\Site\com\keyman\api\AnnualStatistics(); $summary = $stats->execute($mssql, $startDate, $endDate); $downloads = $stats->executeDownloadsByMonth($mssql, $startDate, $endDate); - $data = ["summary" => $summary, "keyboardDownloadsByMonth" => $downloads]; + $appDownloads = $stats->executeAppDownloadsByMonth($mssql, $startDate, $endDate); + $data = ["summary" => $summary, "keyboardDownloadsByMonth" => $downloads, "appDownloadsByMonth" => $appDownloads]; json_print($data); + diff --git a/script/statistics/keyboards.php b/script/statistics/keyboards.php new file mode 100644 index 0000000..5fc7fcd --- /dev/null +++ b/script/statistics/keyboards.php @@ -0,0 +1,49 @@ +executeKeyboards($mssql, $startDate, $endDate); + + if(isset($_REQUEST['csv'])) { + $out = fopen('php://output', 'w'); + + if(sizeof($keyboards) > 0) { + $data = array_keys($keyboards[0]); + fputcsv($out, $data, ',', '"', ''); + foreach($keyboards as $row) { + $data = array_values($row); + fputcsv($out, $data, ',', '"', ''); + } + } + + fclose($out); + } else { + $data = ["keyboards" => $keyboards]; + json_print($data); + } diff --git a/script/windows/14.0/update/WindowsUpdateCheck.php b/script/windows/14.0/update/WindowsUpdateCheck.php index 59b0f8b..94faff1 100644 --- a/script/windows/14.0/update/WindowsUpdateCheck.php +++ b/script/windows/14.0/update/WindowsUpdateCheck.php @@ -133,7 +133,9 @@ private function CheckVersionResponse($tier, $tiers, $InstalledVersion, $regex) } } - $filedata->url = KeymanHosts::Instance()->downloads_keyman_com . "/windows/$tier/{$filedata->version}/{$file}"; + $filedata->url = + KeymanHosts::Instance()->keyman_com . "/go/app/download/windows/{$filedata->version}/$tier?url=" . + rawurlencode(KeymanHosts::Instance()->downloads_keyman_com . "/windows/$tier/{$filedata->version}/{$file}"); return $filedata; } } diff --git a/script/windows/14.0/update/index.php b/script/windows/14.0/update/index.php index 9a536c3..f79fcb3 100644 --- a/script/windows/14.0/update/index.php +++ b/script/windows/14.0/update/index.php @@ -1,4 +1,32 @@ = @prmStartDate AND statdate < @prmEndDate) RawKeyboardDownloadCount GO +/* ======================================================================== */ + DROP PROCEDURE IF EXISTS sp_keyboard_downloads_by_month_statistics; GO @@ -57,4 +61,54 @@ CREATE PROCEDURE sp_keyboard_downloads_by_month_statistics ( WHERE statdate >= @prmStartDate AND statdate < @prmEndDate group by month(statdate), year(statdate) order by 2, 1 -GO \ No newline at end of file +GO + +/* ======================================================================== */ + +DROP PROCEDURE IF EXISTS sp_statistics_keyboard_downloads_by_id; +GO + +CREATE PROCEDURE sp_statistics_keyboard_downloads_by_id ( + @prmStartDate DATE, + @prmEndDate DATE +) AS + + DECLARE @DayCount INT = DATEDIFF(day,@prmStartDate,@prmEndDate) + 1 + + SELECT + k.keyboard_id, + k.name, + SUM(count) RawKeyboardDownloadCount, + SUM(count)/@DayCount DownloadsPerDay + FROM + k0.t_keyboard k LEFT JOIN + kstats.t_keyboard_downloads d ON d.keyboard_id = k.keyboard_id + WHERE + (d.statdate >= @prmStartDate AND d.statdate < @prmEndDate) OR (d.keyboard_id IS NULL) + GROUP BY + k.keyboard_id, + k.name + ORDER BY + k.keyboard_id +GO + +/* ======================================================================== */ + +DROP PROCEDURE IF EXISTS sp_app_downloads_by_month_statistics; +GO + +CREATE PROCEDURE sp_app_downloads_by_month_statistics ( + @prmStartDate DATE, + @prmEndDate DATE +) AS + select + month(statdate) Month, + year(statdate) Year, + product Product, + sum(count) RawAppDownloadCount, + sum(count)/day(eomonth(datefromparts(year(statdate),month(statdate),1))) DownloadsPerDay + from kstats.t_app_downloads + WHERE statdate >= @prmStartDate AND statdate < @prmEndDate + group by month(statdate), year(statdate), product + order by 3, 2, 1 +GO diff --git a/tools/db/build/search.sql b/tools/db/build/search.sql index 547e929..d3aaf68 100644 --- a/tools/db/build/search.sql +++ b/tools/db/build/search.sql @@ -306,7 +306,10 @@ CREATE TABLE t_dbdatasources ( DROP TABLE IF EXISTS t_keyboard_downloads; GO ---add a new schema for kstats here so we can use it in search.sql +-- tables in the kstats schema are persistent on the production +-- infrastructure, unlike the other tables in the database, which +-- are recreated on each deployment. + IF SCHEMA_ID('kstats') IS NULL BEGIN EXEC sp_executesql N'CREATE SCHEMA kstats' @@ -325,3 +328,18 @@ BEGIN keyboard_id, statdate ) INCLUDE (count) END + +IF OBJECT_ID('kstats.t_app_downloads', 'U') IS NULL +BEGIN + CREATE TABLE kstats.t_app_downloads ( + product NVARCHAR(64) NOT NULL, -- "android", "ios", "linux", "macos", "web", "windows", "developer"... + version NVARCHAR(64) NOT NULL, -- "123.456.789" + tier NVARCHAR(16) NOT NULL, -- "alpha", "beta", "stable" + statdate DATE, + count INT NOT NULL + ) + + CREATE INDEX ix_app_downloads ON kstats.t_app_downloads ( + product, version, tier, statdate + ) INCLUDE (count) +END diff --git a/tools/db/build/sp_app_downloads_increment.sql b/tools/db/build/sp_app_downloads_increment.sql new file mode 100644 index 0000000..8b7ea71 --- /dev/null +++ b/tools/db/build/sp_app_downloads_increment.sql @@ -0,0 +1,39 @@ +/* + sp_app_downloads_increment +*/ + +DROP PROCEDURE IF EXISTS sp_app_downloads_increment; +GO + +CREATE PROCEDURE sp_app_downloads_increment ( + @prmProduct NVARCHAR(64), + @prmVersion NVARCHAR(64), + @prmTier NVARCHAR(16) +) AS +BEGIN + SET NOCOUNT ON; + + BEGIN TRANSACTION; + + DECLARE @date DATE, @count INT; + + SET @date = CONVERT(date, GETDATE()); + + UPDATE kstats.t_app_downloads + WITH (UPDLOCK, SERIALIZABLE) -- ensure that this statement is atomic with following INSERT + SET count = count + 1, @count = count + 1 + WHERE product = @prmProduct AND version = @prmVersion AND tier = @prmTier AND statdate = @date; + + IF @@ROWCOUNT = 0 + BEGIN + INSERT kstats.t_app_downloads (product, version, tier, statdate, count) + SELECT @prmProduct, @prmVersion, @prmTier, @date, 1 + SET @count = 1 + END + + SET NOCOUNT OFF + + SELECT @prmProduct product, @prmVersion version, @prmTier tier, @count count + + COMMIT TRANSACTION; +END diff --git a/tools/db/build/sp_increment_download.sql b/tools/db/build/sp_increment_download.sql index c628bcd..2f6f6c8 100644 --- a/tools/db/build/sp_increment_download.sql +++ b/tools/db/build/sp_increment_download.sql @@ -1,5 +1,6 @@ /* sp_increment_download + TODO: rename to sp_keyboard_downloads_increment */ DROP PROCEDURE IF EXISTS sp_increment_download;